Files
assist/tools/patrol.sh
T
jiantw83 4bf99dde76 feat(wiki): 監控目錄頁改走專用存取庫,比對鍵改用裸 HASH
What:MONITOR_CONTENTS 改由 wiki-repo CONTENTS 解析並透過 wiki-contents.sh
upsert 寫入,MONITOR_{HASH} 仍走 wiki-repo MONITOR。目錄頁新增一欄裸 HASH 當
比對鍵,第一欄的絕對網址連結留給人看。主機名一律取短名。

Why:目錄頁集中之後兩頁不再同庫,[[...]] 只在同一個 wiki 內解析,連結必須改絕對
網址。比對鍵原本是含網址的儲存格,換主機、換存取庫或 URL 編碼有差就比對不到,
巡檢每 15 分鐘寫一次,重複列會累積得很快。

How:目錄頁解不出存取庫時不再中止巡檢——那一頁只是索引,內容頁寫成就照樣寫心跳。
內容頁寫不成仍然中止且不寫心跳,自咬環的前提沒有鬆動。

Who:jsc-assist
2026-09-02 11:01:51 +08:00

847 lines
41 KiB
Bash
Executable File
Raw 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
# 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
# 四項各自獨立:一項的來源不見了、或回非 0,只讓那一項標成失敗,其餘三項照跑、照記。
# 四項都不呼叫別的技能、不寫程式碼存取庫、不做決策。
#
# --- version-guard.sh report 的既有缺陷照實記 ---
#
# 這支腳本在部分機器上對每一個 domain 都回「查詢失敗」,recommend 跟著回 unverifiable。
# 巡檢照抄第四欄原字,不自己補查遠端版本、不把「查詢失敗」寫成「相符」或「最新」。
# 查不到就是沒有證據,寫成別的字等於幫一個既有缺陷蓋章。
#
# --- 監控頁固定三塊 ---
#
# 監控頁不再一輪附加一節。15 分鐘一輪,一天就是 96 節,那樣的頁沒有人讀得完,也就沒有人
# 會發現壞掉。改成固定三塊:
# 本頁基本資料 建頁時寫一次,之後一個字都不動
# 最新一輪 每輪整塊換掉,只留最新那一輪的完整內容
# 近 24 輪摘要 一輪一列,最新的在最上面,超過 24 列就丟掉最舊的
# 軌跡留在摘要表:一輪一列,看得出是從哪一輪開始壞的。完整內容只留最新一輪,因為頁面要能
# 讀完才有人讀。
#
# --- 目錄頁與內容頁分屬兩個存取庫 ---
#
# 內容頁 MONITOR_{HASH} 住 MONITOR 那個存取庫,目錄頁 MONITOR_CONTENTS 住 CONTENTS 那個
# 專用存取庫,兩條解析鏈不互相退讓。所以目錄頁那一列指向內容頁的連結不能用 [[...]]:那種
# 連結只在同一個 wiki 裡解得開,跨庫是死連結,畫面上還看不出壞掉,要用絕對網址。
# 絕對網址要等內容頁真的寫進去才查得到(gitea.sh wiki-url 讀的是 API 回的 html_url),
# 而 collect 跑在寫入之前,這裡查不到。所以這支只把列組好、網址留佔位,換字交給呼叫端。
#
# --- 目錄頁的比對鍵是裸 HASH,不是那個連結 ---
#
# 目錄頁那一列另外留一欄裸 HASH(純文字、不帶連結),upsert 就拿那一欄當鍵。wiki-contents.sh
# 比對的是整格文字,拿含網址的連結當鍵太脆:GITEA_HOST 換掉、JSC_WIKI_REPO_MONITOR 換過存取
# 庫、Gitea 對頁名的網址編碼有差,整格文字就變了,鍵對不上就走附加那一支,同一台機器多出第二
# 列,舊列從此不再更新。這一頁每 15 分鐘寫一次,重複列累積得很快。裸 HASH 只由
# {主機名}/{登入帳號} 決定,上面三件事都動不到它。
#
# --- 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= 待辦簿筆數與連續失敗筆數,只供目錄頁那一列用
# pending= 本輪待人處理的筆數
# latest_file= 「最新一輪」那一塊,整塊換掉舊頁同名那一塊
# summary_file= 「近 24 輪摘要」那一塊,表格裡先放本輪這一列,舊頁的資料列接在下面
# summary_row_file= 只有本輪那一列,方便直接插到既有表格最上面
# newpage_file= MONITOR_{HASH} 不存在時要建的整頁內容,三塊都已經排好
# contents_file= MONITOR_CONTENTS 那一列的欄位值。row= 就是整列 markdown,第一欄是連結,
# 網址的位置留 {監控頁絕對網址} 佔位,由呼叫端換掉,理由見下一段;第二欄是
# 裸 HASH,upsert 拿那一欄當鍵,理由見再下一段
#
# 環境變數:
# 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_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"
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
# 這支腳本是不是從 $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 目錄]
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'; }
# 記一個讀不到的來源。同一輪多項失敗就串起來,供監控頁「讀不到的來源」那一列用。
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 的「次數<TAB>名稱」轉成監控頁的列,順便算出本輪增量。
# 增量要有上一輪的累計快照才算得出來;沒有快照的第一輪一律寫「-」,不拿累計冒充本輪。
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 || _rc=$?
if [ "$_rc" -ne 0 ]; then
_part=1; D04_RC="$_rc"
D04_NOTE="version-guard.sh report 回 $_rc"
add_failed_source "$_vg"
printf '**版本比對失敗**:`version-guard.sh report` 回 %s。訊息:%s\n\n' \
"$_rc" "$(cell "$(cat "$RD/d04.err" 2>/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" </dev/null || _rc=$?
if [ "$_rc" -ne 0 ]; then
_part=1; D04_RC="$_rc"
add_failed_source "$_rg"
printf '**重啟閘門讀不到**:`restart-gate.sh report` 回 %s。\n' "$_rc" >>"$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 || _rc=$?
if [ "$_rc" -ne 0 ]; then
D09_RC="$_rc"
D09_NOTE="heartbeat.sh report 回 $_rc"
add_failed_source "$_hb"
printf '**這一項失敗**:%s。訊息:%s\n' "$D09_NOTE" "$(cell "$(cat "$RD/d09.err" 2>/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
}
# --- 待辦簿筆數(只供目錄頁那一列用)---
count_tasks() {
TASKS_TOTAL=0; TASKS_FAILING=0
_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
[ "$_fc" -gt 0 ] && TASKS_FAILING=$(( TASKS_FAILING + 1 ))
done
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 心跳自述。成功 %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'
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'
printf '### 待辦簿到期與逾期\n\n'
printf '**這一輪只數筆數,不逐筆判到期。** 本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逐筆的到期與逾期判定還沒實作,要看逐筆內容就跑 `/jsc-assist:assistant status`。\n\n' \
"$TASKS_TOTAL" "$TASKS_FAILING"
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"
# 摘要表的那一列。欄位刻意只有五個,一列要能一眼看完,才看得出是從哪一輪開始壞的。
# 「警示來源」那一欄不能省:四項讀取全部成功、但讀到的內容有警示時,判定是警示而成敗欄
# 是 4/4,沒有這一欄的話,看的人不知道警示哪來。
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 '> 那一頁只更新自己那一列,別台機器的列一個字都不動,寫入交給 `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"
{
printf 'page=%s\n' "$PAGE"
printf 'host=%s\n' "$HOST"
printf 'user=%s\n' "$USER_NAME"
printf 'heartbeat=%s\n' "$HEARTBEAT_STATE"
printf 'last_patrol=%s\n' "$AT"
printf 'tasks_total=%s\n' "$TASKS_TOTAL"
printf 'tasks_failing=%s\n' "$TASKS_FAILING"
printf 'hash=%s\n' "$HASH"
# 第一欄是連結,網址留佔位由呼叫端換掉,理由見檔頭「目錄頁與內容頁分屬兩個存取庫」。
# 連結文字先寫死成頁名:頁名這裡就知道,只有網址要等內容頁寫成才查得到。
# 第二欄是裸 HASH,upsert 拿它當鍵。鍵不能用第一欄那個連結:那一格含 GITEA_HOST 與頁名的
# 網址編碼,主機位址、存取庫或編碼一變,整格文字就變了,鍵對不上就每輪多附一列。裸 HASH
# 只跟 {主機名}/{登入帳號} 有關,那三件事都動不到它。
printf 'row=| [%s](%s) | %s | %s | %s | %s | %s | %s | %s |\n' \
"$PAGE" '{監控頁絕對網址}' "$HASH" "$HOST" "$USER_NAME" "$HEARTBEAT_STATE" "$AT" \
"$TASKS_TOTAL" "$TASKS_FAILING"
} >"$RD/contents.tsv"
return 0
}
# --- 參數解析 ---
CMD="${1:-}"
[ -n "$CMD" ] || usage
shift
case "$CMD" in collect|finish|abort) ;; *) 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 ;;
*) usage ;;
esac
done
[ -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
count_tasks
tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_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 '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 'pending=%s\n' "$PEND_COUNT"
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.tsv"
[ "$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 || _rc=$?
[ "$_rc" -eq 0 ] \
|| die 5 "heartbeat.sh write 回 $_rc,心跳沒寫成:$(tr '\n' ' ' <"$RD/finish.err" 2>/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 ;;
esac