#!/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