Files
jiantw83andClaude Opus 5 932bfa826a feat(assistant): 一輪算完就寫提醒佇列
助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只留在監控頁上。這一批讓
每一輪把「要送到人面前」的那幾筆寫成一份佇列檔,位置固定在助理狀態目錄
底下,讀的那一端只印、不判。

判定不放在讀的那一邊,理由是漂移:讓讀的人自己拿 due 欄與 next_run 去跟
現在比,就是第二套到期判定,跟這裡那一套遲早對不上,而對不上的那一天
兩邊都說自己是對的。

這樣換來一個新的失效模式,所以檔頭寫足四個值:輪次代號、UTC 時間戳、
連續失敗筆數、待辦總筆數,再加同一刻的 epoch 秒。助理沒在跑的時候佇列
不會更新,而一份舊佇列讀起來跟新的一模一樣——讀的那一端要算得出它多舊,
「沒有提醒」與「沒有人算提醒」不可以長得一樣。epoch 是為了讓讀的那一端
不必自己解 ISO 字串:那等於在每一個讀取端各放一份日期解析。

同一筆待辦既到期又逾期時只列逾期那一行。同一批提醒裡出現兩行會被讀成
兩件事,而「逾期五天」比「排定點過了」講得更清楚。

逾期那幾行的「逾期多久」直接取判定那一支算好的寫法,不在這裡再寫一個
時間長度格式化——同一個秒數在兩個地方就會印出兩種說法。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:16:57 +08:00

1302 lines
62 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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