diff --git a/references/behaviors.md b/references/behaviors.md index d8be1de..1c52963 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -8,6 +8,6 @@ | --- | --- | | 觸發時機 | 要啟動助理、要停止助理、要跑一輪巡檢,或要問助理現在還在不在跑、待辦簿剩下哪幾筆時用。四個操作 `start`、`status`、`patrol`、`stop` 都走這一支。排程每一輪叫起來的也是這一支的 `patrol`。執行環境健檢不走這支,走 `jsc-cli:doctor`。技能使用次數不走這支,走 `jsc-log:stats` | | 關鍵步驟 | 四個操作都先跑同一個前置步驟,取得工具根目錄(本頁記成 `{CURRENT}`),根目錄一律由外面餵進來:排程那一輪從叫用文字裡的「工具根目錄=」那一段取字面絕對路徑,一個指令都不跑;人在現場叫用時,叫用文字帶那一段就取那一段,沒帶才跑一次 `readlink -f "${JSC_HOME:-$HOME/.jsc}/current"` 自己解,那一次會跳一次權限詢問,人按一下就過。無人值守那一輪取不到根目錄就停下回報:說明條目是舊版 `schedule.sh` 裝的、沒有把根目錄寫進提示文字,叫人重跑一次 `start` 或 `schedule.sh install patrol` 把條目重寫,收尾狀態取 `aborted`;一律不跑 `readlink`、不跑 `ls`、不退回帶變數的路徑、不拿技能提示或上一次轉錄裡的路徑、也不猜。整次叫用只取這一次,之後每一次腳本呼叫都填那一個字面絕對路徑,不是每一次呼叫各取一次,也不另外加印路徑的工具,更不另外跑指令去驗那一個路徑。取到的是空的、不是絕對路徑、或那條路徑不是存在的目錄,就回報根目錄不見了、叫人跑 `jsc-cli:deploy`,收尾狀態取 `aborted`;第四項要單獨查,`JSC_HOME` 沒設時 `readlink -f "$JSC_HOME/current"` 印的是 `/current`、結束碼 0,非空又是絕對路徑,前三項全過得了關,之後每一條字面路徑都指向不存在的地方,所以人在現場那一次要在同一步再跑 `[ -d "{剛印出來的路徑}" ]`,目錄存在才算取到根目錄;排程那一輪不查,它的根目錄是裝排程的人寫進條目的,根目錄不對就會在第一支腳本呼叫上失敗。除了人在現場那一次 `readlink`,任何指令列都不得出現 `$JSC_HOME`、`${JSC_HOME}` 或 `~`:權限層比對的是還沒展開的指令字面。實測歸納出兩條判準:一、無人值守時只有允許清單上的完整字面指令跑得動,沒有「預設安全的唯讀指令」這回事,連 `readlink -f "$JSC_HOME/current"`、`ls -d "$JSC_HOME/current"` 與沒有規則的 `ls -d /root/.jsc/current` 都被擋;二、路徑中段的萬用字元不匹配,版本號寫成 `*` 的快取路徑規則一樣擋,規則與指令都必須是完整字面。排程那一輪沒有人可以按同意,被擋就是停在第一支腳本,什麼都不記,心跳也寫不出來。接著認出使用者要的是哪一個操作,`patrol` 那一路全程不問人。`start`:先照 `patrol` 的每一步跑完一輪巡檢,第一次心跳由那一輪寫、不另外寫、跑不完就不算啟動、跑 `heartbeat.sh report` 確認 `state=fresh`、跑 `tools/schedule.sh install patrol` 裝巡檢那一筆排程、把它印的 `allow_rule=` 每一行、`patrol_root=`(條目寫進去的字面根目錄,之後每一輪都從那裡讀)、環境快照提醒與 `current` 連結缺漏的警告原樣轉給人、依結束碼選一段收尾訊息印出——排程接上、排程寫進去了但 cron 沒在跑、排程沒接上三種各一段。心跳那一筆不裝了,`install heartbeat` 一律回 6。`patrol`:跑 `tools/patrol.sh collect` 取鎖並讀五項來源(第五項是執行狀態事件:`collect` 自己叫 `jsc-hooks/tools/report-status.sh drain` 排空,緊接著跑 `rotate`,再把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成監控頁那一節;技能本文一律不自己再跑一次 `drain`)、結束碼 4 就讓開不寫任何東西、結束碼 1 與 3 照樣把這一輪寫上監控頁、`hash` 是空的就 `abort`、經 `jsc-gitea:wiki` 讀回 `MONITOR_{HASH}` 舊頁、基本資料原樣留著、最新一輪那一塊整塊換成 `latest_file`、`summary_file` 的本輪那一列擺最上面(五欄:巡檢時間、本輪判定、各項成敗、待人處理、警示來源)、舊的資料列接在下面並截到 24 列、三塊重組成整頁、寫回之前先把這一頁要放進去的每一個連結交給 `jsc-gitea/tools/link-check.sh`(結束碼 0 才整頁寫回,結束碼 1 就把 DEAD 那幾筆原樣回報並 `abort`,2、3、7 同樣 `abort`,一個連結都沒有就跳過這一次驗證並照實說明)、頁不存在(唯有結束碼 4)才用 `newpage_file` 建頁、讀不回舊頁就不寫、監控頁寫成之後跑 `gitea.sh wiki-url` 取那一頁的絕對網址並依結束碼分流(4 回步驟三重寫、5 沒有 `html_url`、7 與 8 走 `abort`,其餘非 0 也走 `abort`,網址取不到就不寫那一個區塊)、換掉 `contents_file` 那個 H2 區塊裡 `{監控頁絕對網址}` 那個佔位、換完再用 `link-check.sh` 驗那一個網址(結束碼 0 才寫那一個區塊;非 0 一律不寫,比照目錄頁結束碼 3 當成那一個區塊沒更新、這一輪照樣往下寫心跳,並把連不到的那一筆列進待人處理)、用 `jsc-gitea/tools/wiki-contents.sh upsert MONITOR 1 "MONITOR_{HASH}" {區塊檔}` 以 H2 標題(也就是內容頁頁名,取 `collect` 印的 `page=`)當鍵更新 `MONITOR_CONTENTS` 自己那一個區塊並一律帶上 `templates/monitor-contents.md` 當範本(第三個參數 `1` 是 `key-col`,只在舊頁還是 markdown 表格時用得到:舊表格第 1 欄「監控頁」持有身分,那一格是 `[MONITOR_{HASH}](網址)`,轉檔時只取文字當標題;頁面已經是條列格式時這個參數被忽略,照樣固定給 `1`)、目錄頁回 3(`CONTENTS` 存取庫沒設定)不中止這一輪,照樣往下寫心跳,並把「設 `JSC_WIKI_REPO_CONTENTS` 或 `JSC_WIKI_REPO`」列進待人處理、監控頁任一失敗或目錄頁其餘非 0 才 `abort` 且不寫心跳、跑 `tools/patrol.sh finish` 寫心跳、最後印出各項結果、本輪事件數與非 ok 事件數、非 ok 事件的明細(kind、name、status、exit、detail)、以及有 start 沒有配對 end 的那幾支技能(單獨列,那代表那一輪中止了)、兩次寫入各自的連結驗證結果(通過、無連結而跳過、或被擋下並附結束碼與 DEAD 明細)、判成警示時的警示來源與待人處理列。`status`:跑 `heartbeat.sh report` 取心跳現況、把 `state` 對映成新鮮、過期、心跳檔損壞、不存在、不自己解析心跳檔也不自己判定、從 `file=` 解出助理目錄後列出 `tasks/` 底下每一個檔案並解析 `state`、`title`、`next_run`、`fail_count`、跑 `tools/schedule.sh status` 取排程現況與週期、印成心跳、排程、待辦三塊、`fail_count` 大於 0 的列標上「已連續失敗 N 次」、心跳與排程兜起來會誤讀的四種組合各補一句話。`stop`:先跑 `heartbeat.sh report` 留下原本的狀態、再跑 `tools/schedule.sh remove all` 移除排程與舊版遺留的心跳條目、最後才跑 `heartbeat.sh clear` 清掉心跳、印出停止訊息並說明心跳清掉之後閘門會擋人、同時說明閘門還沒接線所以現在擋不到人。四個操作最後都一樣:回報印完之後跑一次 `jsc-hooks/tools/report-status.sh skill-end jsc-assist:assistant {status} {結束碼}`,`start` 由 hook 記、`end` 由這裡寫,不寫就等於這一次自己看起來中止了 | -| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{CURRENT}/jsc-gitea/tools/wiki-contents.sh`,連結驗證那一支是 `{CURRENT}/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `{CURRENT}/jsc-hooks/tools/report-status.sh`,範本是 `{CURRENT}/jsc-assist/templates/monitor-contents.md`;`JSC_HOME` 沒設時,人在現場那一次 `readlink` 自己退回 `~/.jsc` 再解,排程那一輪則直接用條目餵進來的值,指令列上不留變數也不留波浪號;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,快取路徑帶版本號,規則寫成萬用字元也對不上,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會查 `{CURRENT}/jsc-assist` 與 `{CURRENT}/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `patrol_root=`、`--patrol-cmd` 或 `JSC_ASSIST_PATROL_CMD` 給的自訂指令沒帶那一段時只警告不中止、並印出這一輪要開的 `allow_rule=` 規則(七支腳本各三種呼叫形式,含 `gitea.sh`、`wiki-contents.sh`、`link-check.sh` 與 `jsc-hooks/tools/report-status.sh`——`Skill(jsc-gitea:wiki)` 只放行叫用技能,技能內部的 Bash 呼叫仍各自受檢;路徑是 `current` 那一組確切路徑,不用萬用字元);七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat`、週期塞不進門檻、判不出 CLI、那一支 CLI 的執行檔不在 `PATH` 上,以及 `JSC_HOME` 解不出絕對路徑(條目寫不出字面根目錄)。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort` 三個子命令,七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是各項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那五項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`、`jsc-hooks/tools/report-status.sh drain` 與 `rotate`,除了排空會把事件流的位移往前推之外全部只讀,任一項失敗不影響其餘各項。`report-status.sh` 三個結束碼各有處置:0 是排空到新事件、3 是沒有新事件(正常狀態,不是失敗)、2 是呼叫寫錯;找不到這一支、`drain` 回 0 與 3 以外的碼、或 `rotate` 回非 0,都只讓這一項標成失敗或記一筆警示,一律不中止那一輪——回報鏈自己壞掉不可以把被回報的那一輪拖下去。`rotate` 只在 `drain` 成功時緊接著跑:中間隔越久,那段時間新寫進來的事件被搬進備份檔而從此排不到的機會越大;排空失敗時位移狀態未知,這時候輪替會直接吃掉還沒排空的那一批。配對以 `session` 加 `name` 為鍵,不只看 `name`:五支 CLI 併發時同一支技能會有好幾個工作階段同時在跑。沒配對到的 `start` 留在 `$JSC_HOME/assistant/events-open.tsv` 跨輪繼續配對,開超過心跳門檻才算疑似中止,未達門檻的算還在跑,超過一天沒配對到就丟掉。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫;只有目錄頁那一個 H2 區塊例外,走 `jsc-gitea/tools/wiki-contents.sh upsert`,它自己解 `CONTENTS` 存取庫、自己讀回整頁比對標題,舊頁還是 markdown 表格時自己先整頁轉成 H2 區塊再寫,七個結束碼各有處置:0 已更新或已新增、1 組不出頁面內容或寫入失敗要 `abort`(找不到同名標題不算錯,那是附加)、2 參數錯就改正重跑(範本路徑不存在也回這一碼,代表 plugin 沒裝齊)、3 是 `CONTENTS` 存取庫未設定且**不中止這一輪**、4 是頁不存在又沒給範本,本技能一律帶第五個參數所以不會出現、7 金鑰失效要 `abort`、8 其他 API 失敗要 `abort`。比對鍵取 H2 標題,也就是內容頁頁名 `MONITOR_{HASH}`,不取「監控頁」那一條的連結:連結含 `GITEA_HOST` 與頁名的網址編碼,那三樣一變鍵就對不上,同一台機器每輪多附一個區塊;頁名只由 `{主機名}/{登入帳號}` 決定,那三樣都動不到它。連結一律寫成 `[{文字}]({絕對網址})`,網址只取 `gitea.sh wiki-url` 印的那一個、不自己組路徑,那一支的結束碼 4、5、7、8 與其餘非 0 各有處置;每一個要放進頁面的連結在寫入前先過 `jsc-gitea/tools/link-check.sh`,它每個網址印一行 `{OK|DEAD|SKIP}` 加網址加說明,五個結束碼各有處置:0 才准寫入、1 有連不到的就不寫並回報 DEAD 那幾筆、2 是一個網址都沒給要補參數重跑、3 是 `GITEA_HOST` 未設定要先設定且不得跳過驗證、7 是金鑰失效要停下來回報金鑰問題而不是當成死連結;驗證走 API 不看網頁狀態碼,私有存取庫的網頁網址對未登入請求一律回 404。頁名雜湊一律取 `gitea.sh hash-id`/`tools/hash-id` 印的完整 40 碼大寫十六進位,不截短、不加前綴、不手算,空輸入回 2。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | +| 外部呼叫 | 工具一律走前置步驟取得的根目錄底下那一組不帶版本的路徑(本頁記成 `{CURRENT}`,實際填的是像 `/root/.jsc/current` 這種字面絕對路徑):`{CURRENT}/jsc-assist/tools/patrol.sh`、`{CURRENT}/jsc-assist/tools/schedule.sh`、`{CURRENT}/jsc-hooks/hooks/heartbeat.sh`,wiki 那一支是 `{CURRENT}/jsc-gitea/tools/gitea.sh`,目錄頁那一支是 `{CURRENT}/jsc-gitea/tools/wiki-contents.sh`,連結驗證那一支是 `{CURRENT}/jsc-gitea/tools/link-check.sh`,執行狀態事件那一支是 `{CURRENT}/jsc-hooks/tools/report-status.sh`,範本是 `{CURRENT}/jsc-assist/templates/monitor-contents.md`;`JSC_HOME` 沒設時,人在現場那一次 `readlink` 自己退回 `~/.jsc` 再解,排程那一輪則直接用條目餵進來的值,指令列上不留變數也不留波浪號;不拿技能提示給的快取基底目錄組工具路徑——權限只放行 current 那一組,快取路徑帶版本號,規則寫成萬用字元也對不上,用錯路徑會被靜靜擋掉。`jsc-hooks/hooks/heartbeat.sh` 的 `write`、`report`、`clear` 三個子命令,六個結束碼各有處置:0 往下走、1 與 3 印「助理未運行」、2 回報判不出狀態並停下、4 當成不新鮮並回報心跳檔損壞、5 是嚴重狀況要吵出來且不得回報成功、6 是呼叫寫錯要更正後重跑。`write` 只由 `tools/patrol.sh finish` 呼叫,技能自己不呼叫。本 domain 的 `tools/schedule.sh` 的 `install`、`remove`、`status` 三個子命令:`install` 會查 `{CURRENT}/jsc-assist` 與 `{CURRENT}/jsc-gitea` 兩個連結在不在、不在就警告且不代建,會把巡檢的 CLI 用 `command -v` 解成絕對路徑、把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與所有已設定的 `JSC_WIKI_REPO` 系列快照進條目(含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`,名單當下從環境撈、不寫死,新頁型自動涵蓋)、條目自帶 `JSC_GITEA_CONFIRM=yes`、把自己解好的字面根目錄寫進條目的提示文字(固定格式 `工具根目錄={字面絕對路徑}`,那一輪就是從這裡讀根目錄)並印成 `patrol_root=`、`--patrol-cmd` 或 `JSC_ASSIST_PATROL_CMD` 給的自訂指令沒帶那一段時只警告不中止、並印出這一輪要開的 `allow_rule=` 規則(七支腳本各三種呼叫形式,含 `gitea.sh`、`wiki-contents.sh`、`link-check.sh` 與 `jsc-hooks/tools/report-status.sh`——`Skill(jsc-gitea:wiki)` 只放行叫用技能,技能內部的 Bash 呼叫仍各自受檢;路徑是 `current` 那一組確切路徑,不用萬用字元);七個結束碼各有處置:0 往下走、1 是條目裝了但 cron 沒在跑要照實講不會執行、2 是缺 jsc-hooks 導致門檻讀不到、3 是這台機器沒有排程機制、4 是排程操作失敗要原樣引用 stderr、5 是回讀驗證失敗要叫人自己去看 `crontab -l`、6 是呼叫寫錯,含 `install heartbeat`、週期塞不進門檻、判不出 CLI、那一支 CLI 的執行檔不在 `PATH` 上,以及 `JSC_HOME` 解不出絕對路徑(條目寫不出字面根目錄)。本 domain 的 `tools/patrol.sh` 的 `collect`、`finish`、`abort` 三個子命令,七個結束碼各有處置:0 往下走、1 部分失敗照樣寫頁、2 是 finish 找不到 heartbeat.sh 要回報「記下來了但沒有心跳」、3 是各項全失敗照樣寫頁且判定異常、4 是讓開或鎖被搶走一律不寫心跳、5 是檔案系統失敗要吵出來、6 是呼叫寫錯。巡檢那五項讀 `jsc-log/tools/usage-stats.sh`、`jsc-hooks/hooks/version-guard.sh report`、`jsc-hooks/hooks/restart-gate.sh report`、`$JSC_HOME/sessions/*.stage`、`$JSC_HOME/wp/*.pr`、`heartbeat.sh report`、`jsc-hooks/tools/report-status.sh drain` 與 `rotate`,除了排空會把事件流的位移往前推之外全部只讀,任一項失敗不影響其餘各項。`report-status.sh` 三個結束碼各有處置:0 是排空到新事件、3 是沒有新事件(正常狀態,不是失敗)、2 是呼叫寫錯;找不到這一支、`drain` 回 0 與 3 以外的碼、或 `rotate` 回非 0,都只讓這一項標成失敗或記一筆警示,一律不中止那一輪——回報鏈自己壞掉不可以把被回報的那一輪拖下去。`rotate` 只在 `drain` 成功時緊接著跑:中間隔越久,那段時間新寫進來的事件被搬進備份檔而從此排不到的機會越大;排空失敗時位移狀態未知,這時候輪替會直接吃掉還沒排空的那一批。配對以 `session` 加 `name` 為鍵,不只看 `name`:五支 CLI 併發時同一支技能會有好幾個工作階段同時在跑。沒配對到的 `start` 留在 `$JSC_HOME/assistant/events-open.tsv` 跨輪繼續配對,開超過心跳門檻才算疑似中止,未達門檻的算還在跑,超過一天沒配對到就丟掉。wiki 讀寫一律經 `jsc-gitea:wiki`,技能自己不拼 API 呼叫;只有目錄頁那一個 H2 區塊例外,走 `jsc-gitea/tools/wiki-contents.sh upsert`,它自己解 `CONTENTS` 存取庫、自己讀回整頁比對標題,舊頁還是 markdown 表格時自己先整頁轉成 H2 區塊再寫,七個結束碼各有處置:0 已更新或已新增、1 組不出頁面內容或寫入失敗要 `abort`(找不到同名標題不算錯,那是附加)、2 參數錯就改正重跑(範本路徑不存在也回這一碼,代表 plugin 沒裝齊)、3 是 `CONTENTS` 存取庫未設定且**不中止這一輪**、4 是頁不存在又沒給範本,本技能一律帶第五個參數所以不會出現、7 金鑰失效要 `abort`、8 其他 API 失敗要 `abort`。比對鍵取 H2 標題,也就是內容頁頁名 `MONITOR_{HASH}`,不取「監控頁」那一條的連結:連結含 `GITEA_HOST` 與頁名的網址編碼,那三樣一變鍵就對不上,同一台機器每輪多附一個區塊;頁名只由 `{主機名}/{登入帳號}` 決定,那三樣都動不到它。連結一律寫成 `[{文字}]({絕對網址})`,網址只取 `gitea.sh wiki-url` 印的那一個、不自己組路徑,那一支的結束碼 4、5、7、8 與其餘非 0 各有處置;每一個要放進頁面的連結在寫入前先過 `jsc-gitea/tools/link-check.sh`,它每個網址印一行 `{OK|DEAD|SKIP}` 加網址加說明,五個結束碼各有處置:0 才准寫入、1 有連不到的就不寫並回報 DEAD 那幾筆、2 是一個網址都沒給要補參數重跑、3 是 `GITEA_HOST` 未設定要先設定且不得跳過驗證、7 是金鑰失效要停下來回報金鑰問題而不是當成死連結;驗證走 API 不看網頁狀態碼,私有存取庫的網頁網址對未登入請求一律回 404。頁名雜湊一律取 `gitea.sh hash-id`/`tools/hash-id` 印的完整 40 碼大寫十六進位,不截短、不加前綴、不手算,空輸入回 2。crontab 與 schtasks 一律經 `tools/schedule.sh`。另外唯讀 `$JSC_HOME/assistant/tasks/` 底下的檔案。待辦簿的存放格式與讀寫入口是本 domain 的 `tools/tasks.sh`:一筆一檔、純文字 key=value、十四個鍵順序固定、值是空的照樣寫出那一行,讀的時候只在第一個等號斷開,寫的時候把值折成一行,一筆一檔的理由同 `restart-required.d`(並行寫入不互相覆寫),`id` 取共用 hash 規則那四十碼的前 8 碼、碰撞時每次加長兩碼,六個子命令與結束碼的完整說明寫在那一支的檔頭。本技能現在只自己讀那些檔案,`tasks.sh` 的 `list`、`add`、`done`、`fail`、`pause`、`resume` 六個子命令還沒有任何一個操作呼叫得到:這一輪只把存放格式與工具定下來,到期判定、逾期與失敗處理、提醒怎麼送到前景、欄位不足時怎麼補問,一項都還沒接上去。呼叫端沒講清楚要哪一個操作時走 `jsc-ask:ask` 的決策樹問,但 `patrol` 那一路一律不問。不參與閘門判定 | | 完成條件 | 四個操作都要先取得工具根目錄,之後每一支腳本都拿那一個字面絕對路徑呼叫;排程那一輪只從叫用文字取,取不到就回報條目沒帶根目錄並中止,收尾狀態取 `aborted`,不得改跑 `readlink` 或任何解析指令,也不得改用帶變數的路徑硬跑;人在現場叫用時取不到才自己解一次,解出來的要是一條存在的絕對路徑(同一步用 `[ -d ]` 查過),解不出來或目錄不存在就回報缺 `current` 並中止,同樣取 `aborted`。`start` 要那一輪巡檢的 `finish` 回 0 且 `report` 回 `state=fresh`,才算啟動成功;巡檢沒寫成心跳一律回報失敗並停下,不得宣稱啟動;`schedule.sh install patrol` 回 1 要講明條目不會被執行與 `sudo service cron start`,不得宣稱排程會定時執行;回 0 或 1 都要把 `allow_rule=` 各行、「條目含金鑰快照、變數改了要重裝」這句提醒,以及 `current` 連結缺漏的警告轉出去。`patrol` 要五項各自有 `status`、執行狀態事件那一項要印出本輪事件數、非 ok 事件數與未配對的 `start`(`drain` 回 3 是沒有新事件,照樣算這一項讀到底)、監控頁那一頁要放的連結全部通過 `link-check.sh`(或整頁本來就沒有連結)、監控頁三塊重組寫成、目錄頁那一個 H2 區塊的網址通過 `link-check.sh` 後更新成功,或以目錄頁結束碼 3、或以連結驗證非 0 回報成沒更新、`finish` 回 0,才算一輪跑完;`collect` 回 4 是讓開,不算失敗也不寫任何東西;舊頁讀不回來就不寫,回報「這一輪沒有結果」;連結驗證沒過就不寫那一頁,監控頁沒寫成就 `abort`,心跳一定不寫;目錄頁除了結束碼 3 之外的非 0 也一樣 `abort`,結束碼 3 只少一筆索引,那一輪的結果已經在監控頁上,照樣寫心跳並把缺的變數列進待人處理;目錄頁那一個區塊的連結驗不過同樣只少一筆索引,照樣寫心跳並把那一筆列進待人處理。`status` 要印出現況表,或印出「助理未運行」並說明原因;心跳不存在、待辦簿目錄不存在、待辦簿零筆、排程沒裝,四種都算正常結束。`stop` 要 `schedule.sh remove all` 先回 0、`clear` 再回 0,並印出帶三段話的停止訊息;`remove` 非 0 就回報排程還在、助理停不掉,不清心跳也不印停止訊息;`clear` 回 5 就回報心跳檔還在、助理沒有確實停掉,不印停止訊息。四個操作都要在回報之後寫一筆 `skill-end`,`status` 取 ok、blocked、failed、degraded、aborted 五選一,要與回報出去的結果一致;那一支回非 0 只回報成回報鏈的缺陷,不改寫這一次操作的成敗 | | 可驗證跡象 | 四個操作的轉錄裡,每一條指令列都是字面絕對路徑,開頭是 `/`,沒有 `$JSC_HOME`、`${JSC_HOME}` 或 `~`,也沒有任何一次因為路徑帶變數而跳出來的權限詢問;排程那一輪從頭到尾一次 `readlink`、一次 `ls` 都沒有,根目錄直接取自叫用文字;人在現場那一路才可能有 `readlink`,而且同一次叫用只出現一次。`start` 之後 `$JSC_HOME/assistant/heartbeat` 存在,`ts` 是剛才那一輪的時間,`crontab -l` 找得到一筆帶 `# jsc-assist:assistant patrol` 的條目,而且只有一筆,帶 `# jsc-assist:assistant heartbeat` 的舊條目一筆都不剩;那一筆條目裡的 CLI 是絕對路徑,前面帶著 `JSC_GITEA_CONFIRM=yes` 與環境變數快照,提示文字裡有「工具根目錄=」接一個字面絕對路徑,那個值與 install 印的 `patrol_root=` 和 `allow_rule=` 用的根目錄完全相同,不是變數也不是快取實體路徑;install 印出的 `allow_rule=` 都是 current 那一組展開後的字面絕對路徑,沒有變數、沒有波浪號、沒有萬用字元,也沒有 `Write(...)`,而且 `jsc-gitea/tools/link-check.sh` 與 `jsc-hooks/tools/report-status.sh` 那三種呼叫形式都在裡面。`patrol` 跑完之後 wiki 的 `MONITOR_{HASH}` 只有三塊:基本資料一字未改、最新一輪換成本輪、摘要表最上面一列是本輪且總列數不超過 24,頁名的 `{HASH}` 是 40 碼大寫十六進位,雜湊來源那一列寫的是不含網域的短主機名;`CONTENTS` 存取庫裡的 `MONITOR_CONTENTS` 只有自己那一個 H2 區塊變動,同一台機器從頭到尾只有一個區塊,標題是 `MONITOR_` 接 40 碼大寫十六進位、標題上不帶連結也不帶網址,區塊裡「監控頁」那一條是 `[{頁名}]({絕對網址})` 這種連結、點下去開得起那一頁,「HASH」那一條是裸 HASH、40 碼大寫十六進位、不帶連結,八條欄位一條都不缺、格式是 `- {欄位名}:{值}`,頁上一個 markdown 表格都不剩,兩頁上點得到的連結沒有一個是死的——把頁上的網址抓出來重跑一次 `link-check.sh`,應該全部是 `OK`、結束碼 0,別台機器的區塊一字不動,`$JSC_HOME/assistant/patrol/` 底下有本輪的 `latest.md`、`summary.md`、`summary-row.md`、`newpage.md`、`contents-entry.md`,摘要列是五欄、警示來源那一欄有值或寫「無」;兩支腳本不是從 current 那一組路徑跑起來時,stderr 會有一行 `[WARN]` 點出實際路徑與應該用的路徑,`$JSC_HOME/assistant/usage-prev.tsv` 換成本輪的累計數,`$JSC_HOME/assistant/patrol.lock` 已經放掉;監控頁的最新一輪有「執行狀態事件」那一節,節裡有本輪事件數、非 ok 事件數,以及非 ok 明細與未配對 `start` 兩張表(一筆都沒有時寫明「沒有」,不留空表格);`$JSC_HOME/usage/scan-state/events.offset` 的數字往前推到本輪排空的位置,`$JSC_HOME/assistant/events-open.tsv` 只剩下還沒配對到 `end` 的那幾筆。讓開的那一輪沒有任何寫入跡象。`stop` 之後心跳路徑不存在,`crontab -l` 找不到任何 `# jsc-assist:assistant` 條目。以上都不動別人的排程條目,條目數量前後相同。`status` 無寫入跡象,只有回報內容。四個操作跑完,`$JSC_HOME/usage/events.jsonl` 最後都多一筆 `name` 是 `jsc-assist:assistant`、`phase` 是 `end` 的事件,`status` 與回報出去的結果一致,而且同一個 `session` 下它與 hook 記的那一筆 `phase=start` 配得起來。四個操作都不動 `tasks/` 底下的檔案,也不動 worktree 與程式碼存取庫。排程的 log 一律在 `$JSC_HOME/assistant/schedule.log`,不落在任何存取庫 | diff --git a/tools/tasks.sh b/tools/tasks.sh new file mode 100755 index 0000000..8f15e23 --- /dev/null +++ b/tools/tasks.sh @@ -0,0 +1,735 @@ +#!/usr/bin/env sh +# tasks.sh — 助理待辦簿的存放與讀寫(供 jsc-assist:assistant 與巡檢那一輪呼叫)。 +# +# 用法: +# tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo {存取庫}] +# [--no-header] +# tasks.sh add --kind {check|todo} --title {一句話} --action {技能|腳本|remind} +# --trigger {at:...|after:...} --recur {once|every:...|cron:...} +# --origin {user|assistant} [--repo {存取庫}] [--due {ISO 時間}] +# [--dry-run] +# tasks.sh done {id} [--last-run {ISO 時間}] [--next-run {ISO 時間}] +# tasks.sh fail {id} [--last-run {ISO 時間}] +# tasks.sh pause {id} +# tasks.sh resume {id} +# +# 結束碼: +# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了。 +# pause 對已經是 paused 的那一筆、resume 對已經是 pending 的那一筆,照樣回 0:同一個 +# 狀態不算轉移,擋它只會讓呼叫端為了「本來就對」的結果去分流 +# 1 指名的那一筆不存在:done、fail、pause、resume 給的 id 找不到對應檔案 +# 2 欄位值不合法:必填欄位缺、值不在允許集合、事件名不在固定詞彙表、標題折完是空的、 +# id 不是十六進位、fail_count 不是非負整數 +# 3 不合法的狀態轉移,已擋下。哪些合法見下面「狀態怎麼轉」那張表 +# 4 這一筆已經有了:add 算出來的完整雜湊撞上一個「建立時間與標題都相同」的既有檔案 +# 5 檔案系統或雜湊失敗:待辦簿目錄建不起來、檔案寫不進去、這台機器算不出 SHA-1 +# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、缺 id,或 JSC_HOME 與 HOME 都 +# 解不出絕對路徑(沒有根目錄可寫,猜一個等於把待辦簿寫到別的地方去) +# +# --- 這一支負責什麼、不負責什麼 --- +# +# 只負責存放與讀寫:把一筆待辦寫成檔案、讀回來、改狀態。到期判定、逾期判定、提醒怎麼送到 +# 前景、事件名怎麼對上產生者、欄位不足時怎麼問人,全部不在這一支裡面。 +# 所以這一支**留得住**那些欄位,但不對它們做判定: +# 只存放,這一支不判定的欄位 +# trigger 只驗格式與事件詞彙表,不算「現在到期了沒有」 +# recur 只驗格式,不算下一次是什麼時候 +# due 只存字串,不比對現在時間,不標逾期 +# next_run 只存呼叫端算好的值;這一支自己一次都不算 +# last_run done 與 fail 會寫進去,寫的是「這一次執行的時間」,不拿它推算任何事 +# fail_count fail 累加、done 歸零,這一支不因為它到某個數字就改 state +# 這一支自己判定的只有兩件事:欄位值合不合法(結束碼 2),與狀態轉移合不合法(結束碼 3)。 +# 判定邏輯後續才接上來,接的時候不必改這裡的存放格式——欄位已經在檔案裡了。 +# +# --- 一筆一檔的理由 --- +# +# 待辦簿存成 $JSC_HOME/assistant/tasks/{id},一筆一檔,理由同 restart-required.d:並行寫入 +# 不互相覆寫。五支 CLI 加上排程那一輪有可能同時動待辦簿,整本存成一個檔案的話,兩邊各讀 +# 一次整檔、各改自己那一筆、各寫回整檔,後寫的那一次就把前一次的改動整本蓋掉,而且沒有 +# 任何訊號。一筆一檔之下,動的是不同的 id 就是動不同的檔案,彼此看不到對方。 +# 同一個 id 被同時寫時也不會寫出半份:一律先寫進暫存檔再 mv 過去,mv 在同一個檔案系統上是 +# 原子操作,讀的人只會讀到舊的一整份或新的一整份,不會讀到寫到一半的內容。 +# 暫存檔名一律以點號開頭,list 的展開跳過點號開頭的檔案:寫到一半的那一份不會被列出來。 +# +# --- 存放格式:純文字 key=value,一行一欄位 --- +# +# 一筆固定十四個鍵,順序固定,缺一個都不寫。十四個裡有兩個是格式自己需要的: +# id 檔名,也寫進檔案裡一份。只看檔名的話,檔案被複製或改名之後就對不上內容 +# created 建立時間,UTC 的 ISO 時間。id 是由它與 title 算出來的,不存它就再也算不回 +# 同一個 id,也就驗不出檔名對不對,碰撞時也接不下去 +# 其餘十二個是待辦本身的欄位: +# kind check(定期檢查項)或 todo(交辦事項)。同一本簿、同一組欄位,只用它分 +# title 一句話講完要做什麼 +# action 助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手) +# trigger 第一次什麼時候到期。at:{ISO 時間}、at:now,或 after:{事件名} +# recur 做完之後還要不要再排。once、every:{間隔},或 cron:{式子} +# repo 這一筆綁哪一個存取庫。機器層級的檢查項留空 +# due 截止時間。留空就是沒有截止時間,那是合法狀態,不是缺欄位 +# state pending、done 或 paused +# last_run 上一次執行的時間 +# next_run 下一次預定執行的時間 +# fail_count 連續失敗次數 +# origin user(使用者交辦)或 assistant(助理內建) +# +# 值是空的照樣把那一行寫出來(例如 repo=)。空值有明確的意思——沒有綁存取庫、沒有截止 +# 時間、還沒跑過——所以讓每一筆的形狀都一樣,讀的人不必去分「鍵不見了」與「鍵在但是空的」, +# 兩眼一比就看得出哪一欄沒填。不認得的鍵一律忽略,往後加欄位不會讓舊檔案讀不進來。 +# +# --- 值裡有等號或換行怎麼辦 --- +# +# 兩條約定,合起來讓這個格式壞不了,而且不必發明跳脫規則: +# 一、讀的時候只在**第一個等號**斷開。鍵是固定的十四個詞,一個都不含等號,所以第一個 +# 等號一定是分隔符號,後面全部算值。title=a=b 讀回來就是 a=b,寫的時候不必動它。 +# 二、寫的時候把值**折成一行**:換行、歸位、定位字元各折成一個空白,其餘控制字元刪掉, +# 連續空白併成一個,前後空白去掉。折過就在 stderr 記一行,不靜靜改人家的值。 +# 第二條選折行而不選跳脫,理由是讀的人不只這一支腳本:技能本文與巡檢那一輪都會直接把檔案 +# 當 key=value 讀。跳脫規則要每一個讀的人各自實作一次,漏掉一個,那個人就把 \n 兩個字原樣 +# 印進報告或監控頁,看起來還很像正常內容。不跳脫就不用還原,每一個讀的人只要在第一個等號 +# 斷開,拿到的就是存進去的那個值。 +# 代價是值裡真的換行會被折掉。這本簿的每一個欄位本來就都是一行——標題是一句話、時間是一個 +# 時間戳、動作是一個技能名或腳本——折行沒有丟掉屬於這本簿的資訊。真的需要長篇內容的東西 +# 該寫成 wiki 頁再用 action 指過去,不是塞進標題。 +# 另外,命令替換本來就會吃掉結尾的換行,所以值傳到這裡之前結尾的換行已經不見了。這件事 +# 講在這裡,是為了讓人不要以為折行有保住結尾的換行。 +# +# --- id 為什麼取前 8 碼,碰撞怎麼辦 --- +# +# 共用 hash 規則(jsc-gitea 的 tools/hash-id)是完整四十碼大寫、不截短。這一支照樣先算出 +# 完整四十碼,只在取檔名的時候取前 8 碼,理由是兩者的用途不同: +# 四十碼那個規則管的是 wiki 頁名。頁名要在整個站台裡唯一,而且頁名一撞就是兩台機器的 +# 紀錄互相覆寫,看不出來,所以那裡不准截短。 +# 這裡的 id 是本機檔名,還要被人念出來、打進 done 與 pause、印在狀態表與提醒文字裡。 +# 四十碼的十六進位字串塞進表格沒有人讀得完,也沒有人打得對,於是人會改用「第三筆」這種 +# 說法指定要關哪一筆,那才是真正會關錯的地方。 +# 兩者不必一致,因為 id 在 wiki 上只是某一列裡的一個值,不是頁名,撞不到頁名的唯一性。 +# 前 8 碼是完整四十碼的前綴,不是另一套算法:要驗一個 id 對不對,就拿 created 與 title +# 重算四十碼,再比前綴,隨時驗得回來。 +# 碰撞這樣處理: +# 前 8 碼撞上既有檔案,而那個檔案的 created 或 title 跟這一筆不同,就是真的前綴碰撞。 +# 把前綴每次多取兩碼(8、10、12……一路到 40)再試,取到不撞為止。多取的還是同一個 +# 雜湊的前綴,所以前一段那個「重算就驗得回來」的性質不變;不在後面補 -2 這種序號, +# 補序號的 id 就再也算不回來了。 +# created 與 title 都相同的話,那不是碰撞,那是同一筆被登錄兩次——同一秒、同一個標題就是 +# 同一件事。這時候一律不寫,回 4 並把既有的 id 印出來。定期檢查項會因為清單重建而重跑 +# 登錄,靜靜多寫一筆的話,同一個檢查每輪就會做兩次。 +# 同一秒登錄兩筆不同標題的待辦不會撞:雜湊吃的是「建立時間加標題」,標題不同雜湊就不同。 +# +# --- 狀態怎麼轉 --- +# +# 只有三個狀態,合法的轉移就這幾條,其餘一律回 3 擋下: +# 起點 操作 終點 說明 +# (不存在) add pending 一律生在 pending。生在 done 的那一筆是 +# 噪音;生在 paused 是事後才會有的人為決定 +# pending done done(recur 是 once) 一次性做完就收掉 +# pending done pending(recur 會重複) 重複的做完要重新排,所以留在 pending +# pending fail pending 失敗只累加 fail_count,state 不動 +# pending pause paused 只有人會下這個操作 +# paused resume pending paused 只由人設,也只有人解得開 +# pending resume pending(不算轉移,回 0) +# paused pause paused(不算轉移,回 0) +# 被擋下的幾條,各自的理由: +# paused + done 停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收 +# 先 resume,讓「解開」這件事是人做的、看得到的 +# paused + fail 同理。助理沒有跑它,就不可能是它失敗 +# done + 任何 一次性且已經收掉的那一筆不再有下一次。再 done 一次會改寫 last_run, +# 再 pause 一次會讓它看起來在等人解開 +# 助理自己絕不寫 paused:能寫出 paused 的只有 pause 這一個操作,而巡檢那一輪只會叫 done +# 與 fail。失敗連續幾次都一樣留在 pending,靠 fail_count 讓人看到,不自動停掉——自動停掉 +# 等於助理自己決定不做某件事,而且沒有人會發現。 +# +# --- 為什麼是六個操作,不是四個 --- +# +# 存放層要的是四個:list、add、done、pause。另外兩個是補洞,不是加功能: +# fail last_run、next_run、fail_count 三個欄位由助理自己維護、不由人填,但四個操作裡 +# 沒有一個寫得到 fail_count。少了它,fail_count 永遠是 0,監控頁與提醒上的 +# 「已連續失敗 N 次」就永遠是 0 次,於是一個壞掉的項目每輪重試而沒有人知道—— +# 那正是這個欄位要防的事。所以失敗這條路要有自己的入口。 +# resume paused 只由人設,也就只有人解得開,沒有別的元件寫得出這個轉移。只給 pause +# 不給 resume,pause 就是一道單向門:停掉的那一筆再也回不來,人只能去手改檔案, +# 而手改檔案繞過了上面那張轉移表。 +# last_run 與 next_run 不另開操作:done 與 fail 都吃 --last-run 與 --next-run,值由呼叫端 +# 算好餵進來。這一支不算下一次是什麼時候,算的邏輯在別的地方,兩邊各算一次就會漂移。 +# 沒有 edit 操作。改欄位值要重新登錄一筆,理由是 id 由 created 與 title 算出來,改掉標題 +# 之後 id 就對不回去了,留一個算不回來的 id 比多一筆待辦糟。 +# +# 環境變數: +# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 +# JSC_HASH_ID 共用 hash 規則那一支的路徑,優先於自動搜尋 +set -u + +JSC_HOME_RAW="${JSC_HOME:-}" +if [ -z "$JSC_HOME_RAW" ]; then + # JSC_HOME 沒設就退回 ~/.jsc,與這個 domain 的其他腳本同一個預設值:兩邊退回的位置不同, + # 待辦簿就會躲在一個沒有人去讀的目錄裡,而每一支都自認為讀對了。 + JSC_HOME_RAW="${HOME:-}" + [ -n "$JSC_HOME_RAW" ] || { + printf '[jsc][助理待辦簿][ERR]:JSC_HOME 與 HOME 都沒有設定,沒有根目錄可以放待辦簿。這裡不猜一個路徑:猜錯就是把待辦寫到一個沒有人會去讀的地方,而且看起來像成功。請設定 JSC_HOME 再跑一次。\n' >&2 + exit 6 + } + JSC_HOME_RAW="$JSC_HOME_RAW/.jsc" + JSC_HOME_FALLBACK=1 +else + JSC_HOME_FALLBACK=0 +fi + +# 根目錄一定要是絕對路徑。相對路徑在排程那一輪等於指向 cron 的工作目錄,那一輪會把待辦簿 +# 寫到別的地方去,而下一輪從正確的地方讀,看到的是零筆。 +case "$JSC_HOME_RAW" in + /*) ;; + *) + _abs=$(CDPATH= cd -- "$JSC_HOME_RAW" 2>/dev/null && pwd -L) || _abs='' + [ -n "$_abs" ] || { + printf '[jsc][助理待辦簿][ERR]:JSC_HOME 是相對路徑(%s),也解不出絕對路徑。待辦簿的位置必須是字面絕對路徑,請把 JSC_HOME 設成絕對路徑再跑一次。\n' "$JSC_HOME_RAW" >&2 + exit 6 + } + JSC_HOME_RAW="$_abs" ;; +esac + +JSC_HOME="$JSC_HOME_RAW" +STATE_DIR="$JSC_HOME/assistant" +TASKS_DIR="$STATE_DIR/tasks" +CURRENT="$JSC_HOME/current" + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) +SCRIPT_DIR="${SCRIPT_DIR:-.}" + +die() { # $1=結束碼 $2=訊息 + printf '[jsc][助理待辦簿][ERR]:%s\n' "$2" >&2 + exit "$1" +} + +note() { printf '[jsc][助理待辦簿]:%s\n' "$1" >&2; } +warn() { printf '[jsc][助理待辦簿][WARN]:%s\n' "$1" >&2; } + +usage() { + cat >&2 <<'EOF' +usage: tasks.sh list [--kind check|todo] [--state pending|done|paused] [--repo 存取庫] [--no-header] + tasks.sh add --kind check|todo --title 一句話 --action 技能|腳本|remind + --trigger at:...|after:... --recur once|every:...|cron:... + --origin user|assistant [--repo 存取庫] [--due ISO 時間] [--dry-run] + tasks.sh done {id} [--last-run ISO 時間] [--next-run ISO 時間] + tasks.sh fail {id} [--last-run ISO 時間] + tasks.sh pause {id} + tasks.sh resume {id} +EOF + exit 6 +} + +# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。判準與處置同這個 domain 的其他 +# 腳本:只警告、照跑。從工作樹直接跑是開發時的正當用法,中止會把那條路擋掉;真正的失敗 +# 會發生在權限閘門那裡,閘門只放行 current 那一組確切路徑。 +warn_if_not_current() { + _want="$CURRENT/jsc-assist/tools/$(basename -- "$0")" + case "$SCRIPT_DIR/" in + "$CURRENT"/*) return 0 ;; + esac + warn "這支腳本是從 $SCRIPT_DIR/$(basename -- "$0") 跑起來的,不是 $_want。權限閘門只放行 current 那一組確切路徑:無人值守那一輪用別的路徑會被靜靜擋掉。開發時這樣跑沒關係。" + return 0 +} +warn_if_not_current + +[ "$JSC_HOME_FALLBACK" -eq 1 ] && note "JSC_HOME 沒有設定,這一次用 $JSC_HOME。待辦簿的位置會隨 HOME 變動,排程那一輪與現在這個殼的 HOME 不一定相同:要固定就把 JSC_HOME 設起來。" + +# --- 值的讀與寫 --- + +# 取一個鍵的值。只在第一個等號斷開,所以值裡的等號原樣讀回來。 +# 先把歸位字元刪掉:這一支寫出來的檔案沒有歸位字元,但手改過的檔案可能有,留著會混進值裡。 +# 同一個鍵重複出現時只認第一次,不把兩行併起來——併起來會生出一個誰都沒寫過的值。 +kv_get() { # $1=檔案 $2=鍵 + tr -d '\r' <"$1" 2>/dev/null | sed -n "s/^$2=//p" | head -n1 +} + +# 把值折成一行。換行、歸位、定位字元折成空白,其餘控制字元刪掉,連續空白併一個,前後去掉。 +fold_value() { # $1=原值 + printf '%s' "$1" \ + | tr '\n\r\t' ' ' \ + | tr -d '\000-\037' \ + | sed 's/^[[:space:]]*//; s/[[:space:]]*$//; s/[[:space:]][[:space:]]*/ /g' +} + +# 折過就講一聲。靜靜改掉人家給的值,下一次他從報告裡看到的東西跟他給的不一樣,而且找不到 +# 是誰改的。 +fold_and_warn() { # $1=欄位名 $2=原值;印出折好的值 + _f=$(fold_value "$2") + if [ "$_f" != "$2" ]; then + warn "$1 的值裡有換行、定位字元或多餘空白,已經折成一行:「$_f」。這本簿的每一個欄位都是一行,長篇內容請另外寫成 wiki 頁再用 action 指過去。" + fi + printf '%s' "$_f" +} + +# --- 欄位值的合法性 --- + +valid_kind() { case "$1" in check|todo) return 0 ;; esac; return 1; } +valid_state() { case "$1" in pending|done|paused) return 0 ;; esac; return 1; } +valid_origin() { case "$1" in user|assistant) return 0 ;; esac; return 1; } + +# 事件名只認固定詞彙表。理由:填一個永遠不會發生的事件名,那筆待辦就永遠不到期,而且從 +# 檔案上看不出壞在哪——它看起來跟一筆正常的待辦一模一樣。所以寫進去的那一刻就擋。 +# 四個不帶參數,三個一定要帶參數;帶不帶寫錯一律當不合法,不自己補。 +valid_event() { # $1=after: 後面那一整段 + case "$1" in + worklog-written|hook-error|session-start|session-end) return 0 ;; + wp-merged:?*|stage-entered:?*|analyze-completed:?*) return 0 ;; + esac + return 1 +} + +valid_trigger() { # $1=trigger + case "$1" in + at:?*) return 0 ;; + after:?*) valid_event "${1#after:}" && return 0; return 1 ;; + esac + return 1 +} + +valid_recur() { case "$1" in once|every:?*|cron:?*) return 0 ;; esac; return 1; } + +valid_count() { case "$1" in ''|*[!0-9]*) return 1 ;; esac; return 0; } + +# id 直接拿去接檔名,所以只收十六進位。帶斜線或點號開頭的值會把讀寫指到待辦簿目錄外面去。 +# 長度收 8 到 40:8 是預設前綴,碰撞時會加長,加長後最多就是完整四十碼。 +# 用 grep 而不用 case 的否定字集,是因為那種寫法在註解掃描裡會被認成別的東西。 +valid_id() { + _n=${#1} + [ "$_n" -ge 8 ] && [ "$_n" -le 40 ] || return 1 + printf '%s' "$1" | LC_ALL=C grep -qE '^[0-9A-Fa-f]{8,40}$' +} + +# --- 雜湊 --- + +# 找共用 hash 規則那一支。搜尋順序比照這個 domain 其他腳本找 jsc-hooks 的做法:先環境變數 +# 覆寫,再 current 那一組連結,然後開發用的並排存取庫版面,最後已安裝的快取版面。 +# current 排在快取前面是刻意的:技能與權限規則都以 current 為準,腳本內部自己去挑另一個 +# 版本,同一輪就會跑到混版的工具,那種不一致查起來沒有線索。 +hash_id_sh() { + if [ -n "${JSC_HASH_ID:-}" ] && [ -f "$JSC_HASH_ID" ]; then + printf '%s\n' "$JSC_HASH_ID"; return 0 + fi + if [ -f "$CURRENT/jsc-gitea/tools/hash-id" ]; then + printf '%s\n' "$CURRENT/jsc-gitea/tools/hash-id"; return 0 + fi + _root="${CLAUDE_PLUGIN_ROOT:-$SCRIPT_DIR/..}" + for _c in "$_root/../gitea/tools/hash-id" "$_root/../jsc-gitea/tools/hash-id"; do + [ -f "$_c" ] && { (CDPATH= cd -- "$(dirname -- "$_c")" && printf '%s/hash-id\n' "$(pwd)"); return 0; } + done + _c=$(ls "$_root"/../../jsc-gitea/*/tools/hash-id \ + "$_root"/../../gitea/*/tools/hash-id \ + "$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/hash-id 2>/dev/null \ + | sort | tail -n1) + [ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } + return 1 +} + +# 算出完整四十碼大寫。優先叫共用那一支;那一支找不到才自己算。 +# 備援不能拿掉:jsc-gitea 不一定裝在這台機器上,缺了它就一筆待辦都登錄不了,而登錄不了的 +# 那一刻使用者就在現場,錯過了就再也問不到。備援算的是同一條規則——完整四十碼、a-f 轉大寫、 +# 不截短——所以兩條路算出來的值相同,只有「取前綴當檔名」這一步是本機的事。 +hash40() { # $1=要算的字串 + _h=$(hash_id_sh 2>/dev/null) || _h='' + if [ -n "$_h" ]; then + _out=$(printf '%s' "$1" | sh "$_h" 2>/dev/null) || _out='' + case "$_out" in + [0-9A-F]*) printf '%s' "$_out"; return 0 ;; + esac + warn "共用 hash 規則那一支($_h)算不出雜湊,這一次改用本機的 SHA-1。兩者是同一條規則,值相同。" + else + warn '找不到共用 hash 規則那一支(jsc-gitea 的 tools/hash-id),這一次改用本機的 SHA-1。兩者是同一條規則,值相同。' + fi + if command -v sha1sum >/dev/null 2>&1; then + printf '%s' "$1" | sha1sum | awk '{print $1}' | tr a-f A-F; return 0 + fi + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 1 | awk '{print $1}' | tr a-f A-F; return 0 + fi + return 1 +} + +now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# --- 一筆的讀與寫 --- + +F_id=''; F_created=''; F_kind=''; F_title=''; F_action=''; F_trigger='' +F_recur=''; F_repo=''; F_due=''; F_state=''; F_last_run=''; F_next_run='' +F_fail_count=''; F_origin='' + +load_record() { # $1=檔案 + F_id=$(kv_get "$1" id) + F_created=$(kv_get "$1" created) + F_kind=$(kv_get "$1" kind) + F_title=$(kv_get "$1" title) + F_action=$(kv_get "$1" action) + F_trigger=$(kv_get "$1" trigger) + F_recur=$(kv_get "$1" recur) + F_repo=$(kv_get "$1" repo) + F_due=$(kv_get "$1" due) + F_state=$(kv_get "$1" state) + F_last_run=$(kv_get "$1" last_run) + F_next_run=$(kv_get "$1" next_run) + F_fail_count=$(kv_get "$1" fail_count) + F_origin=$(kv_get "$1" origin) + # 手改過的檔案有可能把計數寫成別的東西。當成 0 再往上加,而不是讓算式整支炸掉:這一筆 + # 的計數本來就已經不可信,讓它從 0 重新開始算得出來,比整支停下更有用。 + if ! valid_count "$F_fail_count"; then + [ -n "$F_fail_count" ] && warn "$1 的 fail_count 是「$F_fail_count」,不是非負整數,這一次當成 0。" + F_fail_count=0 + fi + [ -n "$F_state" ] || F_state=pending +} + +# 整份寫進暫存檔再 mv 過去。mv 在同一個檔案系統上是原子操作,所以讀的人只會讀到舊的一整份 +# 或新的一整份。暫存檔名帶行程號,兩個同時在跑的行程不會互搶同一個暫存檔;名字以點號開頭, +# list 的展開跳過它,寫到一半的那一份不會被列出來。 +write_record() { # $1=目標檔案 + _tmp="$TASKS_DIR/.tmp.$$" + { + printf 'id=%s\n' "$F_id" + printf 'created=%s\n' "$F_created" + printf 'kind=%s\n' "$F_kind" + printf 'title=%s\n' "$F_title" + printf 'action=%s\n' "$F_action" + printf 'trigger=%s\n' "$F_trigger" + printf 'recur=%s\n' "$F_recur" + printf 'repo=%s\n' "$F_repo" + printf 'due=%s\n' "$F_due" + printf 'state=%s\n' "$F_state" + printf 'last_run=%s\n' "$F_last_run" + printf 'next_run=%s\n' "$F_next_run" + printf 'fail_count=%s\n' "$F_fail_count" + printf 'origin=%s\n' "$F_origin" + } >"$_tmp" 2>/dev/null || { rm -f "$_tmp"; die 5 "待辦簿寫不進去:$_tmp。請確認 $TASKS_DIR 可寫。"; } + mv "$_tmp" "$1" 2>/dev/null || { rm -f "$_tmp"; die 5 "待辦簿換不上去:$1。請確認 $TASKS_DIR 可寫。"; } +} + +ensure_dir() { + [ -d "$TASKS_DIR" ] && return 0 + mkdir -p "$TASKS_DIR" 2>/dev/null || die 5 "建不出待辦簿目錄:$TASKS_DIR。" +} + +# 指名那一筆的檔案路徑。 +# 這一段刻意不寫成「印出路徑、由呼叫端用命令替換接」的函式:那樣它是在子行程裡跑,裡面的 +# die 只結束子行程,外面照樣往下走,於是「id 不合法」會被回報成「找不到那一筆」,結束碼 +# 也從 2 變成 1。呼叫端拿到的碼與真正的原因不一樣,比沒有分碼更糟。 +resolve_record() { # $1=id;設好 RECORD_FILE + valid_id "$1" || die 2 "id「$1」不是 8 到 40 碼的十六進位。id 直接拿去接檔名,帶別的字元會把讀寫指到待辦簿目錄外面去。" + _up=$(printf '%s' "$1" | tr a-f A-F) + RECORD_FILE="$TASKS_DIR/$_up" + [ -f "$RECORD_FILE" ] || die 1 "待辦簿裡找不到 id=$_up。請先跑 list 看現有的幾筆;id 是十六進位,大小寫都收。" +} + +# 印出改完之後的那一筆,一行講完。改了什麼要看得到,不然呼叫端只拿到一個結束碼。 +print_record_line() { + printf 'id=%s state=%s recur=%s last_run=%s next_run=%s fail_count=%s title=%s\n' \ + "$F_id" "$F_state" "$F_recur" "${F_last_run:--}" "${F_next_run:--}" "$F_fail_count" "$F_title" +} + +# --- list --- + +# 輸出是定位字元分隔。值一律折過,裡面不會有定位字元也不會有換行,所以定位字元分隔讀得準, +# 不必再發明引號規則。空欄位就是空的一欄,不填占位符號:填了占位符號,讀的人得再去分 +# 「真的空」與「占位符號本身」。 +cmd_list() { + _f_kind=''; _f_state=''; _f_repo=''; _header=1 + while [ "$#" -gt 0 ]; do + case "$1" in + --kind) [ "$#" -ge 2 ] || usage; _f_kind="$2"; shift 2 ;; + --state) [ "$#" -ge 2 ] || usage; _f_state="$2"; shift 2 ;; + --repo) [ "$#" -ge 2 ] || usage; _f_repo="$2"; shift 2 ;; + --no-header) _header=0; shift ;; + *) usage ;; + esac + done + [ -z "$_f_kind" ] || valid_kind "$_f_kind" || die 2 "--kind 只收 check 或 todo,給的是「$_f_kind」。" + [ -z "$_f_state" ] || valid_state "$_f_state" || die 2 "--state 只收 pending、done 或 paused,給的是「$_f_state」。" + + [ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\n' + + # 目錄不存在或零筆都算正常結束:助理還沒收過任何一筆待辦,不是失敗。 + if [ ! -d "$TASKS_DIR" ]; then + printf 'count=0 tasks_dir=%s exists=no\n' "$TASKS_DIR" >&2 + return 0 + fi + + _n=0 + # 排序鍵:state 分組(pending、paused、done),再 next_run,再 id。pending 排在前面是 + # 因為那是要看的東西;沒有 next_run 的排在同組最後,鍵補 ~ —— LC_ALL=C 之下它排在 + # 數字與字母後面,所以「還沒排下一次」的那幾筆不會擠在有時間的前面。 + for _fp in "$TASKS_DIR"/*; do + [ -f "$_fp" ] || continue + load_record "$_fp" + [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue + [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue + [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue + case "$F_state" in + pending) _rank=0 ;; + paused) _rank=1 ;; + *) _rank=2 ;; + esac + _nrk="$F_next_run"; [ -n "$_nrk" ] || _nrk='~' + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$_rank" "$_nrk" "$F_id" \ + "$F_id" "$F_kind" "$F_state" "$F_title" "$F_action" "$F_trigger" "$F_recur" \ + "$F_repo" "$F_due" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" + _n=$((_n + 1)) + done | LC_ALL=C sort -t"$(printf '\t')" -k1,1 -k2,2 -k3,3 | cut -f4- + + # 上面那一段在管線的子行程裡跑,_n 加不回來,所以計數另外數一次。 + _n=0 + for _fp in "$TASKS_DIR"/*; do + [ -f "$_fp" ] || continue + load_record "$_fp" + [ -z "$_f_kind" ] || [ "$_f_kind" = "$F_kind" ] || continue + [ -z "$_f_state" ] || [ "$_f_state" = "$F_state" ] || continue + [ -z "$_f_repo" ] || [ "$_f_repo" = "$F_repo" ] || continue + _n=$((_n + 1)) + done + printf 'count=%s tasks_dir=%s exists=yes\n' "$_n" "$TASKS_DIR" >&2 + return 0 +} + +# --- add --- + +cmd_add() { + _kind=''; _title=''; _action=''; _trigger=''; _recur=''; _origin='' + _repo=''; _due=''; _dry=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --kind) [ "$#" -ge 2 ] || usage; _kind="$2"; shift 2 ;; + --title) [ "$#" -ge 2 ] || usage; _title="$2"; shift 2 ;; + --action) [ "$#" -ge 2 ] || usage; _action="$2"; shift 2 ;; + --trigger) [ "$#" -ge 2 ] || usage; _trigger="$2"; shift 2 ;; + --recur) [ "$#" -ge 2 ] || usage; _recur="$2"; shift 2 ;; + --origin) [ "$#" -ge 2 ] || usage; _origin="$2"; shift 2 ;; + --repo) [ "$#" -ge 2 ] || usage; _repo="$2"; shift 2 ;; + --due) [ "$#" -ge 2 ] || usage; _due="$2"; shift 2 ;; + --dry-run) _dry=1; shift ;; + *) usage ;; + esac + done + + # 必填欄位一個都不補預設值。猜出來的時間點與週期會讓助理拿一個沒有人同意過的時程去跑, + # 半筆待辦比沒有待辦更糟。缺了就回 2,讓呼叫端當著使用者的面把它問回來。 + _kind=$(fold_and_warn kind "$_kind") + _title=$(fold_and_warn title "$_title") + _action=$(fold_and_warn action "$_action") + _trigger=$(fold_and_warn trigger "$_trigger") + _recur=$(fold_and_warn recur "$_recur") + _origin=$(fold_and_warn origin "$_origin") + _repo=$(fold_and_warn repo "$_repo") + _due=$(fold_and_warn due "$_due") + + [ -n "$_kind" ] || die 2 '缺 --kind。' + valid_kind "$_kind" || die 2 "--kind 只收 check(定期檢查項)或 todo(交辦事項),給的是「$_kind」。" + [ -n "$_title" ] || die 2 '缺 --title,或標題折完之後是空的。標題是一句話講完要做什麼,空標題在狀態表上認不出是哪一筆。' + [ -n "$_action" ] || die 2 '缺 --action。助理實際要跑的事:技能名、腳本,或 remind(只提醒,不動手)。' + [ -n "$_trigger" ] || die 2 '缺 --trigger。第一次什麼時候到期:at:{ISO 時間}、at:now,或 after:{事件名}。' + valid_trigger "$_trigger" || die 2 "--trigger「$_trigger」不合法。只收 at:{ISO 時間}、at:now,或 after:{事件名};事件名只認這七個:worklog-written、hook-error、session-start、session-end、wp-merged:{工作包代號}、stage-entered:{階段}、analyze-completed:{HASH}。填一個不在表上的事件名,那筆待辦永遠不到期,而且從檔案上看不出壞在哪。" + [ -n "$_recur" ] || die 2 '缺 --recur。做完之後還要不要再排:once、every:{間隔},或 cron:{式子}。' + valid_recur "$_recur" || die 2 "--recur「$_recur」不合法。只收 once、every:{間隔} 或 cron:{式子}。trigger 與 recur 是兩個獨立欄位,四種組合都成立,不要壓成兩種。" + [ -n "$_origin" ] || die 2 '缺 --origin。user(使用者交辦)或 assistant(助理內建)。清單重建時只動 assistant 那幾筆,所以這一欄不能空。' + valid_origin "$_origin" || die 2 "--origin 只收 user 或 assistant,給的是「$_origin」。" + + _created=$(now_iso) + # 雜湊吃的是「建立時間加標題」,中間夾一個定位字元當分隔。標題已經折過,裡面不會有定位 + # 字元,所以這個分隔切得乾淨:不夾分隔的話,時間結尾與標題開頭黏起來會有兩組不同的輸入 + # 算出同一個雜湊。 + _full=$(hash40 "$_created$(printf '\t')$_title") \ + || die 5 '這台機器既沒有 sha1sum 也沒有 shasum,算不出 id。' + case "$_full" in + [0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F]*) ;; + *) die 5 "算出來的雜湊不像完整四十碼大寫十六進位:「$_full」。" ;; + esac + + [ "$_dry" -eq 1 ] || ensure_dir + + # 前綴每次多取兩碼,直到不撞。撞上的那一筆 created 與 title 都相同時不是碰撞,是同一筆 + # 被登錄兩次,回 4 並印出既有的 id。 + _len=8 + _id='' + while [ "$_len" -le 40 ]; do + _cand=$(printf '%s' "$_full" | cut -c1-"$_len") + if [ ! -f "$TASKS_DIR/$_cand" ]; then + _id="$_cand"; break + fi + _old_created=$(kv_get "$TASKS_DIR/$_cand" created) + _old_title=$(kv_get "$TASKS_DIR/$_cand" title) + if [ "$_old_created" = "$_created" ] && [ "$_old_title" = "$_title" ]; then + die 4 "這一筆已經有了:id=$_cand,建立時間與標題都相同。同一秒、同一個標題就是同一件事,不再寫一份——定期檢查項會因為清單重建而重跑登錄,多寫一筆就會讓同一個檢查每輪做兩次。要真的另立一筆,請改標題。" + fi + warn "id 前 $_len 碼撞到既有的 $_cand(那一筆的標題不同),前綴加長兩碼再試。" + _len=$((_len + 2)) + done + [ -n "$_id" ] || die 5 "完整四十碼都撞上既有檔案,而那一筆的建立時間或標題又不同。這在實務上不會發生,請人工檢查 $TASKS_DIR。" + + F_id="$_id"; F_created="$_created"; F_kind="$_kind"; F_title="$_title" + F_action="$_action"; F_trigger="$_trigger"; F_recur="$_recur"; F_repo="$_repo" + F_due="$_due" + # 一律生在 pending。生在 done 的那一筆是噪音,生在 paused 是事後才會有的人為決定。 + F_state=pending + F_last_run=''; F_next_run=''; F_fail_count=0; F_origin="$_origin" + + if [ "$_dry" -eq 1 ]; then + printf 'dryrun=add id=%s file=%s hash40=%s prefix_len=%s\n' "$_id" "$TASKS_DIR/$_id" "$_full" "$_len" + printf -- '--- 會寫進去的內容 ---\n' + printf 'id=%s\ncreated=%s\nkind=%s\ntitle=%s\naction=%s\ntrigger=%s\nrecur=%s\nrepo=%s\ndue=%s\nstate=%s\nlast_run=%s\nnext_run=%s\nfail_count=%s\norigin=%s\n' \ + "$F_id" "$F_created" "$F_kind" "$F_title" "$F_action" "$F_trigger" "$F_recur" \ + "$F_repo" "$F_due" "$F_state" "$F_last_run" "$F_next_run" "$F_fail_count" "$F_origin" + return 0 + fi + + write_record "$TASKS_DIR/$_id" + printf 'added=%s file=%s hash40=%s prefix_len=%s\n' "$_id" "$TASKS_DIR/$_id" "$_full" "$_len" + print_record_line + # next_run 這一支不算。重複的那幾筆要有下一次的時間,由算到期的那一邊算好之後用 done + # 的 --next-run 餵回來;這裡先留空,留空的意思是「還沒排下一次」,不是「不再排」。 + case "$F_recur" in + once) ;; + *) note "這一筆是重複的(recur=$F_recur),next_run 現在留空。下一次什麼時候跑由算到期的那一邊算,算好之後用 done 的 --next-run 寫進來;這一支不算。" ;; + esac + return 0 +} + +# --- done、fail、pause、resume --- + +# 四個操作共用的取件與轉移擋人。轉移表見檔頭「狀態怎麼轉」。 +open_target() { # $1=id + resolve_record "$1" + load_record "$RECORD_FILE" +} + +cmd_done() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + _last=''; _next='' + while [ "$#" -gt 0 ]; do + case "$1" in + --last-run) [ "$#" -ge 2 ] || usage; _last="$2"; shift 2 ;; + --next-run) [ "$#" -ge 2 ] || usage; _next="$2"; shift 2 ;; + *) usage ;; + esac + done + open_target "$_id" + case "$F_state" in + pending) ;; + paused) + die 3 "id=$F_id 現在是 paused,不收 done。停掉的那一筆助理本來就沒有在跑,標成做完等於偷偷把它解開又收掉。要收先跑 resume $F_id,讓「解開」這件事是人做的、看得到的。" ;; + done) + die 3 "id=$F_id 已經是 done,不收第二次 done。一次性且已經收掉的那一筆不再有下一次,再 done 一次只會改寫 last_run,把一個沒發生過的執行記進去。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + + F_last_run=$(fold_and_warn last_run "${_last:-$(now_iso)}") + [ -z "$_next" ] || F_next_run=$(fold_and_warn next_run "$_next") + # 做完就把連續失敗次數歸零。留著的話,一個修好之後又跑成功的項目會一直掛著「已連續失敗 + # N 次」,那個 N 就不再是「連續」。 + F_fail_count=0 + case "$F_recur" in + once) + F_state=done ;; + *) + # 重複的那幾筆做完留在 pending,等下一次。 + F_state=pending + [ -n "$_next" ] || note "這一筆是重複的(recur=$F_recur),這一次沒有帶 --next-run,next_run 維持「${F_next_run:-空}」。下一次什麼時候跑由算到期的那一邊算,這一支不算。" ;; + esac + write_record "$RECORD_FILE" + printf 'done=%s\n' "$F_id" + print_record_line + return 0 +} + +cmd_fail() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + _last='' + while [ "$#" -gt 0 ]; do + case "$1" in + --last-run) [ "$#" -ge 2 ] || usage; _last="$2"; shift 2 ;; + *) usage ;; + esac + done + open_target "$_id" + case "$F_state" in + pending) ;; + paused) + die 3 "id=$F_id 現在是 paused,不收 fail。助理沒有在跑它,就不可能是它失敗。" ;; + done) + die 3 "id=$F_id 已經是 done,不收 fail。收掉的那一筆不再執行,記一次失敗上去會讓它看起來還在重試。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + F_last_run=$(fold_and_warn last_run "${_last:-$(now_iso)}") + F_fail_count=$((F_fail_count + 1)) + # state 一律留 pending,下一輪照重試。助理不自動轉 paused:自動停掉等於助理自己決定不做 + # 某件事,而且沒有人會發現。要讓人看到的是 fail_count,監控頁與提醒都要標「已連續失敗 + # N 次」。 + F_state=pending + write_record "$RECORD_FILE" + printf 'failed=%s fail_count=%s\n' "$F_id" "$F_fail_count" + print_record_line + note "id=$F_id 已連續失敗 $F_fail_count 次,state 留在 pending,下一輪照重試。這一筆要標進監控頁與提醒,不然一個壞掉的項目會每輪重試而沒有人知道。" + return 0 +} + +cmd_pause() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + [ "$#" -eq 0 ] || usage + open_target "$_id" + case "$F_state" in + paused) + # 同一個狀態不算轉移。擋它只會讓呼叫端為了「本來就對」的結果去分流。 + printf 'paused=%s unchanged=1\n' "$F_id" + print_record_line + return 0 ;; + pending) ;; + done) + die 3 "id=$F_id 已經是 done,不收 pause。收掉的那一筆沒有下一次可以停,停了只會讓它看起來在等人解開。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + F_state=paused + write_record "$RECORD_FILE" + printf 'paused=%s\n' "$F_id" + print_record_line + note "paused 只由人設,助理自己不設也解不開:巡檢那一輪只會叫 done 與 fail,寫不出 paused。要讓這一筆再跑就跑 resume $F_id。" + return 0 +} + +cmd_resume() { + _id="${1:-}"; [ -n "$_id" ] || usage; shift + [ "$#" -eq 0 ] || usage + open_target "$_id" + case "$F_state" in + pending) + printf 'resumed=%s unchanged=1\n' "$F_id" + print_record_line + return 0 ;; + paused) ;; + done) + die 3 "id=$F_id 已經是 done,不收 resume。它不是被停掉的,是做完收掉的;要再做一次請重新登錄一筆。" ;; + *) + die 3 "id=$F_id 的 state 是「$F_state」,不在 pending、done、paused 三個裡面,這一筆的狀態不可信,不動它。請人工檢查 $RECORD_FILE。" ;; + esac + F_state=pending + # fail_count 不歸零。它記的是真的發生過的失敗,解開一筆待辦沒有把那些失敗變成沒發生; + # 歸零會把「已連續失敗 N 次」這句提醒抹掉,而那筆待辦一恢復就會照樣再失敗一次。 + write_record "$RECORD_FILE" + printf 'resumed=%s\n' "$F_id" + print_record_line + [ "$F_fail_count" -gt 0 ] && note "id=$F_id 的 fail_count 是 $F_fail_count,解開之後刻意留著:那幾次失敗真的發生過,歸零會把「已連續失敗 N 次」這句提醒抹掉。要歸零請等它跑成功一次,done 會自己歸零。" + return 0 +} + +# --- 主流程 --- + +RECORD_FILE='' +CMD="${1:-}" +[ -n "$CMD" ] || usage +shift +case "$CMD" in + list) cmd_list "$@" ;; + add) cmd_add "$@" ;; + done) cmd_done "$@" ;; + fail) cmd_fail "$@" ;; + pause) cmd_pause "$@" ;; + resume) cmd_resume "$@" ;; + *) usage ;; +esac +exit $?