#!/usr/bin/env sh # patrol.sh — 助理巡檢一輪的收攏與收口(供 jsc-assist:assistant 的 patrol 操作呼叫)。 # # 用法: # patrol.sh collect [--out {目錄}] [--trigger {排程|事件|手動}] # patrol.sh finish --round {輪次代號} [--out {目錄}] [--dry-run] # patrol.sh abort --round {輪次代號} [--out {目錄}] # # collect 帶了 --out,finish 與 abort 就要帶同一個目錄,不然換不到本輪的用量快照。 # # collect 讀各項來源、組出監控頁那三塊、把鎖拿在手上。 # finish 在監控頁寫成功之後才呼叫:寫心跳、換上用量快照、放掉鎖。 # abort 在監控頁沒寫成時呼叫:只放掉鎖,不寫心跳。 # # 結束碼(三個子命令共用一張表,同一碼在不同子命令的成因寫在同一列): # 0 collect:各項全部讀到底(含「來源在、沒有資料」);finish:心跳寫好、快照換上、 # 鎖放掉;abort:鎖放掉,本來就沒鎖也算 # 1 collect:部分成功——至少一項失敗,也至少一項有結果。**結果照樣印得出來,呼叫端 # 照樣要把這一輪寫上監控頁**,只是本輪判定要標成警示 # 2 finish:找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可寫。collect 不會回這 # 一碼——心跳讀不到只是 D-09 這一項失敗,其餘各項照跑 # 3 collect:各項全部失敗,一項資料都沒有。這一輪還是要寫上監控頁,本輪判定標成異常 # 4 上一輪還在跑,本輪讓開(collect),或鎖已經不在自己手上(finish、abort)。這不是 # 失敗,是刻意讓開:不寫心跳、不寫監控頁,下一輪再來 # 5 檔案系統失敗:鎖建不起來或放不掉、暫存檔寫不進去、快照換不上,或 heartbeat.sh write # 回非 0。心跳沒寫成就是沒寫成,一律吵出來 # 6 用法錯誤:不認得的子命令、缺 --round、參數缺值 # # --- 心跳為什麼由這裡寫,不由排程直接寫 --- # # 排程每分鐘直接呼叫 heartbeat.sh write 的話,心跳新鮮只證明 cron 活著。巡檢整個壞掉、 # 一項資料都讀不到、監控頁一頁都沒寫成,心跳照樣新鮮,靠心跳判定的閘門照樣放行,沒有 # 任何訊號。所以心跳改由巡檢寫:跑完一輪、而且結果真的記下來了,才寫那一次心跳。 # # --- 心跳寫不寫,只看結果有沒有記下來 --- # # 各項的成敗不決定心跳。各項全失敗但監控頁寫成了,那一輪還是跑完了,證據也留下來了, # 心跳照寫,頁上判定是異常,看頁的人自己判斷。反過來,監控頁沒寫成就是這一輪沒有結果, # 心跳一定不寫:讓它自己過期,就是「巡檢在空轉」的唯一訊號。 # 所以寫心跳一定是獨立的 finish,時序上排在監控頁寫成之後,不與 collect 綁在一起。 # # --- 上一輪還沒跑完,下一輪被叫起來 --- # # 一輪巡檢包含一次 CLI 呼叫與兩次 wiki 寫入,跑過一個排程週期是有可能的。所以整輪拿一把 # 鎖:$JSC_HOME/assistant/patrol.lock 是目錄,mkdir 是原子操作,搶不到就是別人在跑。 # 搶不到的那一輪回 4 直接讓開,不排隊、不並行——並行的兩輪會互相蓋掉監控頁與用量快照。 # 鎖會逾時自動搶回來,門檻取心跳門檻(heartbeat.sh report 的 ttl 欄):上一輪跑得比門檻 # 還久,它本來就已經維持不住心跳新鮮了,讓新的一輪接手才對。被搶回來的那一輪,finish 會 # 拿 --round 比對出鎖不是自己的,回 4 且不寫心跳。 # 搶回來這件事會記在監控頁上(lock_broken=1),不會安靜發生。 # # --- 這幾項都是純讀取 --- # # D-01 技能與呼叫鏈使用統計 jsc-log 的 tools/usage-stats.sh # D-04 版本落差與重啟閘門 jsc-hooks 的 version-guard.sh report、restart-gate.sh report # D-07 SDLC 階段鎖與工作包鎖 $JSC_HOME/sessions/*.stage、$JSC_HOME/wp/*.pr # D-09 心跳與閘門狀態自述 jsc-hooks 的 heartbeat.sh report # D-11 執行狀態事件 jsc-hooks 的 tools/report-status.sh drain # 各項各自獨立:一項的來源不見了、或回非 0,只讓那一項標成失敗,其餘各項照跑、照記。 # 各項都不呼叫別的技能、不寫程式碼存取庫、不做決策。 # D-11 是唯一會動到別人狀態的一項:drain 會把事件流的位移往前推。理由與配套見下面 # 「執行狀態事件為什麼由這支排空」。 # # 待辦簿那一節不在這五項裡面:它由 tools/due.sh 的 scan 組好,這支只叫它一次再把那一塊接進頁面。 # 它也會動自己的狀態——推進待辦簿的事件快照,性質同 drain:同一輪叫第二次就比不出東西了。 # 那一項的成敗不算進上面五項的成敗,理由寫在 due_scan() 那一段。 # # --- version-guard.sh report 的既有缺陷照實記 --- # # 這支腳本在部分機器上對每一個 domain 都回「查詢失敗」,recommend 跟著回 unverifiable。 # 巡檢照抄第四欄原字,不自己補查遠端版本、不把「查詢失敗」寫成「相符」或「最新」。 # 查不到就是沒有證據,寫成別的字等於幫一個既有缺陷蓋章。 # # --- 監控頁固定三塊 --- # # 監控頁不再一輪附加一節。15 分鐘一輪,一天就是 96 節,那樣的頁沒有人讀得完,也就沒有人 # 會發現壞掉。改成固定三塊: # 本頁基本資料 建頁時寫一次,之後一個字都不動 # 最新一輪 每輪整塊換掉,只留最新那一輪的完整內容 # 近 24 輪摘要 一輪一列,最新的在最上面,超過 24 列就丟掉最舊的 # 軌跡留在摘要表:一輪一列,看得出是從哪一輪開始壞的。完整內容只留最新一輪,因為頁面要能 # 讀完才有人讀。 # # --- 目錄頁與內容頁分屬兩個存取庫 --- # # 內容頁 MONITOR_{HASH} 住 MONITOR 那個存取庫,目錄頁 MONITOR_CONTENTS 住 CONTENTS 那個 # 專用存取庫,兩條解析鏈不互相退讓。連結一律寫成 [{文字}]({絕對網址}),網址取 # gitea.sh wiki-url 印的那一個,不自己組路徑;wiki 自己那種雙中括號寫法只在同一個 wiki 裡 # 解得開,寫錯不會報錯,畫面上看起來像正常文字或死連結,巡不到也修不了。 # 絕對網址要等內容頁真的寫進去才查得到(gitea.sh wiki-url 讀的是 API 回的 html_url), # 而 collect 跑在寫入之前,這裡查不到。所以這支只把區塊組好、網址留佔位,換字交給呼叫端。 # # --- 連結先驗證連得到,才可以寫進頁面 --- # # 這支腳本一頁都不寫:它只組出檔案,兩次 wiki 寫入都在呼叫端。所以驗證的時機也在呼叫端—— # 換掉佔位、拿到真網址之後,寫入之前,把要放進頁面的每一個連結交給 jsc-gitea 的 # tools/link-check.sh,結束碼 0 才寫。有 DEAD 就不寫那一頁或那一個區塊,把連不到的清單回報出去。 # 驗證一律走 API,不看網頁狀態碼:私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會 # 把好連結判成壞的。金鑰失效(結束碼 7)要與「連不到」(結束碼 1)分開看,兩者混用,一次金鑰 # 過期就會把整批還在的頁判成死連結。 # # --- 目錄頁的比對鍵是 H2 標題,不是那個連結 --- # # 目錄頁一台機器一個 H2 區塊,標題寫成內容頁頁名 MONITOR_{HASH},upsert 就拿那個標題當鍵。 # wiki-contents.sh 比對的是標題文字,拿含網址的連結當鍵太脆:GITEA_HOST 換掉、 # JSC_WIKI_REPO_MONITOR 換過存取庫、Gitea 對頁名的網址編碼有差,那一條文字就變了,鍵對不上就走 # 附加那一支,同一台機器多出第二個區塊,舊區塊從此不再更新。這一頁每 15 分鐘寫一次,重複區塊 # 累積得很快。頁名只由 {主機名}/{登入帳號} 決定,上面三件事都動不到它。 # 裸 HASH 照樣在區塊裡留一條:標題是 MONITOR_ 加上它,那一條讓人不必從標題切字串就抄得到。 # # --- 執行狀態事件為什麼由這支排空 --- # # 事件流記的是每一支技能與每一支 hook 的執行結果。技能的 start 由 hook 免費記下,end 只能由 # 技能自己在收尾時寫,所以「有 start 沒有配對的 end」就是那一輪中止了——那是這整套機制唯一 # 分得出中止的訊號,也是這一項最重要的產出。 # # 排空與彙整放在這支,不放在技能本文,有三個理由: # 1. drain 是消耗性讀取:它一讀完就把位移往前推,同一批事件不會再出現第二次。讀回來的內容 # 只存在對話裡的話,模型少抄一行就是那一批事件永遠消失。寫成檔案才留得住。 # 2. 一輪的事件動輒上百行 JSON,逐行判 status 與配對 start/end 交給模型做,既慢又會出錯。 # 3. rotate 一定要緊接在 drain 後面跑。中間隔得越久,那段時間新寫進來的事件被搬進備份檔 # 而從此不會被排空的機率越高。兩件事綁在同一支腳本的同一次執行,那個空窗才最小。 # # 配對以 {session}+{name} 為鍵,不只看 name:五支 CLI 併發時同一支技能會有好幾個工作階段同時 # 在跑,只看 name 會讓 A 工作階段的 end 去配掉 B 工作階段的 start,中止就被蓋掉了。 # # 沒配對到的 start 會留在 $JSC_HOME/assistant/events-open.tsv,跨輪繼續配對。不留的話,一支 # 跑超過一個巡檢週期的技能每一輪都會被報成中止——巡檢週期預設兩分鐘,那種誤報會多到沒人看。 # 開著超過心跳門檻才算「疑似中止」,門檻以內的算「進行中」,兩種分開列。超過一天還沒配對到的 # 就從檔案裡丟掉,那個檔案才不會無止境長大。 # # 這一項失敗(找不到腳本、drain 回非預期結束碼、rotate 失敗)一律只讓這一項標成失敗或記一筆 # 警示,不中止整輪:回報鏈自己壞掉,不可以把被回報的那一輪也拖下去。 # # --- collect 的輸出 --- # # stdout 是 key=value,一行一個鍵,供呼叫端逐行取值。監控頁要用的 markdown 不印在 # stdout,改寫成檔案再把路徑印出來:那一段有好幾百字,經過對話重打一次只會多錯字。 # round= 本輪代號,finish 與 abort 要原樣帶回 # lock= acquired # lock_broken= 0 或 1。1 代表上一輪的鎖逾時被搶回來 # hash= 監控頁雜湊,來源是 {主機名}/{登入帳號};算不出來時為空 # page= MONITOR_{HASH};hash 為空時為空 # host= user= at= 主機名、登入帳號、本輪時間 # item= 一項一行,欄位 status(ok、empty、fail)、rc、note # verdict= 正常、警示、異常 # failed_sources= 讀不到的來源路徑,以「、」分隔;全部讀得到就是「無」 # warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示 # 時,原因只寫在這裡 # tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用 # tasks_overdue= 逾期筆數(截止時間已經過了),取自到期判定那一支;判不出來時是減號 # reminders= reminders_builtin= reminders_file= 提醒佇列逐筆點名的筆數、只算總數的內建項 # 筆數,與佇列路徑。工作階段開始那一支 hook 讀它 # tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空 # events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空 # due_status= due_rc= 到期判定那一支的結果與結束碼 # due_file= 到期判定那一支組好的「待辦簿到期與逾期」那一節,已經接進 latest_file # pending= 本輪待人處理的筆數 # events_total= 本輪排空到的事件筆數 # events_bad= 其中 status 不是 ok 的筆數 # events_unpaired= 有 start 沒有配對 end、而且已經開超過心跳門檻的筆數(疑似中止) # events_running= 有 start 沒有配對 end,但還在門檻以內的筆數(還在跑) # events_rotated= rotated、not-needed、failed、skipped 四選一 # events_file= 本輪排空到的原始事件,一行一筆 JSON。drain 是消耗性讀取,這個檔案是 # 那一批事件在被彙整之外唯一留下的完整原文 # latest_file= 「最新一輪」那一塊,整塊換掉舊頁同名那一塊 # summary_file= 「近 24 輪摘要」那一塊,表格裡先放本輪這一列,舊頁的資料列接在下面 # summary_row_file= 只有本輪那一列,方便直接插到既有表格最上面 # newpage_file= MONITOR_{HASH} 不存在時要建的整頁內容,三塊都已經排好 # contents_file= 目錄頁上本機那一個 H2 區塊,整塊 markdown:`## {頁名}` 那一行、 # 空行,然後各條 `- {欄位名}:{值}`。「監控頁」那一條是 [{頁名}]({絕對網址}) # 這種連結,網址的位置留 {監控頁絕對網址} 佔位,由呼叫端換掉、驗過再寫,理由見 # 下一段。upsert 拿 H2 標題當鍵,理由見再下一段 # # 環境變數: # JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc # JSC_ASSIST_HEARTBEAT_SH heartbeat.sh 路徑覆寫;找不到並排存取庫時才設 # JSC_ASSIST_VERSION_GUARD_SH version-guard.sh 路徑覆寫 # JSC_ASSIST_RESTART_GATE_SH restart-gate.sh 路徑覆寫 # JSC_ASSIST_USAGE_STATS_SH usage-stats.sh 路徑覆寫 # JSC_ASSIST_HASH_ID hash-id 路徑覆寫 # JSC_ASSIST_REPORT_STATUS_SH report-status.sh 路徑覆寫 # JSC_ASSIST_PATROL_LOCK_TTL 鎖的逾時秒數;未設定時取心跳門檻,取不到就用 300 set -u JSC_HOME="${JSC_HOME:-$HOME/.jsc}" STATE_DIR="$JSC_HOME/assistant" CURRENT="$JSC_HOME/current" LOCK="$STATE_DIR/patrol.lock" PREV_SNAP="$STATE_DIR/usage-prev.tsv" # 還沒配對到 end 的 start,跨輪留在這裡。放 $JSC_HOME/assistant 而不放 $RD:$RD 每一輪重寫, # 放那裡就等於不跨輪,跑超過一個週期的技能每輪都會被報成中止。 EVENTS_OPEN="$STATE_DIR/events-open.tsv" RD="$STATE_DIR/patrol" SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) SCRIPT_DIR="${SCRIPT_DIR:-.}" TRIGGER='' ROUND='' DRYRUN=0 LOCK_BROKEN=0 FAILED_SOURCES='' WARN_SOURCES='' OK_COUNT=0 FAIL_COUNT=0 WARN=0 DUE_STATUS=fail DUE_RC=0 DUE_MD='' DUE_ROWS='' DUE_NOTE='' DUE_TASKS='' DUE_EVENTS='' DUE_OVERDUE='' FAILING_LINES='' REMINDERS=0 REMINDERS_BUILTIN=0 REMINDERS_FILE='' # 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。 # 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉; # 真正的失敗會發生在權限閘門那裡——閘門只放行 current 那一組確切路徑,用別的路徑那一輪會 # 被靜靜擋掉、失敗,而且不會寫心跳,外面只看得到心跳過期。這裡先把話講在前面。 warn_if_not_current() { _self="$SCRIPT_DIR/$(basename -- "$0")" _want="$CURRENT/jsc-assist/tools/$(basename -- "$0")" case "$SCRIPT_DIR/" in "$CURRENT"/*) return 0 ;; esac printf '[jsc][助理巡檢][WARN]:這支腳本是從 %s 跑起來的,不是 %s。權限閘門只放行 current 那一組確切路徑:排程那一輪用別的路徑會被靜靜擋掉,那一輪失敗、不寫心跳,外面只看得到心跳過期。開發時這樣跑沒關係,無人值守那一輪一律走 current。\n' \ "$_self" "$_want" >&2 return 0 } warn_if_not_current # 記一個警示來源。每一處把 WARN 設成 1 的地方都經過這裡,摘要表那一欄才看得出警示哪來—— # 各項全過卻判成警示,光看成敗欄是查不出原因的。 add_warn() { # $1=一句話講完的理由 WARN=1 if [ -z "$WARN_SOURCES" ]; then WARN_SOURCES="$1"; else WARN_SOURCES="$WARN_SOURCES、$1"; fi } usage() { cat >&2 <<'EOF' usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動] patrol.sh finish --round 輪次代號 [--out 目錄] [--dry-run] patrol.sh abort --round 輪次代號 [--out 目錄] patrol.sh rebuild --old 舊頁檔 [--out 目錄] EOF exit 6 } die() { # $1=結束碼 $2=訊息 printf '[jsc][助理巡檢][ERR]:%s\n' "$2" >&2 exit "$1" } # 找一支別的 domain 的腳本。搜尋順序比照 schedule.sh 的 heartbeat_sh():先環境變數覆寫, # 再 $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取 # 版面。current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑 # 另一個版本,同一輪就會跑到混版的工具,而那種不一致查起來沒有任何線索。 find_tool() { # $1=domain 短名 $2=domain 內相對路徑 $3=環境變數覆寫值(可為空) if [ -n "$3" ]; then [ -f "$3" ] && { printf '%s\n' "$3"; return 0; } return 1 fi for _c in "$CURRENT/jsc-$1/$2" "$CURRENT/$1/$2"; do [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } done _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" for _c in "$_root/../$1/$2" "$_root/../jsc-$1/$2"; do [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } done _c=$(ls -d "$_root"/../../jsc-"$1"/*/"$2" \ "$_root"/../../"$1"/*/"$2" \ "$HOME"/.claude/plugins/cache/*/jsc-"$1"/*/"$2" 2>/dev/null | sort | tail -n1) [ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } return 1 } fmt_ts() { # $1=epoch 秒數;轉成當地時間字串,轉不動就原樣印 date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null && return 0 date -r "$1" '+%Y-%m-%d %H:%M' 2>/dev/null && return 0 printf '%s\n' "$1" } mtime_of() { # $1=檔案;印出修改時間,取不到印「-」 _t=$(stat -c %Y "$1" 2>/dev/null) || _t='' [ -n "$_t" ] || _t=$(stat -f %m "$1" 2>/dev/null) || _t='' [ -n "$_t" ] || { printf '-'; return 0; } fmt_ts "$_t" | tr -d '\n' } # markdown 表格欄位裡的 `|` 會把欄切開,一律跳脫;換行壓成空白。 cell() { printf '%s' "$1" | tr '\n' ' ' | sed 's/|/\\|/g'; } # 條列一條的值。換行一律壓成空白:一條 bullet 裡的換行會被讀成另一條,或者讓區塊提早結束。 # `|` 在條列裡沒有特殊意義,所以不跳脫——跳脫過的 `\|` 反而會原樣顯示在頁面上。 oneline() { printf '%s' "$1" | tr '\n' ' '; } # 記一個讀不到的來源。同一輪多項失敗就串起來,供監控頁「讀不到的來源」那一列用。 add_failed_source() { # $1=路徑或來源名稱 if [ -z "$FAILED_SOURCES" ]; then FAILED_SOURCES="$1"; else FAILED_SOURCES="$FAILED_SOURCES、$1"; fi } # 記一列待人處理。助理只提醒,不代為執行。 add_pending() { # $1=要處理什麼 $2=來源子節 $3=建議入口 printf '| %s | %s | %s |\n' "$(cell "$1")" "$(cell "$2")" "$(cell "$3")" >>"$RD/pend.md" } # --- 鎖 --- lock_ttl() { # 鎖的逾時秒數 _t="${JSC_ASSIST_PATROL_LOCK_TTL:-}" case "$_t" in ''|*[!0-9]*) _t='' ;; esac [ -n "$_t" ] && { printf '%s' "$_t"; return 0; } [ -n "${HEARTBEAT_TTL:-}" ] && { printf '%s' "$HEARTBEAT_TTL"; return 0; } printf '300' } lock_round() { sed -n 's/^round=//p' "$LOCK/info" 2>/dev/null | head -n1; } lock_age() { _s=$(sed -n 's/^started=//p' "$LOCK/info" 2>/dev/null | head -n1) case "$_s" in ''|*[!0-9]*) printf '999999'; return 0 ;; esac printf '%s' "$(( $(date +%s) - _s ))" } # 搶鎖。搶到回 0,別人在跑回 4,建不起來回 5。 lock_acquire() { mkdir -p "$STATE_DIR" 2>/dev/null || die 5 "建不出助理狀態目錄 $STATE_DIR。" if mkdir "$LOCK" 2>/dev/null; then printf 'round=%s\npid=%s\nstarted=%s\n' "$ROUND" "$$" "$(date +%s)" >"$LOCK/info" 2>/dev/null \ || die 5 "鎖建起來了,卻寫不進 $LOCK/info。" return 0 fi _age=$(lock_age); _ttl=$(lock_ttl) if [ "$_age" -lt "$_ttl" ]; then printf 'lock=busy holder=%s age=%s ttl=%s\n' "$(lock_round)" "$_age" "$_ttl" die 4 "上一輪巡檢還在跑(輪次 $(lock_round),已經跑了 $_age 秒,未達 $_ttl 秒門檻),本輪讓開。" fi # 逾時搶回來。上一輪跑得比心跳門檻還久,它已經維持不住心跳新鮮了,讓新的一輪接手。 rm -rf "$LOCK" 2>/dev/null mkdir "$LOCK" 2>/dev/null || die 5 "上一輪的鎖逾時,卻搶不回來:$LOCK。" printf 'round=%s\npid=%s\nstarted=%s\n' "$ROUND" "$$" "$(date +%s)" >"$LOCK/info" 2>/dev/null \ || die 5 "鎖搶回來了,卻寫不進 $LOCK/info。" LOCK_BROKEN=1 add_warn '上一輪逾時被接手' return 0 } # 放鎖。鎖不在自己手上就回 4,絕不硬放——那會把正在跑的那一輪的鎖拆掉。 lock_release() { [ -d "$LOCK" ] || { printf 'lock=absent\n'; return 0; } _h=$(lock_round) [ "$_h" = "$ROUND" ] || die 4 "鎖不在本輪手上(鎖的輪次是 ${_h:-空值},本輪是 $ROUND),不動它,也不寫心跳。" rm -rf "$LOCK" 2>/dev/null || die 5 "鎖放不掉:$LOCK。" printf 'lock=released\n' return 0 } # --- D-01 技能與呼叫鏈使用統計 --- # 把 usage-stats.sh 的「次數名稱」轉成監控頁的列,順便算出本輪增量。 # 增量要有上一輪的累計快照才算得出來;沒有快照的第一輪一律寫「-」,不拿累計冒充本輪。 usage_rows() { # $1=類別(技能、呼叫鏈) $2=快照鍵(skill、chain) $3=資料檔 while IFS=' ' read -r _cnt _name; do [ -n "$_name" ] || continue printf '%s\t%s\t%s\n' "$2" "$_name" "$_cnt" >>"$RD/usage-next.tsv" _prev='' [ -f "$PREV_SNAP" ] && _prev=$(awk -F'\t' -v k="$2" -v n="$_name" \ '$1 == k && $2 == n { print $3; exit }' "$PREV_SNAP" 2>/dev/null) if [ -n "$_prev" ]; then _delta=$(( _cnt - _prev )); else _delta='-'; fi printf '| %s | %s | %s | %s |\n' "$(cell "$_name")" "$1" "$_delta" "$_cnt" done <"$3" } d01() { D01_STATUS=fail; D01_RC=0; D01_NOTE='' : >"$RD/usage-next.tsv" { printf '### 技能與呼叫鏈使用統計\n\n' printf '資料出自 `%s` 與 `%s`,由 `jsc-log` 的 `tools/usage-stats.sh` 聚合。\n\n' \ "\$JSC_HOME/usage/skills.jsonl" "\$JSC_HOME/usage/chains.jsonl" } >"$RD/d01.md" if ! _us=$(find_tool log tools/usage-stats.sh "${JSC_ASSIST_USAGE_STATS_SH:-}"); then D01_NOTE='找不到 jsc-log 的 tools/usage-stats.sh' D01_RC=127 add_failed_source 'jsc-log/tools/usage-stats.sh' printf '**這一項失敗**:%s。這一輪沒有使用統計,不是「零次」。\n' "$D01_NOTE" >>"$RD/d01.md" add_pending '技能用量讀不到,jsc-log 沒裝或版本太舊' '技能與呼叫鏈使用統計' '/jsc-cli:doctor' return 0 fi _rc1=0; _rc2=0 "$_us" skills >"$RD/d01.skills" 2>"$RD/d01.err" || _rc1=$? "$_us" chains >"$RD/d01.chains" 2>>"$RD/d01.err" || _rc2=$? if [ "$_rc1" -ne 0 ] || [ "$_rc2" -ne 0 ]; then D01_RC=$(( _rc1 > _rc2 ? _rc1 : _rc2 )) D01_NOTE="usage-stats.sh 回非 0(skills=$_rc1、chains=$_rc2)" add_failed_source "$_us" printf '**這一項失敗**:%s。訊息:%s\n' "$D01_NOTE" "$(cell "$(cat "$RD/d01.err" 2>/dev/null)")" >>"$RD/d01.md" add_pending '使用統計腳本回非 0' '技能與呼叫鏈使用統計' '/jsc-log:stats' return 0 fi { printf '| 對象 | 類別 | 本輪次數 | 累計次數 |\n' printf '| --- | --- | ---: | ---: |\n' usage_rows '技能' skill "$RD/d01.skills" usage_rows '呼叫鏈' chain "$RD/d01.chains" } >"$RD/d01.rows" _n=$(grep -c '^| ' "$RD/d01.rows" 2>/dev/null); [ -n "$_n" ] || _n=0 if [ "$_n" -le 2 ]; then D01_STATUS=empty printf '來源檔讀得到,本輪一筆用量都沒有。hook 還沒記過任何一次呼叫就是這個狀態,那是「零次」,不是故障。\n' >>"$RD/d01.md" return 0 fi D01_STATUS=ok cat "$RD/d01.rows" >>"$RD/d01.md" if [ ! -f "$PREV_SNAP" ]; then printf '\n第一輪沒有上一輪的累計快照,「本輪次數」一律寫「-」,不拿累計冒充本輪。\n' >>"$RD/d01.md" fi return 0 } # --- D-04 版本落差與重啟閘門 --- d04() { D04_STATUS=fail; D04_RC=0; D04_NOTE='' { printf '### 版本落差與重啟閘門\n\n' printf '資料出自 `version-guard.sh report` 與 `restart-gate.sh report`。\n\n' } >"$RD/d04.md" _vg=''; _rg='' find_tool hooks hooks/version-guard.sh "${JSC_ASSIST_VERSION_GUARD_SH:-}" >"$RD/vg" 2>/dev/null \ && _vg=$(cat "$RD/vg") find_tool hooks hooks/restart-gate.sh "${JSC_ASSIST_RESTART_GATE_SH:-}" >"$RD/rg" 2>/dev/null \ && _rg=$(cat "$RD/rg") if [ -z "$_vg" ] && [ -z "$_rg" ]; then D04_NOTE='找不到 jsc-hooks 的 version-guard.sh 與 restart-gate.sh' D04_RC=127 add_failed_source 'jsc-hooks/hooks/version-guard.sh、jsc-hooks/hooks/restart-gate.sh' printf '**這一項失敗**:%s。這一輪沒有版本證據,不是「版本都相符」。\n' "$D04_NOTE" >>"$RD/d04.md" add_pending '版本與重啟閘門讀不到,jsc-hooks 沒裝或版本太舊' '版本落差與重啟閘門' '/jsc-cli:doctor' return 0 fi _part=0 # 版本比對表。第四欄照 version-guard.sh 原字抄,不改寫、不補查。 if [ -n "$_vg" ]; then _rc=0 "$_vg" report >"$RD/d04.vg" 2>"$RD/d04.err" /dev/null)")" >>"$RD/d04.md" else { printf '| domain | 本機版本 | 應有版本 | 判定 |\n' printf '| --- | --- | --- | --- |\n' } >>"$RD/d04.md" _unver=0; _rows=0 while IFS=' ' read -r _c1 _c2 _c3 _c4; do [ -n "$_c1" ] || continue case "$_c1" in behind) printf '| (合計) | - | - | 落後 %s 個 |\n' "$(cell "$_c2")" >>"$RD/d04.md"; continue ;; noregistry) printf '| (無註冊檔) | - | - | 查不到本機已裝的 plugin:%s |\n' "$(cell "$_c2")" >>"$RD/d04.md" _unver=1; continue ;; esac _rows=$(( _rows + 1 )) printf '| %s | %s | %s | %s |\n' "$(cell "$_c1")" "$(cell "$_c2")" "$(cell "$_c3")" "$(cell "$_c4")" >>"$RD/d04.md" [ "$_c4" = '查詢失敗' ] && _unver=$(( _unver + 1 )) done <"$RD/d04.vg" [ "$_rows" -eq 0 ] && printf '| (無 domain) | - | - | 這台機器一個 jsc plugin 都沒查到 |\n' >>"$RD/d04.md" printf '\n判定欄照 `version-guard.sh report` 第四欄原字抄。抄到「查詢失敗」就寫「查詢失敗」,不改寫成「相符」或「最新」,也不自己補查遠端版本——查不到是沒有證據,不是版本沒問題。\n\n' >>"$RD/d04.md" if [ "$_unver" -gt 0 ]; then add_warn '版本查詢失敗' printf '**本輪有 %s 列查不到遠端版本。** 同一支腳本在擋人那條路徑查得到遠端版本,report 這條查不到,這是既有缺陷,不是這台機器的網路問題。\n\n' "$_unver" >>"$RD/d04.md" add_pending 'version-guard.sh report 查不到遠端版本,版本落差本輪無證據' '版本落差與重啟閘門' '/jsc-cli:doctor' fi fi else _part=1 add_failed_source 'jsc-hooks/hooks/version-guard.sh' printf '**版本比對失敗**:找不到 `version-guard.sh`。\n\n' >>"$RD/d04.md" fi # 重啟閘門。一行一支還沒重啟的 CLI;一行都沒有就是都放下了。 if [ -n "$_rg" ]; then _rc=0 "$_rg" report >"$RD/d04.rg" 2>>"$RD/d04.err" >"$RD/d04.md" else { printf '| CLI | 重啟閘門 | 升起時間 |\n' printf '| --- | --- | --- |\n' } >>"$RD/d04.md" _up=0 while read -r _cli _rest; do [ -n "$_cli" ] || continue _up=$(( _up + 1 )) _at=$(printf '%s' "$_rest" | sed -n 's/.*at=\([^ ]*\).*/\1/p') printf '| %s | 已升起 | %s |\n' "$(cell "$_cli")" "$(cell "${_at:--}")" >>"$RD/d04.md" add_pending "$_cli 的重啟閘門還升著,那一支要重新啟動" '版本落差與重啟閘門' '重開該支 CLI' done <"$RD/d04.rg" if [ "$_up" -eq 0 ]; then printf '| (無) | 未升起 | - |\n' >>"$RD/d04.md" else add_warn '重啟閘門未清' fi fi else _part=1 add_failed_source 'jsc-hooks/hooks/restart-gate.sh' printf '**重啟閘門讀不到**:找不到 `restart-gate.sh`。\n' >>"$RD/d04.md" fi if [ "$_part" -eq 1 ]; then D04_STATUS=fail [ -n "$D04_NOTE" ] || D04_NOTE='兩份來源有一份讀不到' else D04_STATUS=ok fi return 0 } # --- D-07 SDLC 階段鎖與工作包鎖 --- d07() { D07_STATUS=fail; D07_RC=0; D07_NOTE='' _sess="$JSC_HOME/sessions"; _wp="$JSC_HOME/wp" { printf '### SDLC 階段鎖與工作包鎖現況\n\n' printf '資料出自 `%s` 與 `%s`。只讀狀態,不做判定。\n\n' \ "\$JSC_HOME/sessions/{sid}.stage" "\$JSC_HOME/wp/*.pr" } >"$RD/d07.md" _bad=0 if [ -d "$_sess" ] && [ ! -r "$_sess" ]; then _bad=1; add_failed_source "$_sess"; fi if [ -d "$_wp" ] && [ ! -r "$_wp" ]; then _bad=1; add_failed_source "$_wp"; fi if [ "$_bad" -eq 1 ]; then D07_NOTE='狀態目錄讀不到(權限)' D07_RC=13 printf '**這一項失敗**:%s。這一輪沒有階段鎖與工作包鎖的證據,不是「沒有鎖」。\n' "$D07_NOTE" >>"$RD/d07.md" add_pending 'SDLC 狀態目錄讀不到,權限要修' 'SDLC 階段鎖與工作包鎖現況' '/jsc-cli:setup' return 0 fi { printf '| 工作階段 | 階段 | 必要標籤 | 上鎖時的模型 | 登記時間 |\n' printf '| --- | --- | --- | --- | --- |\n' } >>"$RD/d07.md" _sn=0 for _f in "$_sess"/*.stage; do [ -f "$_f" ] && [ -r "$_f" ] || continue _sn=$(( _sn + 1 )) _sid=$(basename "$_f" .stage) _stage=$(cut -f1 "$_f" 2>/dev/null | head -n1) _req=$(cut -f2 "$_f" 2>/dev/null | head -n1) _mdl=$(cut -f3 "$_f" 2>/dev/null | head -n1) printf '| %s | %s | %s | %s | %s |\n' \ "$(cell "$_sid")" "$(cell "${_stage:--}")" "$(cell "${_req:--}")" \ "$(cell "${_mdl:--}")" "$(mtime_of "$_f")" >>"$RD/d07.md" done [ "$_sn" -eq 0 ] && printf '| (無) | - | - | - | - |\n' >>"$RD/d07.md" { printf '\n| 存取庫 | 工作包 | PR | 上鎖時間 |\n' printf '| --- | --- | --- | --- |\n' } >>"$RD/d07.md" _wn=0 for _f in "$_wp"/*.pr; do [ -f "$_f" ] && [ -r "$_f" ] || continue _wn=$(( _wn + 1 )) _repo=$(sed -n 's/^repo=//p' "$_f" 2>/dev/null | head -n1) _idx=$(sed -n 's/^index=//p' "$_f" 2>/dev/null | head -n1) _wpn=$(sed -n 's/^wp=//p' "$_f" 2>/dev/null | head -n1) _lk=$(sed -n 's/^locked=//p' "$_f" 2>/dev/null | head -n1) printf '| %s | %s | %s | %s |\n' \ "$(cell "${_repo:--}")" "$(cell "${_wpn:--}")" \ "$(cell "${_repo:-?}#${_idx:-?}")" "$(cell "${_lk:--}")" >>"$RD/d07.md" done [ "$_wn" -eq 0 ] && printf '| (無) | - | - | - |\n' >>"$RD/d07.md" printf '\n`.stage` 沒有存取庫欄位,`.pr` 沒有歸屬工作階段欄位,兩欄一律留「-」,不從分支名或目錄名回推——那兩個都會被改。登記時間取檔案的修改時間。\n' >>"$RD/d07.md" if [ "$_sn" -eq 0 ] && [ "$_wn" -eq 0 ]; then D07_STATUS=empty; else D07_STATUS=ok; fi return 0 } # --- D-09 心跳與閘門狀態自述 --- d09() { D09_STATUS=fail; D09_RC=0; D09_NOTE='' HEARTBEAT_STATE='讀不到' { printf '### 心跳與閘門狀態\n\n' printf '資料出自 `heartbeat.sh report`。這一欄讀到的是**上一輪巡檢**寫的心跳:心跳改由巡檢寫,本輪那一次要等監控頁寫成之後才寫。\n\n' } >"$RD/d09.md" if ! _hb=$(find_tool hooks hooks/heartbeat.sh "${JSC_ASSIST_HEARTBEAT_SH:-}"); then D09_NOTE='找不到 jsc-hooks 的 hooks/heartbeat.sh' D09_RC=127 add_failed_source 'jsc-hooks/hooks/heartbeat.sh' printf '**這一項失敗**:%s。心跳判不出來,不代表助理沒在跑,也不代表在跑。\n' "$D09_NOTE" >>"$RD/d09.md" add_pending '心跳腳本找不到,jsc-hooks 沒裝或版本低於 0.3.7' '心跳與閘門狀態' '/jsc-cli:deploy' return 0 fi _rc=0 "$_hb" report >"$RD/d09.line" 2>"$RD/d09.err" /dev/null)")" >>"$RD/d09.md" add_pending '心跳腳本回非 0,心跳判不出來' '心跳與閘門狀態' '/jsc-assist:assistant status' return 0 fi _line=$(cat "$RD/d09.line" 2>/dev/null) # 欄位以空白分隔,路徑擺最後。拆成一行一欄再取,不用貪婪比對——貪婪會抓到後面同名的鍵。 _get() { printf '%s' "$_line" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n1; } _st=$(printf '%s' "$_line" | sed -n 's/^state=\([^ ]*\).*/\1/p') _ts=$(_get ts); _age=$(_get age); _ttl=$(_get ttl) _pid=$(_get pid); _cli=$(_get cli); _sid=$(_get session) _file=$(printf '%s' "$_line" | sed -n 's/.*file=//p') HEARTBEAT_TTL="$_ttl" case "$_st" in fresh) HEARTBEAT_STATE='新鮮' ;; stale) HEARTBEAT_STATE='過期'; add_warn '心跳過期' ;; invalid) HEARTBEAT_STATE='心跳檔損壞'; add_warn '心跳檔損壞' ;; absent) HEARTBEAT_STATE='不存在'; add_warn '心跳不存在' ;; *) HEARTBEAT_STATE="判不出(state=${_st:-空值})"; add_warn '心跳判不出' ;; esac { printf '| 項目 | 內容 |\n' printf '| --- | --- |\n' printf '| 心跳 | %s |\n' "$(cell "$HEARTBEAT_STATE")" if [ -n "$_ts" ]; then printf '| 上次心跳 | %s,距這次巡檢 %s 秒 |\n' "$(fmt_ts "$_ts" | tr -d '\n')" "$(cell "${_age:--}")" else printf '| 上次心跳 | - |\n' fi printf '| 過期門檻 | %s 秒 |\n' "$(cell "${_ttl:--}")" printf '| cli | %s |\n' "$(cell "${_cli:--}")" printf '| session | %s |\n' "$(cell "${_sid:--}")" printf '| pid | %s。只給要找行程的人參考,不參與判定 |\n' "$(cell "${_pid:--}")" printf '| 心跳檔 | `%s` |\n' "$(cell "${_file:--}")" printf '\n心跳的判準只看 `ts` 距現在有沒有超過門檻,不看 pid 存活:五支 CLI 與容器裡的行程互相看不到彼此的 pid。閘門的判定留在 hook,助理只維持心跳,不參與判定。\n' printf '\n心跳新鮮代表上一輪巡檢跑完了,而且結果記上監控頁了。它不代表那一輪各項都成功——各項的成敗看這一塊上面的「本輪判定」。\n' } >>"$RD/d09.md" case "$_st" in fresh|stale|invalid|absent) D09_STATUS=ok ;; *) D09_STATUS=fail; D09_NOTE='report 印不出認得的 state' ;; esac [ "$_st" = absent ] && D09_STATUS=empty return 0 } # --- D-11 執行狀態事件 --- # 一輪最多列幾筆明細。不設上限的話,一次壞掉的 hook 每次提示寫一筆,一輪就能把整頁灌爆, # 而灌爆的頁沒有人讀得完,等於這一節白寫。列不下的用一句話講清楚還有幾筆。 EV_MAX_ROWS=50 # 把事件流的一行 JSON 攤成定位字元分隔的欄位。不引 JSON 解析器:巡檢跑在 cron 上,能倚賴的 # 只有系統本來就有的工具,多一個相依就是多一種在某台機器上跑不起來的方式。 # 取值以「鍵名加冒號加引號」定位,取到下一個引號為止。detail 裡若有被跳脫的引號會在那裡被切斷, # 那只影響顯示的長度,不影響筆數與配對,所以不為它多寫一套解析。 events_to_tsv() { # $1=原始事件檔 $2=輸出檔 awk ' function jstr(s, k, r) { if (match(s, "\"" k "\":\"")) { r = substr(s, RSTART + length(k) + 4) if (match(r, "\"")) return substr(r, 1, RSTART - 1) } return "" } function jnum(s, k, r) { if (match(s, "\"" k "\":[0-9]+")) { r = substr(s, RSTART, RLENGTH); sub(/^.*:/, "", r); return r } return "" } { gsub(/\t/, " ") printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", \ jstr($0, "ts"), jstr($0, "cli"), jstr($0, "session"), jstr($0, "kind"), jstr($0, "name"), \ jstr($0, "phase"), jstr($0, "status"), jnum($0, "exit"), jstr($0, "detail") }' "$1" >"$2" 2>/dev/null } d11() { D11_STATUS=fail; D11_RC=0; D11_NOTE='' EV_TOTAL=0; EV_BAD=0; EV_UNPAIRED=0; EV_RUNNING=0; EV_ROTATED=skipped { printf '### 執行狀態事件\n\n' printf '資料出自 `%s`,由 `jsc-hooks` 的 `tools/report-status.sh drain` 排空,位移記在 `%s`。\n\n' \ "\$JSC_HOME/usage/events.jsonl" "\$JSC_HOME/usage/scan-state/events.offset" printf '技能的 `start` 由 hook 記,`end` 只能由技能自己在收尾時寫。所以**有 `start` 沒有配對的 `end` 就是那一輪中止了**,配對以 `session` 加 `name` 為鍵。\n\n' } >"$RD/d11.md" if ! _rs=$(find_tool hooks tools/report-status.sh "${JSC_ASSIST_REPORT_STATUS_SH:-}"); then D11_NOTE='找不到 jsc-hooks 的 tools/report-status.sh' D11_RC=127 add_failed_source 'jsc-hooks/tools/report-status.sh' printf '**這一項失敗**:%s。這一輪沒有執行狀態事件,不是「每一支都跑成功」。\n' "$D11_NOTE" >>"$RD/d11.md" add_pending '執行狀態事件讀不到,jsc-hooks 沒裝或版本太舊' '執行狀態事件' '/jsc-cli:doctor' return 0 fi # 排空。結束碼 3 是「沒有新事件」,那是正常狀態,不是失敗——每一輪都排空,多數輪次本來 # 就沒有新事件。JSC_HOME 明寫成環境變數傳下去:這支腳本裡的 JSC_HOME 不見得是匯出的, # 子行程自己算預設值時,兩邊指到同一個目錄才算數。 : >"$RD/events.raw" _rc=0 JSC_HOME="$JSC_HOME" "$_rs" drain >"$RD/events.raw" 2>"$RD/d11.err" /dev/null)")" >>"$RD/d11.md" add_pending '事件流排空失敗,本輪沒有執行狀態證據' '執行狀態事件' '/jsc-hooks:repair' return 0 fi # 輪替緊接在排空後面跑,而且只在排空成功時跑。排空失敗時位移的狀態是未知的,這時候輪替 # 會把還沒排空的事件搬進備份檔,那一批從此不會再出現在任何一輪。 _rrc=0 _rout=$(JSC_HOME="$JSC_HOME" "$_rs" rotate 2>>"$RD/d11.err" "$RD/events-bad.tsv"; : >"$RD/events-unpaired.tsv"; : >"$RD/events-open.next" [ -f "$EVENTS_OPEN" ] || : >"$EVENTS_OPEN" # 兩個輸入檔:先讀上一輪留下來還開著的 start,再讀本輪排空到的事件。用 FILENAME 分辨是 # 哪一個檔案,不用 NR==FNR:上一輪那個檔案是空的時候,NR==FNR 會把本輪第一筆事件誤當成 # 舊資料,而「上一輪沒有開著的 start」正是最常見的情況。 awk -F' ' -v prevf="$EVENTS_OPEN" -v now="$_now" -v ttl="$_ttl" -v maxage=86400 \ -v openf="$RD/events-open.next" -v badf="$RD/events-bad.tsv" -v unpf="$RD/events-unpaired.tsv" ' FILENAME == prevf { k = $1 SUBSEP $2; okind[k] = $3; oseen[k] = $4; ots[k] = $5; next } { total++ if ($7 != "ok") { bad++; printf "%s\t%s\t%s\t%s\t%s\t%s\n", $1, $4, $5, $7, $8, $9 >badf } k = $3 SUBSEP $5 if ($6 == "start") { okind[k] = $4 # 第一次看到才記時間:同一支技能在同一個工作階段重複開場時,年紀要從最早那一次算起。 if (!(k in oseen)) { oseen[k] = now; ots[k] = $1 } } else if ($6 == "end") { delete okind[k]; delete oseen[k]; delete ots[k] } } END { for (k in oseen) { age = now - oseen[k] # 開超過一天的丟掉。留著只會讓這個檔案無止境長大,而那麼久沒收尾的 start 早就報過了。 if (age > maxage) continue split(k, p, SUBSEP) printf "%s\t%s\t%s\t%s\t%s\n", p[1], p[2], okind[k], oseen[k], ots[k] >openf if (age >= ttl) { unpaired++; printf "%s\t%s\t%s\t%s\t%s\n", p[2], okind[k], p[1], ots[k], age >unpf } else running++ } printf "total=%d bad=%d unpaired=%d running=%d\n", total, bad, unpaired, running }' "$EVENTS_OPEN" "$RD/events.tsv" >"$RD/d11.counts" 2>>"$RD/d11.err" _counts=$(cat "$RD/d11.counts" 2>/dev/null) for _kv in $_counts; do case "$_kv" in total=*) EV_TOTAL="${_kv#total=}" ;; bad=*) EV_BAD="${_kv#bad=}" ;; unpaired=*) EV_UNPAIRED="${_kv#unpaired=}" ;; running=*) EV_RUNNING="${_kv#running=}" ;; esac done for _v in EV_TOTAL EV_BAD EV_UNPAIRED EV_RUNNING; do eval "_x=\$$_v" case "$_x" in ''|*[!0-9]*) eval "$_v=0" ;; esac done # 換上這一輪之後還開著的 start。換不上不算整項失敗:下一輪頂多重算一次年紀,不會漏報。 cp "$RD/events-open.next" "$EVENTS_OPEN" 2>/dev/null \ || printf '**未配對清單存檔失敗**:`%s` 寫不進去,下一輪的年紀要重算。\n\n' "$EVENTS_OPEN" >>"$RD/d11.md" { printf '| 項目 | 內容 |\n' printf '| --- | --- |\n' printf '| 本輪事件數 | %s |\n' "$EV_TOTAL" printf '| 非 ok 事件數 | %s |\n' "$EV_BAD" printf '| 有 start 沒有 end(開超過 %s 秒,疑似中止) | %s |\n' "$_ttl" "$EV_UNPAIRED" printf '| 有 start 沒有 end(未達門檻,還在跑) | %s |\n' "$EV_RUNNING" printf '| 事件流輪替 | %s |\n' "$EV_ROTATED" printf '\n#### 非 ok 事件明細\n\n' } >>"$RD/d11.md" if [ -s "$RD/events-bad.tsv" ]; then { printf '| 時間 | 類別 | 名稱 | status | 結束碼 | detail |\n' printf '| --- | --- | --- | --- | ---: | --- |\n' } >>"$RD/d11.md" _n=0 while IFS=' ' read -r _ets _ekind _ename _est _eex _edt; do [ -n "$_ename" ] || continue _n=$(( _n + 1 )) [ "$_n" -le "$EV_MAX_ROWS" ] || continue printf '| %s | %s | %s | %s | %s | %s |\n' \ "$(cell "$_ets")" "$(cell "$_ekind")" "$(cell "$_ename")" \ "$(cell "$_est")" "$(cell "${_eex:--}")" "$(cell "${_edt:--}")" >>"$RD/d11.md" done <"$RD/events-bad.tsv" [ "$_n" -gt "$EV_MAX_ROWS" ] \ && printf '\n本輪非 ok 事件共 %s 筆,上表只列前 %s 筆。完整原文在 `%s`。\n' \ "$_n" "$EV_MAX_ROWS" "$RD/events.raw" >>"$RD/d11.md" else printf '本輪沒有 status 不是 ok 的事件。\n' >>"$RD/d11.md" fi { printf '\n#### 有 start 沒有配對的 end\n\n' printf '這一節就是中止的證據。`start` 由 hook 免費記下,`end` 要技能自己寫,所以只有 `start` 的那一筆,代表那一支技能沒有跑到收尾那一步。\n\n' } >>"$RD/d11.md" if [ -s "$RD/events-unpaired.tsv" ]; then { printf '| 名稱 | 類別 | session | start 時間 | 已開著(秒) |\n' printf '| --- | --- | --- | --- | ---: |\n' } >>"$RD/d11.md" _n=0 while IFS=' ' read -r _uname _ukind _usess _uts _uage; do [ -n "$_uname" ] || continue _n=$(( _n + 1 )) [ "$_n" -le "$EV_MAX_ROWS" ] || continue printf '| %s | %s | %s | %s | %s |\n' \ "$(cell "$_uname")" "$(cell "$_ukind")" "$(cell "$_usess")" \ "$(cell "$_uts")" "$(cell "$_uage")" >>"$RD/d11.md" done <"$RD/events-unpaired.tsv" [ "$_n" -gt "$EV_MAX_ROWS" ] \ && printf '\n未配對的 `start` 共 %s 筆,上表只列前 %s 筆。\n' "$_n" "$EV_MAX_ROWS" >>"$RD/d11.md" else printf '本輪沒有開超過門檻又沒收尾的 `start`。\n' >>"$RD/d11.md" fi printf '\n未達門檻的 `start` 不列進上表,它們多半只是還在跑。跨輪繼續配對,紀錄留在 `%s`;不跨輪的話,跑超過一個巡檢週期的技能每一輪都會被報成中止。\n' \ "$EVENTS_OPEN" >>"$RD/d11.md" if [ "$EV_BAD" -gt 0 ]; then add_warn '有非 ok 的執行狀態事件' add_pending "本輪有 $EV_BAD 筆執行狀態不是 ok 的事件" '執行狀態事件' '照明細表的名稱找那一支技能或 hook' fi if [ "$EV_UNPAIRED" -gt 0 ]; then add_warn '有技能只有 start 沒有 end' add_pending "本輪有 $EV_UNPAIRED 支技能只有 start 沒有 end,那幾輪中止了" '執行狀態事件' '照明細表的名稱重跑那一支技能' fi if [ "$EV_TOTAL" -gt 0 ]; then D11_STATUS=ok else D11_STATUS=empty printf '\n來源讀得到,本輪沒有新事件(`drain` 回 3)。那是正常狀態,不是失敗:多數輪次本來就沒有新的技能或 hook 跑過。\n' >>"$RD/d11.md" fi return 0 } # --- 待辦簿筆數(只供目錄頁那一個區塊用)--- count_tasks() { TASKS_TOTAL=0; TASKS_FAILING=0; FAILING_LINES='' _d="$STATE_DIR/tasks" [ -d "$_d" ] && [ -r "$_d" ] || return 0 for _f in "$_d"/*; do [ -f "$_f" ] || continue TASKS_TOTAL=$(( TASKS_TOTAL + 1 )) _fc=$(sed -n 's/^fail_count=//p' "$_f" 2>/dev/null | head -n1) case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac if [ "$_fc" -gt 0 ]; then TASKS_FAILING=$(( TASKS_FAILING + 1 )) # 逐筆點名,不只累加。一個數字說得出「有幾筆在連續失敗」,說不出是哪幾筆,而失敗次數 # 這個欄位存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」——不說出是哪一筆, # 那個理由就只完成了一半。 _ti=$(sed -n 's/^title=//p' "$_f" 2>/dev/null | head -n1) _id=$(sed -n 's/^id=//p' "$_f" 2>/dev/null | head -n1) [ -n "$_id" ] || _id=$(basename -- "$_f") _lr=$(sed -n 's/^last_run=//p' "$_f" 2>/dev/null | head -n1) FAILING_LINES="${FAILING_LINES}$(printf '%s\t%s\t%s\t%s' \ "$_id" "$_fc" "${_lr:--}" "${_ti:--}") " fi done return 0 } # --- 提醒佇列 --- # # 一輪算完之後,把「要送到人面前」的那幾筆寫成一份佇列檔,位置固定在助理狀態目錄底下。 # 讀的那一端是工作階段開始那一支 hook:它只印,一個判定都不做。 # # 為什麼判定不放在 hook 那一邊:hook 跑在每一個工作階段的開頭,它要快、而且絕對不能擋人。 # 更重要的是,讓 hook 自己拿 due 欄與 next_run 去比就是第二套到期判定,跟 due.sh 那一套 # 會漂移,而漂移的那一天兩邊都說自己是對的。所以判定只有一套,佇列是它的輸出。 # # 這樣換來一個新的失效模式,要正面處理:助理沒在跑的時候,這份佇列不會更新,而一份舊佇列 # 讀起來跟新的一模一樣。所以檔頭寫一行 round,帶著這一輪的時間戳,讓讀的那一端算得出 # 它有多舊——「沒有提醒」與「沒有人算提醒」不可以長得一樣。 write_reminders() { REMINDERS=0 REMINDERS_FILE="$STATE_DIR/reminders.tsv" _rt="$REMINDERS_FILE.tmp.$$" { # 檔頭同時寫 ISO 時間與 epoch 秒。ISO 給人看,epoch 給讀的那一端算年紀——讓它自己解 # ISO 字串就是在每一個讀取端各放一份日期解析,而 date -d 不是每一台機器都認得那個格式。 # 最後那個待辦總筆數是給讀的那一端判「該不該吵」用的:佇列空又過期時,待辦簿有東西 # 才代表「有事沒人在算」,零筆就只是助理閒著,那時候安靜才對。 printf 'round\t%s\t%s\t%s\t%s\t%s\n' \ "$ROUND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TASKS_FAILING" "$(date +%s)" "$TASKS_TOTAL" # 逾期那幾筆照抄判定那一支印的行,連「逾期多久」那個給人看的寫法都取它算好的: # 自己再寫一個時間長度格式化,同一個秒數在兩個地方就會印出兩種說法。 _ovids="$RD/overdue-ids" : >"$_ovids" if [ -f "$RD/due.out" ]; then sed -n 's/^overdue_row=//p' "$RD/due.out" 2>/dev/null | while IFS= read -r _l; do [ -n "$_l" ] || continue _oi=${_l%% *} _oh=$(printf '%s' "$_l" | sed -n 's/.* human=\(.*\) title=.*/\1/p') _ot=$(printf '%s' "$_l" | sed -n 's/.* title=//p') printf '%s\n' "$_oi" >>"$_ovids" printf 'overdue\t%s\t%s\t%s\n' "$_oi" "${_oh:--}" "${_ot:--}" done fi # 內建項的識別碼,一行一個。分兩種列要靠它:帶 spec_key 的那幾筆是依委派清單種入的, # 使用者一筆都沒交辦過。 _specids="$RD/spec-ids" : >"$_specids" for _tf in "$STATE_DIR/tasks"/*; do [ -f "$_tf" ] || continue _sk=$(sed -n 's/^spec_key=//p' "$_tf" 2>/dev/null | head -n1) [ -n "$_sk" ] || continue _tid=$(sed -n 's/^id=//p' "$_tf" 2>/dev/null | head -n1) [ -n "$_tid" ] || _tid=$(basename -- "$_tf") printf '%s\n' "$_tid" >>"$_specids" done # 只提醒型而且到期的那幾筆。指令型不進佇列:它們由執行那一支真的跑掉了,人不必接手。 # 已經以逾期身分列過的那幾筆不再列第二次:同一筆待辦在同一批提醒裡出現兩行,讀的人會 # 當成兩件事,而且「逾期五天」比「排定點過了」講得更清楚——留強的那一行就好。 # # 內建項與使用者交辦的分成兩種列,理由是它們該有的處置不一樣。 # 使用者自己登錄的那一筆,人看到就做得了;內建項那幾筆等的是接線不是人——動作是 # 「只提醒」的內建項,到現在還沒有任何執行入口,所以每一輪都到期、每一輪都一樣。 # 實測踩到:這台機器八筆全是那一種,於是每一個工作階段開頭固定吐八行一模一樣的東西。 # 那不是提醒,是噪音;而讀的那一端只印不判,分不出兩者,所以在這裡就把種類標好。 if [ -n "$DUE_ROWS" ] && [ -f "$DUE_ROWS" ]; then awk -F'\t' -v idf="$_ovids" -v sidf="$_specids" ' BEGIN { while ((getline _l < idf) > 0) seen[_l] = 1 while ((getline _s < sidf) > 0) spec[_s] = 1 } NF >= 12 && $1 != "" && $2 == "due" && $5 == "remind" && !($1 in seen) { printf "%s\t%s\t%s\t%s\n", ($1 in spec ? "builtin" : "remind"), \ $1, ($10 == "" ? "-" : $10), ($11 == "" ? "-" : $11) }' "$DUE_ROWS" 2>/dev/null fi } >"$_rt" 2>/dev/null || { add_warn '提醒佇列寫不出來'; rm -f "$_rt"; return 0; } if mv "$_rt" "$REMINDERS_FILE" 2>/dev/null; then # 兩個數字分開算:逐筆點名的那幾筆,與只算一個總數的內建項。 REMINDERS=$(awk -F'\t' '$1 == "remind" || $1 == "overdue" { n++ } END { print n + 0 }' "$REMINDERS_FILE") REMINDERS_BUILTIN=$(awk -F'\t' '$1 == "builtin" { n++ } END { print n + 0 }' "$REMINDERS_FILE") else add_warn '提醒佇列換不上去' rm -f "$_rt" fi return 0 } # --- 待辦簿的事件偵測與到期判定 --- # # 算到期的邏輯不在這一支,也不在 tools/tasks.sh,而在 tools/due.sh,理由寫在那一支的檔頭: # 存放與判定各一支,兩邊各算一次就會漂移。這裡只負責在一輪之內叫它一次。 # 一輪只叫一次,而且只叫 scan:scan 會推進狀態快照,同一輪叫第二次就比不出任何事件了。 # 要看「下一輪會判出什麼」而不想動快照,叫那一支的 events,那個子命令一律唯讀。 # # 這一項刻意不算進上面那五項的成敗。它補的是既有那一節的內容,不是新增一個獨立來源;算進去 # 就要改動摘要表那一欄與「本輪項目」那一列的文字,而舊頁那些列的欄名不會跟著改,同一張表就 # 會有兩種寫法。失敗時記一筆警示、列一筆待人處理,那一節照實寫「這一輪判不出到期」。 due_scan() { DUE_STATUS=fail; DUE_RC=0; DUE_MD=''; DUE_NOTE='' DUE_TASKS=''; DUE_EVENTS='' _due="$SCRIPT_DIR/due.sh" if [ ! -f "$_due" ]; then DUE_NOTE="找不到($_due)" add_failed_source "$_due" add_warn '到期判定那一支找不到' add_pending '待辦簿的到期判定讀不到,jsc-assist 沒裝齊' '待辦簿到期與逾期' '/jsc-cli:deploy' return 0 fi _rc=0 JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" /dev/null | head -n1) # 機器可讀的那一份也要轉出去。判到期與執行是兩支腳本,中間靠這個檔案交棒;不轉出去 # 呼叫端就只能靠預設路徑猜,而那個預設指向的是上一次有人手動跑 scan 留下的檔案。 # 實測踩過:那份舊清單在機器上放了六小時,執行那一支每一輪都對它動手,跑的是一筆已經 # 被移除的待辦,而且看起來完全正常——舊清單當時是對的,內容剛好沒變。 DUE_ROWS=$(sed -n 's/^rows_file=//p' "$RD/due.out" 2>/dev/null | head -n1) DUE_TASKS=$(sed -n 's/^tasks_due=//p' "$RD/due.out" 2>/dev/null | head -n1) DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1) DUE_OVERDUE=$(sed -n 's/^tasks_overdue=//p' "$RD/due.out" 2>/dev/null | head -n1) case "$_rc" in 0) DUE_STATUS=ok ;; 4) # 待辦簿零筆或目錄不存在。助理還沒收過待辦就是這個狀態,不是失敗。 DUE_STATUS=empty ;; 1) DUE_STATUS=ok DUE_NOTE='有狀態來源讀不到' add_warn '到期判定有狀態來源讀不到' add_pending '待辦簿事件偵測有狀態來源讀不到,那個來源本輪不發事件' '待辦簿到期與逾期' '/jsc-cli:setup' ;; 2) DUE_STATUS=ok DUE_NOTE='有待辦的欄位值判不了' add_warn '有待辦的欄位值判不了' add_pending '有待辦的 trigger 或 recur 判不了,那幾筆永遠不會到期' '待辦簿到期與逾期' '/jsc-assist:assistant status' ;; 3) # 快照換不上去是這幾碼裡最要吵的一個:事件算出來了,但下一輪會拿同一份舊快照再比一次, # 同一批事件於是被判第二次,同一件事就做了兩遍。 DUE_STATUS=ok DUE_NOTE='狀態快照換不上去,下一輪會重判同一批事件' add_warn '事件快照換不上去' add_pending '事件快照換不上去,同一批事件下一輪會被判第二次' '待辦簿到期與逾期' '/jsc-cli:setup' ;; *) DUE_NOTE="回結束碼 $_rc:$(tr '\n' ' ' <"$RD/due.err" 2>/dev/null)" DUE_MD='' add_failed_source "$_due" add_warn '到期判定回非預期結束碼' add_pending "待辦簿的到期判定回 $_rc,本輪判不出到期" '待辦簿到期與逾期' '/jsc-assist:assistant status' ;; esac return 0 } # --- 組出監控頁那三塊 --- tally() { # $1=項目狀態 case "$1" in fail) FAIL_COUNT=$(( FAIL_COUNT + 1 )) ;; *) OK_COUNT=$(( OK_COUNT + 1 )) ;; esac } compose() { _v='正常' [ "$WARN" -eq 1 ] && _v='警示' [ "$FAIL_COUNT" -gt 0 ] && _v='警示' [ "$OK_COUNT" -eq 0 ] && _v='異常' VERDICT="$_v" ITEM_TOTAL=$(( OK_COUNT + FAIL_COUNT )) PEND_COUNT=$(grep -c '^| ' "$RD/pend.md" 2>/dev/null); [ -n "$PEND_COUNT" ] || PEND_COUNT=0 { printf '## 最新一輪\n\n' printf '這一塊每輪整塊換掉,只留最新那一輪的完整內容。再往前的軌跡看下面的摘要表。\n\n' printf '| 項目 | 內容 |\n' printf '| --- | --- |\n' printf '| 巡檢時間 | %s |\n' "$AT" printf '| 觸發方式 | %s |\n' "$TRIGGER" printf '| 本輪判定 | %s |\n' "$VERDICT" printf '| 本輪項目 | 五項:D-01 使用統計、D-04 版本與重啟閘門、D-07 階段鎖與工作包鎖、D-09 心跳自述、D-11 執行狀態事件。成功 %s 項、失敗 %s 項 |\n' "$OK_COUNT" "$FAIL_COUNT" printf '| 讀不到的來源 | %s |\n' "$(cell "${FAILED_SOURCES:-無}")" # 警示來源緊接在讀不到的來源後面:兩列語意相近,而且各項讀取全部成功、判定卻是警示 # 時,這一塊裡只有這一列講得出原因,跟摘要表那一欄是同一個理由。 printf '| 警示來源 | %s |\n' "$(cell "${WARN_SOURCES:-無}")" if [ "$LOCK_BROKEN" -eq 1 ]; then printf '| 鎖 | 上一輪的鎖逾時,本輪搶回來了。上一輪沒跑完,那一輪不會寫心跳 |\n' fi printf '\n' cat "$RD/d09.md"; printf '\n' cat "$RD/d01.md"; printf '\n' cat "$RD/d11.md"; printf '\n' printf '### hook 執行期錯誤\n\n' printf '**這一輪不做這一項。** D-02 hook 錯誤巡檢還沒實作,這一節沒有資料不代表沒有 hook 錯誤。要現在查就跑 `/jsc-hooks:hooks-install` 的錯誤掃描,或直接跑 `jsc-hooks` 的 `tools/scan-hook-errors.sh`。\n\n' cat "$RD/d04.md"; printf '\n' cat "$RD/d07.md"; printf '\n' if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then cat "$DUE_MD" printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。\n\n' \ "$TASKS_TOTAL" "$TASKS_FAILING" if [ -n "$FAILING_LINES" ]; then printf '#### 連續失敗的那幾筆\n\n' printf '**這幾筆每一輪都會再試一次,而且不會自動暫停。** 助理不替人按暫停:那個狀態留給人設,也只有人解得開。\n\n' printf '| id | 已連續失敗 | 最後一次執行 | 標題 |\n' printf '| --- | ---: | --- | --- |\n' printf '%s' "$FAILING_LINES" | while IFS="$(printf '\t')" read -r _fi _fn _fl _ft; do [ -n "$_fi" ] || continue printf '| `%s` | %s 次 | %s | %s |\n' "$_fi" "$_fn" "$(cell "$_fl")" "$(cell "$_ft")" done printf '\n' fi else printf '### 待辦簿到期與逾期\n\n' printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \ "$(cell "${DUE_NOTE:-沒有回報原因}")" "$TASKS_TOTAL" "$TASKS_FAILING" fi printf '### 待人處理\n\n' printf '助理只提醒,不代為執行。這一節列的是本輪要人接手的項目。\n\n' printf '| 項目 | 來源子節 | 建議入口 |\n' printf '| --- | --- | --- |\n' if [ -s "$RD/pend.md" ]; then cat "$RD/pend.md"; else printf '| (無) | - | - |\n'; fi } >"$RD/latest.md" # 摘要表的那一列。欄位刻意只有五個,一列要能一眼看完,才看得出是從哪一輪開始壞的。 # 「警示來源」那一欄不能省:各項讀取全部成功、但讀到的內容有警示時,判定是警示而成敗欄 # 是滿分,沒有這一欄的話,看的人不知道警示哪來。 # 欄名寫「各項成敗」而不寫項數:巡檢項目會增加,欄名寫死數字就要跟著改,而舊頁那些列的 # 欄名不會跟著改,同一張表就會有兩種欄名。 printf '| %s | %s | %s/%s | %s | %s |\n' \ "$AT" "$VERDICT" "$OK_COUNT" "$ITEM_TOTAL" "$PEND_COUNT" \ "$(cell "${WARN_SOURCES:-無}")" >"$RD/summary-row.md" # 摘要那一塊:標題、表頭,加上本輪這一列。呼叫端把舊頁的資料列接在這一列下面,截到 24 列。 { printf '## 近 24 輪摘要\n\n' printf '一輪一列,最新的在最上面,超過 24 列就丟掉最舊的那一列。\n\n' printf '| 巡檢時間 | 本輪判定 | 各項成敗 | 待人處理 | 警示來源 |\n' printf '| --- | --- | --- | ---: | --- |\n' cat "$RD/summary-row.md" } >"$RD/summary.md" # 監控頁不存在時要建的整頁內容。基本資料建頁時寫一次,之後不再更動。 { printf '# 助理巡檢 — %s/%s\n\n' "$HOST" "$USER_NAME" printf '> 由 `jsc-assist` 維護。這是監控頁 `%s`。\n' "$PAGE" printf '> 這頁固定三塊:本頁基本資料、最新一輪、近 24 輪摘要。\n' printf '> 最新一輪每輪整塊換掉;摘要表一輪一列往上疊,只留 24 列;基本資料建頁時寫一次就不動。\n' printf '> 完整內容只留最新一輪,頁面才讀得完;軌跡留在摘要表,看得出是從哪一輪開始壞的。\n' printf '> 目錄頁 `MONITOR_CONTENTS` 在 `JSC_WIKI_REPO_CONTENTS` 解出的專用存取庫,和這頁不同庫。\n' printf '> 那一頁一台機器一個 H2 區塊,只更新自己那一個區塊,別台機器的區塊一個字都不動,寫入交給 `jsc-gitea/tools/wiki-contents.sh upsert`。\n\n' printf '```mermaid\nflowchart LR\n' printf ' A[巡檢一輪] --> B[收攏各項結果]\n' printf ' B --> C[讀回舊頁]\n' printf ' C --> D[換掉最新一輪那一塊]\n' printf ' D --> E[本輪摘要列插到表格最上面,截到 24 列]\n' printf ' E --> F[整頁寫回]\n' printf ' F --> G[wiki-contents.sh upsert 更新目錄頁自己那一個區塊]\n' printf ' G --> H[最後才寫心跳]\n' printf '```\n\n' printf '## 本頁基本資料\n\n' printf '建頁時寫一次,之後不再更動。\n\n' printf '| 項目 | 內容 |\n' printf '| --- | --- |\n' printf '| 主機 | %s |\n' "$(cell "$HOST")" printf '| 帳號 | %s |\n' "$(cell "$USER_NAME")" printf '| 雜湊來源 | `%s/%s` |\n' "$(cell "$HOST")" "$(cell "$USER_NAME")" printf '| 狀態檔根目錄 | `$JSC_HOME/assistant/`(`$JSC_HOME` 未設定就退回 `~/.jsc`) |\n\n' cat "$RD/latest.md"; printf '\n' cat "$RD/summary.md" } >"$RD/newpage.md" # 目錄頁那一個 H2 區塊。標題就是內容頁頁名,欄位一行一條,順序照範本從上到下。 # 「監控頁」那一條是 [{頁名}]({絕對網址}) 這種連結,網址留佔位由呼叫端換掉、過完 link-check.sh # 才寫,理由見檔頭「目錄頁與內容頁分屬兩個存取庫」與「連結先驗證連得到」。 # 連結文字先寫死成頁名:頁名這裡就知道,只有網址要等內容頁寫成才查得到。 # upsert 拿 H2 標題當鍵,不拿那條連結:連結含 GITEA_HOST 與頁名的網址編碼,主機位址、存取庫或 # 編碼一變,那一條文字就變了,鍵對不上就每輪多附一個區塊。頁名只跟 {主機名}/{登入帳號} 有關, # 那三件事都動不到它。裸 HASH 照樣留一條,讓人不必從標題切字串就抄得到。 { printf '## %s\n\n' "$PAGE" printf -- '- 監控頁:[%s](%s)\n' "$PAGE" '{監控頁絕對網址}' printf -- '- HASH:%s\n' "$HASH" printf -- '- 主機:%s\n' "$(oneline "$HOST")" printf -- '- 帳號:%s\n' "$(oneline "$USER_NAME")" printf -- '- 心跳:%s\n' "$(oneline "$HEARTBEAT_STATE")" printf -- '- 最後巡檢:%s\n' "$(oneline "$AT")" printf -- '- 待辦筆數:%s\n' "$TASKS_TOTAL" printf -- '- 連續失敗項:%s\n' "$TASKS_FAILING" } >"$RD/contents-entry.md" return 0 } # --- 參數解析 --- CMD="${1:-}" [ -n "$CMD" ] || usage shift case "$CMD" in collect|finish|abort|rebuild) ;; *) usage ;; esac OUT='' while [ "$#" -gt 0 ]; do case "$1" in --out) [ "$#" -ge 2 ] || usage; OUT="$2"; shift 2 ;; --trigger) [ "$#" -ge 2 ] || usage; TRIGGER="$2"; shift 2 ;; --round) [ "$#" -ge 2 ] || usage; ROUND="$2"; shift 2 ;; --dry-run) DRYRUN=1; shift ;; --old) [ "$#" -ge 2 ] || usage; OLD_PAGE="$2"; shift 2 ;; *) usage ;; esac done OLD_PAGE="${OLD_PAGE:-}" [ -n "$OUT" ] && RD="$OUT" case "$CMD" in collect) [ -n "$TRIGGER" ] || { if [ "${JSC_CLI:-}" = cron ]; then TRIGGER='排程'; else TRIGGER='手動'; fi; } case "$TRIGGER" in 排程|事件|手動) ;; *) usage ;; esac ROUND="$(date +%s)-$$" # 主機名一律取短的:第一個小數點之前那一段。CHECK_{HASH} 的雜湊來源同樣是 # {主機}/{帳號},但那一邊由模型自己填,填進去的多半是短主機名;這一邊如果拿到 FQDN, # 同一台機器就會算出兩個雜湊、開出兩張頁,而且兩張都看起來是對的。 # 不用 hostname -s:BusyBox 與部分系統沒有這個旗標,切字串到處都成立。 HOST=$(hostname 2>/dev/null || uname -n 2>/dev/null || printf 'unknown') HOST="${HOST%%.*}" [ -n "$HOST" ] || HOST='unknown' USER_NAME="${USER:-$(id -un 2>/dev/null || printf 'unknown')}" AT=$(date '+%Y-%m-%d %H:%M') # 先讀心跳。門檻要先拿到,鎖的逾時才有依據。 mkdir -p "$RD" 2>/dev/null || die 5 "建不出巡檢暫存目錄 $RD。" : >"$RD/pend.md" HEARTBEAT_TTL='' d09 lock_acquire d01 d04 d07 d11 count_tasks due_scan # 逾期與連續失敗,每一輪都列一筆待人處理。 # # 這兩件事的處置都是人接手,而且**不會自己好**:一筆逾期的交辦不會因為過了更久就不逾期, # 一筆連續失敗的檢查項每一輪都會再試一次然後再失敗一次。所以每一輪都提,不因為上一輪 # 提過就這一輪不提——「提過了」不是「處理過了」,而助理不替人按暫停:那個狀態留給人設。 if [ -n "$DUE_OVERDUE" ] && [ "$DUE_OVERDUE" -gt 0 ] 2>/dev/null; then add_pending "有 $DUE_OVERDUE 筆待辦逾期,截止時間已經過了" '待辦簿到期與逾期' '/jsc-assist:assistant status' fi if [ "$TASKS_FAILING" -gt 0 ]; then add_pending "有 $TASKS_FAILING 筆待辦連續失敗,每一輪都在重試" '待辦簿到期與逾期' '/jsc-assist:assistant status' fi write_reminders tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS" tally "$D11_STATUS" HASH='' if _hi=$(find_tool gitea tools/hash-id "${JSC_ASSIST_HASH_ID:-}"); then HASH=$("$_hi" "$HOST/$USER_NAME" 2>/dev/null) || HASH='' fi if [ -n "$HASH" ]; then PAGE="MONITOR_$HASH"; else PAGE=''; fi compose printf 'round=%s\n' "$ROUND" printf 'lock=acquired\n' printf 'lock_broken=%s\n' "$LOCK_BROKEN" printf 'hash=%s\n' "$HASH" printf 'page=%s\n' "$PAGE" printf 'host=%s\n' "$HOST" printf 'user=%s\n' "$USER_NAME" printf 'at=%s\n' "$AT" printf 'item=D-01 status=%s rc=%s note=%s\n' "$D01_STATUS" "$D01_RC" "$D01_NOTE" printf 'item=D-04 status=%s rc=%s note=%s\n' "$D04_STATUS" "$D04_RC" "$D04_NOTE" printf 'item=D-07 status=%s rc=%s note=%s\n' "$D07_STATUS" "$D07_RC" "$D07_NOTE" printf 'item=D-09 status=%s rc=%s note=%s\n' "$D09_STATUS" "$D09_RC" "$D09_NOTE" printf 'item=D-11 status=%s rc=%s note=%s\n' "$D11_STATUS" "$D11_RC" "$D11_NOTE" printf 'verdict=%s\n' "$VERDICT" printf 'failed_sources=%s\n' "${FAILED_SOURCES:-無}" printf 'warn_sources=%s\n' "${WARN_SOURCES:-無}" printf 'tasks_total=%s\n' "$TASKS_TOTAL" printf 'tasks_failing=%s\n' "$TASKS_FAILING" printf 'tasks_due=%s\n' "$DUE_TASKS" printf 'tasks_overdue=%s\n' "${DUE_OVERDUE:--}" printf 'reminders=%s\n' "$REMINDERS" printf 'reminders_builtin=%s\n' "$REMINDERS_BUILTIN" printf 'reminders_file=%s\n' "$REMINDERS_FILE" printf 'events_new=%s\n' "$DUE_EVENTS" printf 'due_status=%s\n' "$DUE_STATUS" printf 'due_rc=%s\n' "$DUE_RC" printf 'due_file=%s\n' "$DUE_MD" printf 'due_rows_file=%s\n' "${DUE_ROWS:-}" printf 'pending=%s\n' "$PEND_COUNT" printf 'events_total=%s\n' "$EV_TOTAL" printf 'events_bad=%s\n' "$EV_BAD" printf 'events_unpaired=%s\n' "$EV_UNPAIRED" printf 'events_running=%s\n' "$EV_RUNNING" printf 'events_rotated=%s\n' "$EV_ROTATED" printf 'events_file=%s\n' "$RD/events.raw" printf 'latest_file=%s\n' "$RD/latest.md" printf 'summary_file=%s\n' "$RD/summary.md" printf 'summary_row_file=%s\n' "$RD/summary-row.md" printf 'newpage_file=%s\n' "$RD/newpage.md" printf 'contents_file=%s\n' "$RD/contents-entry.md" [ "$OK_COUNT" -eq 0 ] && exit 3 [ "$FAIL_COUNT" -gt 0 ] && exit 1 exit 0 ;; finish) [ -n "$ROUND" ] || usage [ -d "$LOCK" ] || die 4 "本輪的鎖已經不在($LOCK),不寫心跳。鎖多半是逾時被下一輪搶走了。" _h=$(lock_round) [ "$_h" = "$ROUND" ] \ || die 4 "鎖不在本輪手上(鎖的輪次是 ${_h:-空值},本輪是 $ROUND),不寫心跳。上一輪跑太久被搶走了,這一輪的結果不算數。" _hb=$(find_tool hooks hooks/heartbeat.sh "${JSC_ASSIST_HEARTBEAT_SH:-}") \ || die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳沒有東西可寫。請先安裝 jsc-hooks 0.3.7 以上。' if [ "$DRYRUN" -eq 1 ]; then printf 'dryrun=1 heartbeat_cmd=%s write snapshot=%s -> %s lock=%s\n' \ "$_hb" "$RD/usage-next.tsv" "$PREV_SNAP" "$LOCK" exit 0 fi _rc=0 "$_hb" write >/dev/null 2>"$RD/finish.err" /dev/null)" printf 'heartbeat=written\n' # 快照要等這一輪真的收口才換上。半途失敗就換掉的話,下一輪的「本輪次數」會少算。 if [ -f "$RD/usage-next.tsv" ]; then cp "$RD/usage-next.tsv" "$PREV_SNAP" 2>/dev/null || die 5 "用量快照換不上:$PREV_SNAP。" printf 'snapshot=promoted\n' else printf 'snapshot=skipped\n' fi lock_release exit 0 ;; abort) [ -n "$ROUND" ] || usage printf 'heartbeat=not-written\n' lock_release exit 0 ;; # 拿舊頁重組出要寫回去的整頁。三塊的接法固定,所以由這一支做完,呼叫端只要「讀回舊頁、 # 叫這一支、把結果寫回去」三步。 # # 為什麼要有這個子命令:接三塊這件事本來留給呼叫端,而呼叫端是一個代理人,於是它每一輪 # 臨場挑工具——實測某一輪挑了 awk,兩次都被權限擋掉;那一輪有人在場所以它改用別的方式繞 # 過去了,並且自己指出無人值守那一輪遇到同一個擋法會直接卡住,而鎖在第一步就取走了。 # 更根本的是暫存目錄實測累積了 124 個檔案,名字是 newpage.md、new-page.md、new_page.md、 # page-new.md 這種——同一件事每輪重做一次,每輪換一組檔名。規格說那裡只該有五個檔案。 # 接法寫死在這裡之後,那一步不再需要任何通用文字工具,也就沒有東西可以被擋。 rebuild) [ -n "$OLD_PAGE" ] || usage [ -f "$OLD_PAGE" ] || die 6 "舊頁檔讀不到:$OLD_PAGE。這一支不自己讀 wiki,舊頁要由呼叫端先取下來。" for _f in "$RD/latest.md" "$RD/summary.md"; do [ -f "$_f" ] || die 6 "$_f 不在。rebuild 要接的是同一輪 collect 產出的那幾塊,先跑 collect。" done _out="$RD/rebuilt.md" # 舊頁那一塊「本頁基本資料」原樣留著,從它的標題到「最新一輪」前一行為止。整頁沒有 # 「最新一輪」那個標題時視為舊格式:基本資料留著、堆疊的各輪小節丟掉,摘要表從本輪開始。 if grep -q '^## 最新一輪' "$OLD_PAGE" 2>/dev/null; then awk '/^## 最新一輪/{exit} {print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。" printf 'old_format=no\n' else awk '/^## /{n++} n<=1{print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。" printf 'old_format=yes converted=1\n' fi # 舊表的資料列照原順序留在本輪那一列底下,整張表截到 24 列。摘要那一塊已經含標題、 # 表頭與本輪那一列,所以這裡只補舊列。 awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{print}' "$OLD_PAGE" >"$RD/rb-rows.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-rows.md。" _new_rows=$(awk '/^\| 2/{n++} END{print n+0}' "$RD/summary.md") _keep=$((24 - _new_rows)) [ "$_keep" -lt 0 ] && _keep=0 # 基本資料那一塊逐字照抄,連尾端空行都不動——技能本文寫的是「逐字保留」,剝掉再補一個 # 雖然內容沒少,那句話就變成假的了。只在它沒有以空行收尾時補一個分隔,免得下一塊的 # 標題黏在表格最後一列後面。 _tail=$(awk 'END{print (NF==0 ? "blank" : "text")}' "$RD/rb-head.md" 2>/dev/null) { cat "$RD/rb-head.md" [ "$_tail" = "blank" ] || printf '\n' cat "$RD/latest.md" printf '\n' cat "$RD/summary.md" [ "$_keep" -gt 0 ] && head -n "$_keep" "$RD/rb-rows.md" } >"$_out" 2>/dev/null || die 5 "暫存檔寫不進去:$_out。" printf 'rebuilt_file=%s\n' "$_out" printf 'blocks=%s summary_rows=%s old_rows_kept=%s\n' \ "$(grep -c '^## ' "$_out")" \ "$(awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{n++} END{print n+0}' "$_out")" \ "$([ "$_keep" -gt 0 ] && awk 'END{print NR+0}' "$RD/rb-rows.md" | awk -v k="$_keep" '{print ($1