Author SHA1 Message Date
admin 472992029d Merge pull request '釋出:長度警告說得出話,提醒佇列分出內建項' (#46) from develop into master
Reviewed-on: #46
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 05:44:51 +00:00
admin 87ada337b8 Merge pull request '提醒佇列把內建項標成另一種列' (#47) from fix/reminder-queue-marks-builtin into develop
Reviewed-on: #47
2026-09-07 05:42:18 +00:00
jiantw83andClaude Opus 5 61f4a2dbc7 fix(assistant): 提醒佇列把內建項標成另一種列
實測踩到:這台機器的佇列八筆全是委派清單種入的內建項,動作是只提醒、
還沒有執行入口,所以每一輪都到期。於是每一個工作階段開頭固定吐八行
一模一樣的東西。

那不是提醒,是噪音——而讀佇列那一支自己的註解裡就寫著「對著一個刻意的
決定每個工作階段催一次,那是噪音不是提醒」。這一批就犯了那一條,只是
換成另一種來源。

分法照該有的處置切:使用者自己登錄的那一筆,人看到就做得了;內建項那
幾筆等的是接線,不是人。所以佇列的第一欄多一種值 builtin,讀的那一端
把前兩種逐筆點名、第三種收成一行總數。

種類在這一邊判,不在讀的那一邊:spec_key 只有這裡讀得到,而讀的那一端
的規則是只印不判。收尾多印一個 reminders_builtin=。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:39:45 +08:00
admin 404cf8f4fd Merge pull request '條目長度那道警告本來說不出話' (#45) from fix/entry-length-warning-can-speak into develop
Reviewed-on: #45
2026-09-07 05:00:13 +00:00
jiantw83andClaude Opus 5 2265dcd59c fix(schedule): 條目長度那道警告本來說不出話
新加的長度閘門用了 warn 來報「距離上限不到 100」,而這一支只定義 note。
殼回一句「warn: not found」到 stderr,那句提醒一次都沒印出來。

一道說不出話的檢核,跟沒有那道檢核是同一件事——而且它連自己壞了都只在
stderr 留一行,正常輸出上看不出任何異常。

修掉之後它第一次開口就說出一件要處理的事:這台機器的條目有 973 個位元組,
距上限只剩 27。長度大半來自 wiki 存取庫那一系列變數的快照,一支約 40 個
位元組——再多一支頁型變數就裝不起來,而那一次只會回 command too long。
所以訊息裡把這個來源講出來,人才知道要縮哪裡。

這一支之前就踩過同一個坑(另一段檢核也寫了 warn),所以註解裡寫明它只有
note,沒有 warn。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:52:23 +08:00
admin 3e91073292 Merge pull request '釋出:存取庫掃描、逾期判定、提醒佇列,與四個安靜出錯的修正' (#44) from develop into master
Reviewed-on: #44
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 04:17:10 +00:00
admin 3dc6b32088 Merge pull request '一輪算完就寫提醒佇列' (#43) from feat/reminder-queue into develop
Reviewed-on: #43
2026-09-07 04:14:01 +00:00
jiantw83andClaude Opus 5 932bfa826a feat(assistant): 一輪算完就寫提醒佇列
助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只留在監控頁上。這一批讓
每一輪把「要送到人面前」的那幾筆寫成一份佇列檔,位置固定在助理狀態目錄
底下,讀的那一端只印、不判。

判定不放在讀的那一邊,理由是漂移:讓讀的人自己拿 due 欄與 next_run 去跟
現在比,就是第二套到期判定,跟這裡那一套遲早對不上,而對不上的那一天
兩邊都說自己是對的。

這樣換來一個新的失效模式,所以檔頭寫足四個值:輪次代號、UTC 時間戳、
連續失敗筆數、待辦總筆數,再加同一刻的 epoch 秒。助理沒在跑的時候佇列
不會更新,而一份舊佇列讀起來跟新的一模一樣——讀的那一端要算得出它多舊,
「沒有提醒」與「沒有人算提醒」不可以長得一樣。epoch 是為了讓讀的那一端
不必自己解 ISO 字串:那等於在每一個讀取端各放一份日期解析。

同一筆待辦既到期又逾期時只列逾期那一行。同一批提醒裡出現兩行會被讀成
兩件事,而「逾期五天」比「排定點過了」講得更清楚。

逾期那幾行的「逾期多久」直接取判定那一支算好的寫法,不在這裡再寫一個
時間長度格式化——同一個秒數在兩個地方就會印出兩種說法。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:16:57 +08:00
admin 4cbd0e360f Merge pull request '逾期判定接上來,逾期與連續失敗每一輪都提' (#42) from feat/overdue-and-failing-every-round into develop
Reviewed-on: #42
2026-09-07 03:10:39 +00:00
admin 6a2cf045ee Merge pull request '存取庫掃描接上 {repo} 代入點' (#41) from feat/scan-repos-for-repo-placeholder into develop
Reviewed-on: #41
2026-09-07 03:10:23 +00:00
jiantw83andClaude Opus 5 465bd9d40a feat(assistant): 逾期判定接上來,逾期與連續失敗每一輪都提
逾期本來沒實作,監控頁上就寫著「上面的判定只講到期,不講逾期」。

到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個
截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;
一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。
合成一個值就把兩種情況混成同一格。

due.sh 多第十二欄 overdue,值是減號(沒有截止時間)、no、逾期的秒數,
或 bad。bad 算成欄位不合法回 2——一個解不開的截止時間跟沒有截止時間
在畫面上長得一模一樣,而前者是登錄的時候就填錯了。done 那幾筆不算逾期;
被暫停的那幾筆照算,因為暫停中止的是到期判定,不是日曆,而一筆被暫停
又逾期的交辦正好是最容易被忘掉的那一種。

另加唯讀子命令 due.sh overdue 給狀態查詢那一路走。走 scan 會推進快照,
那一輪之後的事件判定會全部落空;走 events 又判不到逾期;讓呼叫端自己
拿 due 欄去比更糟,那是第二套判定,漂移的那一天兩邊都說自己對。

patrol.sh 在逾期或連續失敗筆數大於 0 時各列一筆待人處理,每一輪都列。
兩種都不會自己好:截止時間過了不會因為過更久就不逾期,連續失敗的項目
每一輪都會再試一次然後再失敗一次。「提過了」不是「處理過了」,而助理
不替人按暫停,那個狀態留給人設。

監控頁那一節逐筆點名連續失敗的那幾筆——id、失敗幾次、最後一次執行、
標題。原本只印一個總數,而失敗次數這個欄位存在的理由是指出「有一筆壞掉
的項目每輪重試而沒人知道」,不說出是哪一筆,那個理由只完成一半。

目錄頁刻意不加欄位。那一頁一台機器一個區塊、各自那一輪寫,新加一條只有
跑到新版的機器寫得出來,讀的人分不出「這台沒有逾期」與「這台還沒寫這
一條」——理由與當初把「本輪非 ok 事件數」擋在那一頁外面的一樣。

欄位數從 11 變 12,run-due.sh 那道欄位數檢核跟著改成 12。那道檢核擋的
不是第 12 欄本身,是「兩支不是同一版」——版本不同的時候,前 11 欄的
順序也不能假設還是原來那樣。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:53:00 +08:00
jiantw83andClaude Opus 5 205da9b61f fix(assistant): 判定是不是存取庫交給 git 自己回答
原本看 .git 在不在。這台機器的工作目錄底下剛好有一個目錄的 .git 只剩
一個空的 info/,git 一句「not a git repository」——而掃描把它算成一個
存取庫,還印成「沒有 origin」。那個說法讀起來像「這個存取庫還沒接遠端」,
不像「這裡根本不是存取庫」,兩件事的處置完全不同。

改成由 git 回答,並核對它認定的頂層就是那個目錄:只問「是不是在工作樹裡」
不夠,git 會從那裡往上找,.git 壞掉時它會找到上一層的存取庫然後答「是」。
兩邊都解成實體路徑再比,掃描起點帶符號連結那一段時字串才對得上。

多一個 not_a_repo= 判定行與收尾計數,執行那一支照樣原樣轉出。
存取庫數從 19 個變 18 個。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:39:43 +08:00
jiantw83andClaude Opus 5 e53bb23f8e feat(assistant): 存取庫掃描接上 {repo} 代入點
新增 tools/scan-repos.sh:掃出工作目錄底下的存取庫,交出 {repo} 要代的
那份清單,順便對出每一個存取庫的 REPO_{HASH} 盤點頁頁名(雜湊規則與
盤點頁那一支相同,實測值一致)。

掃描起點刻意沒有預設值。給一個預設就等於猜,而猜錯的後果不是掃不到,
是在猜錯的那些目錄底下跑指令——往上一層是家目錄、再往上是整台機器,
而那一輪沒有人看得到它跑到哪裡去了。沒設就回 3 並說要設哪一個變數。
深度預設一層、上限三層;點開頭的目錄、符號連結、路徑帶空白或殼層特殊
字元的三種一律不交出去,但逐個印出來——那是真的存在卻沒被盤點到的
存取庫,只印一個總數會讀成整批都掃過了。

run-due.sh 改成兩個代入點都代:{cli} 取偵測到的 CLI 代號、{repo} 取
掃到的存取庫,兩個都帶的那一筆目標數是乘積。待辦簿那一筆的 repo 欄
有值時只代那一個,值可以是路徑也可以是 {owner}/{repo};對不出來就
印 held=,不退回全部存取庫——那一筆指名了一個目標。代入之後再驗一次
禁止字元,因為代進去的值是這一輪現場算出來的。

schedule.sh 把 JSC_ASSIST_SCAN_ROOT 與 JSC_ASSIST_SCAN_EXCLUDE 快照進
排程條目。少了掃描起點,帶 {repo} 的內建項每一輪都代不出目標,而那一輪
只印一行 held=,看起來像這一批還沒接上、不像一個變數沒進條目。

同時補兩個只有真的執行才發現得了的缺陷:

一、cron 一行的長度上限只在寫入那一刻由 crontab 自己擋,--dry-run 那一路
根本不碰它,所以預演每一次都過。實測踩過一次:整條 PATH 快照進條目那一批
預演全綠、安裝回結束碼 4。改成建好條目就量,兩個模式都印 entry_len= 與
上限,達到上限當場拒絕並指出長度是從哪幾個快照變數來的。

二、run-due.sh 截錯誤訊息用的 cut -c 數的是位元組不是字元,中文字剛好被
截在中間就在報告上留下一個替代字元。改成截完再過一次 iconv -c 丟掉那個
不完整的序列。這一種不會有人來報:亂碼不影響結束碼。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:36:26 +08:00
admin ebcfaa387c Merge pull request '釋出:拿排程條目記下的 CLI 清單比對,少跑一支就點名' (#39) from develop into master
Reviewed-on: #39
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 01:50:11 +00:00
admin 00ace7bead Merge pull request '釋出:到期清單的路徑一律由呼叫端餵進來,並拒跑過舊的清單' (#37) from develop into master
Reviewed-on: #37
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 01:25:47 +00:00
admin e4e0ff2776 Merge pull request '釋出:監控頁三塊的接法收進腳本,組頁那一步不再由呼叫端臨場挑工具' (#35) from develop into master
Reviewed-on: #35
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 09:46:30 +00:00
admin 78116a61f1 Merge pull request '釋出:條目只帶放得下 CLI 執行檔的那幾個目錄,不快照整條 PATH' (#33) from develop into master
Reviewed-on: #33
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 09:24:51 +00:00
admin 1bebf7450f Merge pull request '釋出:排程條目把 PATH 一起快照進去' (#31) from develop into master
Reviewed-on: #31
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 06:58:38 +00:00
admin 5d353ecc40 Merge pull request '釋出:補印執行那一支的允許規則,並回頭比對本文叫得到的每一支' (#29) from develop into master
Reviewed-on: #29
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 06:26:06 +00:00
admin 0fcce1c96a Merge pull request '釋出:到期的內建檢查項真的跑一遍,成敗回寫待辦簿' (#27) from develop into master
Reviewed-on: #27
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 06:08:58 +00:00
admin 2cccafd3a9 Merge pull request '釋出:排程條目不再寫死工作階段代號,CLI 判定跟上共用函式' (#25) from develop into master
Reviewed-on: #25
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 04:35:13 +00:00
admin e782bee616 Merge pull request '釋出:人在現場那一路的根目錄解析帶上文件記載的預設值' (#23) from develop into master
Reviewed-on: #23
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 03:56:49 +00:00
admin bc1c097a75 Merge pull request '釋出:種入內建項時改讀委派清單的唯讀盤點指令欄' (#21) from develop into master
Reviewed-on: #21
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 03:37:48 +00:00
admin 8803d7a499 Merge pull request '釋出內建檢查項的種入與重建至 master,版本 0.1.8 升到 0.1.9' (#19) from develop into master
Reviewed-on: #19
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 01:21:11 +00:00
admin e0c752543e Merge pull request '釋出助理待辦簿的地基至 master,版本 0.1.6 升到 0.1.8' (#17) from develop into master
Reviewed-on: #17
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-03 08:44:00 +00:00
admin 2e6b0b9b42 Merge pull request '釋出助理巡檢的路徑修正與先前累積的變更至 master,版本 0.1.2 升到 0.1.6' (#15) from develop into master
Reviewed-on: #15
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-03 03:17:06 +00:00
admin c36460dd59 Merge pull request 'release: wiki 目錄頁專用存取庫、HASH 完整 40 碼、閘門依 CLI 分流' (#10) from develop into master
Reviewed-on: #10
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-02 04:20:48 +00:00
admin de3775f970 Merge pull request '釋出 jsc-assist 0.1.1:排程輪次自帶環境與權限,監控頁改成固定三塊' (#8) from develop into master
Reviewed-on: #8
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-02 01:29:25 +00:00
admin 9590adcfd4 Merge pull request '釋出 jsc-assist 0.1.0:助理主體、排程與一輪巡檢' (#6) from develop into master
Reviewed-on: #6
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 07:51:27 +00:00
admin 8a14d04c5d Merge pull request '釋出 jsc-assist 0.0.2:助理 domain 落地與 status 技能' (#3) from develop into master
Reviewed-on: #3
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 04:44:51 +00:00
11 changed files with 769 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-assist", "name": "jsc-assist",
"version": "0.3.1", "version": "0.3.7",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-assist", "name": "jsc-assist",
"version": "0.3.1", "version": "0.3.7",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills", "skills": "./skills",
"jsc": { "jsc": {
+2 -2
View File
@@ -43,8 +43,8 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| 檔案 | 用途 | | 檔案 | 用途 |
| --- | --- | | --- | --- |
| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`。`JSC_WIKI_REPO` 系列含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`:監控頁 `MONITOR_{HASH}` 與目錄頁 `MONITOR_CONTENTS` 分屬不同存取庫,兩支變數都要帶。名單是安裝當下從環境撈出所有已設定的,不寫死,所以新增的頁型變數自動涵蓋,這支不必跟著改——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。安裝當下把解好的字面根目錄寫進條目的提示文字(`工具根目錄={絕對路徑}`)並印成 `patrol_root=`:那一輪自己解不出根目錄,只能從提示文字拿,拿不到就停下回報;自訂巡檢指令沒帶這一段只警告、不中止。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動 | | `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL`、存取庫掃描的 `JSC_ASSIST_SCAN_ROOT` 與 `JSC_ASSIST_SCAN_EXCLUDE`,以及已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`。`JSC_WIKI_REPO` 系列含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`:監控頁 `MONITOR_{HASH}` 與目錄頁 `MONITOR_CONTENTS` 分屬不同存取庫,兩支變數都要帶。名單是安裝當下從環境撈出所有已設定的,不寫死,所以新增的頁型變數自動涵蓋,這支不必跟著改——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。安裝當下把解好的字面根目錄寫進條目的提示文字(`工具根目錄={絕對路徑}`)並印成 `patrol_root=`:那一輪自己解不出根目錄,只能從提示文字拿,拿不到就停下回報;自訂巡檢指令沒帶這一段只警告、不中止。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動。條目長度兩個模式都量,印成 `entry_len=`:cron 一行有長度上限,超過就整批寫不進去,而那個限制是 `crontab` 自己在寫入那一刻才擋,`--dry-run` 那一路根本不碰它——實測踩過一次,預演全綠、安裝回「command too long」 |
| `tools/patrol.sh` | 一輪巡檢的收攏與收口。三個子命令:`collect` 取鎖、讀五項來源、組出監控頁的「最新一輪」與「近 24 輪摘要」兩塊、本輪的摘要列與目錄頁那一個區塊(區塊的 H2 標題是內容頁頁名 `MONITOR_{HASH}`,upsert 拿標題當鍵;「監控頁」那一條是連結,網址留佔位,等監控頁寫成之後由呼叫端用 `gitea.sh wiki-url` 的絕對網址換掉);`finish` 在監控頁寫成之後才寫心跳、換上用量快照、放掉鎖;`abort` 只放掉鎖,不寫心跳。各項來源各自獨立,一項失敗其餘各項照跑,失敗那一項在頁上寫明是「這一項失敗」而不是沒資料。執行狀態事件那一項由 `collect` 自己叫 `jsc-hooks/tools/report-status.sh` 排空再輪替,把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成頁上那一節;`drain` 是消耗性讀取,所以只由這支跑,且它失敗一律不中止那一輪。整輪拿一把目錄鎖,上一輪還在跑就回 4 讓開;鎖逾時(門檻取心跳門檻)會被下一輪搶回來,並在頁上記一筆。`version-guard.sh report` 回「查詢失敗」時照原字抄,不補查、不美化 | | `tools/patrol.sh` | 一輪巡檢的收攏與收口。三個子命令:`collect` 取鎖、讀五項來源、組出監控頁的「最新一輪」與「近 24 輪摘要」兩塊、本輪的摘要列與目錄頁那一個區塊(區塊的 H2 標題是內容頁頁名 `MONITOR_{HASH}`,upsert 拿標題當鍵;「監控頁」那一條是連結,網址留佔位,等監控頁寫成之後由呼叫端用 `gitea.sh wiki-url` 的絕對網址換掉);`finish` 在監控頁寫成之後才寫心跳、換上用量快照、放掉鎖;`abort` 只放掉鎖,不寫心跳。各項來源各自獨立,一項失敗其餘各項照跑,失敗那一項在頁上寫明是「這一項失敗」而不是沒資料。執行狀態事件那一項由 `collect` 自己叫 `jsc-hooks/tools/report-status.sh` 排空再輪替,把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成頁上那一節;`drain` 是消耗性讀取,所以只由這支跑,且它失敗一律不中止那一輪。整輪拿一把目錄鎖,上一輪還在跑就回 4 讓開;鎖逾時(門檻取心跳門檻)會被下一輪搶回來,並在頁上記一筆。`version-guard.sh report` 回「查詢失敗」時照原字抄,不補查、不美化。`collect` 另外把要送到人面前的那幾筆寫成提醒佇列 `$JSC_HOME/assistant/reminders.tsv`:第一行帶輪次與 UTC 時間戳,之後一行一筆,到期的只提醒項與逾期項各一種。判定留在到期判定那一支,佇列只是輸出——讀的那一端只印、不判。時間戳是關鍵:沒有人更新的佇列讀起來跟新的一模一樣 |
| `references/behaviors.md` | 本 domain 的技能行為清單:一支技能一節,五列記下觸發時機、關鍵步驟、外部呼叫、完成條件、可驗證跡象,供稽核與驗證比對。格式合約見 `plugins/meta` 的 `references/guidelines.md`「技能行為清單」 | | `references/behaviors.md` | 本 domain 的技能行為清單:一支技能一節,五列記下觸發時機、關鍵步驟、外部呼叫、完成條件、可驗證跡象,供稽核與驗證比對。格式合約見 `plugins/meta` 的 `references/guidelines.md`「技能行為清單」 |
| `templates/monitor-contents.md` | 目錄頁 `MONITOR_CONTENTS` 的範本,這一頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的專用存取庫,和監控頁不同庫。版面是 H1、`>` 引言,然後一台機器一個 H2 區塊,欄位在標題底下一行一條 `- {欄位名}:{值}`,頁上不放 markdown 表格。H2 標題就是內容頁頁名 `MONITOR_{HASH}`,雜湊來源是 `{主機名}/{登入帳號}`,主機名取短的那一段。寫入一律走 `jsc-gitea/tools/wiki-contents.sh upsert`,比對鍵是 H2 標題:**只更新自己那一個區塊**,別台機器的區塊原樣保留,禁止整頁覆蓋。「監控頁」那一條的連結一律寫成 `[{頁名}]({絕對網址})`,網址取 `gitea.sh wiki-url` 印的那一個,寫入前先過 `jsc-gitea/tools/link-check.sh`、結束碼 0 才寫;但那一條含主機位址與網址編碼,會變,所以不當鍵 | | `templates/monitor-contents.md` | 目錄頁 `MONITOR_CONTENTS` 的範本,這一頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的專用存取庫,和監控頁不同庫。版面是 H1、`>` 引言,然後一台機器一個 H2 區塊,欄位在標題底下一行一條 `- {欄位名}:{值}`,頁上不放 markdown 表格。H2 標題就是內容頁頁名 `MONITOR_{HASH}`,雜湊來源是 `{主機名}/{登入帳號}`,主機名取短的那一段。寫入一律走 `jsc-gitea/tools/wiki-contents.sh upsert`,比對鍵是 H2 標題:**只更新自己那一個區塊**,別台機器的區塊原樣保留,禁止整頁覆蓋。「監控頁」那一條的連結一律寫成 `[{頁名}]({絕對網址})`,網址取 `gitea.sh wiki-url` 印的那一個,寫入前先過 `jsc-gitea/tools/link-check.sh`、結束碼 0 才寫;但那一條含主機位址與網址編碼,會變,所以不當鍵 |
| `templates/monitor-page.md` | 內容頁 `MONITOR_{HASH}` 的範本。記的是這台機器的巡檢軌跡。頁面固定三塊:本頁基本資料建頁時寫一次就不動、最新一輪每輪整塊換掉、近 24 輪摘要一輪一列且最新的在最上面。軌跡留在摘要表,完整內容只留最新一輪,頁面才讀得完 | | `templates/monitor-page.md` | 內容頁 `MONITOR_{HASH}` 的範本。記的是這台機器的巡檢軌跡。頁面固定三塊:本頁基本資料建頁時寫一次就不動、最新一輪每輪整塊換掉、近 24 輪摘要一輪一列且最新的在最上面。軌跡留在摘要表,完整內容只留最新一輪,頁面才讀得完 |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-assist", "name": "jsc-assist",
"version": "0.3.1", "version": "0.3.7",
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)", "description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
"skills": "./skills/", "skills": "./skills/",
"jsc": { "jsc": {
File diff suppressed because one or more lines are too long
+11 -6
View File
@@ -76,6 +76,7 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta
| the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` | | the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` |
| the built-in check items, reconciled against the delegation list | `{CURRENT}/jsc-assist/tools/seed-tasks.sh` | | the built-in check items, reconciled against the delegation list | `{CURRENT}/jsc-assist/tools/seed-tasks.sh` |
| the due built-in check items, actually run | `{CURRENT}/jsc-assist/tools/run-due.sh` | | the due built-in check items, actually run | `{CURRENT}/jsc-assist/tools/run-due.sh` |
| the repositories under the working directory, scanned for `{repo}` | `{CURRENT}/jsc-assist/tools/scan-repos.sh` |
| the heartbeat | `{CURRENT}/jsc-hooks/hooks/heartbeat.sh` | | the heartbeat | `{CURRENT}/jsc-hooks/hooks/heartbeat.sh` |
| the status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` | | the status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` |
| the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` | | the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` |
@@ -185,7 +186,7 @@ Four properties of that script matter enough to state here, because a report tha
- **A written entry is not a running entry.** WSL does not start cron by default, and this is the machine's most likely state. Exit 1 from `install` means the entry is on disk and will never fire. Report that as a failure of the start, name `sudo service cron start`, and say it has to be run again after every WSL restart. Never soften exit 1 into "scheduling is set up". - **A written entry is not a running entry.** WSL does not start cron by default, and this is the machine's most likely state. Exit 1 from `install` means the entry is on disk and will never fire. Report that as a failure of the start, name `sudo service cron start`, and say it has to be run again after every WSL restart. Never soften exit 1 into "scheduling is set up".
- **The log lives at `$JSC_HOME/assistant/schedule.log`**, deliberately outside every repository. Do not offer to move it into a project. - **The log lives at `$JSC_HOME/assistant/schedule.log`**, deliberately outside every repository. Do not offer to move it into a project.
- **The entry runs with no human present.** The command is installed with `</dev/null`, so nothing it runs can block on input. A patrol round that stops to ask for a tool permission hangs that round, and the lock it holds stands the next round down until the lock ages out — which is why `patrol` asks nothing, of anybody, ever. - **The entry runs with no human present.** The command is installed with `</dev/null`, so nothing it runs can block on input. A patrol round that stops to ask for a tool permission hangs that round, and the lock it holds stands the next round down until the lock ages out — which is why `patrol` asks nothing, of anybody, ever.
- **The entry carries its own environment.** cron gives it a short `PATH`, no settings file and no tty, so `schedule.sh` writes three things into the entry: the CLI resolved to an absolute path with `command -v`, a snapshot of the wiki variables taken at install time (`GITEA_HOST`, `GITEA_TOKEN`, `JSC_HOME`, `JSC_ASSISTANT_HEARTBEAT_TTL` and every set `JSC_WIKI_REPO*` — `JSC_WIKI_REPO`, `JSC_WIKI_REPO_MONITOR` for the monitor page and `JSC_WIKI_REPO_CONTENTS` for the directory page, which the script picks up from the environment rather than from a hardcoded list), and `JSC_GITEA_CONFIRM=yes`, because the write confirmation only recognises a tty and an unattended round has nobody to confirm. Two consequences belong in every report: the entry holds a copy of the token, so the crontab file has to stay readable by its owner alone, and a changed variable only reaches the entry after another `install`. `install` prints the snapshotted names in `env_snapshot=` and masks the token in every entry it prints — never print an entry read from `crontab -l` yourself. - **The entry carries its own environment.** cron gives it a short `PATH`, no settings file and no tty, so `schedule.sh` writes three things into the entry: the CLI resolved to an absolute path with `command -v`, a snapshot of the wiki variables taken at install time (`GITEA_HOST`, `GITEA_TOKEN`, `JSC_HOME`, `JSC_ASSISTANT_HEARTBEAT_TTL`, `JSC_ASSIST_SCAN_ROOT` and `JSC_ASSIST_SCAN_EXCLUDE` for the repository scan, and every set `JSC_WIKI_REPO*` — `JSC_WIKI_REPO`, `JSC_WIKI_REPO_MONITOR` for the monitor page and `JSC_WIKI_REPO_CONTENTS` for the directory page, which the script picks up from the environment rather than from a hardcoded list), and `JSC_GITEA_CONFIRM=yes`, because the write confirmation only recognises a tty and an unattended round has nobody to confirm. Two consequences belong in every report: the entry holds a copy of the token, so the crontab file has to stay readable by its owner alone, and a changed variable only reaches the entry after another `install`. `install` prints the snapshotted names in `env_snapshot=` and masks the token in every entry it prints — never print an entry read from `crontab -l` yourself.
- **`install` prints the permission rules that round needs.** One `allow_rule=` line each, every path a full literal under `current` — no variable, no tilde, and no wildcard inside the path, because a rule holding one matches nothing. Hand them to the operator verbatim: an unattended round that hits a permission prompt hangs until the lock ages out, and nobody is there to approve it. `Write(...)` rules do nothing for file writes — only `Edit(...)` is recognised — so never turn a printed `Edit` rule into a `Write` one. - **`install` prints the permission rules that round needs.** One `allow_rule=` line each, every path a full literal under `current` — no variable, no tilde, and no wildcard inside the path, because a rule holding one matches nothing. Hand them to the operator verbatim: an unattended round that hits a permission prompt hangs until the lock ages out, and nobody is there to approve it. `Write(...)` rules do nothing for file writes — only `Edit(...)` is recognised — so never turn a printed `Edit` rule into a `Write` one.
- **`install` writes the tool root into the entry.** The round it schedules cannot resolve the root for itself, so `schedule.sh` puts the literal path into the entry's prompt as `工具根目錄={path}` and prints the same value as `patrol_root=`. Report that value, and treat any hand-edit of the entry that drops it as breaking every future round: from then on each one stops at step 0 with nothing recorded. - **`install` writes the tool root into the entry.** The round it schedules cannot resolve the root for itself, so `schedule.sh` puts the literal path into the entry's prompt as `工具根目錄={path}` and prints the same value as `patrol_root=`. Report that value, and treat any hand-edit of the entry that drops it as breaking every future round: from then on each one stops at step 0 with nothing recorded.
@@ -263,7 +264,7 @@ The delegation list holds one row per jsc skill and records whether that skill c
**A `probe` that cannot be substituted falls back to `remind` and is reported, never dropped.** The path is not `{root}/jsc-{domain}/...`, the command carries a `$` or a `~`, a brace other than the three known holes survived, the root could not be resolved, or the script is not on this machine: each prints `probe_bad=` and the entry is still seeded, as a reminder. Not seeding it would mean removing it on `apply`, so one mistyped cell upstream would delete an entry carrying its own `last_run` and `fail_count` history. **A `probe` that cannot be substituted falls back to `remind` and is reported, never dropped.** The path is not `{root}/jsc-{domain}/...`, the command carries a `$` or a `~`, a brace other than the three known holes survived, the root could not be resolved, or the script is not on this machine: each prints `probe_bad=` and the entry is still seeded, as a reminder. Not seeding it would mean removing it on `apply`, so one mistyped cell upstream would delete an entry carrying its own `last_run` and `fail_count` history.
**`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** `run-due.sh` is what executes one, and it is the only thing that does: `tasks.sh` stores the value, `due.sh` prints it, `status` and the monitor page print it. That tool substitutes `{cli}` from `jsc-cli/tools/detect-clis.sh` and runs once per target, with the targets' results together counting as the entry's one success or failure. `{repo}` has no source yet — the repository scan is not built — so an entry carrying that hole is held, reported, and left with its `last_run` and `fail_count` untouched. **Holding is not failing**: an entry that cannot be given a target did nothing wrong, and recording it as a failure grows a counter nobody can bring down by fixing that entry, which is exactly what that counter exists to make visible. **`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** `run-due.sh` is what executes one, and it is the only thing that does: `tasks.sh` stores the value, `due.sh` prints it, `status` and the monitor page print it. That tool substitutes `{cli}` from `jsc-cli/tools/detect-clis.sh` and `{repo}` from `jsc-assist/tools/scan-repos.sh`, then runs once per target, with the targets' results together counting as the entry's one success or failure. An entry carrying both holes runs once per pair — three repositories and five CLIs is fifteen targets. An entry whose `repo=` field names a repository is given that one repository only, matched against the scan by working directory or by `{owner}/{repo}`; a name the scan did not find is held rather than widened back to every repository, because that entry asked for one target. **The scan refuses to guess its starting point**: without `JSC_ASSIST_SCAN_ROOT` it reports that and nothing else, because guessing one layer too high turns the working directory into the home directory and then into the whole machine, and the commands would run there with nobody watching. So an entry carrying `{repo}` on a machine where that variable never reached the scheduled entry is held with the scan's own words as the reason — that is what tells a person which variable to set and that a fresh `schedule.sh install patrol` is what carries it into the entry. **Holding is not failing**: an entry that cannot be given a target did nothing wrong, and recording it as a failure grows a counter nobody can bring down by fixing that entry, which is exactly what that counter exists to make visible.
**A `pending` row stays a reminder but is never reported as an ordinary one.** The reason lives in the report, as a `pending=` line, and nothing is written into the task file. Putting the marker in the title would change the `id` and cut that entry's history, and the drift check compares `kind`, `action`, `trigger` and `recur` but not the title, so a stale marker would never be caught; adding a sixteenth key would change the task book's fixed storage format for a piece of upstream prose the task book has no way to edit later. `status` runs `plan`, so `pending=`, `held=` and `drift=` all reach a human in the same place. **A `pending` row stays a reminder but is never reported as an ordinary one.** The reason lives in the report, as a `pending=` line, and nothing is written into the task file. Putting the marker in the title would change the `id` and cut that entry's history, and the drift check compares `kind`, `action`, `trigger` and `recur` but not the title, so a stale marker would never be caught; adding a sixteenth key would change the task book's fixed storage format for a piece of upstream prose the task book has no way to edit later. `status` runs `plan`, so `pending=`, `held=` and `drift=` all reach a human in the same place.
@@ -329,7 +330,7 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by
3. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported. 3. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported.
4. **Install the patrol entry.** Run `{CURRENT}/jsc-assist/tools/schedule.sh install patrol`. Judge the result by the schedule.sh exit-code table, and keep the printed `entry=`, `ttl=`, `period=`, `legacy_removed=`, `others_kept=`, `env_snapshot=`, `patrol_root=`, every `allow_rule=` line and `service=` for the report. Exit 1 is the case to get right: the entry is installed and inert, so step 5 reports a started assistant whose heartbeat will expire, not a scheduled one. Exit 6 with a CLI executable that is not on `PATH` is the second one: nothing was installed, and the fix is to install that CLI or to pass `--patrol-cmd`, not to write a bare command name into the entry. On 2, 3, 4, 5 or 6 nothing is scheduled — report the code, say the round ran but no further round will, and do not claim the assistant will stay alive. Completion condition: the exit code is recorded, and on exit 0 the entry line, the TTL, the period, the legacy count, the surviving-entry count, the snapshotted variable names, the tool root the entry carries and the allow rules are recorded with it. 4. **Install the patrol entry.** Run `{CURRENT}/jsc-assist/tools/schedule.sh install patrol`. Judge the result by the schedule.sh exit-code table, and keep the printed `entry=`, `entry_len=`, `ttl=`, `period=`, `legacy_removed=`, `others_kept=`, `env_snapshot=`, `patrol_root=`, every `allow_rule=` line and `service=` for the report. `entry_len=` is measured against cron's per-line limit in both the real install and `--dry-run`, because that limit is enforced by `crontab` itself: a snapshot that pushes the entry over it fails the install with a bare "command too long" while every dry run stays green. Exit 1 is the case to get right: the entry is installed and inert, so step 5 reports a started assistant whose heartbeat will expire, not a scheduled one. Exit 6 with a CLI executable that is not on `PATH` is the second one: nothing was installed, and the fix is to install that CLI or to pass `--patrol-cmd`, not to write a bare command name into the entry. On 2, 3, 4, 5 or 6 nothing is scheduled — report the code, say the round ran but no further round will, and do not claim the assistant will stay alive. Completion condition: the exit code is recorded, and on exit 0 the entry line, the TTL, the period, the legacy count, the surviving-entry count, the snapshotted variable names, the tool root the entry carries and the allow rules are recorded with it.
5. **Report the start.** Print the round's verdict and its four item results, the monitor page that was written, the heartbeat path, the local time of `ts`, the TTL in seconds, `pid`, `cli` and `session` as hints, then the scheduler mechanism, the derived period, the installed entry line as the script printed it with the token already masked, the `patrol_root=` the entry carries — that is what every later round reads its tool root from — how many legacy heartbeat entries were removed, and how many other entries were left untouched. Then hand over the two operator items the install printed: the `allow_rule=` lines verbatim, so the unattended round never meets a permission prompt, and the reminder that the entry holds a snapshot of the listed variables including the token — keep the crontab file readable by its owner alone, and run `install` again after any of those variables changes. 5. **Report the start.** Print the round's verdict and its four item results, the monitor page that was written, the heartbeat path, the local time of `ts`, the TTL in seconds, `pid`, `cli` and `session` as hints, then the scheduler mechanism, the derived period, the installed entry line as the script printed it with the token already masked, the `patrol_root=` the entry carries — that is what every later round reads its tool root from — how many legacy heartbeat entries were removed, and how many other entries were left untouched. Then hand over the two operator items the install printed: the `allow_rule=` lines verbatim, so the unattended round never meets a permission prompt, and the reminder that the entry holds a snapshot of the listed variables including the token — keep the crontab file readable by its owner alone, and run `install` again after any of those variables changes.
@@ -347,7 +348,11 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by
One round: read five sources, record the result, then beat. Everything before the heartbeat is read-only except the round's own scratch files. Ask nobody anything. One round: read five sources, record the result, then beat. Everything before the heartbeat is read-only except the round's own scratch files. Ask nobody anything.
1. **Collect.** Run `{CURRENT}/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). That is the same split step 0 branched on: 排程 is the unattended round that read its root out of the invocation text, 手動 the round somebody asked for. Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=`, `contents_file=` and `due_rows_file=` — that last one is what step 5 has to be given, and an empty value there means the judging step produced no list, so step 5 has nothing to act on and says so rather than falling back to anything. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped. 1. **Collect.** Run `{CURRENT}/jsc-assist/tools/patrol.sh collect --trigger 排程` (use `--trigger 手動` when a person asked for this round). That is the same split step 0 branched on: 排程 is the unattended round that read its root out of the invocation text, 手動 the round somebody asked for. Judge the exit code by the patrol.sh table. Exit 4 stands the round down — report the holder and its age from the printed `lock=busy` line, and stop; write no page and no heartbeat. Exit 5 and 6 stop the round the same way, with the code and the stderr text. Exit 0, 1 and 3 all carry on to step 2. Record `round=`, `lock_broken=`, `hash=`, `page=`, `verdict=`, `failed_sources=`, `warn_sources=`, `pending=`, `tasks_total=`, `tasks_failing=`, `tasks_due=`, `tasks_overdue=`, `reminders=`, `reminders_builtin=`, every `item=` line, and the file paths `latest_file=`, `summary_file=`, `summary_row_file=`, `newpage_file=`, `contents_file=` and `due_rows_file=` — that last one is what step 5 has to be given, and an empty value there means the judging step produced no list, so step 5 has nothing to act on and says so rather than falling back to anything. Completion condition: the round id, the page name and the five file paths are recorded, or the stand-down or the failure was reported and the round stopped.
**The round also writes the reminder queue.** `collect` leaves `$JSC_HOME/assistant/reminders.tsv` behind — a tab-separated file whose first line is `round`, the round id, the round's UTC timestamp, the failing count and the same moment in epoch seconds — the ISO string is for a person to read and the epoch is what a reader does arithmetic on, so no reader has to carry its own date parser — followed by one row per item to surface, each carrying its kind, the id, the reason or the how-long-overdue wording, and the title. There are three kinds, and the split is the point: `overdue` for an entry past its deadline, `remind` for a reminder-type entry a person registered, and `builtin` for one the delegation list seeded. **A built-in reminder waits on plumbing, not on a person** — its action is `remind`, nothing executes it yet, so it comes due every round and reads identically every round; eight of them on this machine turned every session's opening into eight lines nobody could act on. Whatever prints the queue names the first two kinds and gives the third a single count line, which is why the kind is decided here, where `spec_key` is readable, and not there. The counts are printed as `reminders=` for the named kinds and `reminders_builtin=` for the third, alongside `reminders_file=`. **The judgement stays here and the queue is only its output**: whatever reads it later prints and nothing more, because a reader that compared `due` and `next_run` against the clock itself would be a second judgement of the same thing, and the day the two disagreed both would look right. The timestamp on that first line is what the design turns on — a queue nobody refreshed reads exactly like a fresh one, so the reader has to be able to say how old it is. **"No reminders" and "nobody computed the reminders" must never look the same.**
**`tasks_overdue=` and `tasks_failing=` earn a 待人處理 row every round, and `collect` writes both of them itself.** Neither condition heals on its own: a deadline that has passed does not become un-passed, and an entry that fails retries next round and fails again. So the row is repeated every round rather than suppressed after the first — **"already reported" is not "already handled"**, and the assistant does not pause an entry on anybody's behalf; `paused` is a state a person sets and only a person clears. The overdue count comes from the judging step and is a dash when that step could not judge, which is not the same as zero. The monitor page carries the two named tables under 待辦簿到期與逾期: which entries are overdue and by how long, and which ones have been failing and how many times. **The directory page gains no field for either.** That page holds one block per machine, each written by that machine's own round, so a new field would only appear for machines already on the new version, and a reader could not tell "nothing overdue here" from "this machine has not written that field yet" — the same reason the 本輪非 ok 事件數 field was kept off it.
**The status event lines come out of the same call.** `collect` drained the stream and rotated it (see 「The status event stream」 above), so record `events_total=`, `events_bad=`, `events_unpaired=`, `events_running=`, `events_rotated=` and `events_file=` alongside the rest, and read `item=D-11` for whether that source was readable at all. The 執行狀態事件 subsection of `latest_file` already carries the two detail tables — the non-`ok` events and the starts with no matching end — so never rebuild either by hand and never call `report-status.sh` yourself: a second `drain` this round would either return exit 3 or eat events that then reach no page at all. **The status event lines come out of the same call.** `collect` drained the stream and rotated it (see 「The status event stream」 above), so record `events_total=`, `events_bad=`, `events_unpaired=`, `events_running=`, `events_rotated=` and `events_file=` alongside the rest, and read `item=D-11` for whether that source was readable at all. The 執行狀態事件 subsection of `latest_file` already carries the two detail tables — the non-`ok` events and the starts with no matching end — so never rebuild either by hand and never call `report-status.sh` yourself: a second `drain` this round would either return exit 3 or eat events that then reach no page at all.
@@ -399,7 +404,7 @@ One round: read five sources, record the result, then beat. Everything before th
Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran. Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran.
5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was. 5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **`repos=` and `repo_scan=` are the same kind of reading for `{repo}`.** `repos=0` with `repo_scan=rc3` means the scan had no starting point, so every entry carrying `{repo}` was held this round — report the variable to set, not "there is nothing to inventory". `repo_scan=rc1` means the scan handed over what it could and named the rest: each `repo_scan_note=` line is a directory the scan looked at and did not hand over whole: a path carrying a space or a shell metacharacter, a repository with no `origin` to compute a `REPO_{HASH}` page name from, or a `not_a_repo=` — a directory holding a `.git` that git itself refuses, which is a leftover rather than a repository waiting to be wired. Those lines go into the report as findings; a round that prints only the count reads as a full sweep. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was.
6. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code. 6. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code.
@@ -441,7 +446,7 @@ Read-only throughout. This operation creates, modifies and deletes nothing under
Completion condition: every matching sentence is printed, or none of the four combinations applied. Completion condition: every matching sentence is printed, or none of the four combinations applied.
6. **Flag the repeatedly failing tasks.** Append 已連續失敗 N 次 to every row whose `fail_count` is above 0, with `N` taken verbatim from the file. A broken entry that retries every round with nobody noticing is the reason this field exists, so let no such row leave the table unmarked. Completion condition: every row with `fail_count` above 0 carries the marker and its number matches the file. 6. **Flag the repeatedly failing and the overdue tasks.** Append 已連續失敗 N 次 to every row whose `fail_count` is above 0, with `N` taken verbatim from the file. A broken entry that retries every round with nobody noticing is the reason this field exists, so let no such row leave the table unmarked. Then run `{CURRENT}/jsc-assist/tools/due.sh overdue` and append 逾期 {human} to every row it names, with the wording taken from that tool's `human=` field. **Do not compare the `due` field against the clock yourself**: that would be a second judgement of the same thing, and the day the two disagree both will look right. That subcommand is read-only by construction — it advances no snapshot and compares no events, which is why `status` may call it while `scan` may not be called here at all. Exit 2 means at least one `due` field could not be parsed: report those as entries whose deadline was mistyped at registration, because **an unparseable deadline and no deadline look identical on screen**. A paused entry past its deadline is still overdue and is still named; being paused suspends the due judgement, not the calendar. Completion condition: every row with `fail_count` above 0 carries its marker, every row the overdue judgement named carries its own, and the numbers match what the two tools printed.
7. **Check the built-in items against the delegation list, read-only.** Run `{CURRENT}/jsc-assist/tools/seed-tasks.sh plan --root {CURRENT}`. `plan` writes nothing at all — it prints what a reconcile would do and stops — which is what makes it safe here, and `apply` must never be run from `status`. Judge the code by the seed-tasks.sh table and report the difference: the count of items the list expects but the task book lacks, the count of orphans the task book still holds, every `held=` row by name, every `drift=` row with the change the list asks for, every `pending=` row as a slice with an entry point still unwired — this is where a human finds out which reminders are waiting on plumbing rather than on them — and every `probe_bad=` row as an item that fell back to reminding. Say plainly that `start` is what applies any of it. On exit 1, 2 or 3 report that the comparison could not be made and why, and never present that as an aligned task book. Completion condition: the difference is reported with its counts and the held rows named, or the reason it could not be computed is reported, and nothing under `$JSC_HOME` was written. 7. **Check the built-in items against the delegation list, read-only.** Run `{CURRENT}/jsc-assist/tools/seed-tasks.sh plan --root {CURRENT}`. `plan` writes nothing at all — it prints what a reconcile would do and stops — which is what makes it safe here, and `apply` must never be run from `status`. Judge the code by the seed-tasks.sh table and report the difference: the count of items the list expects but the task book lacks, the count of orphans the task book still holds, every `held=` row by name, every `drift=` row with the change the list asks for, every `pending=` row as a slice with an entry point still unwired — this is where a human finds out which reminders are waiting on plumbing rather than on them — and every `probe_bad=` row as an item that fell back to reminding. Say plainly that `start` is what applies any of it. On exit 1, 2 or 3 report that the comparison could not be made and why, and never present that as an aligned task book. Completion condition: the difference is reported with its counts and the held rows named, or the reason it could not be computed is reported, and nothing under `$JSC_HOME` was written.
+163 -23
View File
@@ -8,8 +8,9 @@
# [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}] # [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}]
# [--now {epoch 秒}] # [--now {epoch 秒}]
# #
# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期。 # scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期與逾期。
# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。 # events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。
# overdue 只判逾期,唯讀:一份快照都不推進,一個事件都不比。狀態查詢那一路走這一個。
# next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。 # next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。
# #
# 結束碼: # 結束碼:
@@ -17,7 +18,8 @@
# 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、 # 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、
# 快照那一段原樣留著,呼叫端要把它標成警示 # 快照那一段原樣留著,呼叫端要把它標成警示
# 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、 # 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、
# --now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,其餘各筆照判 # due 欄的截止時間解不開、--now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,
# 其餘各筆照判
# 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批 # 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批
# 事件會被判第二次。這一碼要吵出來,不能當成成功 # 事件會被判第二次。這一碼要吵出來,不能當成成功
# 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」 # 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」
@@ -139,8 +141,13 @@
# #
# --- 這一支不判什麼 --- # --- 這一支不判什麼 ---
# #
# 不判逾期(due 欄有沒有過),不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。 # 不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。
# 它只回答兩個問題:本輪有哪些新事件、哪幾筆現在到期。做不做、怎麼做、失敗怎麼記,都在呼叫端。 # 它只回答三個問題:本輪有哪些新事件、哪幾筆現在到期、哪幾筆逾期了。做不做、怎麼做、失敗怎麼記,
# 都在呼叫端。
#
# 到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個截止時間過了」。
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的交辦
# 可能早就逾期,卻因為排定點還沒到而判成「還沒到」。合成一個值就會把這兩種情況混成同一格。
# #
# 環境變數: # 環境變數:
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜 # JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜
@@ -195,8 +202,9 @@ note() { printf '[jsc][助理到期判定]:%s\n' "$1" >&2; }
usage() { usage() {
cat >&2 <<'EOF' cat >&2 <<'EOF'
usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run] usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run]
due.sh events [--now epoch 秒] due.sh events [--now epoch 秒]
due.sh overdue [--now epoch 秒]
due.sh next --trigger at:...|after:... --recur once|every:間隔 due.sh next --trigger at:...|after:... --recur once|every:間隔
[--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字] [--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字]
[--now epoch 秒] [--now epoch 秒]
@@ -869,15 +877,51 @@ kv_get() { # $1=檔案 $2=鍵
# 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是 # 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是
# recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位 # recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位
# 字元也不會有換行,所以定位字元切得準。 # 字元也不會有換行,所以定位字元切得準。
row_out() { # 十一個欄位,順序如上 row_out() { # 十二個欄位,順序如上
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$(flat "$1")" "$(flat "$2")" "$(flat "$3")" "$(flat "$4")" "$(flat "$5")" \ "$(flat "$1")" "$(flat "$2")" "$(flat "$3")" "$(flat "$4")" "$(flat "$5")" \
"$(flat "$6")" "$(flat "$7")" "$(flat "$8")" "$(flat "$9")" \ "$(flat "$6")" "$(flat "$7")" "$(flat "$8")" "$(flat "$9")" \
"$(flat "${10}")" "$(flat "${11}")" >>"$ROWS" "$(flat "${10}")" "$(flat "${11}")" "$(flat "${12}")" >>"$ROWS"
}
# 逾期判定。到期與逾期是兩件事:到期是「該跑了」,逾期是「截止時間過了」。
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的
# 交辦事項可能早就逾期,卻因為排定點還沒到而判成「還沒到」。兩個判定各自獨立,一欄一個值。
#
# 回傳三種值:due 欄是空的印減號、還沒過印 no、過了印逾期的秒數。
# 印秒數不印 yes 的理由是「逾期一分鐘」與「逾期三個月」在報告上要看得出差別,而呼叫端
# 只要判「是不是數字」就分得出有沒有逾期。
# due 欄解不開的一律印 bad 並算成欄位不合法:一個解不開的截止時間跟沒有截止時間,
# 在畫面上長得一模一樣,而前者是登錄時就填錯了。
# 逾期多久,給人看的寫法。機器可讀的那幾處一律留秒數:秒數比得出大小、也不必解析。
# 頁面上只印秒數讀不出輕重——「528462 秒」要算過才知道是六天,而報告的用途正是讓人一眼
# 分出「逾期一分鐘」與「逾期三個月」。
dur_human() { # $1=秒數
_ds=$(( $1 / 86400 )); _hs=$(( ($1 % 86400) / 3600 )); _ms=$(( ($1 % 3600) / 60 ))
if [ "$_ds" -gt 0 ]; then printf '%s 天 %s 小時' "$_ds" "$_hs"
elif [ "$_hs" -gt 0 ]; then printf '%s 小時 %s 分' "$_hs" "$_ms"
else printf '%s 分' "$_ms"; fi
}
overdue_of() { # $1=due 欄的值;回傳值印在 stdout
case "$1" in
''|'-') printf '%s' '-'; return 0 ;;
esac
_de=$(iso_to_epoch "$1") || _de=''
case "${_de:-}" in
''|*[!0-9]*) printf '%s' 'bad'; return 1 ;;
esac
if [ "$NOW" -gt "$_de" ]; then
printf '%s' "$((NOW - _de))"
else
printf '%s' 'no'
fi
return 0
} }
scan_tasks() { scan_tasks() {
T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0 T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0
T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
true >"$ROWS" 2>/dev/null || { RC_FS=5; die 5 "逐筆判定的輸出檔寫不進去:$ROWS。"; } true >"$ROWS" 2>/dev/null || { RC_FS=5; die 5 "逐筆判定的輸出檔寫不進去:$ROWS。"; }
if [ ! -d "$TASKS_DIR" ]; then if [ ! -d "$TASKS_DIR" ]; then
RC_EMPTY=4 RC_EMPTY=4
@@ -898,11 +942,28 @@ scan_tasks() {
_cr=$(kv_get "$_f" created) _cr=$(kv_get "$_f" created)
_lr=$(kv_get "$_f" last_run) _lr=$(kv_get "$_f" last_run)
_fc=$(kv_get "$_f" fail_count) _fc=$(kv_get "$_f" fail_count)
_du=$(kv_get "$_f" due)
# 逾期照每一筆都判,連 paused 那幾筆也判。到期判定跳過 paused 是對的(那是人設的,
# 助理判它到期只會讓人以為它還在跑),但截止時間過了這件事不會因為有人按了暫停就消失
# ——一筆被暫停又逾期的交辦,正好是最容易被忘掉的那一種。
# done 那幾筆不算逾期:做完了就沒有截止時間可以過。
_ov='-'
if [ "$_st" != done ]; then
_ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; }
case "$_ov" in
''|*[!0-9]*) ;;
*) T_OVERDUE=$(( T_OVERDUE + 1 ))
OVERDUE_LINES="${OVERDUE_LINES}$(printf '%s\t%s\t%s\t%s\t%s' \
"$(flat "$_id")" "$(flat "$_st")" "$(flat "$_du")" "$_ov" "$(flat "$_ti")")
" ;;
esac
fi
if [ "$_st" != pending ]; then if [ "$_st" != pending ]; then
# done 與 paused 一律不判。paused 是人設的,助理判它到期只會讓人以為它還在跑。 # done 與 paused 一律不判到期。
T_SKIP=$(( T_SKIP + 1 )) T_SKIP=$(( T_SKIP + 1 ))
row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" row_out "$_id" skip "$_st" "$_kd" "$_ac" '' '' '' '' 'state 不是 pending,不判到期' "$_ti" "$_ov"
continue continue
fi fi
@@ -914,7 +975,7 @@ scan_tasks() {
unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;; unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;;
esac esac
row_out "$_id" "$R_VERDICT" "$_st" "$_kd" "$_ac" "$_tg" "$_rc" \ row_out "$_id" "$R_VERDICT" "$_st" "$_kd" "$_ac" "$_tg" "$_rc" \
"$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" "$R_NEXT" "$R_REARM" "$R_WHY" "$_ti" "$_ov"
done done
[ "$T_TOTAL" -eq 0 ] && { [ "$T_TOTAL" -eq 0 ] && {
RC_EMPTY=4 RC_EMPTY=4
@@ -931,7 +992,7 @@ compose_md() {
printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \ printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \
"$PREV" "$SEEN" "$PREV" "$SEEN"
printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n' printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n'
printf '這一節不判逾期(`due` 欄有沒有過),也不執行任何一筆。到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n' printf '到期與逾期是兩件事,這一節兩件都判:到期是「該跑了」,逾期是「`due` 欄那個截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。這一節不執行任何一筆——到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n'
printf '| 項目 | 內容 |\n' printf '| 項目 | 內容 |\n'
printf '| --- | --- |\n' printf '| --- | --- |\n'
printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")" printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")"
@@ -943,10 +1004,12 @@ compose_md() {
printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW" printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW"
printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL" printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL"
printf '| 到期 | %s |\n' "$T_DUE" printf '| 到期 | %s |\n' "$T_DUE"
printf '| 逾期 | %s |\n' "$T_OVERDUE"
printf '| 還沒到 | %s |\n' "$T_WAIT" printf '| 還沒到 | %s |\n' "$T_WAIT"
printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID" printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID"
printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED" printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED"
printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP" printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP"
[ "$T_DUEBAD" -gt 0 ] && printf '| `due` 欄解不開 | %s |\n' "$T_DUEBAD"
printf '\n#### 狀態來源\n\n' printf '\n#### 狀態來源\n\n'
printf '| 來源 | 狀態 | 筆數 | 說明 |\n' printf '| 來源 | 狀態 | 筆數 | 說明 |\n'
@@ -975,19 +1038,36 @@ compose_md() {
printf '本輪沒有新事件。\n' printf '本輪沒有新事件。\n'
fi fi
printf '\n#### 逾期\n\n'
if [ "$T_OVERDUE" -gt 0 ]; then
printf '**這幾筆的截止時間過了。逾期每一輪都提,不因為提過就不再提**——助理只提醒,不代為執行,也不自己把它移出這一節。\n\n'
printf '| id | state | 截止時間 | 逾期多久 | 標題 |\n'
printf '| --- | --- | --- | ---: | --- |\n'
printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do
[ -n "$_oi" ] || continue
printf '| `%s` | %s | %s | %s | %s |\n' "$_oi" "$_os" "$(cell "$_od")" "$(dur_human "$_oa")" "$(cell "${_ot:--}")"
done
printf '\n被暫停的那幾筆也算在裡面:截止時間過了這件事不會因為有人按了暫停就消失,而一筆被暫停又逾期的交辦正好是最容易被忘掉的那一種。\n'
else
printf '沒有逾期的待辦。`due` 欄是空的那幾筆沒有截止時間,永遠不會逾期。\n'
fi
if [ "$T_DUEBAD" -gt 0 ]; then
printf '\n有 %s 筆的 `due` 欄解不開,上表 `overdue` 那一欄是 `bad`。**一個解不開的截止時間跟沒有截止時間,在畫面上長得一模一樣**,而前者是登錄的時候就填錯了:重新登錄一筆,`id` 由建立時間與標題算出來,改欄位值算不回同一個 id。\n' "$T_DUEBAD"
fi
printf '\n#### 逐筆判定\n\n' printf '\n#### 逐筆判定\n\n'
if [ -s "$ROWS" ]; then if [ -s "$ROWS" ]; then
printf '| id | 判定 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n' printf '| id | 判定 | 逾期 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n'
printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n' printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n'
# 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會 # 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會
# 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄 # 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄
# 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。 # 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。
awk -F'\t' ' awk -F'\t' '
function d(s) { return s == "" ? "-" : s } function d(s) { return s == "" ? "-" : s }
function esc(s) { gsub(/\|/, "\\|", s); return s } function esc(s) { gsub(/\|/, "\\|", s); return s }
NF >= 11 && $1 != "" { NF >= 12 && $1 != "" {
printf "| `%s` | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \ printf "| `%s` | %s | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \
$1, $2, esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10)) $1, $2, d($12), esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10))
}' "$ROWS" }' "$ROWS"
else else
printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n' printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n'
@@ -1042,9 +1122,9 @@ cmd_scan() {
# stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這 # stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這
# 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。 # 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。
if [ -s "$ROWS" ]; then if [ -s "$ROWS" ]; then
awk -F'\t' 'NF >= 11 && $1 != "" { awk -F'\t' 'NF >= 12 && $1 != "" {
printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s why=%s title=%s\n", \ printf "task=%s verdict=%s state=%s kind=%s action=%s next_run=%s rearm_at=%s trigger=%s recur=%s overdue=%s why=%s title=%s\n", \
$1, $2, $3, $4, $5, $8, $9, $6, $7, $10, $11 $1, $2, $3, $4, $5, $8, $9, $6, $7, $12, $10, $11
}' "$ROWS" }' "$ROWS"
fi fi
printf 'tasks_total=%s\n' "$T_TOTAL" printf 'tasks_total=%s\n' "$T_TOTAL"
@@ -1053,8 +1133,19 @@ cmd_scan() {
printf 'tasks_invalid=%s\n' "$T_INVALID" printf 'tasks_invalid=%s\n' "$T_INVALID"
printf 'tasks_unwired=%s\n' "$T_UNWIRED" printf 'tasks_unwired=%s\n' "$T_UNWIRED"
printf 'tasks_skipped=%s\n' "$T_SKIP" printf 'tasks_skipped=%s\n' "$T_SKIP"
printf 'tasks_overdue=%s\n' "$T_OVERDUE"
printf 'tasks_due_bad=%s\n' "$T_DUEBAD"
# 逾期的那幾筆逐筆點名,不只印一個數字。一份只有數字的回報讀不出「是哪一筆過期了」,
# 而那正是唯一有用的資訊;逾期的處置一律是人接手,人要知道接哪一筆。
if [ -n "$OVERDUE_LINES" ]; then
printf '%s' "$OVERDUE_LINES" | while IFS="$(printf '\t')" read -r _oi _os _od _oa _ot; do
[ -n "$_oi" ] || continue
printf 'overdue_row=%s state=%s due=%s overdue_secs=%s human=%s title=%s\n' \
"$_oi" "$_os" "$_od" "$_oa" "$(dur_human "$_oa")" "$_ot"
done
fi
printf 'rows_file=%s\n' "$ROWS" printf 'rows_file=%s\n' "$ROWS"
printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title\n' printf 'rows_columns=id verdict state kind action trigger recur next_run rearm_at why title overdue\n'
printf 'due_file=%s\n' "$MD" printf 'due_file=%s\n' "$MD"
printf 'prev_file=%s\n' "$PREV" printf 'prev_file=%s\n' "$PREV"
printf 'seen_file=%s\n' "$SEEN" printf 'seen_file=%s\n' "$SEEN"
@@ -1062,6 +1153,53 @@ cmd_scan() {
finish_rc finish_rc
} }
# 只判逾期,一律唯讀。
#
# 為什麼另開一個子命令:逾期判定跟事件與到期判定不同,它一個狀態都不必比、一份快照都不必推進
# ——只要把每一筆的 due 欄跟現在比一下。狀態查詢那一路每次都想知道「有沒有逾期的」,走 scan
# 就會推進快照,同一輪之後的事件判定會全部落空;走 events 又判不到逾期。
# 讓呼叫端自己拿 due 欄去比更糟:那就是第二套判定,跟這一支的規則會漂移,而漂移的那一天
# 兩邊都說自己是對的。
cmd_overdue() {
while [ "$#" -gt 0 ]; do
case "$1" in
--now) [ "$#" -ge 2 ] || usage; NOW_ARG="$2"; shift 2 ;;
*) usage ;;
esac
done
resolve_now
T_TOTAL=0; T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
if [ ! -d "$TASKS_DIR" ]; then
RC_EMPTY=4
note "待辦簿目錄不存在($TASKS_DIR),這一輪沒有任何一筆可判。「沒判過」不等於「都沒逾期」。"
else
for _f in "$TASKS_DIR"/*; do
[ -f "$_f" ] || continue
T_TOTAL=$(( T_TOTAL + 1 ))
_id=$(kv_get "$_f" id); [ -n "$_id" ] || _id=$(basename "$_f")
_st=$(kv_get "$_f" state); [ -n "$_st" ] || _st=pending
_ti=$(kv_get "$_f" title)
_du=$(kv_get "$_f" due)
[ "$_st" = done ] && continue
_ov=$(overdue_of "$_du") || { T_DUEBAD=$(( T_DUEBAD + 1 )); RC_FIELD=2; }
case "$_ov" in
''|*[!0-9]*) continue ;;
esac
T_OVERDUE=$(( T_OVERDUE + 1 ))
printf 'overdue_row=%s state=%s due=%s overdue_secs=%s human=%s title=%s\n' \
"$_id" "$_st" "$_du" "$_ov" "$(dur_human "$_ov")" "$_ti"
done
[ "$T_TOTAL" -eq 0 ] && { RC_EMPTY=4; note "待辦簿目錄在($TASKS_DIR),但零筆。"; }
fi
printf 'now=%s\n' "$(epoch_to_iso "$NOW")"
printf 'tasks_total=%s\n' "$T_TOTAL"
printf 'tasks_overdue=%s\n' "$T_OVERDUE"
printf 'tasks_due_bad=%s\n' "$T_DUEBAD"
note '這是唯讀判定:一份快照都沒推進,一筆待辦都沒改。逾期的處置一律是人接手。'
cleanup_tmp
finish_rc
}
cmd_events() { cmd_events() {
while [ "$#" -gt 0 ]; do while [ "$#" -gt 0 ]; do
case "$1" in case "$1" in
@@ -1149,6 +1287,7 @@ FIRST_RUN=0
EV_NAMES='' EV_NAMES=''
EV_NEW=0 EV_NEW=0
T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0 T_TOTAL=0; T_DUE=0; T_WAIT=0; T_INVALID=0; T_UNWIRED=0; T_SKIP=0
T_OVERDUE=0; T_DUEBAD=0; OVERDUE_LINES=''
CMD="${1:-}" CMD="${1:-}"
[ -n "$CMD" ] || usage [ -n "$CMD" ] || usage
@@ -1156,6 +1295,7 @@ shift
case "$CMD" in case "$CMD" in
scan) cmd_scan "$@" ;; scan) cmd_scan "$@" ;;
events) cmd_events "$@" ;; events) cmd_events "$@" ;;
overdue) cmd_overdue "$@" ;;
next) cmd_next "$@" ;; next) cmd_next "$@" ;;
*) usage ;; *) usage ;;
esac esac
+132 -3
View File
@@ -148,6 +148,9 @@
# warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示 # warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示
# 時,原因只寫在這裡 # 時,原因只寫在這裡
# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用 # tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用
# tasks_overdue= 逾期筆數(截止時間已經過了),取自到期判定那一支;判不出來時是減號
# reminders= reminders_builtin= reminders_file= 提醒佇列逐筆點名的筆數、只算總數的內建項
# 筆數,與佇列路徑。工作階段開始那一支 hook 讀它
# tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空 # tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空
# events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空 # events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空
# due_status= due_rc= 到期判定那一支的結果與結束碼 # due_status= due_rc= 到期判定那一支的結果與結束碼
@@ -209,6 +212,11 @@ DUE_ROWS=''
DUE_NOTE='' DUE_NOTE=''
DUE_TASKS='' DUE_TASKS=''
DUE_EVENTS='' DUE_EVENTS=''
DUE_OVERDUE=''
FAILING_LINES=''
REMINDERS=0
REMINDERS_BUILTIN=0
REMINDERS_FILE=''
# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。 # 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。
# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉; # 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉;
@@ -875,7 +883,7 @@ d11() {
# --- 待辦簿筆數(只供目錄頁那一個區塊用)--- # --- 待辦簿筆數(只供目錄頁那一個區塊用)---
count_tasks() { count_tasks() {
TASKS_TOTAL=0; TASKS_FAILING=0 TASKS_TOTAL=0; TASKS_FAILING=0; FAILING_LINES=''
_d="$STATE_DIR/tasks" _d="$STATE_DIR/tasks"
[ -d "$_d" ] && [ -r "$_d" ] || return 0 [ -d "$_d" ] && [ -r "$_d" ] || return 0
for _f in "$_d"/*; do for _f in "$_d"/*; do
@@ -883,11 +891,104 @@ count_tasks() {
TASKS_TOTAL=$(( TASKS_TOTAL + 1 )) TASKS_TOTAL=$(( TASKS_TOTAL + 1 ))
_fc=$(sed -n 's/^fail_count=//p' "$_f" 2>/dev/null | head -n1) _fc=$(sed -n 's/^fail_count=//p' "$_f" 2>/dev/null | head -n1)
case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac case "$_fc" in ''|*[!0-9]*) _fc=0 ;; esac
[ "$_fc" -gt 0 ] && TASKS_FAILING=$(( TASKS_FAILING + 1 )) if [ "$_fc" -gt 0 ]; then
TASKS_FAILING=$(( TASKS_FAILING + 1 ))
# 逐筆點名,不只累加。一個數字說得出「有幾筆在連續失敗」,說不出是哪幾筆,而失敗次數
# 這個欄位存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」——不說出是哪一筆,
# 那個理由就只完成了一半。
_ti=$(sed -n 's/^title=//p' "$_f" 2>/dev/null | head -n1)
_id=$(sed -n 's/^id=//p' "$_f" 2>/dev/null | head -n1)
[ -n "$_id" ] || _id=$(basename -- "$_f")
_lr=$(sed -n 's/^last_run=//p' "$_f" 2>/dev/null | head -n1)
FAILING_LINES="${FAILING_LINES}$(printf '%s\t%s\t%s\t%s' \
"$_id" "$_fc" "${_lr:--}" "${_ti:--}")
"
fi
done done
return 0 return 0
} }
# --- 提醒佇列 ---
#
# 一輪算完之後,把「要送到人面前」的那幾筆寫成一份佇列檔,位置固定在助理狀態目錄底下。
# 讀的那一端是工作階段開始那一支 hook:它只印,一個判定都不做。
#
# 為什麼判定不放在 hook 那一邊:hook 跑在每一個工作階段的開頭,它要快、而且絕對不能擋人。
# 更重要的是,讓 hook 自己拿 due 欄與 next_run 去比就是第二套到期判定,跟 due.sh 那一套
# 會漂移,而漂移的那一天兩邊都說自己是對的。所以判定只有一套,佇列是它的輸出。
#
# 這樣換來一個新的失效模式,要正面處理:助理沒在跑的時候,這份佇列不會更新,而一份舊佇列
# 讀起來跟新的一模一樣。所以檔頭寫一行 round,帶著這一輪的時間戳,讓讀的那一端算得出
# 它有多舊——「沒有提醒」與「沒有人算提醒」不可以長得一樣。
write_reminders() {
REMINDERS=0
REMINDERS_FILE="$STATE_DIR/reminders.tsv"
_rt="$REMINDERS_FILE.tmp.$$"
{
# 檔頭同時寫 ISO 時間與 epoch 秒。ISO 給人看,epoch 給讀的那一端算年紀——讓它自己解
# ISO 字串就是在每一個讀取端各放一份日期解析,而 date -d 不是每一台機器都認得那個格式。
# 最後那個待辦總筆數是給讀的那一端判「該不該吵」用的:佇列空又過期時,待辦簿有東西
# 才代表「有事沒人在算」,零筆就只是助理閒著,那時候安靜才對。
printf 'round\t%s\t%s\t%s\t%s\t%s\n' \
"$ROUND" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$TASKS_FAILING" "$(date +%s)" "$TASKS_TOTAL"
# 逾期那幾筆照抄判定那一支印的行,連「逾期多久」那個給人看的寫法都取它算好的:
# 自己再寫一個時間長度格式化,同一個秒數在兩個地方就會印出兩種說法。
_ovids="$RD/overdue-ids"
: >"$_ovids"
if [ -f "$RD/due.out" ]; then
sed -n 's/^overdue_row=//p' "$RD/due.out" 2>/dev/null | while IFS= read -r _l; do
[ -n "$_l" ] || continue
_oi=${_l%% *}
_oh=$(printf '%s' "$_l" | sed -n 's/.* human=\(.*\) title=.*/\1/p')
_ot=$(printf '%s' "$_l" | sed -n 's/.* title=//p')
printf '%s\n' "$_oi" >>"$_ovids"
printf 'overdue\t%s\t%s\t%s\n' "$_oi" "${_oh:--}" "${_ot:--}"
done
fi
# 內建項的識別碼,一行一個。分兩種列要靠它:帶 spec_key 的那幾筆是依委派清單種入的,
# 使用者一筆都沒交辦過。
_specids="$RD/spec-ids"
: >"$_specids"
for _tf in "$STATE_DIR/tasks"/*; do
[ -f "$_tf" ] || continue
_sk=$(sed -n 's/^spec_key=//p' "$_tf" 2>/dev/null | head -n1)
[ -n "$_sk" ] || continue
_tid=$(sed -n 's/^id=//p' "$_tf" 2>/dev/null | head -n1)
[ -n "$_tid" ] || _tid=$(basename -- "$_tf")
printf '%s\n' "$_tid" >>"$_specids"
done
# 只提醒型而且到期的那幾筆。指令型不進佇列:它們由執行那一支真的跑掉了,人不必接手。
# 已經以逾期身分列過的那幾筆不再列第二次:同一筆待辦在同一批提醒裡出現兩行,讀的人會
# 當成兩件事,而且「逾期五天」比「排定點過了」講得更清楚——留強的那一行就好。
#
# 內建項與使用者交辦的分成兩種列,理由是它們該有的處置不一樣。
# 使用者自己登錄的那一筆,人看到就做得了;內建項那幾筆等的是接線不是人——動作是
# 「只提醒」的內建項,到現在還沒有任何執行入口,所以每一輪都到期、每一輪都一樣。
# 實測踩到:這台機器八筆全是那一種,於是每一個工作階段開頭固定吐八行一模一樣的東西。
# 那不是提醒,是噪音;而讀的那一端只印不判,分不出兩者,所以在這裡就把種類標好。
if [ -n "$DUE_ROWS" ] && [ -f "$DUE_ROWS" ]; then
awk -F'\t' -v idf="$_ovids" -v sidf="$_specids" '
BEGIN {
while ((getline _l < idf) > 0) seen[_l] = 1
while ((getline _s < sidf) > 0) spec[_s] = 1
}
NF >= 12 && $1 != "" && $2 == "due" && $5 == "remind" && !($1 in seen) {
printf "%s\t%s\t%s\t%s\n", ($1 in spec ? "builtin" : "remind"), \
$1, ($10 == "" ? "-" : $10), ($11 == "" ? "-" : $11)
}' "$DUE_ROWS" 2>/dev/null
fi
} >"$_rt" 2>/dev/null || { add_warn '提醒佇列寫不出來'; rm -f "$_rt"; return 0; }
if mv "$_rt" "$REMINDERS_FILE" 2>/dev/null; then
# 兩個數字分開算:逐筆點名的那幾筆,與只算一個總數的內建項。
REMINDERS=$(awk -F'\t' '$1 == "remind" || $1 == "overdue" { n++ } END { print n + 0 }' "$REMINDERS_FILE")
REMINDERS_BUILTIN=$(awk -F'\t' '$1 == "builtin" { n++ } END { print n + 0 }' "$REMINDERS_FILE")
else
add_warn '提醒佇列換不上去'
rm -f "$_rt"
fi
return 0
}
# --- 待辦簿的事件偵測與到期判定 --- # --- 待辦簿的事件偵測與到期判定 ---
# #
# 算到期的邏輯不在這一支,也不在 tools/tasks.sh,而在 tools/due.sh,理由寫在那一支的檔頭: # 算到期的邏輯不在這一支,也不在 tools/tasks.sh,而在 tools/due.sh,理由寫在那一支的檔頭:
@@ -920,6 +1021,7 @@ due_scan() {
DUE_ROWS=$(sed -n 's/^rows_file=//p' "$RD/due.out" 2>/dev/null | head -n1) DUE_ROWS=$(sed -n 's/^rows_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_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) DUE_EVENTS=$(sed -n 's/^events_new=//p' "$RD/due.out" 2>/dev/null | head -n1)
DUE_OVERDUE=$(sed -n 's/^tasks_overdue=//p' "$RD/due.out" 2>/dev/null | head -n1)
case "$_rc" in case "$_rc" in
0) DUE_STATUS=ok ;; 0) DUE_STATUS=ok ;;
4) 4)
@@ -997,8 +1099,19 @@ compose() {
cat "$RD/d07.md"; printf '\n' cat "$RD/d07.md"; printf '\n'
if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then
cat "$DUE_MD" cat "$DUE_MD"
printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。逾期(`due` 欄已經過了)還沒實作,上面的判定只講到期,不講逾期。\n\n' \ printf '\n本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。\n\n' \
"$TASKS_TOTAL" "$TASKS_FAILING" "$TASKS_TOTAL" "$TASKS_FAILING"
if [ -n "$FAILING_LINES" ]; then
printf '#### 連續失敗的那幾筆\n\n'
printf '**這幾筆每一輪都會再試一次,而且不會自動暫停。** 助理不替人按暫停:那個狀態留給人設,也只有人解得開。\n\n'
printf '| id | 已連續失敗 | 最後一次執行 | 標題 |\n'
printf '| --- | ---: | --- | --- |\n'
printf '%s' "$FAILING_LINES" | while IFS="$(printf '\t')" read -r _fi _fn _fl _ft; do
[ -n "$_fi" ] || continue
printf '| `%s` | %s 次 | %s | %s |\n' "$_fi" "$_fn" "$(cell "$_fl")" "$(cell "$_ft")"
done
printf '\n'
fi
else else
printf '### 待辦簿到期與逾期\n\n' printf '### 待辦簿到期與逾期\n\n'
printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \ printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \
@@ -1129,6 +1242,18 @@ case "$CMD" in
d11 d11
count_tasks count_tasks
due_scan due_scan
# 逾期與連續失敗,每一輪都列一筆待人處理。
#
# 這兩件事的處置都是人接手,而且**不會自己好**:一筆逾期的交辦不會因為過了更久就不逾期,
# 一筆連續失敗的檢查項每一輪都會再試一次然後再失敗一次。所以每一輪都提,不因為上一輪
# 提過就這一輪不提——「提過了」不是「處理過了」,而助理不替人按暫停:那個狀態留給人設。
if [ -n "$DUE_OVERDUE" ] && [ "$DUE_OVERDUE" -gt 0 ] 2>/dev/null; then
add_pending "有 $DUE_OVERDUE 筆待辦逾期,截止時間已經過了" '待辦簿到期與逾期' '/jsc-assist:assistant status'
fi
if [ "$TASKS_FAILING" -gt 0 ]; then
add_pending "有 $TASKS_FAILING 筆待辦連續失敗,每一輪都在重試" '待辦簿到期與逾期' '/jsc-assist:assistant status'
fi
write_reminders
tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS" tally "$D01_STATUS"; tally "$D04_STATUS"; tally "$D07_STATUS"; tally "$D09_STATUS"
tally "$D11_STATUS" tally "$D11_STATUS"
@@ -1159,6 +1284,10 @@ case "$CMD" in
printf 'tasks_total=%s\n' "$TASKS_TOTAL" printf 'tasks_total=%s\n' "$TASKS_TOTAL"
printf 'tasks_failing=%s\n' "$TASKS_FAILING" printf 'tasks_failing=%s\n' "$TASKS_FAILING"
printf 'tasks_due=%s\n' "$DUE_TASKS" printf 'tasks_due=%s\n' "$DUE_TASKS"
printf 'tasks_overdue=%s\n' "${DUE_OVERDUE:--}"
printf 'reminders=%s\n' "$REMINDERS"
printf 'reminders_builtin=%s\n' "$REMINDERS_BUILTIN"
printf 'reminders_file=%s\n' "$REMINDERS_FILE"
printf 'events_new=%s\n' "$DUE_EVENTS" printf 'events_new=%s\n' "$DUE_EVENTS"
printf 'due_status=%s\n' "$DUE_STATUS" printf 'due_status=%s\n' "$DUE_STATUS"
printf 'due_rc=%s\n' "$DUE_RC" printf 'due_rc=%s\n' "$DUE_RC"
+125 -27
View File
@@ -46,12 +46,16 @@
# #
# --- 代不出目標不算失敗 --- # --- 代不出目標不算失敗 ---
# #
# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來, # {cli} 由偵測到的 CLI 代號代入,{repo} 由 scan-repos.sh 掃到的存取庫工作目錄代入。
# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的 # 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run,
# last_run,也不加失敗次數**。 # 也不加失敗次數**。
# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數 # 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。 # 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。 # 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
#
# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支
# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到
# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。
set -u set -u
usage() { usage() {
@@ -60,6 +64,16 @@ usage() {
} }
die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; } die() { _c=$1; shift; printf '[jsc][助理執行][ERR]:%s\n' "$*" >&2; exit "$_c"; }
# 取第一行、截到指定長度。cut 數的是位元組不是字元,中文字剛好被切成一半時尾巴就變成
# 替代字元——實測踩到:一行中文錯誤訊息截在第 200 個位元組,報告上留下一個亂碼字。
# 截完再過一次 iconv -c 把那個不完整的序列丟掉。iconv 不在這台機器上就退回原樣,
# 不自己拼一套 UTF-8 邊界判定:那是在錯誤訊息這條路上加一個會出錯的新東西。
clip() { # $1=位元組上限;讀標準輸入
_raw=$(head -n1 | cut -c"1-$1")
_cln=$(printf '%s' "$_raw" | iconv -c -f UTF-8 -t UTF-8 2>/dev/null)
if [ -n "$_cln" ]; then printf '%s' "$_cln"; else printf '%s' "$_raw"; fi
}
note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; } note() { printf '[jsc][助理執行]:%s\n' "$*" >&2; }
warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; } warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; }
@@ -186,9 +200,52 @@ if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
done done
fi fi
# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令, # --- {repo} 的代入來源 ---
# 而那一輪沒有人看得到它跑到哪裡去了。
REPO_READY=0 # 掃到的存取庫工作目錄,一行一條。掃描規則、掃描起點與排除清單全在 scan-repos.sh 那一支,
# 這裡不自己再判一次:兩邊各有一套掃描規則就會漂移,而漂移的那一天沒有人會收到通知。
# 兩份:REPOS 是路徑清單,給沒綁存取庫的那幾筆代成每一個目標;REPOMAP 多帶一欄
# {owner}/{repo},給綁了存取庫的那幾筆對出它綁的是哪一條路徑。
REPOS="$TMPD/repos.txt"
REPOMAP="$TMPD/repomap.txt"
: >"$REPOS"; : >"$REPOMAP"
REPO_WHY=''
REPO_SCAN='-'
SCAN_SH="$HERE/scan-repos.sh"
[ -f "$SCAN_SH" ] || SCAN_SH=$(find_tool assist tools/scan-repos.sh) || SCAN_SH=''
if [ -z "$SCAN_SH" ]; then
REPO_WHY='找不到 scan-repos.sh,{repo} 沒有代入來源'
REPO_SCAN='missing'
else
"$SCAN_SH" list --root "$ROOT" >"$TMPD/scan.out" 2>"$TMPD/scan.err"
_src=$?
REPO_SCAN="rc$_src"
case "$_src" in
# 0 是全部都對得出盤點頁頁名,1 是有幾個對不出來或路徑被拒——後者交出來的那幾條照用,
# 對不出頁名不影響唯讀盤點跑得動。其餘各碼一律當成沒有來源,並把它印的理由帶上。
0|1)
sed -n 's/^repo=\([^ ]*\) slug=\([^ ]*\).*/\1'"$TAB"'\2/p' "$TMPD/scan.out" >"$REPOMAP"
cut -f1 "$REPOMAP" >"$REPOS" ;;
*) REPO_WHY=$(sed -n 's/^\[jsc\]\[助理掃描\]\[ERR\]://p' "$TMPD/scan.err" 2>/dev/null | clip 200)
[ -n "$REPO_WHY" ] || REPO_WHY="存取庫掃描回結束碼 $_src,{repo} 代不出目標" ;;
esac
# 掃描那一支的逐項判定行被導進暫存檔,所以外面看不到。原樣轉出來:被拒的那幾個存取庫是
# 這一輪「掃到但沒交出去」的名單,那份名單不能只留在暫存檔裡。
sed -n 's/^\(rejected=\|unnamed=\|excluded=\|skipped_link=\|not_a_repo=\)/repo_scan_note=\1/p' \
"$TMPD/scan.out" "$TMPD/scan.err" 2>/dev/null
fi
N_REPOS=$(awk 'END{print NR+0}' "$REPOS")
if [ -z "$REPO_WHY" ] && [ "$N_REPOS" -eq 0 ]; then
REPO_WHY='存取庫掃描一個都沒掃到,{repo} 代不出目標'
fi
# 這一筆綁的存取庫對出一條掃到的路徑。綁的值可以是工作目錄路徑,也可以是 {owner}/{repo}——
# 待辦簿的欄位說明只寫「這一筆綁哪一個存取庫」,兩種寫法都有人會填,所以兩種都認。
# 對不出來一律回失敗,不退回「全部存取庫」:那一筆指名了一個目標,代成全部就是跑了
# 十九個沒有人要它跑的地方。
repo_path_of() { # $1=綁定值
awk -F"$TAB" -v want="$1" '$1==want || $2==want {print $1; found=1; exit} END{exit !found}' "$REPOMAP"
}
# --- 判斷動作是哪一種 --- # --- 判斷動作是哪一種 ---
@@ -221,6 +278,25 @@ cmd_shape_ok() { # $1=指令
return 0 return 0
} }
# 把一個代入點換成清單裡的每一個值,一個值一行。清單是空的又剛好命中代入點,那一行就
# 整行消失——所以呼叫端要先確定清單非空,別靠這裡回錯:目標數變成 0 的那一筆會被當成
# 「沒有東西要跑」而算成功,而它其實一次都沒跑。
expand_over() { # $1=代入點 $2=值清單檔 $3=輸入檔 $4=輸出檔
: >"$4" 2>/dev/null || return 1
while IFS= read -r _line; do
[ -n "$_line" ] || continue
case "$_line" in
*"$1"*)
while IFS= read -r _v; do
[ -n "$_v" ] || continue
printf '%s\n' "$(printf '%s' "$_line" | sed "s|$1|$_v|g")" >>"$4"
done <"$2" ;;
*) printf '%s\n' "$_line" >>"$4" ;;
esac
done <"$3"
return 0
}
# --- 逐筆處理 --- # --- 逐筆處理 ---
N_DUE=0; N_RUN=0; N_OK=0; N_FAIL=0; N_HELD=0; N_SKIP=0; N_WRITE_BAD=0 N_DUE=0; N_RUN=0; N_OK=0; N_FAIL=0; N_HELD=0; N_SKIP=0; N_WRITE_BAD=0
@@ -230,9 +306,9 @@ RC_CMD=0; RC_WRITE=0
# 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。 # 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。
_cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null) _cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null)
case "${_cols:-0}" in case "${_cols:-0}" in
11) ;; 12) ;;
0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=11 ;; 0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=12 ;;
*) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 11 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。" ;; *) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 12 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。第 12 欄是逾期判定,跟這一支要跑什麼無關,但欄位數不對就代表兩支不是同一版,那時候前 11 欄的順序也不能假設還是原來那樣。" ;;
esac esac
while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recur c_next c_rearm c_why c_rest; do while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recur c_next c_rearm c_why c_rest; do
@@ -272,23 +348,42 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
_targets="$TMPD/targets.$c_id" _targets="$TMPD/targets.$c_id"
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。" : >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
_hold='' _hold=''
# 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。
_repovals="$REPOS"
case "$c_action" in case "$c_action" in
*'{repo}'*) *'{repo}'*)
[ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{repo} 代不出目標' ;; if [ -n "$REPO_WHY" ]; then
_hold="$REPO_WHY"
else
_bind=$(sed -n 's/^repo=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
if [ -n "$_bind" ]; then
if _bp=$(repo_path_of "$_bind"); then
_repovals="$TMPD/repoval.$c_id"
printf '%s\n' "$_bp" >"$_repovals" 2>/dev/null || die 5 "暫存檔寫不進去:$_repovals。"
else
_hold="這一筆綁的存取庫「$_bind」這一輪沒掃到,代不出目標"
fi
fi
fi ;;
esac
case "$c_action" in
*'{cli}'*) [ "$N_CLI" -gt 0 ] || _hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' ;;
esac esac
if [ -z "$_hold" ]; then if [ -z "$_hold" ]; then
case "$c_action" in # 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {repo} 與 {cli} 時,目標數是兩邊
*'{cli}'*) # 的乘積——三個存取庫乘五支 CLI 就是十五個目標,一個目標跑一次。
if [ "$N_CLI" -eq 0 ]; then printf '%s\n' "$c_action" >"$TMPD/x0.$c_id"
_hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' expand_over '{repo}' "$_repovals" "$TMPD/x0.$c_id" "$TMPD/x1.$c_id" \
else || die 5 "暫存檔寫不進去:$TMPD/x1.$c_id。"
while IFS= read -r _c; do expand_over '{cli}' "$CLIS" "$TMPD/x1.$c_id" "$_targets" \
[ -n "$_c" ] || continue || die 5 "暫存檔寫不進去:$_targets。"
printf '%s\n' "$(printf '%s' "$c_action" | sed "s|{cli}|$_c|g")" >>"$_targets" # 代入之後再驗一次禁止字元。種入那一支與上面的 cmd_shape_ok 驗的是還沒代入的字面,
done <"$CLIS" # 代進去的值是這一輪現場算出來的——路徑與 CLI 代號都有可能帶進新的字元,而帶進來的
fi ;; # 那一刻就是送進殼的前一刻。
*) printf '%s\n' "$c_action" >>"$_targets" ;; if grep -q '[$`;|&~]' "$_targets" 2>/dev/null; then
esac _hold='代入之後出現金錢符號、反引號、分號、管線、連接符號或波浪號,代入來源有問題'
: >"$_targets"
fi
fi fi
if [ -n "$_hold" ]; then if [ -n "$_hold" ]; then
N_HELD=$((N_HELD + 1)) N_HELD=$((N_HELD + 1))
@@ -323,9 +418,9 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t" printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t"
else else
_entry_rc=1 _entry_rc=1
[ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | head -n1 | cut -c1-160) [ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | clip 160)
printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \ printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | head -n1 | cut -c1-160)" "$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | clip 160)"
fi fi
done <"$_targets" done <"$_targets"
@@ -362,10 +457,13 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
fi fi
done <"$ROWS" done <"$ROWS"
printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s\n' \ printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s repos=%s repo_scan=%s\n' \
"$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \ "$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \
"$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}" "$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}" \
"$N_REPOS" "$REPO_SCAN"
[ -n "$REPO_WHY" ] && note "存取庫清單這一輪是空的:$REPO_WHY。帶 {repo} 代入點的那幾筆全部跳過,逐筆印在上面的 held= 那幾行。"
[ "$REPO_SCAN" = 'rc1' ] && note "存取庫掃描回 1:掃到的存取庫裡有幾個對不出 REPO_{HASH} 盤點頁頁名,或路徑帶了空白與殼層特殊字元而被拒。交出來的那幾條照用,被拒的那幾個這一輪沒有被盤點到——理由逐個印在掃描那一支的 rejected= 與 unnamed= 那幾行。"
[ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 CLI 反覆消失就要考慮把它裝到穩定位置。" [ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 CLI 反覆消失就要考慮把它裝到穩定位置。"
[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。" [ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。"
[ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。" [ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。"
+299
View File
@@ -0,0 +1,299 @@
#!/usr/bin/env sh
# scan-repos.sh — 掃出工作目錄底下的存取庫,交出 {repo} 代入點要用的那份清單。
#
# 用法:
# scan-repos.sh list [--scan-root {字面絕對路徑}] [--depth {0..3}] [--root {字面絕對根目錄}]
# scan-repos.sh paths [同上]
#
# list 一行一個存取庫,帶工作目錄、{owner}/{repo} 與對應的 REPO_{HASH} 頁名
# paths 只印工作目錄,一行一條。給 {repo} 代入點直接讀
#
# 環境變數:
# JSC_ASSIST_SCAN_ROOT 掃描起點。--scan-root 優先於它
# JSC_ASSIST_SCAN_EXCLUDE 排除清單,空白分隔,比對目錄名(不是整條路徑),可用萬用字元
# JSC_ASSIST_HASH_ID hash-id 路徑覆寫;找不到並排存取庫時才設
#
# 結束碼:
# 0 掃到至少一個存取庫,而且每一個都對得出 REPO_{HASH}、路徑也都送得進殼
# 1 掃到了,但有幾個對不出頁名或路徑被拒。可用的那幾個照印,被拒的逐行印在 rejected=
# 有 .git 卻不被 git 認的那幾個也算在這一碼裡,印在 not_a_repo=
# 2 用法錯誤:不認得的子命令或選項、選項缺值、--scan-root 不是絕對路徑、--depth 超出範圍
# 3 掃描起點沒設定,或那條路徑不是存在的目錄——**什麼都沒掃**,不等於「這台機器沒有存取庫」
# 4 掃過了,一個 git 存取庫都沒有
#
# --- 為什麼掃描起點不給預設值 ---
#
# 這份清單的用途是把存取庫路徑代進待辦簿的指令,然後在無人值守那一輪送進殼執行。
# 猜錯掃描起點的後果不是掃不到東西,是**在猜錯的那些目錄底下跑指令**,而那一輪沒有人
# 看得到它跑到哪裡去了。往上猜一層就從工作目錄變成家目錄,再往上就是整台機器。
# 所以沒設就回結束碼 3 並說清楚要設哪一個變數,不退回任何一條路徑。
# 排程那一輪的值由 schedule.sh install 當下從殼裡快照進條目,跟 wiki 存取庫那幾個變數同一條路。
#
# --- 為什麼預設只掃一層 ---
#
# 工作目錄的版面是「一個存取庫一個子目錄」,一層就夠。往下遞歸會踩到三種東西:
# 存取庫自己的 node_modules 與 vendor 底下的第三方存取庫、封存目錄裡的舊版存取庫、
# 還有 worktree 的複本。三種都會被當成現役存取庫盤點,而盤點結果讀起來完全正常。
# --depth 最多收到 3:真的有嵌套版面時夠用,而再深就不是「工作目錄那一層」了,
# 那種版面該把掃描起點指到那一層,不是把界線調鬆。
#
# --- 三種東西一定跳過,不看排除清單 ---
#
# 一、名稱以點開頭的目錄。快取、封存、暫存都藏在那裡,掃進去會把封存版當現役版盤點。
# 二、符號連結。連結會把掃描帶出掃描起點,而那條界線一旦被繞過就沒有第二道。
# 三、路徑裡有空白或殼層特殊字元的存取庫。執行那一支是用 `sh -c` 送出指令的,那條路徑
# 會被殼再解一次——帶空白的路徑會被切成兩個參數,帶金錢符號的會被展開成別的東西。
# 這種一律列進 rejected= 不交出去,不是靜靜跳過:那是一個真的存在的存取庫,
# 它沒有被盤點到這件事要有人知道。
set -u
usage() {
echo 'usage: scan-repos.sh {list|paths} [--scan-root 絕對路徑] [--depth 0..3] [--root 絕對路徑]' >&2
exit 2
}
die() { _c=$1; shift; printf '[jsc][助理掃描][ERR]:%s\n' "$*" >&2; exit "$_c"; }
# 逐項的判定行與收尾統計。paths 那個模式的標準輸出要是純路徑——呼叫端拿它當代入點來源,
# 混一行 unnamed= 進去就會被代成一條不存在的路徑然後送進殼。所以那個模式一律改走標準錯誤。
emit() { if [ "$MODE" = 'paths' ]; then printf '%s\n' "$*" >&2; else printf '%s\n' "$*"; fi; }
note() { printf '[jsc][助理掃描]:%s\n' "$*" >&2; }
warn() { printf '[jsc][助理掃描][WARN]:%s\n' "$*" >&2; }
[ "$#" -ge 1 ] || usage
MODE=$1; shift
case "$MODE" in
list|paths) ;;
*) usage ;;
esac
SCAN_ROOT=''
OPT_ROOT=''
DEPTH=1
while [ "$#" -gt 0 ]; do
case "$1" in
--scan-root) [ "$#" -ge 2 ] || usage; SCAN_ROOT=$2; shift 2 ;;
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
--depth) [ "$#" -ge 2 ] || usage; DEPTH=$2; shift 2 ;;
*) usage ;;
esac
done
case "$DEPTH" in
0|1|2|3) ;;
*) die 2 "--depth 只收 0 到 3,收到的是「$DEPTH」。0 只看掃描起點自己,1 是它底下那一層。要掃更深的版面就把掃描起點指到那一層。" ;;
esac
case "$OPT_ROOT" in
''|/*) ;;
*) die 2 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
esac
[ -n "$SCAN_ROOT" ] || SCAN_ROOT="${JSC_ASSIST_SCAN_ROOT:-}"
if [ -z "$SCAN_ROOT" ]; then
die 3 '掃描起點沒設定。這一支不猜:猜錯的後果是在猜錯的那些目錄底下跑指令,而無人值守那一輪沒有人看得到它跑到哪裡去了。請帶 --scan-root,或在殼裡設 JSC_ASSIST_SCAN_ROOT 之後重跑一次 schedule.sh install patrol,把值快照進排程條目。'
fi
case "$SCAN_ROOT" in
/*) ;;
*) die 2 "掃描起點要是字面絕對路徑,收到的是「$SCAN_ROOT」。權限層比對的是還沒展開的指令字面,帶變數或波浪號的路徑進不了允許清單。" ;;
esac
SCAN_ROOT=${SCAN_ROOT%/}
# 去掉尾斜線之後空字串就是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,
# 那不是設定錯誤就是打錯字,兩種都不該照著跑。
[ -n "$SCAN_ROOT" ] || die 2 '掃描起點是根目錄。掃根目錄那一層等於把整台機器的第一層當成工作目錄,這一支不接。'
[ -d "$SCAN_ROOT" ] || die 3 "掃描起點不是存在的目錄:$SCAN_ROOT。什麼都沒掃,跟「這台機器沒有存取庫」不是同一件事——後者要回 4。"
TAB=$(printf '\t')
TMPD=$(mktemp -d 2>/dev/null) || die 3 '暫存目錄建不起來。'
trap 'rm -rf "$TMPD"' EXIT
# --- 找 hash-id ---
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
ROOT="$OPT_ROOT"
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
hash_id_sh() {
if [ -n "${JSC_ASSIST_HASH_ID:-}" ] && [ -f "$JSC_ASSIST_HASH_ID" ]; then
printf '%s' "$JSC_ASSIST_HASH_ID"; return 0
fi
for _d in "jsc-gitea" "gitea"; do
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/tools/hash-id" ] && { printf '%s' "$ROOT/$_d/tools/hash-id"; return 0; }
done
return 1
}
HASH_ID=$(hash_id_sh) || HASH_ID=''
# 頁名的雜湊規則跟 jsc-sdlc 盤點頁是同一條:對那個存取庫自己的 {owner}/{repo} 取完整四十碼
# 大寫 SHA-1。共用那一支找不到就退回本機的 sha1sum——同一條規則、同一個值,只是少一層共用。
# 兩邊都算不出來就不硬湊一個頁名:湊出來的頁名指向沒有人讀的那一頁。
#
# 用哪一種先決定好,不在每一筆裡各判一次。算雜湊那一段跑在命令替換裡,也就是子殼裡,
# 在那裡設「這一輪用了退路」的旗標,回到外面就沒了——那句提示會永遠印不出來。
HASH_MODE='none'
if [ -n "$HASH_ID" ] && [ -n "$(printf '%s' probe | "$HASH_ID" 2>/dev/null)" ]; then
HASH_MODE='shared'
elif command -v sha1sum >/dev/null 2>&1; then
HASH_MODE='sha1sum'
elif command -v shasum >/dev/null 2>&1; then
HASH_MODE='shasum'
fi
hash40() { # $1=要算的字串
case "$HASH_MODE" in
shared) printf '%s' "$1" | "$HASH_ID" 2>/dev/null ;;
sha1sum) printf '%s' "$1" | sha1sum | awk '{print toupper($1)}' ;;
shasum) printf '%s' "$1" | shasum -a 1 | awk '{print toupper($1)}' ;;
*) return 1 ;;
esac
}
# --- 找候選目錄 ---
# 排除清單比對目錄名,不比對整條路徑:整條路徑會隨掃描起點變,同一份清單換一台機器就失效。
EXCLUDED_NAMES="${JSC_ASSIST_SCAN_EXCLUDE:-}"
excluded() { # $1=目錄名
[ -n "$EXCLUDED_NAMES" ] || return 1
for _pat in $EXCLUDED_NAMES; do
# shellcheck disable=SC2254
case "$1" in
$_pat) return 0 ;;
esac
done
return 1
}
CAND="$TMPD/cand.txt"
: >"$CAND"
LINKS="$TMPD/links.txt"
: >"$LINKS"
# find 預設不跟符號連結走,所以連結目錄不會出現在 -type d 的結果裡。這裡另外撈一次 -type l
# 是為了把它們印出來:一個被跳過的連結跟一個不存在的目錄,在報告上看起來不能是同一件事。
#
# 排除清單不寫進 find 的剪枝條件,改在下面逐個判定時濾掉。理由是剪掉的目錄就不會出現在
# 結果裡,也就報不出「這個被排除了」——而一份沒說自己排除了什麼的清單,讀起來跟沒有排除
# 清單一樣。代價是深度兩層以上時會走進被排除的目錄裡再一層,而深度上限是 3,所以最多一層。
if [ "$DEPTH" -ge 1 ]; then
find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \
-name '.*' -prune -o -type l -print 2>/dev/null >"$LINKS"
find "$SCAN_ROOT" -mindepth 1 -maxdepth "$DEPTH" \
-name '.*' -prune -o -type d -print 2>/dev/null >"$CAND"
fi
# depth 0 與更深的版面都要把掃描起點自己算進來:工作目錄本身也可能就是一個存取庫。
printf '%s\n' "$SCAN_ROOT" >>"$CAND"
sort -u -o "$CAND" "$CAND"
# --- 逐個判定 ---
N_REPO=0; N_REJ=0; N_UNNAMED=0; N_EXCL=0; N_LINK=0; N_FAKE=0
OUT="$TMPD/out.txt"
: >"$OUT"
while IFS= read -r d; do
[ -n "$d" ] || continue
_name=$(basename -- "$d")
if excluded "$_name"; then
N_EXCL=$((N_EXCL + 1))
emit "excluded=$d reason=在排除清單上"
continue
fi
# .git 可能是目錄(一般存取庫),也可能是檔案(worktree 與子模組),所以先看它在不在,
# 但**在不在只是初篩,不是判定**。
[ -e "$d/.git" ] || continue
# 判定交給 git 自己。有 .git 不等於是存取庫:實測這台機器上有一個目錄底下的 .git 只剩
# 一個空的 info/,git 一句「not a git repository」,而只看 .git 在不在的掃描把它算成
# 一個存取庫、印成「沒有 origin」——那個說法讀起來像「這個存取庫還沒接遠端」,
# 不像「這裡根本不是存取庫」。兩件事的處置完全不同。
#
# 只問「是不是在工作樹裡」不夠:git 會從這個目錄往上找,$d/.git 壞掉時它會找到上一層的
# 存取庫然後回答「是」。所以要再核對它認定的頂層就是 $d 自己。兩邊都解成實體路徑再比,
# 掃描起點帶符號連結那一段時字串才對得上。
_top=$(git -C "$d" rev-parse --show-toplevel 2>/dev/null) || _top=''
_dreal=$(CDPATH= cd -P -- "$d" 2>/dev/null && pwd -P) || _dreal=''
_topreal=''
[ -n "$_top" ] && { _topreal=$(CDPATH= cd -P -- "$_top" 2>/dev/null && pwd -P) || _topreal=''; }
if [ -z "$_topreal" ] || [ "$_topreal" != "$_dreal" ]; then
N_FAKE=$((N_FAKE + 1))
if [ -z "$_top" ]; then
emit "not_a_repo=$d reason=有 .git 但 git 自己不認,多半是刪剩的殘骸"
else
emit "not_a_repo=$d reason=有 .git 但 git 認定的頂層是 $_top,這裡不是存取庫的根"
fi
continue
fi
case "$d" in
*[!A-Za-z0-9/._-]*)
N_REJ=$((N_REJ + 1))
emit "rejected=$d reason=路徑裡有空白或殼層特殊字元,送進 sh -c 會被再解一次"
continue ;;
esac
_url=$(git -C "$d" remote get-url origin 2>/dev/null) || _url=''
_slug=''
if [ -n "$_url" ]; then
# 兩種形狀:git@host:owner/repo.git 與 https://host/owner/repo.git。都只取最後兩段。
_s=${_url%.git}
_s=${_s#*://}
_s=${_s#*@}
case "$_s" in
*:*) _s=${_s#*:} ;;
esac
_s=${_s#/}
_owner=''
_repo=${_s##*/}
_rest=${_s%/*}
[ "$_rest" != "$_s" ] && _owner=${_rest##*/}
[ -n "$_owner" ] && [ -n "$_repo" ] && _slug="$_owner/$_repo"
fi
_page='-'
if [ -n "$_slug" ]; then
_h=$(hash40 "$_slug") || _h=''
[ -n "$_h" ] && _page="REPO_$_h"
fi
if [ "$_page" = '-' ]; then
N_UNNAMED=$((N_UNNAMED + 1))
if [ -z "$_slug" ]; then
emit "unnamed=$d reason=沒有 origin 遠端,或網址對不出 {owner}/{repo},算不出盤點頁頁名"
else
emit "unnamed=$d slug=$_slug reason=這台機器算不出 SHA-1,算不出盤點頁頁名"
fi
fi
N_REPO=$((N_REPO + 1))
printf '%s%s%s%s%s\n' "$d" "$TAB" "${_slug:--}" "$TAB" "$_page" >>"$OUT"
done <"$CAND"
while IFS= read -r l; do
[ -n "$l" ] || continue
N_LINK=$((N_LINK + 1))
emit "skipped_link=$l reason=符號連結,跟著走會把掃描帶出掃描起點"
done <"$LINKS"
if [ "$MODE" = 'paths' ]; then
cut -f1 "$OUT"
else
while IFS="$TAB" read -r p s g; do
printf 'repo=%s slug=%s page=%s\n' "$p" "$s" "$g"
done <"$OUT"
fi
emit "scan_root=$SCAN_ROOT depth=$DEPTH repos=$N_REPO unnamed=$N_UNNAMED rejected=$N_REJ excluded=$N_EXCL skipped_links=$N_LINK not_a_repo=$N_FAKE"
case "$HASH_MODE" in
sha1sum|shasum) note '找不到共用那一支 hash-id(jsc-gitea 的 tools/hash-id),這一輪的頁名改用本機的 SHA-1 算。兩者是同一條規則、值相同,只是少了一層共用;--root 餵對就會用共用那一支。' ;;
none) warn '這台機器既沒有共用那一支 hash-id,也沒有 sha1sum 與 shasum,這一輪一個盤點頁頁名都算不出來。存取庫路徑照樣交出去代 {repo},唯讀盤點跑得動;盤點結果沒有頁可以寫回去。' ;;
esac
[ "$N_REJ" -gt 0 ] && warn "有 $N_REJ 個存取庫的路徑送不進殼,這一份清單裡沒有它們,逐個印在上面的 rejected= 那幾行。那幾個是真的存在的存取庫,只是路徑帶了空白或殼層特殊字元——要盤點得到就得換路徑。"
[ "$N_UNNAMED" -gt 0 ] && warn "有 $N_UNNAMED 個存取庫對不出 REPO_{HASH} 盤點頁頁名,逐個印在上面的 unnamed= 那幾行。它們的路徑照樣交出去代 {repo},唯讀盤點跑得動;但盤點結果沒有頁可以寫回去。"
if [ "$N_REPO" -eq 0 ]; then
die 4 "掃過 $SCAN_ROOT 底下 $DEPTH 層,一個 git 存取庫都沒有。掃描起點指錯一層就會長這樣——工作目錄的版面是「一個存取庫一個子目錄」,指到某一個存取庫裡面就只剩它自己那一個,指到家目錄就一個都沒有。"
fi
[ "$N_FAKE" -gt 0 ] && warn "有 $N_FAKE 個目錄底下有 .git 卻不被 git 認,逐個印在上面的 not_a_repo= 那幾行。那不是「還沒接遠端的存取庫」,是刪剩的殘骸或者不是存取庫的根——只看 .git 在不在的掃描會把它算成一個存取庫。"
if [ "$N_REJ" -gt 0 ] || [ "$N_UNNAMED" -gt 0 ] || [ "$N_FAKE" -gt 0 ]; then
exit 1
fi
exit 0
+32 -1
View File
@@ -125,6 +125,9 @@
# JSC_WIKI_REPO_{TYPE} 各頁型的 wiki 存取庫。已設定的全部快照進條目。名單是當下從 # JSC_WIKI_REPO_{TYPE} 各頁型的 wiki 存取庫。已設定的全部快照進條目。名單是當下從
# 環境撈出來的,不寫死,所以新增頁型自動涵蓋,這支不必跟著改。 # 環境撈出來的,不寫死,所以新增頁型自動涵蓋,這支不必跟著改。
# 目錄頁那一支專用變數也在裡面 # 目錄頁那一支專用變數也在裡面
# JSC_ASSIST_SCAN_ROOT 存取庫掃描的起點。install 當下快照進條目;沒有它,帶 {repo}
# 代入點的內建項每一輪都代不出目標
# JSC_ASSIST_SCAN_EXCLUDE 存取庫掃描的排除清單。install 當下快照進條目
set -u set -u
MARK_PREFIX='# jsc-assist:assistant' MARK_PREFIX='# jsc-assist:assistant'
@@ -133,6 +136,7 @@ STATE_DIR="$JSC_HOME/assistant"
CURRENT="$JSC_HOME/current" CURRENT="$JSC_HOME/current"
LOG="$STATE_DIR/schedule.log" LOG="$STATE_DIR/schedule.log"
CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}" CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}"
CRON_LINE_MAX="${JSC_ASSIST_CRON_LINE_MAX:-1000}"
TASK_PREFIX='jsc-assist-assistant' TASK_PREFIX='jsc-assist-assistant'
DRYRUN=0 DRYRUN=0
@@ -344,7 +348,12 @@ snapshot_names() {
# 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己 # 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己
# 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份 # 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份
# 路徑清單,而清單會過期。 # 路徑清單,而清單會過期。
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL # 掃描起點與排除清單也要快照。存取庫掃描那一支刻意不猜掃描起點,沒有值就整個代入點
# 代不出來,帶 {repo} 的內建項每一輪都會被跳過——而那一輪只會印一行 held=,看起來像
# 「這一批還沒接上」,不像「有一個變數沒進條目」。排除清單少了同樣要命:殼裡排除掉的
# 那幾個目錄,在排程那一輪會被當成要盤點的存取庫。
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL \
JSC_ASSIST_SCAN_ROOT JSC_ASSIST_SCAN_EXCLUDE
env 2>/dev/null \ env 2>/dev/null \
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \ | sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
| sort -u | sort -u
@@ -708,6 +717,28 @@ crontab_install() {
printf '\n' >>"$_new" printf '\n' >>"$_new"
done done
# 條目長度先量過再往下走,兩個模式都量。
#
# cron 的一行有長度上限,超過就整批寫不進去,而錯誤訊息是 crontab 自己吐的一句
# 「command too long」。實測踩過:把整條 PATH 快照進條目之後 install 回 4——而同一組參數的
# --dry-run 全綠,因為那一路根本不碰 crontab。一道只在真的寫入時才會發現的限制,等於
# 沒有預檢。
# 上限取 1000:那是 vixie-cron 的 MAX_COMMAND,不是這台機器上量出來的,所以留一個變數
# 可以覆寫。這台機器實測 810 字元的條目裝得進去,加上整條 PATH 的那一次裝不進去。
for _job in $JOBS; do
_len=$(cron_entry "$_job" | wc -c | tr -d ' ')
printf 'entry_len=%s job=%s limit=%s\n' "$_len" "$_job" "$CRON_LINE_MAX"
if [ "$_len" -ge "$CRON_LINE_MAX" ]; then
die 4 "$_job 的條目有 $_len 個位元組,達到 cron 一行的上限 $CRON_LINE_MAX,這一批裝不進去。條目長度的來源是環境變數快照($SNAPSHOT_NAMES)與 PATH 快照——先看哪一個值特別長,多半是掃描起點或排除清單寫得太長。縮短那個值,或用 JSC_ASSIST_CRON_LINE_MAX 覆寫上限(覆寫之前先確認這台機器的 cron 真的收得下)。"
fi
# 這一支只定義 note,沒有 warn。這裡本來寫 warn,於是殼回一句「warn: not found」而那句
# 提醒一次都沒印出來——條目長到 973 個位元組、距上限只剩 27,而該說話的那道檢核是啞的。
# 一道說不出話的檢核跟沒有那道檢核是同一件事,而且它連自己壞了都只在 stderr 留一行。
if [ "$_len" -ge $((CRON_LINE_MAX - 100)) ]; then
note "$_job 的條目有 $_len 個位元組,距離 cron 一行的上限 $CRON_LINE_MAX 不到 100。再多加一個環境變數快照就會裝不進去,而那一次的錯誤訊息只會說 command too long。條目長度大半來自 wiki 存取庫那一系列變數的快照,一支約 40 個位元組。"
fi
done
if [ "$DRYRUN" -eq 1 ]; then if [ "$DRYRUN" -eq 1 ]; then
for _job in $JOBS; do for _job in $JOBS; do
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)" printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)"