Files
assist/tools/tasks.sh
T
jiantw83 a168c5904c feat(seed): 依委派清單種入與重建助理的內建定期檢查項
待辦簿做好了但是空的,也沒有東西會去填它。內建的定期檢查項該排哪些、什麼時候排、多久一次,這些資料就在委派清單裡——每一支非不交的技能都有時間點與週期兩欄。這一輪把清單接成待辦簿的資料來源。

反查靠新增的一個欄位,它非有不可。清單移除一支之後要刪掉對應那筆,但待辦簿的識別碼是建立時間加標題的雜湊,跟技能名無關。拿標題當鍵等於把措辭變成介面,改一個字舊那筆就再也認不出來,於是每次重建都刪不掉舊的又加一筆新的,同一個檢查每輪做兩次。拿動作當鍵,提醒類那十一筆全是同一個字、一支都分不出來,而觸發類那幾筆會撞上使用者自己交辦、動作剛好是同一支技能的那一筆——撞上就是把使用者交辦的事當成內建項刪掉。所以另立一欄記那一筆對應哪一支技能。

因為要加欄位,寫入端只能改存放那一支——它是唯一寫得出待辦檔的入口,這一輪沒有自己寫檔。連帶補上移除那個操作:規格要求不留孤兒,而原本六個操作一個都刪不掉。它對使用者交辦的那幾筆一律擋下、除非人親自帶強制旗標,而種入這一支一次都不帶。擋在單一寫入者這裡最省,那條規定寫在呼叫端的話,呼叫端每多一個就要各自再實作一次。

交出方式與動作是兩套詞彙,對映是這一輪的重點。含觸發就取技能名,其餘一律取提醒。觸發的定義就是呼叫既有技能、內容照那支技能自己的流程走,所以填技能名等於照判定結果做。巡檢與提醒那兩種沒有獨立入口——沒有任何腳本或技能名代表得了某一支技能的唯讀切片——這時候填技能名,助理下一輪就會把整支技能一路跑完,那正是切片交要防的事。所以填提醒:照時程提醒、指出入口,不動手。實測十六筆裡五筆是技能名、十一筆是提醒。

條件式交的三支一律不種入,但逐支吵出來。條件本身是散文,清單裡沒有機器讀得懂的條件欄位,所以條件成立了沒有現在只有人答得出來。種進去的代價有現成例子:其中一支的條件明寫要等它自家路徑不再帶版本號,而那個條件現在不成立,種進去助理每輪都會叫它、每輪停在第一支自家腳本,沒有錯誤、沒有輸出、心跳照寫,看起來完全正常。一個會無聲卡死的項目比一個缺掉的項目難查得多。不種入的代價是看不出為什麼少了它,用逐支印一行保留原因補掉。人確認過某一支條件成立就一支一支帶旗標放行,不給全部放行的旗標,那等於用一個決定蓋掉三個不同的條件。

種入與重建是同一段程式,啟動時每次都跑。不記跑過沒有,也沒有第一次旗標,要不要動手完全由現況決定。分成兩段的話,兩段各自回答該有哪幾筆這同一個問題,等其中一段改了判準就會一邊加一邊刪同一筆,每輪反覆。啟動時跑實際套用、查現況時跑唯讀預覽,巡檢那一輪兩個都不跑——無人值守那一輪移除一筆會把那一筆的執行紀錄與失敗次數一起弄丟,而清單同步到一半就會刪錯,破壞性清理留給人。

清單讀不到的三種情況一筆都不移除。讀不到時,清單上沒有與這台機器沒裝那個外掛分不出來,照字面跑會把所有內建項一次刪光,而且結束碼看起來完全成功。

清單上還在、還可交,只是時間點或週期換了值的那種情況,規格三條規則一條都沒講到。處置是預設只報差異不改:存放那一支刻意沒有編輯操作,改值只能移除再重登,那會換識別碼、把執行紀錄與失敗次數歸零,一個已經連續失敗五次的項目會看起來像全新的。人要換就帶旗標。

跨外掛相依宣告到清單所在的那個外掛,版本下限取現行的發行版——那是清單與它的欄位說明都已經在上面的版本。寫更低的下限會讓一台裝著舊版、清單還不存在或欄位不同的機器通過相依檢查,然後在讀清單那一步才失敗。清單路徑照今天剛改的規則走,由叫用時餵進來的根目錄組成,那一支自己不解。

三份 manifest 的版號一併從 0.1.8 升到 0.1.9,並在相依欄加上清單所在的那個外掛。這一次沒有把版號分成獨立一筆:相依宣告與版號是同一個決定的兩半,宣告了新相依卻不升版,安裝端不會知道要重新檢查相依。
2026-09-03 19:05:07 +08:00

