助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只留在監控頁上。這一批讓 每一輪把「要送到人面前」的那幾筆寫成一份佇列檔,位置固定在助理狀態目錄 底下,讀的那一端只印、不判。 判定不放在讀的那一邊,理由是漂移:讓讀的人自己拿 due 欄與 next_run 去跟 現在比,就是第二套到期判定,跟這裡那一套遲早對不上,而對不上的那一天 兩邊都說自己是對的。 這樣換來一個新的失效模式,所以檔頭寫足四個值:輪次代號、UTC 時間戳、 連續失敗筆數、待辦總筆數,再加同一刻的 epoch 秒。助理沒在跑的時候佇列 不會更新,而一份舊佇列讀起來跟新的一模一樣——讀的那一端要算得出它多舊, 「沒有提醒」與「沒有人算提醒」不可以長得一樣。epoch 是為了讓讀的那一端 不必自己解 ISO 字串:那等於在每一個讀取端各放一份日期解析。 同一筆待辦既到期又逾期時只列逾期那一行。同一批提醒裡出現兩行會被讀成 兩件事,而「逾期五天」比「排定點過了」講得更清楚。 逾期那幾行的「逾期多久」直接取判定那一支算好的寫法,不在這裡再寫一個 時間長度格式化——同一個秒數在兩個地方就會印出兩種說法。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1302 lines
62 KiB
Bash
Executable File
1302 lines
62 KiB
Bash
Executable File
#!/usr/bin/env sh
|
||
# due.sh — 事件偵測與到期判定(供 jsc-assist:assistant 的 patrol 那一輪呼叫)。
|
||
#
|
||
# 用法:
|
||
# due.sh scan [--out {目錄}] [--now {epoch 秒}] [--dry-run]
|
||
# due.sh events [--now {epoch 秒}]
|
||
# due.sh next --trigger {at:...|after:...} --recur {once|every:{間隔}}
|
||
# [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}]
|
||
# [--now {epoch 秒}]
|
||
#
|
||
# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期與逾期。
|
||
# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。
|
||
# overdue 只判逾期,唯讀:一份快照都不推進,一個事件都不比。狀態查詢那一路走這一個。
|
||
# next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。
|
||
#
|
||
# 結束碼:
|
||
# 0 scan:這一輪判完了(零筆待辦、零個新事件都算判完);events:印完;next:算出來了
|
||
# 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、
|
||
# 快照那一段原樣留著,呼叫端要把它標成警示
|
||
# 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、
|
||
# due 欄的截止時間解不開、--now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,
|
||
# 其餘各筆照判
|
||
# 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批
|
||
# 事件會被判第二次。這一碼要吵出來,不能當成成功
|
||
# 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」
|
||
# 不等於「都沒到期」
|
||
# 5 檔案系統失敗:助理狀態目錄建不起來、暫存檔寫不進去
|
||
# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、next 缺 --trigger 或 --recur,或
|
||
# JSC_HOME 與 HOME 都解不出絕對路徑
|
||
# 逐筆的機器可讀輸出在 rows_file 那個定位字元分隔的檔案,欄位順序印在 rows_columns=。
|
||
# stdout 上的 task= 那幾行只給人看:trigger、recur、why、title 都可能帶空白,切不回欄位。
|
||
# 同一次執行同時命中好幾碼時,回報順序是 5、3、2、1、4:前面的蓋掉後面的。5 之下什麼都不算,
|
||
# 3 會讓下一輪重判同一批事件,那是最需要人知道的一件事;4 只是沒東西可判,排最後。
|
||
#
|
||
# --- 為什麼另開一支,不併進 tasks.sh ---
|
||
#
|
||
# tasks.sh 的檔頭明寫它只負責存放與讀寫,trigger 只驗格式不算到期、recur 只驗格式不算下一次、
|
||
# next_run 只存呼叫端算好的值。判定邏輯併進去,那句話就變成假的,而它是呼叫端唯一的依據:
|
||
# 讀的人以為那一支不判定,於是自己也判一次,兩邊各判一次就會漂移。
|
||
# 反過來,判定這一邊也不重寫存放格式:這一支只讀待辦檔、只把算出來的值印出來,改狀態一律回去
|
||
# 叫 tasks.sh 的 done 與 fail。所以「誰算」與「誰寫」各一支,兩支都只有一個入口。
|
||
#
|
||
# --- 事件偵測靠比對狀態快照,不改任何產生者 ---
|
||
#
|
||
# 每一輪讀那幾支產生者留下的狀態,跟上一輪存下來的快照比,有差別就算事件發生。
|
||
# 事件名 狀態來源
|
||
# wp-merged:{工作包代號} $JSC_HOME/wp/*.pr 鎖檔從有變成沒有
|
||
# stage-entered:{階段} $JSC_HOME/sessions/{sid}.stage 的值換掉
|
||
# hook-error $JSC_HOME/errors/hooks.jsonl 行數變多
|
||
# session-start $JSC_HOME/sessions/{sid}.start 新增或值換掉
|
||
# session-end $JSC_HOME/sessions/{sid}.end 新增或值換掉
|
||
# worklog-written $JSC_HOME/worklog-pending/{hash}/ 從有暫存變成沒有
|
||
# analyze-completed:{HASH} 還沒接線,理由見下面「這一個刻意不接線」
|
||
#
|
||
# **這個選擇有一個明確的代價:兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期
|
||
# 之內鎖上又合併、一個工作階段在同一個週期之內開又關、暫存區清空之後又被塞回東西,這一支都
|
||
# 看不到——它看到的只有兩張快照的差別,中間的過程沒有留下痕跡。
|
||
# 往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。
|
||
#
|
||
# 不為了補這個洞去改那幾支產生者。要它們各自在事件發生的那一刻多寫一筆,任何一支忘了寫就會
|
||
# 讓對應的事件從此不再發生,而那同樣是無聲的失效——而且散在五個 domain 裡,查不出是誰漏的。
|
||
# 快照比對至少壞掉的時候看得出來:快照檔不見了、來源讀不到、比對不出東西,這一支都會印出來。
|
||
#
|
||
# --- 快照存哪、什麼格式 ---
|
||
#
|
||
# 兩個檔案,都在 $JSC_HOME/assistant 底下,與既有的 usage-prev.tsv、events-open.tsv 同一層、
|
||
# 同一種命名:巡檢那一輪本來就在讀寫這個目錄,快照跟著放才不會多出第二個狀態落點。
|
||
# events-prev.tsv 上一輪的狀態快照。三欄,定位字元分隔:{來源}、{鍵}、{值}
|
||
# events-seen.tsv 每一個事件名的累計次數與最後發生時間。三欄:{事件名}、{累計次數}、{epoch}
|
||
# 選純文字 TSV 而不選 JSON,理由同待辦簿選 key=value:讀的人不只這一支腳本,巡檢那一輪與人
|
||
# 都會直接看它,TSV 用 awk 與 cut 就讀得準,不必多帶一個解析器。值裡的定位字元與換行寫進去
|
||
# 之前一律折成空白,所以欄位切得乾淨。
|
||
# 快照另外留一列 meta,記每一個來源上一輪是「看得到」還是「不存在」,理由見下一段。
|
||
#
|
||
# --- 第一次跑不該把既有狀態都當成剛發生 ---
|
||
#
|
||
# 快照不存在的那一輪一律只建快照,不發任何事件(印 first_run=1)。第一次跑的時候,機器上所有
|
||
# 既有狀態都是以前就發生過的事,當成剛發生會讓每一筆事件型待辦一次全到期,而那批執行沒有一次
|
||
# 是使用者要的。
|
||
# 同一條理由逐個來源再套一次:某個來源上一輪不存在、這一輪才出現(例如後來才裝上別的 domain,
|
||
# $JSC_HOME/wp 這才被建起來),那個來源這一輪也只建快照、不發事件。所以快照要留 meta 那幾列——
|
||
# 少了它,分不出「上一輪沒有這個來源」與「上一輪這個來源是空的」,而後者的差別才算事件。
|
||
# 來源目錄從「看得到」變成「不存在」時也不發事件,只記一筆警示:整個目錄被砍掉不是每一支工作包
|
||
# 都合併了,把它當成一批 wp-merged 會一次觸發一堆待辦。
|
||
#
|
||
# --- 這一個刻意不接線:analyze-completed ---
|
||
#
|
||
# 它的狀態來源是 wiki 上分析頁的勾選,或那一份分析的工作包議題全關,兩條都要連網、要金鑰,跟
|
||
# 其他六個純本機檔案讀取不同性質。這一輪不接線,理由兩條:
|
||
# 一、把一個要連網的來源混進來,金鑰過期或 Gitea 連不上就會讓事件偵測整項每輪失敗,而那幾個
|
||
# 本機來源本來一輪都不會失敗。
|
||
# 二、更糟的是判不出來的時候,after:analyze-completed:{HASH} 那幾筆會靜靜地永遠不到期,跟填
|
||
# 一個不存在的事件名一樣看不出壞在哪。
|
||
# 所以這一支對那幾筆的處置是**吵出來**:印一行 unwired=、列進要人接手的那一節,明確說「這一筆
|
||
# 現在判不出到期,不是還沒發生」。不接線與靜靜當成沒發生是兩件事,差別就在有沒有這一行。
|
||
#
|
||
# --- cron:{式子} 也還沒接線 ---
|
||
#
|
||
# tasks.sh 收 cron:{式子},這一支不算它。cron 式子要自己實作五個欄位的萬用字元、清單與步進,
|
||
# 算錯一格就是差一小時或差一天跑一次,而那種錯要等到真的跑錯才看得出來。處置同上:標 unwired、
|
||
# 吵出來,不假裝算得出 next_run。
|
||
#
|
||
# --- 四種組合,一個都不壓 ---
|
||
#
|
||
# recur: once recur: every:{間隔}
|
||
# trigger: at:{時間} 那個時間做一次 從那個時間起,每隔一段做一次
|
||
# trigger: after:{事} 下一次該事件發生時做一次 每次該事件發生都做
|
||
#
|
||
# at 型的排定點一律從 at 那個時間往後數,一次加一個間隔:next_run 取第一個比 last_run 還晚的
|
||
# 排定點。從 at 起算而不從 last_run 起算,是為了不漂移——每次拿 last_run 加間隔,執行慢個幾秒
|
||
# 就往後挪幾秒,一天下來就偏掉了。
|
||
# 漏掉的排定點只補一次,不逐個追補:機器關了一天再開,這一支算出來的是「上一次執行之後的第一個
|
||
# 排定點」,跑完 last_run 換成當下,下一次就跳到未來那一個。追補全部漏掉的次數,開機那一刻會
|
||
# 連續跑幾十輪同一件事。
|
||
# 事件型配 every:{間隔} 時,那個間隔照 tasks.sh 檔頭的定義當「重新武裝的最短間隔」:事件發生了、
|
||
# 而且距上一次執行已經超過那個間隔,才算到期。間隔還沒到就先不做,事件下一次發生時再看。
|
||
#
|
||
# --- 同一件事不重複觸發,靠的是 last_run ---
|
||
#
|
||
# 事件型的到期判準是「那個事件最後一次發生的時間,比這一筆的 last_run 還晚」。所以同一次事件
|
||
# 發生只會觸發一次:執行完呼叫端寫回 last_run,那個時間就追過了事件發生的時間,同一次事件再也
|
||
# 湊不出「還沒做過」。沒跑過的那一筆拿 created 當基準線,不拿 0:一筆今天才登錄的待辦,不該被
|
||
# 上個星期發生過的事件觸發。
|
||
# 這條保證掛在 last_run 上,而 last_run 只有 tasks.sh 的 done 與 fail 寫得出來。呼叫端跑完不
|
||
# 回寫,那一筆下一輪照樣到期——那是對的,它真的沒做完;但呼叫端要知道這個相依,不回寫就等於
|
||
# 每一輪重跑同一件事。
|
||
# 這裡有一條例外要挑明:fail 也會寫 last_run,照上面那條規則,失敗過的事件型待辦就再也等不到
|
||
# 同一次事件了,而規格要的是「失敗下一輪照重試」。兩條規則在同一個欄位上打架,所以改用
|
||
# fail_count 分開:state 還是 pending 而 fail_count 大於 0,代表上一次執行失敗,一律算到期,
|
||
# 不再比事件也不看排定點。done 會把 fail_count 歸零,所以成功那一次不會被誤判成要重試。
|
||
#
|
||
# --- next_run 對事件型填什麼 ---
|
||
#
|
||
# 事件型一律留空。next_run 這一欄的語意是「下一次預定執行的時間」,而事件型沒有預定時間,它等的
|
||
# 是一件不知道什麼時候發生的事。
|
||
# 算得出來的只有配 every:{間隔} 時的重新武裝時刻(上一次執行加上間隔),但那個值填進 next_run 會
|
||
# 被狀態表與提醒讀成「那個時間會跑」,它其實只是「那個時間之前不會跑」——兩者的差別在畫面上看不
|
||
# 出來,而看不出來的錯就是無聲的錯。所以那個值改印成 rearm_at=,要看的人看得到,不寫進那一欄。
|
||
# 留空的意思 tasks.sh 已經定義過:還沒排下一次,不是不再排。事件型永遠停在那個狀態,直到事件真的
|
||
# 發生,這正好就是它的實情。
|
||
#
|
||
# --- 這一支不判什麼 ---
|
||
#
|
||
# 不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。
|
||
# 它只回答三個問題:本輪有哪些新事件、哪幾筆現在到期、哪幾筆逾期了。做不做、怎麼做、失敗怎麼記,
|
||
# 都在呼叫端。
|
||
#
|
||
# 到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個截止時間過了」。
|
||
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的交辦
|
||
# 可能早就逾期,卻因為排定點還沒到而判成「還沒到」。合成一個值就會把這兩種情況混成同一格。
|
||
#
|
||
# 環境變數:
|
||
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜
|
||
set -u
|
||
|
||
JSC_HOME_RAW="${JSC_HOME:-}"
|
||
if [ -z "$JSC_HOME_RAW" ]; then
|
||
JSC_HOME_RAW="${HOME:-}"
|
||
[ -n "$JSC_HOME_RAW" ] || {
|
||
printf '[jsc][助理到期判定][ERR]:JSC_HOME 與 HOME 都沒有設定,找不到狀態檔的根目錄。這裡不猜一個路徑:猜錯會拿別的地方的快照來比對,比出來的事件全是假的,而且看起來像成功。請設定 JSC_HOME 再跑一次。\n' >&2
|
||
exit 6
|
||
}
|
||
JSC_HOME_RAW="$JSC_HOME_RAW/.jsc"
|
||
fi
|
||
case "$JSC_HOME_RAW" in
|
||
/*) ;;
|
||
*)
|
||
_abs=$(CDPATH= cd -- "$JSC_HOME_RAW" 2>/dev/null && pwd -L) || _abs=''
|
||
[ -n "$_abs" ] || {
|
||
printf '[jsc][助理到期判定][ERR]:JSC_HOME 是相對路徑(%s),也解不出絕對路徑。快照的位置必須是字面絕對路徑,不然排程那一輪與人在現場那一次會比對到兩份不同的快照。請把 JSC_HOME 設成絕對路徑再跑一次。\n' "$JSC_HOME_RAW" >&2
|
||
exit 6
|
||
}
|
||
JSC_HOME_RAW="$_abs" ;;
|
||
esac
|
||
|
||
JSC_HOME="$JSC_HOME_RAW"
|
||
STATE_DIR="$JSC_HOME/assistant"
|
||
TASKS_DIR="$STATE_DIR/tasks"
|
||
CURRENT="$JSC_HOME/current"
|
||
PREV="$STATE_DIR/events-prev.tsv"
|
||
SEEN="$STATE_DIR/events-seen.tsv"
|
||
|
||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
||
SCRIPT_DIR="${SCRIPT_DIR:-.}"
|
||
|
||
TAB=$(printf '\t')
|
||
|
||
# 結束碼一律經這裡收斂,順序見檔頭。分散在各處直接 exit 的話,一筆判不了的待辦會蓋掉「快照
|
||
# 換不上」這種更嚴重的事,而後者才是下一輪會重判同一批事件的原因。
|
||
RC_FS=0
|
||
RC_SNAP=0
|
||
RC_FIELD=0
|
||
RC_SOURCE=0
|
||
RC_EMPTY=0
|
||
|
||
die() { # $1=結束碼 $2=訊息
|
||
printf '[jsc][助理到期判定][ERR]:%s\n' "$2" >&2
|
||
exit "$1"
|
||
}
|
||
warn() { printf '[jsc][助理到期判定][WARN]:%s\n' "$1" >&2; }
|
||
note() { printf '[jsc][助理到期判定]:%s\n' "$1" >&2; }
|
||
|
||
usage() {
|
||
cat >&2 <<'EOF'
|
||
usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run]
|
||
due.sh events [--now epoch 秒]
|
||
due.sh overdue [--now epoch 秒]
|
||
due.sh next --trigger at:...|after:... --recur once|every:間隔
|
||
[--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字]
|
||
[--now epoch 秒]
|
||
EOF
|
||
exit 6
|
||
}
|
||
|
||
# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。判準與處置同這個 domain 的其他
|
||
# 腳本:只警告、照跑。從工作樹直接跑是開發時的正當用法;真正的失敗會發生在權限閘門那裡。
|
||
warn_if_not_current() {
|
||
_want="$CURRENT/jsc-assist/tools/$(basename -- "$0")"
|
||
case "$SCRIPT_DIR/" in
|
||
"$CURRENT"/*) return 0 ;;
|
||
esac
|
||
warn "這支腳本是從 $SCRIPT_DIR/$(basename -- "$0") 跑起來的,不是 $_want。權限閘門只放行 current 那一組確切路徑:無人值守那一輪用別的路徑會被靜靜擋掉。開發時這樣跑沒關係。"
|
||
return 0
|
||
}
|
||
warn_if_not_current
|
||
|
||
# --- 時間 ---
|
||
|
||
# 把值折成一行再塞進 TSV。定位字元與換行折成空白,前後空白去掉:不折的話一個換行就多切出一列,
|
||
# 而多出來的那一列看起來像一筆正常的快照紀錄。
|
||
flat() { # $1=原值
|
||
printf '%s' "$1" | tr '\n\r\t' ' ' | tr -d '\000-\037' \
|
||
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[[:space:]][[:space:]]*/ /g'
|
||
}
|
||
|
||
# markdown 表格欄位裡的 `|` 會把欄切開,一律跳脫。做法與巡檢那一支的 cell() 相同。
|
||
cell() { printf '%s' "$1" | sed 's/|/\\|/g'; }
|
||
|
||
# ISO 時間轉 epoch。算不出來就印空字串、回 1。
|
||
# 三條路依序試,理由是這一支跑在 cron 上,能倚賴的只有系統本來就有的東西:
|
||
# GNU date、BSD date,最後是自己算。自己算那一條不能拿掉——BusyBox 的 date 兩種旗標都不吃,
|
||
# 而少了它,那台機器上每一筆 at:{時間} 都會被判成「算不出來」,也就永遠不到期。
|
||
iso_to_epoch() { # $1=ISO 時間字串
|
||
[ -n "$1" ] || { printf ''; return 1; }
|
||
_e=$(date -u -d "$1" +%s 2>/dev/null) || _e=''
|
||
case "$_e" in ''|*[!0-9-]*) _e='' ;; esac
|
||
[ -n "$_e" ] && { printf '%s' "$_e"; return 0; }
|
||
_e=$(date -u -j -f '%Y-%m-%dT%H:%M:%SZ' "$1" +%s 2>/dev/null) || _e=''
|
||
case "$_e" in ''|*[!0-9-]*) _e='' ;; esac
|
||
[ -n "$_e" ] && { printf '%s' "$_e"; return 0; }
|
||
# 自己算。吃 YYYY-MM-DD 加可省略的 THH:MM:SS,尾巴可以是 Z、+HH:MM 或 -HH:MM。
|
||
# 沒有尾巴時當成 UTC:這本簿的時間全部由 date -u 產生,帶的就是 Z。
|
||
_e=$(printf '%s' "$1" | awk '
|
||
function days(y, m, d, yy, era, yoe, doy, doe) {
|
||
yy = y - (m <= 2 ? 1 : 0)
|
||
era = int((yy >= 0 ? yy : yy - 399) / 400)
|
||
yoe = yy - era * 400
|
||
doy = int((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1
|
||
doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy
|
||
return era * 146097 + doe - 719468
|
||
}
|
||
{
|
||
s = $0
|
||
if (match(s, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/) == 0) exit 1
|
||
y = substr(s, 1, 4) + 0; mo = substr(s, 6, 2) + 0; d = substr(s, 9, 2) + 0
|
||
if (mo < 1 || mo > 12 || d < 1 || d > 31) exit 1
|
||
hh = 0; mi = 0; ss = 0; off = 0
|
||
r = substr(s, 11)
|
||
if (match(r, /^[T ][0-9][0-9]:[0-9][0-9]/)) {
|
||
hh = substr(r, 2, 2) + 0; mi = substr(r, 5, 2) + 0
|
||
if (match(substr(r, 7), /^:[0-9][0-9]/)) { ss = substr(r, 8, 2) + 0; r = substr(r, 10) }
|
||
else r = substr(r, 7)
|
||
if (match(r, /^[+-][0-9][0-9]:?[0-9][0-9]$/)) {
|
||
sg = (substr(r, 1, 1) == "-" ? -1 : 1)
|
||
oh = substr(r, 2, 2) + 0
|
||
om = substr(r, length(r) - 1, 2) + 0
|
||
off = sg * (oh * 3600 + om * 60)
|
||
}
|
||
}
|
||
if (hh > 23 || mi > 59 || ss > 60) exit 1
|
||
printf "%d", days(y, mo, d) * 86400 + hh * 3600 + mi * 60 + ss - off
|
||
}' 2>/dev/null) || _e=''
|
||
case "$_e" in ''|*[!0-9-]*) printf ''; return 1 ;; esac
|
||
printf '%s' "$_e"
|
||
return 0
|
||
}
|
||
|
||
epoch_to_iso() { # $1=epoch 秒
|
||
case "$1" in ''|*[!0-9]*) printf ''; return 0 ;; esac
|
||
date -u -d "@$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null && return 0
|
||
date -u -r "$1" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null && return 0
|
||
printf '%s' "$1"
|
||
}
|
||
|
||
# every:{間隔} 的間隔轉秒。收「正整數加單位」,單位是 s、m、h、d、w(秒、分、時、日、週)。
|
||
# 不收裸數字:這個技能組裡的排程週期以分鐘為單位、心跳門檻以秒為單位,裸數字兩種都讀得通,
|
||
# 而讀錯就是差六十倍,那種錯要等到跑得太密或太疏才看得出來。所以一定要帶單位。
|
||
interval_secs() { # $1=every: 後面那一段
|
||
_n=$(printf '%s' "$1" | sed -n 's/^\([0-9][0-9]*\)[smhdw]$/\1/p')
|
||
[ -n "$_n" ] || { printf ''; return 1; }
|
||
[ "$_n" -ge 1 ] || { printf ''; return 1; }
|
||
case "$1" in
|
||
*s) printf '%s' "$_n" ;;
|
||
*m) printf '%s' "$(( _n * 60 ))" ;;
|
||
*h) printf '%s' "$(( _n * 3600 ))" ;;
|
||
*d) printf '%s' "$(( _n * 86400 ))" ;;
|
||
*w) printf '%s' "$(( _n * 604800 ))" ;;
|
||
*) printf ''; return 1 ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# --- 快照的讀寫 ---
|
||
|
||
# 取快照裡某一列的值。來源與鍵都當字面比對,不用正規表示式:鍵裡有工作包代號與雜湊,那些字
|
||
# 在正規表示式裡有別的意思。
|
||
snap_get() { # $1=來源 $2=鍵
|
||
[ -f "$PREV" ] || return 1
|
||
awk -F'\t' -v s="$1" -v k="$2" '$1 == s && $2 == k { print $3; found = 1; exit } END { exit found ? 0 : 1 }' "$PREV" 2>/dev/null
|
||
}
|
||
|
||
snap_meta() { # $1=來源;上一輪這個來源是 present、absent 還是 unreadable,沒有那一列就印空
|
||
snap_get meta "$1" 2>/dev/null || printf ''
|
||
}
|
||
|
||
# 事件計數。印「累計次數 最後發生 epoch」,沒有那一列就印「0 」。
|
||
seen_get() { # $1=事件名
|
||
[ -f "$SEEN" ] || { printf '0 '; return 0; }
|
||
awk -F'\t' -v e="$1" '$1 == e { printf "%s %s", $2, $3; found = 1; exit } END { if (!found) printf "0 " }' "$SEEN" 2>/dev/null
|
||
}
|
||
|
||
# --- 狀態來源逐個掃 ---
|
||
#
|
||
# 每一個來源都做同一件事:把現在的狀態寫成快照的幾列(收到 $NEXT),跟上一輪比出事件(收到
|
||
# $FIRED,一行一個事件名)。來源不存在、第一次看到、讀不到,三種各有處置,見檔頭。
|
||
|
||
NEXT=''
|
||
FIRED=''
|
||
SRC_LINES=''
|
||
|
||
add_src() { # $1=來源 $2=狀態 $3=筆數 $4=說明
|
||
SRC_LINES="${SRC_LINES}source=$1 status=$2 count=$3 note=$4
|
||
"
|
||
}
|
||
|
||
fire() { # $1=事件名 $2=一句話講完哪裡看出來的
|
||
printf '%s\t%s\n' "$1" "$(flat "$2")" >>"$FIRED"
|
||
}
|
||
|
||
put() { # $1=來源 $2=鍵 $3=值
|
||
printf '%s\t%s\t%s\n' "$1" "$(flat "$2")" "$(flat "$3")" >>"$NEXT"
|
||
}
|
||
|
||
# 目錄能不能讀。回 0 讀得到、1 不存在、2 存在但讀不到。
|
||
dir_state() { # $1=目錄
|
||
[ -d "$1" ] || return 1
|
||
[ -r "$1" ] && [ -x "$1" ] || return 2
|
||
return 0
|
||
}
|
||
|
||
# 來源不存在或讀不到時共用的收尾。回 1 代表這個來源這一輪不用再往下掃。
|
||
src_guard() { # $1=來源名 $2=路徑 $3=dir_state 的結果 $4=人看的來源說明
|
||
case "$3" in
|
||
1)
|
||
put meta "$1" absent
|
||
add_src "$1" absent 0 "來源不存在($2)"
|
||
# 上一輪看得到、這一輪整個不見了:不發事件。整個目錄被砍掉不是每一筆都完成了。
|
||
if [ "$(snap_meta "$1")" = present ]; then
|
||
warn "$4 上一輪還在($2),這一輪整個不見了。這一輪不發任何事件:整個來源消失不等於裡面每一筆都完成了,當成完成會一次觸發一堆待辦。要是那個目錄是被誤刪的,事件從此就少了一種。"
|
||
fi
|
||
return 1 ;;
|
||
2)
|
||
# 讀不到就把上一輪那幾列原樣留著。改寫成空的話,下一輪會把「原本就在」的東西比成新增,
|
||
# 或把讀不到那一輪的空白比成一批消失,兩種都是假事件。
|
||
# meta 那一列改成 unreadable,於是權限修好之後的那一輪走「第一次看得到」那一條,只建
|
||
# 快照、不發事件。代價是讀不到那段時間裡真的發生過的事會漏掉;換來的是不會在恢復的那
|
||
# 一瞬間一次比出一批假事件。漏掉這件事本來就是快照比對的既有代價,見檔頭。
|
||
if [ -f "$PREV" ]; then
|
||
awk -F'\t' -v s="$1" '$1 == s' "$PREV" >>"$NEXT" 2>/dev/null
|
||
fi
|
||
put meta "$1" unreadable
|
||
add_src "$1" unreadable 0 "來源讀不到($2)"
|
||
RC_SOURCE=1
|
||
warn "$4 讀不到($2),權限或掛載有問題。這一輪不比對這個來源,快照那幾列原樣留著;改寫成空的會在下一輪比出一批假事件。"
|
||
return 1 ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
# wp-merged:{工作包代號} — 鎖檔從有變成沒有。
|
||
# 鎖檔只有一條自動消失的路:工作包閘門查到 PR 已合併,順手解鎖。人手動解鎖也會讓它消失,那一次
|
||
# 會誤報一次 wp-merged。要分辨就得連網查那支 PR 的狀態,那又把本機比對變成連網比對,代價見檔頭
|
||
# 對 analyze-completed 的說明。所以照實記在這裡:鎖檔消失就當合併。
|
||
src_wp() {
|
||
_d="$JSC_HOME/wp"
|
||
dir_state "$_d"; _ds=$?
|
||
src_guard wp "$_d" "$_ds" '工作包鎖目錄' || return 0
|
||
|
||
_n=0
|
||
for _f in "$_d"/*.pr; do
|
||
[ -f "$_f" ] || continue
|
||
_k=$(basename "$_f" .pr)
|
||
_wp=$(sed -n 's/^wp=//p' "$_f" 2>/dev/null | head -n1)
|
||
[ -n "$_wp" ] || _wp='-'
|
||
put wp "$_k" "$_wp"
|
||
_n=$(( _n + 1 ))
|
||
done
|
||
put meta wp present
|
||
add_src wp ok "$_n" '鎖檔數'
|
||
|
||
[ "$(snap_meta wp)" = present ] || {
|
||
note "工作包鎖目錄這一輪第一次看得到($_d),只建快照、不發事件。既有的鎖檔是以前就鎖上的,當成剛合併會一次觸發一堆待辦。"
|
||
return 0
|
||
}
|
||
# 上一輪有、這一輪沒有的那幾支。
|
||
awk -F'\t' -v tab="$TAB" '
|
||
FILENAME == ARGV[1] { if ($1 == "wp") now[$2] = 1; next }
|
||
$1 == "wp" && !($2 in now) { print $2 tab $3 }
|
||
' "$NEXT" "$PREV" 2>/dev/null | while IFS="$TAB" read -r _k _wp; do
|
||
[ -n "$_k" ] || continue
|
||
if [ "$_wp" = '-' ] || [ -z "$_wp" ]; then
|
||
warn "工作包鎖檔 $_k.pr 不見了(多半是那支 PR 合併了),但那個鎖檔沒有 wp= 欄位,算不出事件名要接哪一個工作包代號,所以這一輪不發事件。鎖上的時候帶 --wp 就不會有這個洞。"
|
||
continue
|
||
fi
|
||
fire "wp-merged:$_wp" "鎖檔 $_k.pr 從有變成沒有"
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# stage-entered:{階段} — .stage 的值換掉。第一欄才是階段,後面兩欄是必要標籤與上鎖時的模型。
|
||
src_stage() {
|
||
_d="$JSC_HOME/sessions"
|
||
dir_state "$_d"; _ds=$?
|
||
src_guard stage "$_d" "$_ds" '工作階段狀態目錄' || return 0
|
||
|
||
_n=0
|
||
for _f in "$_d"/*.stage; do
|
||
[ -f "$_f" ] && [ -r "$_f" ] || continue
|
||
_k=$(basename "$_f" .stage)
|
||
_v=$(cut -f1 "$_f" 2>/dev/null | head -n1)
|
||
[ -n "$_v" ] || _v='-'
|
||
put stage "$_k" "$_v"
|
||
_n=$(( _n + 1 ))
|
||
done
|
||
put meta stage present
|
||
add_src stage ok "$_n" '階段鎖數'
|
||
|
||
[ "$(snap_meta stage)" = present ] || {
|
||
note "工作階段狀態目錄這一輪第一次看得到($_d),階段這一項只建快照、不發事件。"
|
||
return 0
|
||
}
|
||
# 值換掉或這一輪才出現的,都算進入了那個階段。階段離開(檔案消失)不是事件:詞彙表裡沒有
|
||
# 「離開某階段」這個事件名,硬塞一個進去就是自由填字串,那正是詞彙表要擋的事。
|
||
awk -F'\t' -v tab="$TAB" '
|
||
FILENAME == ARGV[1] { if ($1 == "stage") old[$2] = $3; next }
|
||
$1 == "stage" && $3 != "-" && (!($2 in old) || old[$2] != $3) { print $2 tab $3 }
|
||
' "$PREV" "$NEXT" 2>/dev/null | while IFS="$TAB" read -r _k _v; do
|
||
[ -n "$_v" ] || continue
|
||
fire "stage-entered:$_v" "工作階段 $_k 的階段換成 $_v"
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# session-start、session-end — .start 與 .end 新增或值換掉。
|
||
# .end 消失不是事件:同一個工作階段重新開場時,計時那一支會把 .end 刪掉,那一刻真正發生的是
|
||
# session-start,已經由 .start 的值換掉抓到了。
|
||
src_session() {
|
||
_d="$JSC_HOME/sessions"
|
||
dir_state "$_d"; _ds=$?
|
||
src_guard sess "$_d" "$_ds" '工作階段計時目錄' || return 0
|
||
|
||
_n=0
|
||
for _sfx in start end; do
|
||
for _f in "$_d"/*."$_sfx"; do
|
||
[ -f "$_f" ] && [ -r "$_f" ] || continue
|
||
_k="$(basename "$_f" ".$_sfx").$_sfx"
|
||
_v=$(head -n1 "$_f" 2>/dev/null)
|
||
[ -n "$_v" ] || _v='-'
|
||
put sess "$_k" "$_v"
|
||
_n=$(( _n + 1 ))
|
||
done
|
||
done
|
||
put meta sess present
|
||
add_src sess ok "$_n" '計時檔數'
|
||
|
||
[ "$(snap_meta sess)" = present ] || {
|
||
note "工作階段計時目錄這一輪第一次看得到($_d),開始與結束這兩項只建快照、不發事件。"
|
||
return 0
|
||
}
|
||
awk -F'\t' -v tab="$TAB" '
|
||
FILENAME == ARGV[1] { if ($1 == "sess") old[$2] = $3; next }
|
||
$1 == "sess" && (!($2 in old) || old[$2] != $3) { print $2 tab $3 }
|
||
' "$PREV" "$NEXT" 2>/dev/null | while IFS="$TAB" read -r _k _v; do
|
||
[ -n "$_k" ] || continue
|
||
case "$_k" in
|
||
*.start) fire session-start "工作階段 ${_k%.start} 的 .start 新增或換值" ;;
|
||
*.end) fire session-end "工作階段 ${_k%.end} 的 .end 新增或換值" ;;
|
||
esac
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# hook-error — errors/hooks.jsonl 行數變多。
|
||
# 事件名不帶參數,所以一輪只發一次,不管這一輪新增了幾列。行數變少一律不發事件:那是檔案被
|
||
# 輪替或截斷,不是錯誤消失了;把它當成事件就是把「有人清了紀錄」報成「又出錯了」。
|
||
src_hookerr() {
|
||
_f="$JSC_HOME/errors/hooks.jsonl"
|
||
if [ ! -f "$_f" ]; then
|
||
put meta hookerr absent
|
||
add_src hookerr absent 0 "來源不存在($_f)"
|
||
[ "$(snap_meta hookerr)" = present ] \
|
||
&& warn "hook 錯誤紀錄上一輪還在($_f),這一輪不見了。這一輪不發事件:檔案消失不是錯誤消失。"
|
||
return 0
|
||
fi
|
||
if [ ! -r "$_f" ]; then
|
||
if [ -f "$PREV" ]; then
|
||
awk -F'\t' '$1 == "hookerr"' "$PREV" >>"$NEXT" 2>/dev/null
|
||
fi
|
||
put meta hookerr unreadable
|
||
add_src hookerr unreadable 0 "來源讀不到($_f)"
|
||
RC_SOURCE=1
|
||
warn "hook 錯誤紀錄讀不到($_f)。這一輪不比對,快照那一列原樣留著。"
|
||
return 0
|
||
fi
|
||
|
||
_now_lines=$(wc -l <"$_f" 2>/dev/null | tr -d ' ')
|
||
case "$_now_lines" in ''|*[!0-9]*) _now_lines=0 ;; esac
|
||
put hookerr lines "$_now_lines"
|
||
put meta hookerr present
|
||
add_src hookerr ok "$_now_lines" '紀錄列數'
|
||
|
||
[ "$(snap_meta hookerr)" = present ] || {
|
||
note "hook 錯誤紀錄這一輪第一次看得到($_f,共 $_now_lines 列),只建快照、不發事件。既有的那幾列是以前就記下的。"
|
||
return 0
|
||
}
|
||
_old_lines=$(snap_get hookerr lines 2>/dev/null) || _old_lines=''
|
||
case "$_old_lines" in ''|*[!0-9]*) _old_lines=0 ;; esac
|
||
if [ "$_now_lines" -gt "$_old_lines" ]; then
|
||
fire hook-error "hook 錯誤紀錄從 $_old_lines 列變成 $_now_lines 列"
|
||
elif [ "$_now_lines" -lt "$_old_lines" ]; then
|
||
warn "hook 錯誤紀錄從 $_old_lines 列變成 $_now_lines 列,變少了(多半是被輪替或截斷)。這一輪不發事件,下一輪拿新的列數當基準。"
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# worklog-written — 某個雜湊的暫存區從有暫存變成沒有。
|
||
# 暫存區清空那一刻就是日誌寫進 wiki 那一刻:清空只在寫入成功之後才做,寫失敗時暫存一律留著。
|
||
# 所以「從有變成沒有」比「有沒有那個目錄」準:目錄可能一直在,只是裡面空了。
|
||
src_worklog() {
|
||
_d="$JSC_HOME/worklog-pending"
|
||
dir_state "$_d"; _ds=$?
|
||
src_guard worklog "$_d" "$_ds" '工作日誌暫存目錄' || return 0
|
||
|
||
_n=0
|
||
for _sub in "$_d"/*; do
|
||
[ -d "$_sub" ] || continue
|
||
_k=$(basename "$_sub")
|
||
# .merge 是合併檔的落點,不是某一個雜湊的暫存區,比對它會把一次合併報成一次寫入。
|
||
[ "$_k" = '.merge' ] && continue
|
||
_c=0
|
||
for _pf in "$_sub"/*; do
|
||
[ -f "$_pf" ] || continue
|
||
_c=$(( _c + 1 ))
|
||
done
|
||
put worklog "$_k" "$_c"
|
||
_n=$(( _n + 1 ))
|
||
done
|
||
put meta worklog present
|
||
add_src worklog ok "$_n" '暫存區數'
|
||
|
||
[ "$(snap_meta worklog)" = present ] || {
|
||
note "工作日誌暫存目錄這一輪第一次看得到($_d),只建快照、不發事件。"
|
||
return 0
|
||
}
|
||
# 上一輪大於 0、這一輪是 0 或整個目錄不見了,都算清空。
|
||
awk -F'\t' -v tab="$TAB" '
|
||
FILENAME == ARGV[1] { if ($1 == "worklog") now[$2] = $3; next }
|
||
$1 == "worklog" && $3 + 0 > 0 {
|
||
if (!($2 in now)) { print $2 tab "0"; next }
|
||
if (now[$2] + 0 == 0) print $2 tab now[$2]
|
||
}
|
||
' "$NEXT" "$PREV" 2>/dev/null | while IFS="$TAB" read -r _k _c; do
|
||
[ -n "$_k" ] || continue
|
||
fire worklog-written "工作日誌暫存區 $_k 從有暫存變成沒有"
|
||
done
|
||
return 0
|
||
}
|
||
|
||
# analyze-completed:{HASH} — 還沒接線,理由見檔頭。這裡只把它記成 unwired,讓它在輸出上看得到。
|
||
src_analyze() {
|
||
add_src analyze unwired 0 '狀態來源在 wiki 的分析頁上,要連網才判得出來,這一輪沒有接線'
|
||
return 0
|
||
}
|
||
|
||
# --- 事件偵測一輪 ---
|
||
|
||
# $1=1 代表要推進快照與事件計數;0 是唯讀預覽。
|
||
detect() { # $1=是否寫回
|
||
_write="$1"
|
||
_tmp="$STATE_DIR/.due.$$"
|
||
mkdir -p "$STATE_DIR" 2>/dev/null || { RC_FS=5; die 5 "建不出助理狀態目錄 $STATE_DIR。"; }
|
||
NEXT="$_tmp.next"
|
||
FIRED="$_tmp.fired"
|
||
# 清空一個檔案一律寫成 true >檔案,不寫成 : >檔案。冒號是 POSIX 的特殊內建指令,重導向
|
||
# 失敗時整個殼直接結束,後面那個 || 一次都不會跑到——於是「暫存檔寫不進去」會回成殼自己的
|
||
# 結束碼 2,而這一支的 2 是「欄位值不合法」,呼叫端會照那一碼去叫人改待辦,方向完全錯。
|
||
# true 是普通內建指令,重導向失敗只讓它自己回非 0,|| 那一段才接得到。
|
||
true >"$NEXT" 2>/dev/null || { RC_FS=5; die 5 "暫存檔寫不進去:$NEXT。"; }
|
||
true >"$FIRED" 2>/dev/null || { RC_FS=5; die 5 "暫存檔寫不進去:$FIRED。"; }
|
||
|
||
FIRST_RUN=0
|
||
if [ ! -f "$PREV" ]; then
|
||
FIRST_RUN=1
|
||
note "找不到上一輪的狀態快照($PREV),這一輪只建快照、不發任何事件。第一次跑的時候機器上所有既有狀態都是以前就發生過的事,當成剛發生會讓每一筆事件型待辦一次全到期。"
|
||
fi
|
||
|
||
src_wp
|
||
src_stage
|
||
src_session
|
||
src_hookerr
|
||
src_worklog
|
||
src_analyze
|
||
|
||
# 第一輪把偵測結果整批丟掉。上面每一個來源自己也有「第一次看到」的擋法,這一道是總開關:
|
||
# 快照檔整個不見(被誤刪、換機器)時,每一個來源都會走到第一次看到那一條,但擋在這裡比較
|
||
# 顯眼——輸出上有一行 first_run=1,看的人立刻知道這一輪為什麼零事件。
|
||
[ "$FIRST_RUN" -eq 1 ] && true >"$FIRED"
|
||
|
||
# 同一輪同一個事件名發生好幾次(兩個工作階段同時進同一個階段)只算一個事件名,但累計次數
|
||
# 照實加:次數要對得上真的發生幾次,不然它就只是一個「有沒有發生過」的旗標。
|
||
EV_NAMES=$(LC_ALL=C cut -f1 "$FIRED" 2>/dev/null | LC_ALL=C sort -u)
|
||
EV_NEW=0
|
||
[ -n "$EV_NAMES" ] && EV_NEW=$(printf '%s\n' "$EV_NAMES" | grep -c .)
|
||
case "$EV_NEW" in ''|*[!0-9]*) EV_NEW=0 ;; esac
|
||
|
||
if [ "$_write" -eq 1 ]; then
|
||
# 事件計數先算成一份新的,再整份換上去。半途改寫的話,換到一半失敗會留下一份少了幾列的
|
||
# 計數檔,而那幾列少掉之後,對應的事件型待辦會退回「沒發生過」。
|
||
_sn="$_tmp.seen"
|
||
[ -f "$SEEN" ] || true >"$SEEN" 2>/dev/null
|
||
awk -F'\t' -v now="$NOW" -v tab="$TAB" '
|
||
FILENAME == ARGV[1] { cnt[$1]++; next }
|
||
{ old[$1] = $2 tab $3 }
|
||
END {
|
||
for (e in cnt) {
|
||
n = 0
|
||
if (e in old) { split(old[e], p, tab); n = p[1] + 0 }
|
||
printf "%s\t%d\t%s\n", e, n + cnt[e], now
|
||
}
|
||
for (e in old) {
|
||
if (e in cnt) continue
|
||
split(old[e], p, tab)
|
||
printf "%s\t%s\t%s\n", e, p[1], p[2]
|
||
}
|
||
}
|
||
' "$FIRED" "$SEEN" 2>/dev/null | LC_ALL=C sort >"$_sn"
|
||
if ! mv "$_sn" "$SEEN" 2>/dev/null; then
|
||
RC_SNAP=3
|
||
rm -f "$_sn"
|
||
warn "事件計數換不上去($SEEN)。這一輪的事件算出來了,但下一輪會拿舊的計數比,同一批事件會被判第二次。"
|
||
fi
|
||
if ! mv "$NEXT" "$PREV" 2>/dev/null; then
|
||
RC_SNAP=3
|
||
warn "狀態快照換不上去($PREV)。這一輪的事件算出來了,但下一輪會拿同一份舊快照再比一次,同一批事件會被判第二次。"
|
||
fi
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
print_events() {
|
||
printf 'first_run=%s\n' "$FIRST_RUN"
|
||
printf '%s' "$SRC_LINES"
|
||
if [ -s "$FIRED" ]; then
|
||
printf '%s\n' "$EV_NAMES" | while IFS= read -r _e; do
|
||
[ -n "$_e" ] || continue
|
||
_c=$(awk -F'\t' -v e="$_e" '$1 == e { n++ } END { printf "%d", n + 0 }' "$FIRED" 2>/dev/null)
|
||
_w=$(awk -F'\t' -v e="$_e" '$1 == e { print $2; exit }' "$FIRED" 2>/dev/null)
|
||
printf 'event=%s times=%s at=%s note=%s\n' "$_e" "$_c" "$(epoch_to_iso "$NOW")" "$_w"
|
||
done
|
||
fi
|
||
printf 'events_new=%s\n' "$EV_NEW"
|
||
}
|
||
|
||
# --- 到期判定 ---
|
||
|
||
# 算一筆的 next_run 與到不到期。設好 R_* 那幾個變數,回 0;欄位判不了時設 R_VERDICT=invalid
|
||
# 或 unwired,一樣回 0——一筆判不了不該讓其餘各筆停下。
|
||
#
|
||
# R_VERDICT due(到期)、wait(還沒到)、invalid(欄位判不了)、unwired(來源還沒接線)
|
||
# R_NEXT next_run 該填的值;事件型一律空,理由見檔頭
|
||
# R_REARM 事件型配間隔時的重新武裝時刻;其餘為空
|
||
# R_WHY 一句話講完為什麼是這個判定
|
||
judge() { # $1=trigger $2=recur $3=created $4=last_run $5=fail_count
|
||
R_VERDICT=''; R_NEXT=''; R_REARM=''; R_WHY=''
|
||
_tg="$1"; _rc="$2"; _cr="$3"; _lr="$4"; _fc="$5"
|
||
case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac
|
||
|
||
# 間隔先解。cron 這一條在這裡就攔下:它與 trigger 的兩種型別都配得起來,不先攔就會兩邊各
|
||
# 寫一次「還沒接線」。
|
||
_iv=''
|
||
case "$_rc" in
|
||
once) ;;
|
||
every:*)
|
||
_iv=$(interval_secs "${_rc#every:}") || {
|
||
R_VERDICT=invalid
|
||
R_WHY="recur「$_rc」的間隔解不開。只收正整數加單位 s、m、h、d、w(秒、分、時、日、週),例如 every:15m、every:1d。裸數字一律不收:這個技能組裡排程週期以分鐘算、心跳門檻以秒算,裸數字兩種都讀得通,讀錯就差六十倍。"
|
||
return 0
|
||
} ;;
|
||
cron:*)
|
||
R_VERDICT=unwired
|
||
R_WHY="recur「$_rc」還沒接線。cron 式子要自己實作五個欄位的萬用字元、清單與步進,算錯一格就是差一小時或差一天跑一次。這一筆現在算不出 next_run,不是還沒到期。"
|
||
return 0 ;;
|
||
*)
|
||
R_VERDICT=invalid
|
||
R_WHY="recur「$_rc」不合法。只收 once、every:{間隔} 或 cron:{式子}。"
|
||
return 0 ;;
|
||
esac
|
||
|
||
# 上一次執行失敗過就一律算到期,不再比事件也不看排定點。理由見檔頭「同一件事不重複觸發」
|
||
# 最後那一段:不重複觸發與失敗照重試兩條規則在 last_run 這一欄上打架,靠 fail_count 分開。
|
||
_lre=''
|
||
if [ -n "$_lr" ]; then
|
||
_lre=$(iso_to_epoch "$_lr") || _lre=''
|
||
[ -n "$_lre" ] || {
|
||
R_VERDICT=invalid
|
||
R_WHY="last_run「$_lr」解不出時間。這一筆的上一次執行時間不可信,算不出下一次;請重新登錄一筆。"
|
||
return 0
|
||
}
|
||
fi
|
||
|
||
# last_run 比現在還晚,那是時鐘被往回調過、或那個值是手填的。這種狀態不當成到期:拿一個
|
||
# 未來的時間當基準線,算出來的間隔是負數,畫面上會印出「已經過了負幾百秒」那種讀不懂的字,
|
||
# 而真正的問題(時鐘不對)反而被藏在裡面。所以單獨判一條,把時鐘這件事講出來。
|
||
# 失敗過的那一筆例外,照重試優先:時鐘不對不影響「上一次真的失敗了」這件事。
|
||
_future=0
|
||
[ -n "$_lre" ] && [ "$_lre" -gt "$NOW" ] && _future=1
|
||
|
||
case "$_tg" in
|
||
at:*)
|
||
_base=''
|
||
_at="${_tg#at:}"
|
||
if [ "$_at" = now ]; then
|
||
# at:now 的排定點取建立時間:登錄那一刻就是「立刻要做」的那一刻。取當下時間的話,
|
||
# 每一輪都算出一個新的排定點,那一筆就永遠是剛剛才到期,也永遠算不出下一次。
|
||
_base=$(iso_to_epoch "$_cr") || _base=''
|
||
[ -n "$_base" ] || {
|
||
R_VERDICT=invalid
|
||
R_WHY="trigger 是 at:now,但 created「$_cr」解不出時間,算不出第一次的排定點。"
|
||
return 0
|
||
}
|
||
else
|
||
_base=$(iso_to_epoch "$_at") || _base=''
|
||
[ -n "$_base" ] || {
|
||
R_VERDICT=invalid
|
||
R_WHY="trigger「$_tg」的時間解不開。只收 ISO 時間(例如 at:2026-09-05T09:00:00Z)或 at:now。"
|
||
return 0
|
||
}
|
||
fi
|
||
|
||
if [ -z "$_lre" ]; then
|
||
_n="$_base"
|
||
elif [ -z "$_iv" ]; then
|
||
# 一次性且跑過了。這一筆該由 tasks.sh 的 done 收成 done,還留在 pending 只有一種情況:
|
||
# 上一次是 fail。所以 next_run 留空,到不到期交給下面那條重試判準。
|
||
_n=''
|
||
else
|
||
_d=$(( _lre - _base ))
|
||
if [ "$_d" -lt 0 ]; then _n="$_base"; else _n=$(( _base + ( _d / _iv + 1 ) * _iv )); fi
|
||
fi
|
||
R_NEXT=$(epoch_to_iso "$_n")
|
||
|
||
if [ "$_fc" -gt 0 ]; then
|
||
R_VERDICT=due
|
||
R_WHY="上一次執行失敗(fail_count=$_fc),照重試,不等下一個排定點。"
|
||
return 0
|
||
fi
|
||
if [ "$_future" -eq 1 ]; then
|
||
R_VERDICT=wait
|
||
R_WHY="last_run($_lr)比現在($(epoch_to_iso "$NOW"))還晚。這一筆先不判到期:時鐘被往回調過,或那個值是手填的。時鐘校正之後這一筆自己就會恢復。"
|
||
return 0
|
||
fi
|
||
if [ -z "$_n" ]; then
|
||
R_VERDICT=wait
|
||
R_WHY='一次性的項目已經跑過了,沒有下一個排定點。'
|
||
return 0
|
||
fi
|
||
if [ "$_n" -le "$NOW" ]; then
|
||
R_VERDICT=due
|
||
R_WHY="排定點 $(epoch_to_iso "$_n") 已經到了或過了。"
|
||
else
|
||
R_VERDICT=wait
|
||
R_WHY="下一個排定點是 $(epoch_to_iso "$_n"),還沒到。"
|
||
fi
|
||
return 0 ;;
|
||
|
||
after:*)
|
||
_ev="${_tg#after:}"
|
||
case "$_ev" in
|
||
analyze-completed:*)
|
||
R_VERDICT=unwired
|
||
R_WHY="事件「$_ev」的狀態來源在 wiki 的分析頁上,要連網才判得出來,這一輪沒有接線。這一筆現在判不出到期,不是還沒發生——不吵出來的話它會看起來就是一筆正常等著的待辦。"
|
||
return 0 ;;
|
||
worklog-written|hook-error|session-start|session-end|wp-merged:?*|stage-entered:?*) ;;
|
||
*)
|
||
R_VERDICT=invalid
|
||
R_WHY="事件名「$_ev」不在固定詞彙表上。只認 worklog-written、hook-error、session-start、session-end、wp-merged:{工作包代號}、stage-entered:{階段}、analyze-completed:{HASH}。"
|
||
return 0 ;;
|
||
esac
|
||
|
||
# 事件型的 next_run 一律留空,理由見檔頭。重新武裝時刻另外印成 rearm_at。
|
||
_sg=$(seen_get "$_ev")
|
||
_evat=$(printf '%s' "$_sg" | awk '{ print $2 }')
|
||
case "$_evat" in ''|*[!0-9]*) _evat='' ;; esac
|
||
|
||
if [ -n "$_iv" ] && [ -n "$_lre" ]; then
|
||
R_REARM=$(epoch_to_iso "$(( _lre + _iv ))")
|
||
fi
|
||
|
||
if [ "$_fc" -gt 0 ]; then
|
||
R_VERDICT=due
|
||
R_WHY="上一次執行失敗(fail_count=$_fc),照重試,不等事件再發生一次。"
|
||
return 0
|
||
fi
|
||
if [ "$_future" -eq 1 ]; then
|
||
R_VERDICT=wait
|
||
R_WHY="last_run($_lr)比現在($(epoch_to_iso "$NOW"))還晚。這一筆先不判到期:時鐘被往回調過,或那個值是手填的。時鐘校正之後這一筆自己就會恢復。"
|
||
return 0
|
||
fi
|
||
|
||
# 沒跑過的那一筆拿 created 當基準線,不拿 0:一筆今天才登錄的待辦,不該被上個星期發生
|
||
# 過的事件觸發。created 解不開就判不了——沒有基準線就分不出事件是登錄之前還是之後發生的。
|
||
if [ -n "$_lre" ]; then
|
||
_bl="$_lre"
|
||
else
|
||
_bl=$(iso_to_epoch "$_cr") || _bl=''
|
||
[ -n "$_bl" ] || {
|
||
R_VERDICT=invalid
|
||
R_WHY="created「$_cr」解不出時間,算不出事件的基準線,分不出事件是這一筆登錄之前還是之後發生的。"
|
||
return 0
|
||
}
|
||
fi
|
||
|
||
if [ -z "$_evat" ]; then
|
||
R_VERDICT=wait
|
||
R_WHY="事件「$_ev」到現在一次都沒被偵測到。"
|
||
return 0
|
||
fi
|
||
if [ "$_evat" -le "$_bl" ]; then
|
||
R_VERDICT=wait
|
||
R_WHY="事件「$_ev」最後一次發生是 $(epoch_to_iso "$_evat"),不比基準線 $(epoch_to_iso "$_bl") 晚,同一次發生不重複觸發。"
|
||
return 0
|
||
fi
|
||
if [ -n "$_iv" ] && [ -n "$_lre" ] && [ "$(( NOW - _lre ))" -lt "$_iv" ]; then
|
||
R_VERDICT=wait
|
||
R_WHY="事件「$_ev」發生過了,但距上一次執行還不到重新武裝的最短間隔(要 $_iv 秒,現在 $(( NOW - _lre )) 秒),$R_REARM 之後才算到期。"
|
||
return 0
|
||
fi
|
||
R_VERDICT=due
|
||
R_WHY="事件「$_ev」在 $(epoch_to_iso "$_evat") 發生,比基準線 $(epoch_to_iso "$_bl") 晚。"
|
||
return 0 ;;
|
||
|
||
*)
|
||
R_VERDICT=invalid
|
||
R_WHY="trigger「$_tg」不合法。只收 at:{ISO 時間}、at:now,或 after:{事件名}。"
|
||
return 0 ;;
|
||
esac
|
||
}
|
||
|
||
kv_get() { # $1=檔案 $2=鍵
|
||
tr -d '\r' <"$1" 2>/dev/null | sed -n "s/^$2=//p" | head -n1
|
||
}
|
||
|
||
# --- scan ---
|
||
|
||
# 逐筆判定寫成定位字元分隔的一列,欄位順序固定十一個:
|
||
# id、判定、state、kind、action、trigger、recur、next_run、rearm_at、why、title
|
||
# 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是
|
||
# recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位
|
||
# 字元也不會有換行,所以定位字元切得準。
|
||
row_out() { # 十二個欄位,順序如上
|
||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||
"$(flat "$1")" "$(flat "$2")" "$(flat "$3")" "$(flat "$4")" "$(flat "$5")" \
|
||
"$(flat "$6")" "$(flat "$7")" "$(flat "$8")" "$(flat "$9")" \
|
||
"$(flat "${10}")" "$(flat "${11}")" "$(flat "${12}")" >>"$ROWS"
|
||
}
|
||
|
||
# 逾期判定。到期與逾期是兩件事:到期是「該跑了」,逾期是「截止時間過了」。
|
||
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的
|
||
# 交辦事項可能早就逾期,卻因為排定點還沒到而判成「還沒到」。兩個判定各自獨立,一欄一個值。
|
||
#
|
||
# 回傳三種值:due 欄是空的印減號、還沒過印 no、過了印逾期的秒數。
|
||
# 印秒數不印 yes 的理由是「逾期一分鐘」與「逾期三個月」在報告上要看得出差別,而呼叫端
|
||
# 只要判「是不是數字」就分得出有沒有逾期。
|
||
# due 欄解不開的一律印 bad 並算成欄位不合法:一個解不開的截止時間跟沒有截止時間,
|
||
# 在畫面上長得一模一樣,而前者是登錄時就填錯了。
|
||
# 逾期多久,給人看的寫法。機器可讀的那幾處一律留秒數:秒數比得出大小、也不必解析。
|
||
# 頁面上只印秒數讀不出輕重——「528462 秒」要算過才知道是六天,而報告的用途正是讓人一眼
|
||
# 分出「逾期一分鐘」與「逾期三個月」。
|
||
dur_human() { # $1=秒數
|
||
_ds=$(( $1 / 86400 )); _hs=$(( ($1 % 86400) / 3600 )); _ms=$(( ($1 % 3600) / 60 ))
|
||
if [ "$_ds" -gt 0 ]; then printf '%s 天 %s 小時' "$_ds" "$_hs"
|
||
elif [ "$_hs" -gt 0 ]; then printf '%s 小時 %s 分' "$_hs" "$_ms"
|
||
else printf '%s 分' "$_ms"; fi
|
||
}
|
||
|
||
overdue_of() { # $1=due 欄的值;回傳值印在 stdout
|
||
case "$1" in
|
||
''|'-') printf '%s' '-'; return 0 ;;
|
||
esac
|
||
_de=$(iso_to_epoch "$1") || _de=''
|
||
case "${_de:-}" in
|
||
''|*[!0-9]*) printf '%s' 'bad'; return 1 ;;
|
||
esac
|
||
if [ "$NOW" -gt "$_de" ]; then
|
||
printf '%s' "$((NOW - _de))"
|
||
else
|
||
printf '%s' 'no'
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
scan_tasks() {
|
||
T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0
|
||
T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
|
||
true >"$ROWS" 2>/dev/null || { RC_FS=5; die 5 "逐筆判定的輸出檔寫不進去:$ROWS。"; }
|
||
if [ ! -d "$TASKS_DIR" ]; then
|
||
RC_EMPTY=4
|
||
note "待辦簿目錄不存在($TASKS_DIR),這一輪沒有任何一筆可判。這不是失敗,助理還沒收過待辦就是這個狀態;但「沒判過」不等於「都沒到期」。"
|
||
return 0
|
||
fi
|
||
for _f in "$TASKS_DIR"/*; do
|
||
[ -f "$_f" ] || continue
|
||
T_TOTAL=$(( T_TOTAL + 1 ))
|
||
_id=$(kv_get "$_f" id)
|
||
[ -n "$_id" ] || _id=$(basename "$_f")
|
||
_st=$(kv_get "$_f" state); [ -n "$_st" ] || _st=pending
|
||
_kd=$(kv_get "$_f" kind)
|
||
_ti=$(kv_get "$_f" title)
|
||
_ac=$(kv_get "$_f" action)
|
||
_tg=$(kv_get "$_f" trigger)
|
||
_rc=$(kv_get "$_f" recur)
|
||
_cr=$(kv_get "$_f" created)
|
||
_lr=$(kv_get "$_f" last_run)
|
||
_fc=$(kv_get "$_f" fail_count)
|
||
_du=$(kv_get "$_f" due)
|
||
|
||
# 逾期照每一筆都判,連 paused 那幾筆也判。到期判定跳過 paused 是對的(那是人設的,
|
||
# 助理判它到期只會讓人以為它還在跑),但截止時間過了這件事不會因為有人按了暫停就消失
|
||
# ——一筆被暫停又逾期的交辦,正好是最容易被忘掉的那一種。
|
||
# done 那幾筆不算逾期:做完了就沒有截止時間可以過。
|
||
_ov='-'
|
||
if [ "$_st" != done ]; then
|
||
_ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; }
|
||
case "$_ov" in
|
||
''|*[!0-9]*) ;;
|
||
*) T_OVERDUE=$(( T_OVERDUE + 1 ))
|
||
OVERDUE_LINES="${OVERDUE_LINES}$(printf '%s\t%s\t%s\t%s\t%s' \
|
||
"$(flat "$_id")" "$(flat "$_st")" "$(flat "$_du")" "$_ov" "$(flat "$_ti")")
|
||
" ;;
|
||
esac
|
||
fi
|
||
|
||
if [ "$_st" != pending ]; then
|
||
# done 與 paused 一律不判到期。
|
||
T_SKIP=$(( T_SKIP + 1 ))
|
||
row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" "$_ov"
|
||
continue
|
||
fi
|
||
|
||
judge "$_tg" "$_rc" "$_cr" "$_lr" "$_fc"
|
||
case "$R_VERDICT" in
|
||
due) T_DUE=$(( T_DUE + 1 )) ;;
|
||
wait) T_WAIT=$(( T_WAIT + 1 )) ;;
|
||
invalid) T_INVALID=$(( T_INVALID + 1 )); RC_FIELD=2 ;;
|
||
unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;;
|
||
esac
|
||
row_out "$_id" "$R_VERDICT" "$_st" "$_kd" "$_ac" "$_tg" "$_rc" \
|
||
"$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" "$_ov"
|
||
done
|
||
[ "$T_TOTAL" -eq 0 ] && {
|
||
RC_EMPTY=4
|
||
note "待辦簿目錄在($TASKS_DIR),但零筆,這一輪沒有任何一筆可判。"
|
||
}
|
||
return 0
|
||
}
|
||
|
||
# 監控頁那一節。這一支一頁都不寫,只把 markdown 組成檔案:那一段有好幾百字,經過對話重打一次
|
||
# 只會多錯字,理由同巡檢那一支把三塊寫成檔案。
|
||
compose_md() {
|
||
{
|
||
printf '### 待辦簿到期與逾期\n\n'
|
||
printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \
|
||
"$PREV" "$SEEN"
|
||
printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n'
|
||
printf '到期與逾期是兩件事,這一節兩件都判:到期是「該跑了」,逾期是「`due` 欄那個截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。這一節不執行任何一筆——到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n'
|
||
printf '| 項目 | 內容 |\n'
|
||
printf '| --- | --- |\n'
|
||
printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")"
|
||
if [ "$FIRST_RUN" -eq 1 ]; then
|
||
printf '| 快照 | 上一輪的快照不存在,這一輪只建快照、不發任何事件 |\n'
|
||
else
|
||
printf '| 快照 | 比對過上一輪的快照 |\n'
|
||
fi
|
||
printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW"
|
||
printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL"
|
||
printf '| 到期 | %s |\n' "$T_DUE"
|
||
printf '| 逾期 | %s |\n' "$T_OVERDUE"
|
||
printf '| 還沒到 | %s |\n' "$T_WAIT"
|
||
printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID"
|
||
printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED"
|
||
printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP"
|
||
[ "$T_DUEBAD" -gt 0 ] && printf '| `due` 欄解不開 | %s |\n' "$T_DUEBAD"
|
||
|
||
printf '\n#### 狀態來源\n\n'
|
||
printf '| 來源 | 狀態 | 筆數 | 說明 |\n'
|
||
printf '| --- | --- | ---: | --- |\n'
|
||
printf '%s' "$SRC_LINES" | while IFS= read -r _l; do
|
||
[ -n "$_l" ] || continue
|
||
_s=$(printf '%s' "$_l" | sed -n 's/^source=\([^ ]*\).*/\1/p')
|
||
_t=$(printf '%s' "$_l" | sed -n 's/.* status=\([^ ]*\).*/\1/p')
|
||
_c=$(printf '%s' "$_l" | sed -n 's/.* count=\([^ ]*\).*/\1/p')
|
||
_nt=$(printf '%s' "$_l" | sed -n 's/.* note=//p')
|
||
printf '| %s | %s | %s | %s |\n' "$_s" "$_t" "$_c" "$_nt"
|
||
done
|
||
|
||
printf '\n#### 本輪新事件\n\n'
|
||
if [ "$EV_NEW" -gt 0 ]; then
|
||
printf '| 事件 | 本輪發生次數 | 累計次數 | 哪裡看出來的 |\n'
|
||
printf '| --- | ---: | ---: | --- |\n'
|
||
printf '%s\n' "$EV_NAMES" | while IFS= read -r _e; do
|
||
[ -n "$_e" ] || continue
|
||
_c=$(awk -F'\t' -v e="$_e" '$1 == e { n++ } END { printf "%d", n + 0 }' "$FIRED" 2>/dev/null)
|
||
_tt=$(seen_get "$_e" | awk '{ print $1 }')
|
||
_w=$(awk -F'\t' -v e="$_e" '$1 == e { print $2; exit }' "$FIRED" 2>/dev/null)
|
||
printf '| `%s` | %s | %s | %s |\n' "$_e" "$_c" "$_tt" "$_w"
|
||
done
|
||
else
|
||
printf '本輪沒有新事件。\n'
|
||
fi
|
||
|
||
printf '\n#### 逾期\n\n'
|
||
if [ "$T_OVERDUE" -gt 0 ]; then
|
||
printf '**這幾筆的截止時間過了。逾期每一輪都提,不因為提過就不再提**——助理只提醒,不代為執行,也不自己把它移出這一節。\n\n'
|
||
printf '| id | state | 截止時間 | 逾期多久 | 標題 |\n'
|
||
printf '| --- | --- | --- | ---: | --- |\n'
|
||
printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do
|
||
[ -n "$_oi" ] || continue
|
||
printf '| `%s` | %s | %s | %s | %s |\n' "$_oi" "$_os" "$(cell "$_od")" "$(dur_human "$_oa")" "$(cell "${_ot:--}")"
|
||
done
|
||
printf '\n被暫停的那幾筆也算在裡面:截止時間過了這件事不會因為有人按了暫停就消失,而一筆被暫停又逾期的交辦正好是最容易被忘掉的那一種。\n'
|
||
else
|
||
printf '沒有逾期的待辦。`due` 欄是空的那幾筆沒有截止時間,永遠不會逾期。\n'
|
||
fi
|
||
if [ "$T_DUEBAD" -gt 0 ]; then
|
||
printf '\n有 %s 筆的 `due` 欄解不開,上表 `overdue` 那一欄是 `bad`。**一個解不開的截止時間跟沒有截止時間,在畫面上長得一模一樣**,而前者是登錄的時候就填錯了:重新登錄一筆,`id` 由建立時間與標題算出來,改欄位值算不回同一個 id。\n' "$T_DUEBAD"
|
||
fi
|
||
|
||
printf '\n#### 逐筆判定\n\n'
|
||
if [ -s "$ROWS" ]; then
|
||
printf '| id | 判定 | 逾期 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n'
|
||
printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n'
|
||
# 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會
|
||
# 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄
|
||
# 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。
|
||
awk -F'\t' '
|
||
function d(s) { return s == "" ? "-" : s }
|
||
function esc(s) { gsub(/\|/, "\\|", s); return s }
|
||
NF >= 12 && $1 != "" {
|
||
printf "| `%s` | %s | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \
|
||
$1, $2, d($12), esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10))
|
||
}' "$ROWS"
|
||
else
|
||
printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n'
|
||
fi
|
||
|
||
if [ "$T_UNWIRED" -gt 0 ] || [ "$T_INVALID" -gt 0 ]; then
|
||
printf '\n#### 判不了的那幾筆\n\n'
|
||
printf '上表 `unwired` 與 `invalid` 那幾筆**現在判不出到期,不是還沒發生**。不寫這一段的話,它們在頁面上跟一筆正常等著的待辦長得一模一樣。\n'
|
||
printf -- '- `analyze-completed:{HASH}` 的狀態來源在 wiki 的分析頁上,要連網才判得出來,還沒接線。\n'
|
||
printf -- '- `cron:{式子}` 還沒接線,重複的項目請改用 `every:{間隔}`。\n'
|
||
printf -- '- 欄位不合法的那幾筆要重新登錄一筆:`id` 由建立時間與標題算出來,改欄位值算不回同一個 id。\n'
|
||
fi
|
||
} >"$MD"
|
||
return 0
|
||
}
|
||
|
||
cmd_scan() {
|
||
_dry=0
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--out) [ "$#" -ge 2 ] || usage; OUT="$2"; shift 2 ;;
|
||
--now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;;
|
||
--dry-run) _dry=1; shift ;;
|
||
*) usage ;;
|
||
esac
|
||
done
|
||
resolve_now
|
||
[ -n "$OUT" ] || OUT="$STATE_DIR/due"
|
||
mkdir -p "$OUT" 2>/dev/null || die 5 "建不出輸出目錄 $OUT。"
|
||
ROWS="$OUT/rows.txt"
|
||
MD="$OUT/due.md"
|
||
|
||
if [ "$_dry" -eq 1 ]; then
|
||
detect 0
|
||
else
|
||
detect 1
|
||
fi
|
||
scan_tasks
|
||
compose_md
|
||
|
||
printf 'now=%s\n' "$(epoch_to_iso "$NOW")"
|
||
if [ "$_dry" -eq 1 ]; then
|
||
printf 'snapshot=preview\n'
|
||
elif [ "$RC_SNAP" -ne 0 ]; then
|
||
printf 'snapshot=failed\n'
|
||
elif [ "$FIRST_RUN" -eq 1 ]; then
|
||
printf 'snapshot=created\n'
|
||
else
|
||
printf 'snapshot=advanced\n'
|
||
fi
|
||
print_events
|
||
# stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這
|
||
# 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。
|
||
if [ -s "$ROWS" ]; then
|
||
awk -F'\t' 'NF >= 12 && $1 != "" {
|
||
printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s overdue=%s why=%s title=%s\n", \
|
||
$1, $2, $3, $4, $5, $8, $9, $6, $7, $12, $10, $11
|
||
}' "$ROWS"
|
||
fi
|
||
printf 'tasks_total=%s\n' "$T_TOTAL"
|
||
printf 'tasks_due=%s\n' "$T_DUE"
|
||
printf 'tasks_wait=%s\n' "$T_WAIT"
|
||
printf 'tasks_invalid=%s\n' "$T_INVALID"
|
||
printf 'tasks_unwired=%s\n' "$T_UNWIRED"
|
||
printf 'tasks_skipped=%s\n' "$T_SKIP"
|
||
printf 'tasks_overdue=%s\n' "$T_OVERDUE"
|
||
printf 'tasks_due_bad=%s\n' "$T_DUEBAD"
|
||
# 逾期的那幾筆逐筆點名,不只印一個數字。一份只有數字的回報讀不出「是哪一筆過期了」,
|
||
# 而那正是唯一有用的資訊;逾期的處置一律是人接手,人要知道接哪一筆。
|
||
if [ -n "$OVERDUE_LINES" ]; then
|
||
printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do
|
||
[ -n "$_oi" ] || continue
|
||
printf 'overdue_row=%s state=%s due=%s overdue_secs=%s human=%s title=%s\n' \
|
||
"$_oi" "$_os" "$_od" "$_oa" "$(dur_human "$_oa")" "$_ot"
|
||
done
|
||
fi
|
||
printf 'rows_file=%s\n' "$ROWS"
|
||
printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title overdue\n'
|
||
printf 'due_file=%s\n' "$MD"
|
||
printf 'prev_file=%s\n' "$PREV"
|
||
printf 'seen_file=%s\n' "$SEEN"
|
||
cleanup_tmp
|
||
finish_rc
|
||
}
|
||
|
||
# 只判逾期,一律唯讀。
|
||
#
|
||
# 為什麼另開一個子命令:逾期判定跟事件與到期判定不同,它一個狀態都不必比、一份快照都不必推進
|
||
# ——只要把每一筆的 due 欄跟現在比一下。狀態查詢那一路每次都想知道「有沒有逾期的」,走 scan
|
||
# 就會推進快照,同一輪之後的事件判定會全部落空;走 events 又判不到逾期。
|
||
# 讓呼叫端自己拿 due 欄去比更糟:那就是第二套判定,跟這一支的規則會漂移,而漂移的那一天
|
||
# 兩邊都說自己是對的。
|
||
cmd_overdue() {
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;;
|
||
*) usage ;;
|
||
esac
|
||
done
|
||
resolve_now
|
||
T_TOTAL=0; T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
|
||
if [ ! -d "$TASKS_DIR" ]; then
|
||
RC_EMPTY=4
|
||
note "待辦簿目錄不存在($TASKS_DIR),這一輪沒有任何一筆可判。「沒判過」不等於「都沒逾期」。"
|
||
else
|
||
for _f in "$TASKS_DIR"/*; do
|
||
[ -f "$_f" ] || continue
|
||
T_TOTAL=$(( T_TOTAL + 1 ))
|
||
_id=$(kv_get "$_f" id); [ -n "$_id" ] || _id=$(basename "$_f")
|
||
_st=$(kv_get "$_f" state); [ -n "$_st" ] || _st=pending
|
||
_ti=$(kv_get "$_f" title)
|
||
_du=$(kv_get "$_f" due)
|
||
[ "$_st" = done ] && continue
|
||
_ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; }
|
||
case "$_ov" in
|
||
''|*[!0-9]*) continue ;;
|
||
esac
|
||
T_OVERDUE=$(( T_OVERDUE + 1 ))
|
||
printf 'overdue_row=%s state=%s due=%s overdue_secs=%s human=%s title=%s\n' \
|
||
"$_id" "$_st" "$_du" "$_ov" "$(dur_human "$_ov")" "$_ti"
|
||
done
|
||
[ "$T_TOTAL" -eq 0 ] && { RC_EMPTY=4; note "待辦簿目錄在($TASKS_DIR),但零筆。"; }
|
||
fi
|
||
printf 'now=%s\n' "$(epoch_to_iso "$NOW")"
|
||
printf 'tasks_total=%s\n' "$T_TOTAL"
|
||
printf 'tasks_overdue=%s\n' "$T_OVERDUE"
|
||
printf 'tasks_due_bad=%s\n' "$T_DUEBAD"
|
||
note '這是唯讀判定:一份快照都沒推進,一筆待辦都沒改。逾期的處置一律是人接手。'
|
||
cleanup_tmp
|
||
finish_rc
|
||
}
|
||
|
||
cmd_events() {
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;;
|
||
*) usage ;;
|
||
esac
|
||
done
|
||
resolve_now
|
||
# 一律唯讀。events 也推進快照的話,同一輪先叫 events 再叫 scan 就會把那一批事件吃掉,而
|
||
# scan 那一次會回報零事件——看起來完全正常,實際上那一批事件永遠不會觸發任何一筆待辦。
|
||
detect 0
|
||
printf 'now=%s\n' "$(epoch_to_iso "$NOW")"
|
||
printf 'snapshot=preview\n'
|
||
print_events
|
||
note '這是唯讀預覽:快照與事件計數一個都沒動,所以同一批事件下一次 scan 還會再判出來一次。'
|
||
cleanup_tmp
|
||
finish_rc
|
||
}
|
||
|
||
cmd_next() {
|
||
_tg=''; _rc=''; _cr=''; _lr=''; _fc=0
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--trigger) [ "$#" -ge 2 ] || usage; _tg="$2"; shift 2 ;;
|
||
--recur) [ "$#" -ge 2 ] || usage; _rc="$2"; shift 2 ;;
|
||
--created) [ "$#" -ge 2 ] || usage; _cr="$2"; shift 2 ;;
|
||
--last-run) [ "$#" -ge 2 ] || usage; _lr="$2"; shift 2 ;;
|
||
--fail-count) [ "$#" -ge 2 ] || usage; _fc="$2"; shift 2 ;;
|
||
--now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;;
|
||
*) usage ;;
|
||
esac
|
||
done
|
||
[ -n "$_tg" ] || usage
|
||
[ -n "$_rc" ] || usage
|
||
resolve_now
|
||
case "$_fc" in ''|*[!0-9]*) die 2 "--fail-count「$_fc」不是非負整數。" ;; esac
|
||
# created 沒給就退回當下:next 是純算,沒有待辦檔可讀。退回這件事要講一聲——at:now 與事件型
|
||
# 的基準線都取 created,退回當下算出來的值跟真正那一筆算出來的不一樣。
|
||
[ -n "$_cr" ] || { _cr=$(epoch_to_iso "$NOW"); note "沒給 --created,這一次退回當下($_cr)。at:now 的排定點與事件型的基準線都取 created,要算得跟待辦簿裡那一筆一樣就把它帶上。"; }
|
||
judge "$_tg" "$_rc" "$_cr" "$_lr" "$_fc"
|
||
printf 'now=%s\n' "$(epoch_to_iso "$NOW")"
|
||
printf 'verdict=%s\n' "$R_VERDICT"
|
||
printf 'next_run=%s\n' "$R_NEXT"
|
||
printf 'rearm_at=%s\n' "$R_REARM"
|
||
printf 'why=%s\n' "$R_WHY"
|
||
case "$R_VERDICT" in
|
||
invalid) exit 2 ;;
|
||
esac
|
||
exit 0
|
||
}
|
||
|
||
cleanup_tmp() {
|
||
rm -f "$STATE_DIR/.due.$$.next" "$STATE_DIR/.due.$$.fired" "$STATE_DIR/.due.$$.seen" 2>/dev/null
|
||
return 0
|
||
}
|
||
|
||
# 結束碼收斂。順序見檔頭:5、3、2、1、4。
|
||
finish_rc() {
|
||
[ "$RC_FS" -ne 0 ] && exit "$RC_FS"
|
||
[ "$RC_SNAP" -ne 0 ] && exit "$RC_SNAP"
|
||
[ "$RC_FIELD" -ne 0 ] && exit "$RC_FIELD"
|
||
[ "$RC_SOURCE" -ne 0 ] && exit "$RC_SOURCE"
|
||
[ "$RC_EMPTY" -ne 0 ] && exit "$RC_EMPTY"
|
||
exit 0
|
||
}
|
||
|
||
resolve_now() {
|
||
if [ -n "$NOW_ARG" ]; then
|
||
case "$NOW_ARG" in ''|*[!0-9]*) die 2 "--now「$NOW_ARG」不是非負整數的 epoch 秒。" ;; esac
|
||
NOW="$NOW_ARG"
|
||
else
|
||
NOW=$(date -u +%s)
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
# --- 主流程 ---
|
||
|
||
NOW=0
|
||
NOW_ARG=''
|
||
OUT=''
|
||
ROWS=''
|
||
MD=''
|
||
FIRST_RUN=0
|
||
EV_NAMES=''
|
||
EV_NEW=0
|
||
T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0
|
||
T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
|
||
|
||
CMD="${1:-}"
|
||
[ -n "$CMD" ] || usage
|
||
shift
|
||
case "$CMD" in
|
||
scan) cmd_scan "$@" ;;
|
||
events) cmd_events "$@" ;;
|
||
overdue) cmd_overdue "$@" ;;
|
||
next) cmd_next "$@" ;;
|
||
*) usage ;;
|
||
esac
|