#!/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 && 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 | 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 >%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 寫,所以它也要有自己這一條:巡檢那一輪會直接叫它, # 少了規則就會停在權限詢問,而那一輪沒有人可以按同意。 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"; 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 > \"$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