839 lines
47 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
# 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 時間}]
# [--spec-key jsc-{domain}:{技能名}] [--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}
# tasks.sh remove {id} [--force]
#
# 結束碼:
# 0 成功。list 印完(零筆也算成功);add 寫成一筆;done、fail、pause、resume 改成了;
# remove 把那一個檔案刪掉了。
# pause 對已經是 paused 的那一筆、resume 對已經是 pending 的那一筆,照樣回 0:同一個
# 狀態不算轉移,擋它只會讓呼叫端為了「本來就對」的結果去分流
# 1 指名的那一筆不存在:done、fail、pause、resume、remove 給的 id 找不到對應檔案
# 2 欄位值不合法:必填欄位缺、值不在允許集合、事件名不在固定詞彙表、標題折完是空的、
# id 不是十六進位、fail_count 不是非負整數、spec_key 不是 jsc-{domain}:{技能名} 這個形狀,
# 或 spec_key 配上 origin=user
# 3 不合法的狀態轉移,已擋下。哪些合法見下面「狀態怎麼轉」那張表
# 4 這一筆已經有了:add 算出來的完整雜湊撞上一個「建立時間與標題都相同」的既有檔案
# 5 檔案系統或雜湊失敗:待辦簿目錄建不起來、檔案寫不進去、這台機器算不出 SHA-1
# 6 用法錯誤:不認得的子命令、不認得的選項、選項缺值、缺 id,或 JSC_HOME 與 HOME 都
# 解不出絕對路徑(沒有根目錄可寫,猜一個等於把待辦簿寫到別的地方去)
# 7 remove 擋下:那一筆的 origin 是 user,而這一次沒有帶 --force。理由見下面
# 「remove 為什麼要擋使用者交辦的那幾筆」
#
# --- 這一支負責什麼、不負責什麼 ---
#
# 只負責存放與讀寫:把一筆待辦寫成檔案、讀回來、改狀態。到期判定、逾期判定、提醒怎麼送到
# 前景、事件名怎麼對上產生者、欄位不足時怎麼問人,全部不在這一支裡面。
# 所以這一支**留得住**那些欄位,但不對它們做判定:
# 只存放,這一支不判定的欄位
# 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(助理內建)
# spec_key 這一筆是哪一支技能的內建項,寫成 jsc-{domain}:{技能名}。使用者交辦的一律空
#
# 值是空的照樣把那一行寫出來(例如 repo=)。空值有明確的意思——沒有綁存取庫、沒有截止
# 時間、還沒跑過——所以讓每一筆的形狀都一樣,讀的人不必去分「鍵不見了」與「鍵在但是空的」,
# 兩眼一比就看得出哪一欄沒填。不認得的鍵一律忽略,往後加欄位不會讓舊檔案讀不進來。
#
# --- spec_key 為什麼非有不可 ---
#
# 助理的內建檢查項是照委派清單種進來的,清單改了就要重建:清單新增一支就加一筆,一支改成
# 不交或整列被刪掉就把對應那一筆移除。所以重建那一邊一定要能從「一支技能」反查到「待辦簿裡
# 屬於它的那一筆」,而其他每一個欄位都反查不了:
# id 是「建立時間加標題」的雜湊,跟技能名沒有關係,算不回來也查不過去
# title 標題是給人看的一句話。拿它當鍵,等於把標題的措辭變成介面:改一個字,舊那一筆
# 就再也認不出來,於是每次重建都刪不掉舊的、又加一筆新的,同一個檢查每輪做兩次
# action 交出方式是提醒的那幾筆,action 全部都是 remind 這個同一個字,一支都分不出來;
# 而交出方式是觸發的那幾筆,action 是技能名,會跟使用者自己交辦、動作剛好也是
# 那一支技能的那一筆撞在一起——撞上就會把使用者交辦的事當成內建項刪掉
# origin 只分得出「助理內建」與「使用者交辦」兩群,群裡是哪一支分不出來
# 所以身分要有自己的一欄。spec_key 只放身分,不放判定結果:清單裡的 trigger、recur、way
# 各自對映到別的欄位,那幾欄會隨清單改動而變,身分不會。
# 這一欄與 origin 是兩件事,不可以互相推導:origin=assistant 而 spec_key 是空的,代表這一筆
# 是助理自己因為別的理由建的、不受清單管;origin=user 而帶 spec_key 一律擋下(回 2),
# 不然下一次重建就會拿清單去刪使用者交辦的事,而規格明寫那幾筆一律不動。
#
# --- 值裡有等號或換行怎麼辦 ---
#
# 兩條約定,合起來讓這個格式壞不了,而且不必發明跳脫規則:
# 一、讀的時候只在**第一個等號**斷開。鍵是固定的十四個詞,一個都不含等號,所以第一個
# 等號一定是分隔符號,後面全部算值。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)
# 任何狀態 remove (不存在) 這一筆整個不見。三個狀態都收,理由見下面
# 「remove 為什麼要擋使用者交辦的那幾筆」
# 被擋下的幾條,各自的理由:
# 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 就是一道單向門:停掉的那一筆再也回不來,人只能去手改檔案,
# 而手改檔案繞過了上面那張轉移表。
# remove 規格要求「清單移除或改成不交,待辦簿移除對應那筆,不留孤兒」,而六個操作裡沒有
# 一個刪得掉一筆。少了它,重建那一邊只剩兩條路:把孤兒留著,於是助理會去跑一支
# 判過不交、甚至已經被刪掉的技能,失敗還不會自動暫停,一路重試;或者自己去 rm
# 那個檔案,而這一支的檔頭明寫它是唯一寫得出待辦檔的入口,繞過去之後上面那張
# 轉移表與碰撞規則就只約束得到一半的寫入者。所以刪除要走同一個入口。
# last_run 與 next_run 不另開操作:done 與 fail 都吃 --last-run 與 --next-run,值由呼叫端
# 算好餵進來。這一支不算下一次是什麼時候,算的邏輯在別的地方,兩邊各算一次就會漂移。
# 沒有 edit 操作。改欄位值要重新登錄一筆,理由是 id 由 created 與 title 算出來,改掉標題
# 之後 id 就對不回去了,留一個算不回來的 id 比多一筆待辦糟。
#
# --- remove 為什麼要擋使用者交辦的那幾筆 ---
#
# remove 是這一支唯一真的會弄丟資料的操作,而它的主要呼叫端是無人值守那一輪的清單重建。
# 規格對那一輪的規定只有一條:origin=user 的項目一律不動——助理不會因為一支技能改判就把
# 使用者交辦的事刪掉。那條規定寫在呼叫端,可是刪除只有這一個入口,所以擋在這裡最省:
# 呼叫端每多一個,那條規定就要各自再實作一次,漏掉一個就刪掉一件沒有人同意刪的事,而且是
# 在沒有人看的時候刪的,事後也查不出來是誰刪的。
# 擋法是回 7 並且什麼都不動,不是靜靜跳過:靜靜跳過會讓呼叫端以為刪掉了,於是它下一步就
# 去補一筆新的,最後兩筆並存。
# --force 留給人:使用者要刪自己交辦的那一筆是正當的,那一次有人在現場。無人值守那一輪
# 一律不帶這個旗標。
# 三個狀態都收得下 remove,包含 paused:那不是狀態轉移,是這一筆整個不再存在。擋 paused
# 反而會讓一支已經刪掉的技能留下一筆永遠刪不掉的孤兒,只因為有人先按了暫停。
#
# 環境變數:
# 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 存取庫]
[--spec-key jsc-{domain}:{技能名}] [--no-header]
tasks.sh add --kind check|todo --title 一句話 --action 技能|腳本|remind
--trigger at:...|after:... --recur once|every:...|cron:...
--origin user|assistant [--repo 存取庫] [--due ISO 時間]
[--spec-key jsc-{domain}:{技能名}] [--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}
tasks.sh remove {id} [--force]
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; }
# spec_key 是重建內建項時的反查鍵,形狀固定 jsc-{domain}:{技能名},兩段都不得為空。
# 收得寬一點的話,一個打錯的鍵會變成一筆永遠對不上清單的孤兒:重建那一邊查不到它,
# 所以既不會更新也不會移除,而它看起來跟一筆正常的內建項一模一樣。
# 兩段都只收小寫英數與連字號:清單的前兩欄本來就長這樣,而冒號是分隔符號,值裡再出現一個
# 就切不回兩段。
valid_spec_key() {
printf '%s' "$1" | LC_ALL=C grep -qE '^jsc-[a-z0-9-]+:[a-z0-9-]+$'
}
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=''; F_spec_key=''
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)
# 舊檔案沒有這一鍵,讀回來就是空的,那正好是「不受清單管」的意思,不必補預設值也不必轉檔。
F_spec_key=$(kv_get "$1" spec_key)
# 手改過的檔案有可能把計數寫成別的東西。當成 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"
printf 'spec_key=%s\n' "$F_spec_key"
} >"$_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 spec_key=%s title=%s\n' \
"$F_id" "$F_state" "$F_recur" "${F_last_run:--}" "${F_next_run:--}" "$F_fail_count" \
"${F_spec_key:--}" "$F_title"
}
# --- list ---
# 輸出是定位字元分隔。值一律折過,裡面不會有定位字元也不會有換行,所以定位字元分隔讀得準,
# 不必再發明引號規則。空欄位就是空的一欄,不填占位符號:填了占位符號,讀的人得再去分
# 「真的空」與「占位符號本身」。
cmd_list() {
_f_kind=''; _f_state=''; _f_repo=''; _f_spec=''; _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 ;;
--spec-key) [ "$#" -ge 2 ] || usage; _f_spec="$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」。"
# 這個篩選是重建內建項時的反查入口,所以形狀擋在這裡:一個打錯的鍵篩出零筆,跟「這一支
# 還沒種進來」的結果一模一樣,而後者會讓呼叫端再補一筆。
[ -z "$_f_spec" ] || valid_spec_key "$_f_spec" || die 2 "--spec-key「$_f_spec」不是 jsc-{domain}:{技能名} 這個形狀。打錯的鍵篩出零筆,跟「還沒種進來」看起來一樣,而那會讓呼叫端多加一筆。"
[ "$_header" -eq 1 ] && printf 'id\tkind\tstate\ttitle\taction\ttrigger\trecur\trepo\tdue\tlast_run\tnext_run\tfail_count\torigin\tspec_key\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
[ -z "$_f_spec" ] || [ "$_f_spec" = "$F_spec_key" ] || 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\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" "$F_spec_key"
_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
[ -z "$_f_spec" ] || [ "$_f_spec" = "$F_spec_key" ] || 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=''; _spec=''; _dry=0
while [ "$#" -gt 0 ]; do
case "$1" in
--spec-key) [ "$#" -ge 2 ] || usage; _spec="$2"; shift 2 ;;
--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")
_spec=$(fold_and_warn spec_key "$_spec")
[ -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」。"
if [ -n "$_spec" ]; then
valid_spec_key "$_spec" || die 2 "--spec-key「$_spec」不是 jsc-{domain}:{技能名} 這個形狀。形狀不對的鍵在重建時對不上清單,於是那一筆既不會更新也不會移除,而它看起來跟一筆正常的內建項一樣。"
[ "$_origin" = assistant ] || die 2 "--spec-key 只能配 --origin assistant,這一次的 origin 是「$_origin」。使用者交辦的那一筆帶上清單鍵,下一次重建就會拿清單去刪它,而規格明寫 origin=user 的項目一律不動。要照清單種入請用 assistant;要記下是為了哪一支技能交辦的,請寫進標題。"
fi
_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"
F_spec_key="$_spec"
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\nspec_key=%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" \
"$F_spec_key"
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
}
# --- remove ---
cmd_remove() {
_id="${1:-}"; [ -n "$_id" ] || usage; shift
_force=0
while [ "$#" -gt 0 ]; do
case "$1" in
--force) _force=1; shift ;;
*) usage ;;
esac
done
open_target "$_id"
# 擋在這裡而不擋在呼叫端,理由見檔頭「remove 為什麼要擋使用者交辦的那幾筆」。
if [ "$F_origin" = user ] && [ "$_force" -eq 0 ]; then
die 7 "id=$F_id 的 origin 是 user,這是使用者交辦的事,remove 不動它。清單重建那一輪一律不帶 --force:助理不會因為一支技能改判就把使用者交辦的事刪掉。要真的刪請人親自帶 --force 再跑一次。"
fi
# 先把內容留在畫面上再刪。刪掉之後那一筆的欄位就再也拿不回來了,回報裡只剩一個 id 的話,
# 誰都看不出剛剛不見的是什麼。
print_record_line
rm -f "$RECORD_FILE" 2>/dev/null || die 5 "刪不掉 $RECORD_FILE。請確認 $TASKS_DIR 可寫。"
# rm 回 0 不保證檔案真的不在了:唯讀目錄底下的 rm 有可能什麼都沒做。所以回報之前再看一次。
[ -f "$RECORD_FILE" ] && die 5 "$RECORD_FILE 還在,這一筆沒有刪掉。請確認 $TASKS_DIR 可寫。"
printf 'removed=%s file=%s origin=%s spec_key=%s\n' \
"$F_id" "$RECORD_FILE" "$F_origin" "${F_spec_key:--}"
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 "$@" ;;
remove) cmd_remove "$@" ;;
*) usage ;;
esac
exit $?