diff --git a/tools/run-due.sh b/tools/run-due.sh new file mode 100755 index 0000000..56d28a6 --- /dev/null +++ b/tools/run-due.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env sh +# run-due.sh — 把到期的內建檢查項真的跑一遍,成敗回寫待辦簿。 +# +# 用法: +# run-due.sh run [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}] [--dry-run] +# run-due.sh plan [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}] +# +# run 逐筆執行並回寫待辦簿。--dry-run 只印要跑什麼、不執行也不回寫 +# plan 等同 run --dry-run,另取一個名字是為了讓唯讀那一路在指令列上看得出來 +# +# 結束碼: +# 0 這一輪跑完了。零筆到期、每一筆都代不出目標,都算跑完 +# 1 至少一支指令回非零。那一筆已經記成失敗、失敗次數加一,其餘各筆照跑 +# 2 到期清單讀不到或欄位對不上,**一筆都沒跑**。判到期那一支沒跑過,或它的輸出換了格式 +# 4 至少一筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆 +# 5 檔案系統失敗:暫存檔寫不進去 +# 6 用法錯誤:不認得的子命令或選項、選項缺值、--root 不是絕對路徑 +# 同時命中好幾碼時,回報順序是 5、2、4、1:前面的蓋掉後面的。4 排在 1 前面是因為 +# 「跑了但沒記到」會讓同一件事每輪重跑,比「跑了而且失敗」更需要人知道。 +# +# --- 這一支只跑指令型,不叫技能也不送提醒 --- +# +# 待辦簿的動作欄有三種值:一行指令、技能名、remind。這一支只跑第一種。 +# 技能名那一種不跑的理由:叫用一整支技能的代價與風險都大得多——它會寫 wiki、開 PR、改檔案, +# 而這一支跑在沒有人看的那一輪裡。唯讀盤點指令失敗最多就是回一個非零碼,那一筆記一次失敗; +# 一支技能中途失敗可能留下寫到一半的頁或開錯的 PR。兩種代價不同,就該分兩次判、分兩次接。 +# remind 那一種不跑的理由更直接:它本來就沒有東西可跑,提醒怎麼送到前景是另一件待辦。 +# +# --- 只跑內建項,使用者交辦的一律不碰 --- +# +# 只有 spec_key 非空的那幾筆會被執行,也就是依委派清單種入的內建項。 +# 理由是那幾筆的指令經過種入那一支的檢核:路徑一定是 {root}/jsc-{domain}/ 開頭、不含金錢符號 +# 與波浪號、代入點只認得三個、腳本一定在這台機器上。使用者親手登錄的那幾筆沒有經過那道關, +# 動作欄想寫什麼都行,在無人值守那一輪把它送進殼是另一回事,要另外判。 +# 執行這一刻再驗一次同樣那幾項,不因為種入時驗過就省掉:待辦檔是純文字,中間可能被改。 +# +# --- 權限閘門與這一支的關係,講白 --- +# +# 這一支自己執行那些指令,所以權限層只看得到 run-due.sh 這一條指令,看不到裡面跑了什麼。 +# 那不是繞過閘門,是閘門本來就不是沙箱:它是一份「可信入口」的白名單,而清單上的每一支腳本 +# 本來就做得了它該做的事——巡檢那一支會寫 wiki 頁,部署那一支會改整台機器的外掛。 +# 防線改由三層擋:一、只跑 spec_key 非空的內建項,那幾筆的來源是版本控管、要走 PR 的委派清單; +# 二、執行前再驗一次指令形狀,不信任待辦檔的內容;三、代不出來的目標一律不跑。 +# 這一段寫在這裡是因為下一個維護的人一定會問,而「為什麼不會被擋」跟「為什麼不需要被擋」 +# 是兩個不同的答案,後者才是對的那一個。 +# +# --- 代不出目標不算失敗 --- +# +# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來, +# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的 +# last_run,也不加失敗次數**。 +# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數 +# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。 +# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。 +set -u + +usage() { + echo 'usage: run-due.sh {run|plan} [--rows 檔案] [--root 絕對路徑] [--now epoch] [--dry-run]' >&2 + exit 6 +} + +die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; } +note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; } +warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; } + +[ "$#" -ge 1 ] || usage +MODE=$1; shift +case "$MODE" in + run) DRYRUN=0 ;; + plan) DRYRUN=1 ;; + *) usage ;; +esac + +ROWS='' +OPT_ROOT='' +NOW='' +while [ "$#" -gt 0 ]; do + case "$1" in + --rows) [ "$#" -ge 2 ] || usage; ROWS=$2; shift 2 ;; + --root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;; + --now) [ "$#" -ge 2 ] || usage; NOW=$2; shift 2 ;; + --dry-run) DRYRUN=1; shift ;; + *) usage ;; + esac +done + +case "$OPT_ROOT" in + ''|/*) ;; + *) die 6 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;; +esac + +# 助理狀態目錄。判到期那一支把 rows.txt 放在這底下,兩支要對得上同一個位置。 +JSC_HOME_RESOLVED=${JSC_HOME:-${HOME:-}/.jsc} +case "$JSC_HOME_RESOLVED" in + /*) ;; + *) die 6 'JSC_HOME 與 HOME 都解不出絕對路徑,找不到助理狀態目錄。' ;; +esac +[ -n "$ROWS" ] || ROWS="$JSC_HOME_RESOLVED/assistant/due/rows.txt" + +[ -f "$ROWS" ] || die 2 "到期清單讀不到:$ROWS。請先跑 due.sh scan——沒跑過判定,跟「都沒到期」不是同一件事。" + +TAB=$(printf '\t') +TMPD=$(mktemp -d 2>/dev/null) || die 5 '暫存目錄建不起來。' +trap 'rm -rf "$TMPD"' EXIT + +[ -n "$NOW" ] || NOW=$(date -u +%s) +NOW_ISO=$(date -u -d "@$NOW" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \ + || NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + +# --- 找同一組工具 --- + +HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P) +ROOT="$OPT_ROOT" +# 沒帶 --root 就從自己的位置往上推兩層。呼叫端該餵進來,這只是開發時跑得動的退路。 +[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT='' + +find_tool() { # $1=domain $2=相對路徑 + for _d in "jsc-$1" "$1"; do + [ -n "$ROOT" ] && [ -f "$ROOT/$_d/$2" ] && { printf '%s' "$ROOT/$_d/$2"; return 0; } + done + return 1 +} + +TASKS_SH="$HERE/tasks.sh" +[ -f "$TASKS_SH" ] || TASKS_SH=$(find_tool assist tools/tasks.sh) \ + || die 2 '找不到 tasks.sh,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。' +DUE_SH="$HERE/due.sh" +[ -f "$DUE_SH" ] || DUE_SH=$(find_tool assist tools/due.sh) || DUE_SH='' + +# --- {cli} 的代入來源 --- + +# 偵測到的 CLI 代號,一行一個。取不到就當成代不出來,帶 {cli} 的那幾筆這一輪跳過。 +CLIS="$TMPD/clis.txt" +: >"$CLIS" +if _det=$(find_tool cli tools/detect-clis.sh); then + "$_det" 2>/dev/null | awk -F"$TAB" 'NF>0 && $1 != "" {print $1}' >"$CLIS" 2>/dev/null || : >"$CLIS" +fi +N_CLI=$(awk 'END{print NR+0}' "$CLIS") + +# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令, +# 而那一輪沒有人看得到它跑到哪裡去了。 +REPO_READY=0 + +# --- 判斷動作是哪一種 --- + +# 技能名的形狀是 jsc-{domain}:{技能名}。比對整串,不用前綴比對:一行以 jsc- 開頭的指令 +# 路徑不該被當成技能名。 +is_skill_name() { # $1=action + case "$1" in + jsc-*:*) + case "$1" in + *' '*|*/*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +# 執行前再驗一次指令形狀。種入那一支驗過同樣幾項,這裡不省:待辦檔是純文字,中間可能被改。 +# 驗不過就當成代不出來,跳過不跑,不記失敗——這一筆的內容有問題,那是清單或待辦檔的事。 +cmd_shape_ok() { # $1=指令 + case "$1" in + *'$'*|*'`'*|*'~'*) BAD_WHY='指令裡有金錢符號、反引號或波浪號'; return 1 ;; + *';'*|*'|'*|*'&'*) BAD_WHY='指令裡有分號、管線或連接符號,一筆內建項只該是一行單一指令'; return 1 ;; + esac + # 路徑一定要落在這一輪的根目錄底下。字面絕對路徑是權限層唯一認得的形狀,也是唯一 + # 看得出「這支腳本是不是我們自己的」的形狀。 + case "$1" in + *"$ROOT"/*) ;; + *) BAD_WHY="指令裡沒有這一輪的根目錄 $ROOT"; return 1 ;; + esac + return 0 +} + +# --- 逐筆處理 --- + +N_DUE=0; N_RUN=0; N_OK=0; N_FAIL=0; N_HELD=0; N_SKIP=0; N_WRITE_BAD=0 +RC_CMD=0; RC_WRITE=0 + +# 欄位順序取自 due.sh 印的 rows_columns=。這裡寫死同一個順序,換了就對不上——所以先驗一次 +# 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。 +_cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null) +case "${_cols:-0}" in + 11) ;; + 0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=11 ;; + *) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 11 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。" ;; +esac + +while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recur c_next c_rearm c_why c_rest; do + [ -n "${c_id:-}" ] || continue + [ "${c_verdict:-}" = "due" ] || continue + N_DUE=$((N_DUE + 1)) + + # 只跑內建項。使用者交辦的那幾筆沒有經過種入那一支的檢核,動作欄想寫什麼都行。 + _spec=$(sed -n 's/^spec_key=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1) + if [ -z "$_spec" ]; then + N_SKIP=$((N_SKIP + 1)) + printf 'skip=%s reason=不是委派清單種入的內建項,這一支不跑使用者交辦的那幾筆\n' "$c_id" + continue + fi + + # 三種動作,這一輪只跑第一種。 + if [ "$c_action" = "remind" ]; then + N_SKIP=$((N_SKIP + 1)) + printf 'skip=%s spec=%s reason=動作是只提醒,沒有東西可跑\n' "$c_id" "$_spec" + continue + fi + if is_skill_name "$c_action"; then + N_SKIP=$((N_SKIP + 1)) + printf 'skip=%s spec=%s reason=動作是技能名,叫用整支技能這一批還沒接 action=%s\n' \ + "$c_id" "$_spec" "$c_action" + continue + fi + + BAD_WHY='' + if ! cmd_shape_ok "$c_action"; then + N_HELD=$((N_HELD + 1)) + printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$BAD_WHY" "$c_action" + continue + fi + + # 代入點展開:一個目標一行,寫進暫存檔。 + _targets="$TMPD/targets.$c_id" + : >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。" + _hold='' + case "$c_action" in + *'{repo}'*) + [ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{repo} 代不出目標' ;; + esac + if [ -z "$_hold" ]; then + case "$c_action" in + *'{cli}'*) + if [ "$N_CLI" -eq 0 ]; then + _hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' + else + while IFS= read -r _c; do + [ -n "$_c" ] || continue + printf '%s\n' "$(printf '%s' "$c_action" | sed "s|{cli}|$_c|g")" >>"$_targets" + done <"$CLIS" + fi ;; + *) printf '%s\n' "$c_action" >>"$_targets" ;; + esac + fi + if [ -n "$_hold" ]; then + N_HELD=$((N_HELD + 1)) + printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$_hold" "$c_action" + continue + fi + + # 代完之後還留著大括號就是還有認不得的代入點。原樣送進殼會跑到一個沒有人寫過的地方。 + if grep -q '[{}]' "$_targets" 2>/dev/null; then + N_HELD=$((N_HELD + 1)) + printf 'held=%s spec=%s reason=代完之後還留著大括號,認不得的代入點 action=%s\n' \ + "$c_id" "$_spec" "$c_action" + continue + fi + + _n=$(awk 'END{print NR+0}' "$_targets") + if [ "$DRYRUN" -eq 1 ]; then + N_RUN=$((N_RUN + 1)) + printf 'would_run=%s spec=%s targets=%s\n' "$c_id" "$_spec" "$_n" + while IFS= read -r _t; do printf ' target=%s\n' "$_t"; done <"$_targets" + continue + fi + + # 真的跑。一個目標一次,全部成功這一筆才算成功——一支 CLI 接線壞掉就是一筆要人看的發現。 + N_RUN=$((N_RUN + 1)) + _entry_rc=0 + _first_err='' + while IFS= read -r _t; do + [ -n "$_t" ] || continue + _out=$(sh -c "$_t" &1); _rc=$? + if [ "$_rc" -eq 0 ]; then + printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t" + else + _entry_rc=1 + [ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | head -n1 | cut -c1-160) + printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \ + "$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | head -n1 | cut -c1-160)" + fi + done <"$_targets" + + # 回寫。下一次什麼時候到期由判定那一支算,這一支不自己算——兩邊各算一次就會漂移。 + if [ "$_entry_rc" -eq 0 ]; then + _next='' + if [ -n "$DUE_SH" ]; then + _next=$("$DUE_SH" next --trigger "$c_trigger" --recur "$c_recur" \ + --last-run "$NOW_ISO" --now "$NOW" 2>/dev/null | sed -n 's/^next_run=//p' | head -n1) + fi + if [ -n "$_next" ]; then + "$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" --next-run "$_next" >/dev/null 2>&1 + else + "$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1 + fi + _wrc=$? + if [ "$_wrc" -eq 0 ]; then + N_OK=$((N_OK + 1)) + printf 'done=%s spec=%s targets=%s next_run=%s\n' "$c_id" "$_spec" "$_n" "${_next:--}" + else + N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1 + printf 'write_failed=%s spec=%s op=done rc=%s\n' "$c_id" "$_spec" "$_wrc" + fi + else + RC_CMD=1 + "$TASKS_SH" fail "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1; _wrc=$? + if [ "$_wrc" -eq 0 ]; then + N_FAIL=$((N_FAIL + 1)) + printf 'failed=%s spec=%s targets=%s first_error=%s\n' "$c_id" "$_spec" "$_n" "${_first_err:--}" + else + N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1 + printf 'write_failed=%s spec=%s op=fail rc=%s\n' "$c_id" "$_spec" "$_wrc" + fi + fi +done <"$ROWS" + +printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s\n' \ + "$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \ + "$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" + +[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。" +[ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。" +[ "$RC_WRITE" -ne 0 ] && warn "有 $N_WRITE_BAD 筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆,逐筆印在上面的 write_failed= 那幾行,各自帶了 tasks.sh 的結束碼。" +[ "$RC_CMD" -ne 0 ] && warn "有 $N_FAIL 筆的指令回非零,已經記成失敗、失敗次數加一。逐筆印在上面的 failed= 與 target_fail= 那幾行。" + +[ "$RC_WRITE" -eq 0 ] || exit 4 +[ "$RC_CMD" -eq 0 ] || exit 1 +exit 0