Files
assist/tools/schedule.sh
T
jiantw83 9ea83da38b feat(link): 連結一律寫成 [文字](絕對網址),寫入前先驗證連得到
取消 [[頁名]] 與 [[顯示文字|頁名]] 兩種同 wiki 寫法,不再分「同存取庫」與
「跨存取庫」兩條規則。那種寫法只在自己那個 wiki 內解析,寫錯不報錯,畫面上
看起來像普通文字或死連結,巡不到也修不了。

連結寫進頁面前先過 jsc-gitea 的 link-check.sh,結束碼 0 才寫。驗證一律走 API,
不看網頁狀態碼:私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把
好連結判成壞的。認證失敗回 7,與死連結的 1 分開,免得金鑰一過期就把還在的頁
整批判死。
2026-09-02 14:27:18 +08:00

691 lines
32 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
# schedule.sh — 助理系統排程的安裝、移除與查現況(供 jsc-assist:assistant 呼叫)。
#
# 用法:
# schedule.sh install [patrol|all] [--dry-run] [--cli {代號}] [--patrol-cmd {指令}] [--period {分鐘}]
# schedule.sh remove [heartbeat|patrol|all] [--dry-run]
# schedule.sh status [--dry-run]
#
# 工作代號省略時一律是 patrol。這一版只裝巡檢那一筆,心跳由巡檢跑完那一輪自己寫。
#
# 結束碼:
# 0 成功:install 條目寫進去也回讀得到、排程服務在跑;remove 移除完成,或本來就沒裝;
# status 印完現況(有沒有裝都算成功,看 installed 欄)
# 1 install 寫進去了,但排程服務沒在跑——條目不會被執行。WSL 預設不啟動 cron,這一碼
# 多半就是它。呼叫端一律照實講「排程裝了但不會執行」,不可以宣稱會定時執行
# 2 找不到 jsc-hooks 的 hooks/heartbeat.sh。心跳門檻查不到,週期算不出來,整支停下
# 3 這台機器沒有可用的排程機制:認不得作業系統,或 crontab 與 schtasks 都找不到
# 4 排程操作失敗:讀不到現有排程(且失敗原因不是「沒有排程」)、寫入或刪除回非 0
# 5 回讀驗證失敗:寫入回 0 但條目不在,或移除回 0 但條目還在,又或其他人的條目數量對不上
# 6 用法錯誤:不認得的子命令、不認得的工作代號、缺參數、判不出要用哪一支 CLI 跑巡檢,
# 或 --period 給的週期塞不進心跳的過期門檻
#
# --- 排程只叫巡檢,心跳由巡檢寫 ---
#
# 舊版排程每分鐘直接呼叫 heartbeat.sh write。那樣心跳新鮮只證明 cron 活著:巡檢整個壞掉、
# 一輪都沒跑成,心跳照樣新鮮,靠心跳判定的閘門照樣放行,沒有任何訊號。
# 現在排程只叫巡檢,巡檢把結果寫上監控頁之後才寫那一次心跳。心跳新鮮於是等於「上一輪巡檢
# 真的做完了,而且結果記下來了」。
# 所以 `install heartbeat` 直接回 6,不給裝:裝了就是有第二個寫心跳的人,心跳的意思立刻回到
# 舊版。舊機器上留著的那一筆 heartbeat 條目,`install patrol` 會順手清掉,並在輸出印
# legacy_removed=1——不清的話它每分鐘照樣寫,這次改動等於白做。
# `remove` 與 `status` 仍然認得 heartbeat 這個代號,就是為了清掉與看得到那一筆舊條目。
#
# --- 巡檢週期怎麼定 ---
#
# 心跳的更新頻率現在等於巡檢週期,所以週期一定要塞得進心跳的過期門檻,不然心跳永遠是過期。
# 門檻不寫死,改讀 `heartbeat.sh report` 的 `ttl` 欄——那是這台機器實際生效的值。
# 週期取「漏掉一輪還算新鮮、漏掉兩輪就過期」:2 × 週期 × 60 < 門檻,再往下取一個能整除一小時
# 的分鐘數,排程間隔才規律。門檻預設 300 秒時算出來是每 2 分鐘一輪。
# 要拉長巡檢週期就先把門檻調大(`JSC_ASSISTANT_HEARTBEAT_TTL`,單位秒),週期會跟著變長:
# 門檻 1800 秒算出每 12 分鐘一輪。週期與門檻兩個數字綁在一起算,不會再各走各的。
#
# --- 只動自己那一筆 ---
#
# 每一筆條目行尾都帶固定標記「# jsc-assist:assistant {工作代號}」,安裝與移除都靠它比對。
# 安裝先用 `grep -vF` 濾掉自己這幾個工作的舊條目,再把新條目追加上去,整份寫回;**絕不**
# 把 crontab 當空的重寫。`crontab -l` 在沒有任何排程時會回非 0,錯誤訊息才分得出是「沒有
# 排程」還是「權限不足」——後者當成空的寫回去,會把使用者整份排程刪光,所以這裡讀不懂
# 錯誤訊息就直接回 4,不猜。
# 寫回之後還會數行數:其他人的條目一行都不能少,少了就回 5。
#
# --- 排程服務沒在跑,等於沒裝 ---
#
# 條目寫進去不代表會被執行。WSL 預設不啟動 cron,要 `sudo service cron start`,而且重開
# WSL 之後要再啟動一次。安裝完一律檢查服務在不在跑,沒跑就回 1 並講清楚,不可以只回報
# 「排程已建立」。
#
# --- 排程是非互動環境 ---
#
# 條目一律接 `</dev/null`,跑的東西讀不到標準輸入,卡不住。heartbeat.sh 本來就不讀 stdin,
# 這一條是給巡檢那一筆用的:助理跳出權限詢問就會整輪卡死,等於排程沒跑。
#
# --- 排程那一輪的環境跟你現在這個殼不一樣 ---
#
# cron 給的 PATH 很短,多半只有 /usr/bin 與 /bin,裝在 ~/.local/bin 的 CLI 一律 not found。
# cron 也不讀 .bashrc,所以殼裡設好的 GITEA_HOST、GITEA_TOKEN、JSC_WIKI_REPO_* 那一輪全是
# 空的,wiki 連不上,監控頁寫不成,巡檢就不寫心跳,整條排程空轉。
# 所以條目自己把環境帶齊,兩件事:
# 一、CLI 用 `command -v` 解成絕對路徑再寫進條目,解不到就回 6 停下,不猜路徑——猜錯只是
# 把「每輪 not found」換成「每輪跑錯東西」,一樣沒有人在現場看得到。
# 二、要用到的環境變數在 install 當下就地快照,以 `名稱='值'` 寫成條目的指令前綴。
# 條目裡不 source 任何設定檔:很多機器的 .bashrc 開頭就是「非互動就 return」,那一招在 cron
# 底下會安靜失效,失效的樣子跟沒設一模一樣,查起來要花一整輪。快照的代價是條目寫死了安裝
# 當下的值,環境變數改過就要重跑一次 install,條目才會跟著換。
# 條目裡帶著金鑰快照,crontab 檔案要保持只有本人讀得到;本腳本印出條目時一律把金鑰遮掉。
#
# --- 無人值守那一輪要自己帶寫入確認 ---
#
# jsc-gitea 的寫入確認只認 tty,而條目帶 `</dev/null`,排程那一輪永遠沒有 tty,寫 wiki 一定
# 被擋。頁寫不成就不寫心跳,於是排程裝著卻永遠空轉。所以條目自帶 JSC_GITEA_CONFIRM=yes:
# 無人值守的那一輪本來就沒有人可以按同意,擋下來也沒有人會看到。
#
# --- 裝完要開哪些權限 ---
#
# 排程那一輪跑在沒有人的工作階段,跳出權限詢問就是卡住整輪,而且卡到鎖逾時才有下一輪。
# install 成功之後會把那一輪需要的 allow 規則印出來,一行一條。
# 路徑一律走 $JSC_HOME/current/{外掛名}:那是一組不帶版本的連結,指向該外掛在快取裡的最新
# 版,由 deploy 維護。規則就是那組確切路徑,比對得準,外掛升版也不用回頭改設定。
# 檔案寫入只認 Edit(...),Write(...) 規則沒有作用,所以不印 Write。
# 連結不在就先警告:那一輪會因為找不到工具而失敗。連結由 deploy 建,本腳本不代建——排程
# 腳本自己去補外掛的部署結構,等於兩個地方管同一件事,壞掉的時候查不出是誰建的。
#
# --- log 放哪裡 ---
#
# $JSC_HOME/assistant/schedule.log(JSC_HOME 未設定時為 ~/.jsc)。刻意放在專案外面:寫進
# 任何存取庫都會多出未追蹤檔,污染別人的變更盤點。
#
# 環境變數:
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc
# JSC_ASSIST_CRONTAB_CMD crontab 執行檔,預設 crontab。crontab 不在標準路徑,或要用
# 假的 crontab 驗濾除邏輯時才設
# JSC_ASSIST_PATROL_CMD 巡檢要跑的指令,優先於 --patrol-cmd 以外的所有推斷
# JSC_CLI 目前是哪一支 CLI,決定巡檢預設指令
# JSC_ASSISTANT_HEARTBEAT_TTL 心跳過期門檻,單位秒。巡檢週期由它算出來,也會快照進條目
# GITEA_HOST GITEA_TOKEN wiki 連線用。install 當下快照進條目
# JSC_WIKI_REPO wiki 存取庫預設值。install 當下快照進條目
# JSC_WIKI_REPO_{TYPE} 各頁型的 wiki 存取庫。已設定的全部快照進條目。名單是當下從
# 環境撈出來的,不寫死,所以新增頁型自動涵蓋,這支不必跟著改。
# 目錄頁那一支專用變數也在裡面
set -u
MARK_PREFIX='# jsc-assist:assistant'
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
STATE_DIR="$JSC_HOME/assistant"
CURRENT="$JSC_HOME/current"
LOG="$STATE_DIR/schedule.log"
CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}"
TASK_PREFIX='jsc-assist-assistant'
DRYRUN=0
CLI=''
PATROL_CMD="${JSC_ASSIST_PATROL_CMD:-}"
PERIOD=''
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
SCRIPT_DIR="${SCRIPT_DIR:-.}"
# 這支腳本是不是從 $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
usage() {
cat >&2 <<'EOF'
usage: schedule.sh install [patrol|all] [--dry-run] [--cli 代號] [--patrol-cmd 指令] [--period 分鐘]
schedule.sh remove [heartbeat|patrol|all] [--dry-run]
schedule.sh status [--dry-run]
EOF
exit 6
}
die() { # $1=結束碼 $2=訊息
printf '[jsc][助理排程][ERR]:%s\n' "$2" >&2
exit "$1"
}
note() { printf '[jsc][助理排程]:%s\n' "$1" >&2; }
# 找出 jsc-hooks 的 hooks/heartbeat.sh 絕對路徑。搜尋順序:先環境變數覆寫,再
# $JSC_HOME/current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的 plugin 快取版面。
# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部再自己去挑另一個
# 版本,同一輪就會跑到混版的工具,而那種不一致查起來沒有任何線索。
heartbeat_sh() {
if [ -n "${JSC_HOOKS_HOOKS:-}" ] && [ -f "$JSC_HOOKS_HOOKS/heartbeat.sh" ]; then
printf '%s\n' "$JSC_HOOKS_HOOKS/heartbeat.sh"; return 0
fi
if [ -f "$CURRENT/jsc-hooks/hooks/heartbeat.sh" ]; then
printf '%s\n' "$CURRENT/jsc-hooks/hooks/heartbeat.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}"
for _c in "$_root/../hooks/hooks/heartbeat.sh" "$_root/../jsc-hooks/hooks/heartbeat.sh"; do
[ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/heartbeat.sh\n' "$(pwd)"); return 0; }
done
_c=$(ls -d "$_root"/../../jsc-hooks/*/hooks/heartbeat.sh \
"$_root"/../../hooks/*/hooks/heartbeat.sh \
"$HOME"/.claude/plugins/cache/*/jsc-hooks/*/hooks/heartbeat.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v heartbeat.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
os_kind() {
case "$(uname -s 2>/dev/null)" in
Linux) printf 'linux' ;;
Darwin) printf 'darwin' ;;
MINGW*|MSYS*|CYGWIN*|Windows_NT) printf 'windows' ;;
*) printf 'unknown' ;;
esac
}
# 這台機器要用哪一種排程機制。Linux、WSL 與 macOS 都用 crontab;macOS 若要改用 launchd
# 請自行改寫,本腳本不代為產生 plist。
mechanism() {
case "$(os_kind)" in
windows) command -v schtasks >/dev/null 2>&1 && { printf 'schtasks'; return 0; } ;;
linux|darwin) command -v "$CRONTAB_CMD" >/dev/null 2>&1 && { printf 'crontab'; return 0; } ;;
esac
return 1
}
# 排程服務在不在跑。回 running、stopped 或 unknown。
service_state() {
case "$(os_kind)" in
darwin)
# macOS 的 cron 由 launchd 隨用隨起,查不到行程不代表沒在跑,一律回 unknown。
printf 'unknown'; return 0 ;;
windows)
printf 'unknown'; return 0 ;;
esac
if command -v pgrep >/dev/null 2>&1; then
if pgrep -x cron >/dev/null 2>&1 || pgrep -x crond >/dev/null 2>&1; then
printf 'running'
else
printf 'stopped'
fi
return 0
fi
if command -v ps >/dev/null 2>&1; then
if ps -e 2>/dev/null | grep -qE '[ /](cron|crond)$'; then printf 'running'; else printf 'stopped'; fi
return 0
fi
printf 'unknown'
}
marker_of() { printf '%s %s' "$MARK_PREFIX" "$1"; }
# 數行數。用 grep -c '' 不用 wc -l:最後一行沒有換行時 wc -l 會少數一行。
# grep 數到 0 會回非 0,數字照樣印得出來,所以只在完全沒有輸出時才補 0。
count_lines() { _n=$(grep -c '' "$1" 2>/dev/null); [ -n "$_n" ] || _n=0; printf '%s' "$_n"; }
# 數管線進來的行數,語意同 count_lines。
count_stdin() { _n=$(grep -c '' 2>/dev/null); [ -n "$_n" ] || _n=0; printf '%s' "$_n"; }
# 這台機器實際生效的心跳過期門檻。取自 heartbeat.sh report 的 ttl 欄,不自己重算:
# 門檻的唯一來源是那支腳本,兩邊各算一次就會漂移。讀不到就退回 300。
heartbeat_ttl() {
_t=$("$HEARTBEAT" report </dev/null 2>/dev/null | tr ' ' '\n' | sed -n 's/^ttl=//p' | head -n1)
case "$_t" in ''|*[!0-9]*) _t=300 ;; esac
[ "$_t" -gt 0 ] || _t=300
printf '%s' "$_t"
}
# 由門檻算出巡檢週期,單位分鐘。條件是 2 × 週期 × 60 < 門檻:漏掉一輪還算新鮮,漏掉兩輪
# 才過期。再往下取一個能整除一小時的分鐘數,`*/N` 的間隔才規律。
period_for_ttl() { # $1=門檻秒數
_max=$(( ($1 - 1) / 120 ))
[ "$_max" -lt 1 ] && _max=1
_p=1
for _d in 1 2 3 4 5 6 10 12 15 20 30 60; do
[ "$_d" -le "$_max" ] && _p="$_d"
done
printf '%s' "$_p"
}
spec_of() {
case "$1" in
heartbeat) printf '* * * * *' ;;
patrol) printf '*/%s * * * *' "$PERIOD" ;;
esac
}
# 巡檢要跑的指令。優先序:--patrol-cmd 或 JSC_ASSIST_PATROL_CMD > 依 CLI 代號推斷。
# 判不出 CLI,或那一支的執行檔不在 PATH 上,都回非 0 由主流程回 6,不猜。
# 執行檔一律用 `command -v` 解成絕對路徑:cron 的 PATH 只有 /usr/bin 與 /bin,裸的指令名
# 每一輪都是 not found,而那一輪不會有人看到錯誤訊息。
patrol_command() {
[ -n "$PATROL_CMD" ] && { printf '%s' "$PATROL_CMD"; return 0; }
_cli="$CLI"
[ -n "$_cli" ] || _cli="${JSC_CLI:-}"
[ -n "$_cli" ] || { [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && _cli=claude; }
case "$_cli" in
claude) _bin='claude'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;;
codex) _bin='codex'; _args="exec '\$assistant 跑一輪巡檢'" ;;
copilot) _bin='copilot'; _args='-p "跑一輪助理巡檢"' ;;
antigravity) _bin='agy'; _args='-p "/jsc-assist:assistant 跑一輪巡檢"' ;;
kiro) _bin='kiro-cli'; _args='-p "跑一輪助理巡檢"' ;;
*) printf '判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。\n' >&2
return 1 ;;
esac
_abs=$(command -v "$_bin" 2>/dev/null) || _abs=''
# 內建指令與別名也會被 command -v 認出來,但那些寫進 cron 沒有意義,只收絕對路徑。
case "$_abs" in
/*) ;;
*) printf '這台機器的 PATH 上找不到 %s 的執行檔,排程條目沒有絕對路徑可以寫。請先確認 %s 裝好了,或改用 --patrol-cmd 指定完整指令。\n' "$_cli" "$_bin" >&2
return 1 ;;
esac
printf "'%s' %s" "$_abs" "$_args"
}
# 要快照進條目的環境變數名稱。前四支是巡檢那一輪一定要用到的;JSC_WIKI_REPO 系列逐台機器
# 不同,直接從現在的環境撈出所有已設定的,不寫死清單。
# 不寫死是刻意的:頁型會增加,目錄頁後來也分了自己的存取庫變數,寫死清單就要跟著改一次,
# 而漏掉的那一個在排程那一輪是空值,wiki 寫不成、心跳不寫,外面只看得到心跳過期。
snapshot_names() {
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
env 2>/dev/null \
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
| sort -u
}
# 把值包成單引號字串。值裡的單引號照 POSIX 的 '\'' 寫法拆開再接回來,不然一個引號就把
# 整條 cron 指令切斷。
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值——
# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
# 一定寫不成,而頁寫不成就不寫心跳,排程等於永遠空轉。
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
_p='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes'
_names=''
for _n in $(snapshot_names); do
eval "_v=\${$_n:-}"
[ -n "$_v" ] || continue
_p="$_p $_n=$(shq "$_v")"
if [ -z "$_names" ]; then _names="$_n"; else _names="$_names,$_n"; fi
done
[ -n "${1:-}" ] && printf '%s' "$_names" >"$1"
printf '%s' "$_p"
}
# 條目裡有金鑰快照,原樣印出來就是把金鑰留在對話紀錄與 log 裡,所以印之前先遮掉值。
# 遮到下一個空白為止,不遮到下一個單引號:值裡的單引號會跳脫成 '\'',遇到引號就收手會把
# 金鑰的後半段漏出來。Gitea 的金鑰不含空白,所以以空白為界是安全的。
mask_secret() { sed "s/GITEA_TOKEN=[^ ]*/GITEA_TOKEN='***'/g"; }
# 組出一筆 crontab 條目。`%` 在 crontab 是換行符號,一律跳脫。
cron_entry() { # $1=工作代號
_spec=$(spec_of "$1")
case "$1" in
heartbeat) _cmd="JSC_CLI=cron JSC_SESSION_ID=schedule '$HEARTBEAT' write" ;;
patrol) _cmd="$ENV_PREFIX $PATROL_RESOLVED" ;;
esac
printf '%s %s </dev/null >>%s 2>&1 %s' \
"$_spec" "$_cmd" "'$LOG'" "$(marker_of "$1")" | sed 's/%/\\%/g'
}
task_name() { printf '%s-%s' "$TASK_PREFIX" "$1"; }
# 讀現有的 crontab 到 $1。沒有任何排程時 crontab -l 會回非 0,那算正常;讀不懂的錯誤
# 一律回 4,不當成空的——當成空的寫回去會把使用者整份排程刪光。
cron_read() { # $1=輸出檔
_err="$TMPD/err"
if "$CRONTAB_CMD" -l >"$1" 2>"$_err"; then return 0; fi
if [ ! -s "$_err" ] || grep -qiE 'no crontab|沒有 crontab' "$_err"; then
: >"$1"; return 0
fi
die 4 "讀不到現有排程,原因不是「沒有排程」:$(tr '\n' ' ' <"$_err")"
}
cron_lines_for() { # $1=crontab 檔 $2=工作代號;印出該工作的條目
grep -F "$(marker_of "$2")" "$1" 2>/dev/null || true
}
# --- 參數解析 ---
CMD="${1:-}"
[ -n "$CMD" ] || usage
shift
case "$CMD" in install|remove|status) ;; *) usage ;; esac
JOBS='patrol'
if [ "$#" -gt 0 ]; then
case "$1" in
heartbeat) JOBS='heartbeat'; shift ;;
patrol) JOBS='patrol'; shift ;;
all) JOBS='heartbeat patrol'; shift ;;
esac
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRYRUN=1; shift ;;
--cli) [ "$#" -ge 2 ] || usage; CLI="$2"; shift 2 ;;
--patrol-cmd) [ "$#" -ge 2 ] || usage; PATROL_CMD="$2"; shift 2 ;;
--period) [ "$#" -ge 2 ] || usage; PERIOD="$2"; shift 2 ;;
*) usage ;;
esac
done
# 心跳那一筆不給裝。理由見檔頭「排程只叫巡檢,心跳由巡檢寫」:多一個寫心跳的人,心跳的
# 意思立刻退回舊版。install all 只裝巡檢那一筆。
case "$CMD:$JOBS" in
install:heartbeat)
die 6 '心跳那一筆不裝了。心跳改由巡檢跑完那一輪自己寫,排程只叫巡檢:請跑 `schedule.sh install patrol`。舊機器上留著的 heartbeat 條目,install patrol 會順手清掉。' ;;
install:*heartbeat*) JOBS='patrol' ;;
esac
HEARTBEAT=$(heartbeat_sh) \
|| die 2 '找不到 jsc-hooks 的 hooks/heartbeat.sh,心跳的過期門檻查不到,巡檢週期算不出來。請先安裝 jsc-hooks 0.3.7 以上。'
MECH=$(mechanism) \
|| die 3 "這台機器沒有可用的排程機制(作業系統:$(os_kind),找不到 $CRONTAB_CMD 或 schtasks)。"
# 巡檢週期與心跳門檻綁在一起算。--period 給的值一樣要通過同一條式子,否則裝出來的排程
# 會讓心跳永遠過期,而且沒有人看得出來是週期設錯。
TTL=$(heartbeat_ttl)
if [ -n "$PERIOD" ]; then
case "$PERIOD" in ''|*[!0-9]*) usage ;; esac
[ "$PERIOD" -ge 1 ] || usage
[ $(( 2 * PERIOD * 60 )) -lt "$TTL" ] \
|| die 6 "巡檢週期 $PERIOD 分鐘塞不進心跳的過期門檻 $TTL 秒(要 2 × 週期 × 60 < 門檻)。心跳現在由巡檢寫,週期比門檻長的話心跳永遠是過期。請改短週期,或先把 JSC_ASSISTANT_HEARTBEAT_TTL 調大。"
else
PERIOD=$(period_for_ttl "$TTL")
fi
TMPD=$(mktemp -d) || die 4 '建不出暫存目錄。'
trap 'rm -rf "$TMPD"' EXIT
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
PATROL_RESOLVED=''
ENV_PREFIX='JSC_CLI=cron JSC_SESSION_ID=schedule JSC_GITEA_CONFIRM=yes'
SNAPSHOT_NAMES=''
case "$CMD:$JOBS" in
install:*patrol*)
if ! PATROL_RESOLVED=$(patrol_command 2>"$TMPD/cmd.err"); then
_why=$(tr '\n' ' ' <"$TMPD/cmd.err" 2>/dev/null)
[ -n "$_why" ] || _why='判不出要用哪一支 CLI 跑巡檢,請帶 --cli {claude|codex|copilot|antigravity|kiro} 或 --patrol-cmd「指令」。'
die 6 "$_why"
fi
ENV_PREFIX=$(env_prefix "$TMPD/snapnames")
SNAPSHOT_NAMES=$(cat "$TMPD/snapnames" 2>/dev/null) ;;
esac
# --- 裝完要開的權限 ---
# 排程那一輪跑在沒有人的工作階段:跳出一次權限詢問就是整輪卡住,卡到鎖逾時才有下一輪。
# 這一輪會用到的每一支腳本,各印裸路徑、`sh 路徑`、`bash 路徑` 三種呼叫形式——同一支腳本換
# 一種叫法就是另一條規則,少印一種就會在那一種叫法上卡住。
# 路徑一律是 $JSC_HOME/current/{外掛名} 那一組,不是這支腳本現在被放在哪裡:規則放行的是
# 那一組路徑,巡檢那一輪也只能用那一組路徑去叫工具,兩邊對得起來才不會被靜靜擋掉。
print_allow_rules() {
# gitea.sh 一定要有自己這一條。`Skill(jsc-gitea:wiki)` 只放行「叫用那支技能」,技能裡的
# 每一個 Bash 呼叫仍然各自受檢,少了這一條,那一輪會在寫監控頁時靜靜被擋——頁寫不成就
# 不寫心跳,外面只看得到心跳過期,看不出是權限擋的。
# 目錄頁那一列改由 wiki-contents.sh 寫,所以它也要有自己這一條:巡檢那一輪會直接叫它,
# 少了規則就會停在權限詢問,而那一輪沒有人可以按同意。
# link-check.sh 同理:兩次寫入前都要先驗連結,少了這一條,驗證那一步就停在權限詢問,那一輪
# 什麼都寫不成。它排在寫入之前,所以擋住它等於整輪報廢。
for _s in "$CURRENT/jsc-assist/tools/schedule.sh" \
"$CURRENT/jsc-assist/tools/patrol.sh" \
"$CURRENT/jsc-hooks/hooks/heartbeat.sh" \
"$CURRENT/jsc-gitea/tools/gitea.sh" \
"$CURRENT/jsc-gitea/tools/wiki-contents.sh" \
"$CURRENT/jsc-gitea/tools/link-check.sh"; do
printf 'allow_rule=Bash(%s:*)\n' "$_s"
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
done
# 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名
# 等於這條規則永遠比對不到。
printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR"
printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR"
printf 'allow_rule=Skill(jsc-gitea:wiki)\n'
}
# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查
# 與警告,不代建:連結是 deploy 的職責,兩個地方都在建同一組連結,壞掉時查不出是誰建的。
check_current_links() {
_miss=''; _paths=''
for _p in jsc-assist jsc-gitea; do
[ -e "$CURRENT/$_p" ] && continue
if [ -z "$_miss" ]; then _miss="$_p"; _paths="$CURRENT/$_p"
else _miss="$_miss,$_p"; _paths="$_paths、$CURRENT/$_p"; fi
done
if [ -n "$_miss" ]; then
printf 'current_links=missing:%s\n' "$_miss"
note "找不到這幾個連結:$_paths。巡檢那一輪會照這一組路徑叫工具,連結不在就叫不到,那一輪一定失敗,也不會寫心跳。請先跑 deploy 把連結建起來——本腳本不代建。"
return 0
fi
printf 'current_links=ok\n'
return 0
}
# --- schtasks(Windows)---
schtasks_install() {
_rc=0
# 舊版的 heartbeat 任務一併刪掉,理由同 crontab 那一邊。刪不掉不算失敗,往下照裝。
_legacy=0
if schtasks /Query /TN "$(task_name heartbeat)" >/dev/null 2>&1; then
schtasks /Delete /TN "$(task_name heartbeat)" /F >/dev/null 2>&1 && _legacy=1
fi
for _job in $JOBS; do
_tn=$(task_name "$_job")
case "$_job" in
patrol) _mo="$PERIOD"; _run="$PATROL_RESOLVED" ;;
*) continue ;;
esac
_tr="cmd /c $_run <NUL >> \"$LOG\" 2>&1"
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=schtasks job=%s task=%s interval=%s cmd=%s\n' "$_job" "$_tn" "$_mo" "$_tr"
continue
fi
# /F 只覆蓋同名任務,也就是本腳本自己那一筆,不影響別人的排程。
schtasks /Create /TN "$_tn" /SC MINUTE /MO "$_mo" /F /TR "$_tr" >/dev/null 2>&1 \
|| die 4 "schtasks 建立 $_tn 失敗。"
schtasks /Query /TN "$_tn" >/dev/null 2>&1 || die 5 "schtasks 建立 $_tn 回 0,卻查不到這個任務。"
printf 'installed=%s task=%s\n' "$_job" "$_tn"
_rc=0
done
printf 'ttl=%s period=%s legacy_removed=%s log=%s\n' "$TTL" "$PERIOD" "$_legacy" "$LOG"
return "$_rc"
}
schtasks_remove() {
for _job in $JOBS; do
_tn=$(task_name "$_job")
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=schtasks job=%s task=%s action=delete\n' "$_job" "$_tn"
continue
fi
if ! schtasks /Query /TN "$_tn" >/dev/null 2>&1; then
note "$_job 本來就沒有排程,不用移除。"
continue
fi
schtasks /Delete /TN "$_tn" /F >/dev/null 2>&1 || die 4 "schtasks 刪除 $_tn 失敗。"
schtasks /Query /TN "$_tn" >/dev/null 2>&1 && die 5 "schtasks 刪除 $_tn 回 0,任務卻還在。"
done
return 0
}
schtasks_status() {
printf 'mechanism=schtasks service=%s ttl=%s period=%s log=%s\n' \
"$(service_state)" "$TTL" "$PERIOD" "$LOG"
for _job in heartbeat patrol; do
_tn=$(task_name "$_job")
if schtasks /Query /TN "$_tn" >/dev/null 2>&1; then
printf 'job=%s installed=yes task=%s\n' "$_job" "$_tn"
else
printf 'job=%s installed=no task=%s\n' "$_job" "$_tn"
fi
done
return 0
}
# --- crontab(Linux、WSL、macOS)---
crontab_install() {
_cur="$TMPD/cur"; _new="$TMPD/new"
cron_read "$_cur"
cp "$_cur" "$_new"
# 先濾掉自己這幾個工作的舊條目,再追加新的。重跑不會疊成兩筆,別人的條目原樣留著。
# 舊版的 heartbeat 條目一併清掉:留著它每分鐘照樣寫心跳,心跳就退回「只證明 cron 活著」。
_legacy=$(cron_lines_for "$_new" heartbeat | count_stdin)
for _job in $JOBS heartbeat; do
grep -vF "$(marker_of "$_job")" "$_new" >"$TMPD/f" 2>/dev/null || true
mv "$TMPD/f" "$_new"
done
_others=$(count_lines "$_new")
for _job in $JOBS; do
cron_entry "$_job" >>"$_new"
printf '\n' >>"$_new"
done
if [ "$DRYRUN" -eq 1 ]; then
for _job in $JOBS; do
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)"
done
printf 'dryrun=crontab action=write ttl=%s period=%s legacy_removed=%s others_kept=%s total_lines=%s env_snapshot=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_others" "$(count_lines "$_new")" "${SNAPSHOT_NAMES:-無}"
printf -- '--- 寫回後的 crontab ---\n'
mask_secret <"$_new"
return 0
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
"$CRONTAB_CMD" "$_new" >/dev/null 2>"$TMPD/err" \
|| die 4 "crontab 寫入失敗:$(tr '\n' ' ' <"$TMPD/err")"
_chk="$TMPD/chk"; cron_read "$_chk"
for _job in $JOBS; do
[ -n "$(cron_lines_for "$_chk" "$_job")" ] \
|| die 5 "crontab 寫入回 0,卻讀不到 $_job 的條目。"
[ "$(cron_lines_for "$_chk" "$_job" | count_stdin)" -eq 1 ] \
|| die 5 "$_job 的條目不只一筆,排程會重複執行。"
done
_kept=$(grep -vF "$MARK_PREFIX" "$_chk" 2>/dev/null | count_stdin)
[ "$_kept" -eq "$_others" ] \
|| die 5 "別人的排程條目從 $_others 筆變成 $_kept 筆,寫回不完整。"
for _job in $JOBS; do
printf 'installed=%s entry=%s\n' "$_job" "$(cron_lines_for "$_chk" "$_job" | mask_secret)"
done
printf 'ttl=%s period=%s legacy_removed=%s others_kept=%s env_snapshot=%s log=%s\n' \
"$TTL" "$PERIOD" "$_legacy" "$_kept" "${SNAPSHOT_NAMES:-無}" "$LOG"
return 0
}
crontab_remove() {
_cur="$TMPD/cur"; _new="$TMPD/new"
cron_read "$_cur"
_hit=0
cp "$_cur" "$_new"
for _job in $JOBS; do
if [ -n "$(cron_lines_for "$_new" "$_job")" ]; then
_hit=$((_hit + 1))
else
note "$_job 本來就沒有排程,不用移除。"
continue
fi
grep -vF "$(marker_of "$_job")" "$_new" >"$TMPD/f" 2>/dev/null || true
mv "$TMPD/f" "$_new"
done
if [ "$_hit" -eq 0 ]; then
printf 'removed=0 others_kept=%s\n' "$(count_lines "$_cur")"
return 0
fi
if [ "$DRYRUN" -eq 1 ]; then
printf 'dryrun=crontab action=remove jobs=%s removed=%s\n' "$JOBS" "$_hit"
printf -- '--- 寫回後的 crontab ---\n'
mask_secret <"$_new"
return 0
fi
"$CRONTAB_CMD" "$_new" >/dev/null 2>"$TMPD/err" \
|| die 4 "crontab 寫入失敗:$(tr '\n' ' ' <"$TMPD/err")"
_chk="$TMPD/chk"; cron_read "$_chk"
for _job in $JOBS; do
[ -z "$(cron_lines_for "$_chk" "$_job")" ] \
|| die 5 "crontab 刪除回 0,$_job 的條目卻還在。"
done
_before=$(grep -vF "$MARK_PREFIX" "$_cur" 2>/dev/null | count_stdin)
_after=$(grep -vF "$MARK_PREFIX" "$_chk" 2>/dev/null | count_stdin)
[ "$_before" -eq "$_after" ] \
|| die 5 "別人的排程條目從 $_before 筆變成 $_after 筆,移除動到了不該動的東西。"
printf 'removed=%s others_kept=%s\n' "$_hit" "$_after"
return 0
}
crontab_status() {
_cur="$TMPD/cur"; cron_read "$_cur"
printf 'mechanism=crontab service=%s ttl=%s period=%s log=%s\n' \
"$(service_state)" "$TTL" "$PERIOD" "$LOG"
for _job in heartbeat patrol; do
_line=$(cron_lines_for "$_cur" "$_job" | mask_secret)
if [ -n "$_line" ]; then
printf 'job=%s installed=yes entry=%s\n' "$_job" "$_line"
else
printf 'job=%s installed=no entry=\n' "$_job"
fi
done
return 0
}
# --- 主流程 ---
case "$CMD" in
install)
case "$MECH" in
crontab) crontab_install ;;
schtasks) schtasks_install ;;
esac
# 權限規則與快照提醒排在服務檢查前面:結束碼 1 那一條也是條目已經寫進去了,那台機器
# 一樣要開權限,只是還要先把 cron 服務叫起來。
print_allow_rules
check_current_links
note '上面這幾條 allow 規則要先開,排程那一輪才不會停在權限詢問——那一輪沒有人可以按同意。規則放行的是 $JSC_HOME/current 那一組路徑,巡檢也只能用那一組路徑叫工具。檔案寫入只認 Edit,Write 規則沒有作用。'
note "條目帶著安裝當下的環境變數快照(${SNAPSHOT_NAMES:-無}),其中含 Gitea 金鑰:crontab 檔案請保持只有本人讀得到。這幾個變數改過就要重跑一次 install,條目才會跟著換。"
[ "$DRYRUN" -eq 1 ] && exit 0
_svc=$(service_state)
if [ "$_svc" = stopped ]; then
printf 'service=stopped\n'
die 1 '排程條目寫進去了,但 cron 服務沒在跑,條目一次都不會被執行。WSL 預設不啟動 cron:請跑 `sudo service cron start`,而且重開 WSL 之後要再啟動一次。'
fi
printf 'service=%s\n' "$_svc"
[ "$_svc" = unknown ] && note '判不出排程服務在不在跑,請自行確認條目真的會被執行。'
exit 0 ;;
remove)
case "$MECH" in
crontab) crontab_remove ;;
schtasks) schtasks_remove ;;
esac
exit 0 ;;
status)
case "$MECH" in
crontab) crontab_status ;;
schtasks) schtasks_status ;;
esac
exit 0 ;;
esac