釋出助理待辦簿的地基至 master,版本 0.1.6 升到 0.1.8 #17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"jsc": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.8",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills/",
|
||||
"jsc": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Executable
+1161
File diff suppressed because it is too large
Load Diff
+89
-4
@@ -61,6 +61,10 @@
|
||||
# D-11 是唯一會動到別人狀態的一項:drain 會把事件流的位移往前推。理由與配套見下面
|
||||
# 「執行狀態事件為什麼由這支排空」。
|
||||
#
|
||||
# 待辦簿那一節不在這五項裡面:它由 tools/due.sh 的 scan 組好,這支只叫它一次再把那一塊接進頁面。
|
||||
# 它也會動自己的狀態——推進待辦簿的事件快照,性質同 drain:同一輪叫第二次就比不出東西了。
|
||||
# 那一項的成敗不算進上面五項的成敗,理由寫在 due_scan() 那一段。
|
||||
#
|
||||
# --- version-guard.sh report 的既有缺陷照實記 ---
|
||||
#
|
||||
# 這支腳本在部分機器上對每一個 domain 都回「查詢失敗」,recommend 跟著回 unverifiable。
|
||||
@@ -143,7 +147,11 @@
|
||||
# failed_sources= 讀不到的來源路徑,以「、」分隔;全部讀得到就是「無」
|
||||
# warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示
|
||||
# 時,原因只寫在這裡
|
||||
# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,只供目錄頁那一個區塊用
|
||||
# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用
|
||||
# tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空
|
||||
# events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空
|
||||
# due_status= due_rc= 到期判定那一支的結果與結束碼
|
||||
# due_file= 到期判定那一支組好的「待辦簿到期與逾期」那一節,已經接進 latest_file
|
||||
# pending= 本輪待人處理的筆數
|
||||
# events_total= 本輪排空到的事件筆數
|
||||
# events_bad= 其中 status 不是 ok 的筆數
|
||||
@@ -194,6 +202,12 @@ WARN_SOURCES=''
|
||||
OK_COUNT=0
|
||||
FAIL_COUNT=0
|
||||
WARN=0
|
||||
DUE_STATUS=fail
|
||||
DUE_RC=0
|
||||
DUE_MD=''
|
||||
DUE_NOTE=''
|
||||
DUE_TASKS=''
|
||||
DUE_EVENTS=''
|
||||
|
||||
# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。
|
||||
# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉;
|
||||
@@ -872,6 +886,65 @@ count_tasks() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 待辦簿的事件偵測與到期判定 ---
|
||||
#
|
||||
# 算到期的邏輯不在這一支,也不在 tools/tasks.sh,而在 tools/due.sh,理由寫在那一支的檔頭:
|
||||
# 存放與判定各一支,兩邊各算一次就會漂移。這裡只負責在一輪之內叫它一次。
|
||||
# 一輪只叫一次,而且只叫 scan:scan 會推進狀態快照,同一輪叫第二次就比不出任何事件了。
|
||||
# 要看「下一輪會判出什麼」而不想動快照,叫那一支的 events,那個子命令一律唯讀。
|
||||
#
|
||||
# 這一項刻意不算進上面那五項的成敗。它補的是既有那一節的內容,不是新增一個獨立來源;算進去
|
||||
# 就要改動摘要表那一欄與「本輪項目」那一列的文字,而舊頁那些列的欄名不會跟著改,同一張表就
|
||||
# 會有兩種寫法。失敗時記一筆警示、列一筆待人處理,那一節照實寫「這一輪判不出到期」。
|
||||
due_scan() {
|
||||
DUE_STATUS=fail; DUE_RC=0; DUE_MD=''; DUE_NOTE=''
|
||||
DUE_TASKS=''; DUE_EVENTS=''
|
||||
_due="$SCRIPT_DIR/due.sh"
|
||||
if [ ! -f "$_due" ]; then
|
||||
DUE_NOTE="找不到($_due)"
|
||||
add_failed_source "$_due"
|
||||
add_warn '到期判定那一支找不到'
|
||||
add_pending '待辦簿的到期判定讀不到,jsc-assist 沒裝齊' '待辦簿到期與逾期' '/jsc-cli:deploy'
|
||||
return 0
|
||||
fi
|
||||
_rc=0
|
||||
JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" </dev/null || _rc=$?
|
||||
DUE_RC="$_rc"
|
||||
DUE_MD=$(sed -n 's/^due_file=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
||||
DUE_TASKS=$(sed -n 's/^tasks_due=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
||||
DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
||||
case "$_rc" in
|
||||
0) DUE_STATUS=ok ;;
|
||||
4)
|
||||
# 待辦簿零筆或目錄不存在。助理還沒收過待辦就是這個狀態,不是失敗。
|
||||
DUE_STATUS=empty ;;
|
||||
1)
|
||||
DUE_STATUS=ok
|
||||
DUE_NOTE='有狀態來源讀不到'
|
||||
add_warn '到期判定有狀態來源讀不到'
|
||||
add_pending '待辦簿事件偵測有狀態來源讀不到,那個來源本輪不發事件' '待辦簿到期與逾期' '/jsc-cli:setup' ;;
|
||||
2)
|
||||
DUE_STATUS=ok
|
||||
DUE_NOTE='有待辦的欄位值判不了'
|
||||
add_warn '有待辦的欄位值判不了'
|
||||
add_pending '有待辦的 trigger 或 recur 判不了,那幾筆永遠不會到期' '待辦簿到期與逾期' '/jsc-assist:assistant status' ;;
|
||||
3)
|
||||
# 快照換不上去是這幾碼裡最要吵的一個:事件算出來了,但下一輪會拿同一份舊快照再比一次,
|
||||
# 同一批事件於是被判第二次,同一件事就做了兩遍。
|
||||
DUE_STATUS=ok
|
||||
DUE_NOTE='狀態快照換不上去,下一輪會重判同一批事件'
|
||||
add_warn '事件快照換不上去'
|
||||
add_pending '事件快照換不上去,同一批事件下一輪會被判第二次' '待辦簿到期與逾期' '/jsc-cli:setup' ;;
|
||||
*)
|
||||
DUE_NOTE="回結束碼 $_rc:$(tr '\n' ' ' <"$RD/due.err" 2>/dev/null)"
|
||||
DUE_MD=''
|
||||
add_failed_source "$_due"
|
||||
add_warn '到期判定回非預期結束碼'
|
||||
add_pending "待辦簿的到期判定回 $_rc,本輪判不出到期" '待辦簿到期與逾期' '/jsc-assist:assistant status' ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# --- 組出監控頁那三塊 ---
|
||||
|
||||
tally() { # $1=項目狀態
|
||||
@@ -915,9 +988,15 @@ compose() {
|
||||
printf '**這一輪不做這一項。** D-02 hook 錯誤巡檢還沒實作,這一節沒有資料不代表沒有 hook 錯誤。要現在查就跑 `/jsc-hooks:hooks-install` 的錯誤掃描,或直接跑 `jsc-hooks` 的 `tools/scan-hook-errors.sh`。\n\n'
|
||||
cat "$RD/d04.md"; printf '\n'
|
||||
cat "$RD/d07.md"; printf '\n'
|
||||
printf '### 待辦簿到期與逾期\n\n'
|
||||
printf '**這一輪只數筆數,不逐筆判到期。** 本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逐筆的到期與逾期判定還沒實作,要看逐筆內容就跑 `/jsc-assist:assistant status`。\n\n' \
|
||||
"$TASKS_TOTAL" "$TASKS_FAILING"
|
||||
if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then
|
||||
cat "$DUE_MD"
|
||||
printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逾期(`due` 欄已經過了)還沒實作,上面的判定只講到期,不講逾期。\n\n' \
|
||||
"$TASKS_TOTAL" "$TASKS_FAILING"
|
||||
else
|
||||
printf '### 待辦簿到期與逾期\n\n'
|
||||
printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \
|
||||
"$(cell "${DUE_NOTE:-沒有回報原因}")" "$TASKS_TOTAL" "$TASKS_FAILING"
|
||||
fi
|
||||
printf '### 待人處理\n\n'
|
||||
printf '助理只提醒,不代為執行。這一節列的是本輪要人接手的項目。\n\n'
|
||||
printf '| 項目 | 來源子節 | 建議入口 |\n'
|
||||
@@ -1040,6 +1119,7 @@ case "$CMD" in
|
||||
d07
|
||||
d11
|
||||
count_tasks
|
||||
due_scan
|
||||
tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS"
|
||||
tally "$D11_STATUS"
|
||||
|
||||
@@ -1069,6 +1149,11 @@ case "$CMD" in
|
||||
printf 'warn_sources=%s\n' "${WARN_SOURCES:-無}"
|
||||
printf 'tasks_total=%s\n' "$TASKS_TOTAL"
|
||||
printf 'tasks_failing=%s\n' "$TASKS_FAILING"
|
||||
printf 'tasks_due=%s\n' "$DUE_TASKS"
|
||||
printf 'events_new=%s\n' "$DUE_EVENTS"
|
||||
printf 'due_status=%s\n' "$DUE_STATUS"
|
||||
printf 'due_rc=%s\n' "$DUE_RC"
|
||||
printf 'due_file=%s\n' "$DUE_MD"
|
||||
printf 'pending=%s\n' "$PEND_COUNT"
|
||||
printf 'events_total=%s\n' "$EV_TOTAL"
|
||||
printf 'events_bad=%s\n' "$EV_BAD"
|
||||
|
||||
Executable
+735
@@ -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 $?
|
||||
Reference in New Issue
Block a user