Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a3bf8ca87 | ||
|
|
87ada337b8 | ||
|
|
61f4a2dbc7 | ||
|
|
404cf8f4fd | ||
|
|
2265dcd59c | ||
|
|
3dc6b32088 | ||
|
|
932bfa826a | ||
|
|
4cbd0e360f | ||
|
|
6a2cf045ee | ||
|
|
465bd9d40a | ||
|
|
205da9b61f | ||
|
|
e53bb23f8e | ||
|
|
98dd0761fa | ||
|
|
f6a293ddab | ||
|
|
633f56e7ea | ||
|
|
6cb071a3e7 | ||
|
|
0df1605a20 | ||
|
|
4562da2bea | ||
|
|
ccb28e2bb3 | ||
|
|
06c5f3aaaf | ||
|
|
a050404a77 | ||
|
|
ebce7fc0c3 | ||
|
|
cc5faaff21 | ||
|
|
ffe9a20178 | ||
|
|
2c0718ee19 | ||
|
|
044676b1fc | ||
|
|
6dbf2f1a22 | ||
|
|
3784cb51be | ||
|
|
dc7c164d9a | ||
|
|
7d40c9dded | ||
|
|
bccdbbbd24 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.7",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.7",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"jsc": {
|
||||
|
||||
@@ -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/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/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` 回「查詢失敗」時照原字抄,不補查、不美化。`collect` 另外把要送到人面前的那幾筆寫成提醒佇列 `$JSC_HOME/assistant/reminders.tsv`:第一行帶輪次與 UTC 時間戳,之後一行一筆,到期的只提醒項與逾期項各一種。判定留在到期判定那一支,佇列只是輸出——讀的那一端只印、不判。時間戳是關鍵:沒有人更新的佇列讀起來跟新的一模一樣 |
|
||||
| `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-page.md` | 內容頁 `MONITOR_{HASH}` 的範本。記的是這台機器的巡檢軌跡。頁面固定三塊:本頁基本資料建頁時寫一次就不動、最新一輪每輪整塊換掉、近 24 輪摘要一輪一列且最新的在最上面。軌跡留在摘要表,完整內容只留最新一輪,頁面才讀得完 |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.2.2",
|
||||
"version": "0.3.8",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills/",
|
||||
"jsc": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
+42
-11
@@ -71,10 +71,12 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta
|
||||
|
||||
| What it does | Path to run |
|
||||
| --- | --- |
|
||||
| one patrol round | `{CURRENT}/jsc-assist/tools/patrol.sh` |
|
||||
| one patrol round, and joining the monitor page's three blocks | `{CURRENT}/jsc-assist/tools/patrol.sh` |
|
||||
| the system scheduler | `{CURRENT}/jsc-assist/tools/schedule.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 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 status event stream | `{CURRENT}/jsc-hooks/tools/report-status.sh` |
|
||||
| the wiki, through `jsc-gitea:wiki` | `{CURRENT}/jsc-gitea/tools/gitea.sh` |
|
||||
@@ -184,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".
|
||||
- **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 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` 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.
|
||||
|
||||
@@ -262,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.
|
||||
|
||||
**`{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.** Nothing executes an `action` today — `tasks.sh` stores it, `due.sh` prints it, `status` and the monitor page print it — so the rule is aimed at whoever wires execution up later: substitute `{cli}` with each detected CLI token and `{repo}` with each scanned repository working directory, run once per target, and let the targets' results together count as this entry's one success or failure.
|
||||
**`{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.
|
||||
|
||||
@@ -284,14 +286,29 @@ The delegation list holds one row per jsc skill and records whether that skill c
|
||||
| 5 | Filesystem failure — the scratch directory or a scratch file could not be written | Report it with the path; the reconcile could not even be computed |
|
||||
| 6 | Usage error — an unknown subcommand or option, a `--root` that is not absolute, or an `--allow-cond` value that is not `jsc-{domain}:{skill}` | A defect in the call. Correct it and run it once more |
|
||||
|
||||
## run-due.sh exit codes
|
||||
|
||||
| Code | Meaning | What to do |
|
||||
| --- | --- | --- |
|
||||
| 0 | The round's due entries were dealt with. Zero due entries, and every entry held for want of a target, are both this code | Carry on, and carry the `done=`, `failed=`, `held=`, `skip=` and `write_failed=` counts into the report |
|
||||
| 1 | At least one entry's command returned non-zero. That entry is already recorded as failed and its failure count went up; the rest still ran | Report every `failed=` and `target_fail=` line with the command and the first line of its output. This is a finding about the thing that was checked, not a fault in the round |
|
||||
| 2 | The due list could not be read, or its column count is not the one this tool knows, so **nothing ran** | Report it and say the judging step has not run, or its output format changed. Never read this as "nothing was due" |
|
||||
| 4 | At least one write-back to `tasks.sh` failed. The command ran but the task book did not record it, so the same entry runs again next round | Say that out loud with the `write_failed=` lines and their `tasks.sh` codes — a command that runs every round and is never recorded is the failure mode this code exists to name |
|
||||
| 5 | Filesystem failure — a scratch file could not be written | Report it with the path |
|
||||
| 6 | Usage error — an unknown sub-command or option, a missing option value, or a `--root` that is not absolute | A defect in the call. Correct it and run it once more |
|
||||
|
||||
**A held entry is not a failed entry.** `held=` covers an entry whose `{cli}` or `{repo}` could not be given a target, and one whose command no longer passes the shape check. Its `last_run` and `fail_count` are left exactly as they were, and the report has to keep that distinction: an entry nobody could give a target to did nothing wrong, and letting its failure count climb buries the entries that really are broken under ones that are merely unwired.
|
||||
|
||||
## Boundaries
|
||||
|
||||
The six limits in `AGENTS.md`「助理的界線」 hold for all four operations. Four of them need saying out loud here:
|
||||
|
||||
- **This skill never judges a gate.** It maintains the heartbeat and prints what the heartbeat says. Whether a stale heartbeat blocks a skill call is decided by a hook, synchronously and offline; nothing in this skill blocks or waves through anything. 界線 2.
|
||||
- **A command the permission layer refuses is reported with its full command line, wherever in the round it happened.** A refusal is not an error the tool returned: there is no exit code, no stderr, and nothing in the tool's own log. The command text is the only evidence, and it is the only thing that can be checked against the allow list — which matches the text as written, before the shell expands anything. So quote the line verbatim, prefix included, and name the step that submitted it. 界線 1 says a round never asks; this is what makes a refused round diagnosable instead of merely reported. One round wrote 「the write was refused」 and named neither the command nor the tool: every obvious suspect was ruled out afterwards and the real one was never found, so that round is still unexplained.
|
||||
- **A patrol round asks nothing.** It runs from cron with nobody present, so there is no one to answer and a question hangs the round. Every branch in the patrol steps below resolves without a question: a missing source is recorded as missing, an ambiguous result is recorded verbatim, and a round that cannot proceed aborts and reports. Never call `jsc-ask:ask` from `patrol`. A command that is not on the allow list is a question too — the permission prompt is one, and it is the one nobody sees — which is why step 0 hands that round its root instead of letting it resolve one. 界線 1.
|
||||
- **A patrol round rewrites the monitor page as three fixed blocks.** Read the old page back first; keep 本頁基本資料 as it stands, replace 最新一輪 whole, put this round's row on top of the summary table and cut it to 24; then put the whole page. The directory page is a separate write in a separate wiki repo, and `wiki-contents.sh` does it: that page keeps one H2 block per machine, and this machine's block is the only one that is updated. A page that could not be read is a page that does not get written — the summary table only survives if the old one came back. 界線 4.
|
||||
- **A patrol round reports; it never acts on what it found.** The 待人處理 rows name an entry point for a human. The patrol does not run that entry point, does not fix a hook, does not update a plugin and does not touch a repository. 界線 3 and 界線 6.
|
||||
- **Running the due built-in items is not an exception to that.** Those entries are the assistant's own scheduled work, put there by a reconcile against a delegation list that goes through review; a 待人處理 row is a finding about somebody else's machine state. The first is a round doing the job it was given, the second is a round deciding what somebody else's job is. Step 5 keeps the line where it belongs by running only read-only probe commands from the list, never the skill named by an `invoke` row and never anything a person entered by hand — and the moment a command that writes appears in that column, this paragraph is the one that has to be re-argued, not quietly widened.
|
||||
- **Only a human-initiated operation reconciles the built-in items; an unattended round reports the difference and stops there.** `start` runs `seed-tasks.sh apply`, because somebody asked for it and is there to read what it added and removed. `status` runs `seed-tasks.sh plan`, which writes nothing. `patrol` runs neither: removing a check entry destroys that entry's `last_run` and `fail_count` history, and 界線 5 keeps destructive cleanup with the human — a round that deletes a row at three in the morning because the list was mid-sync leaves nobody able to see that the row ever existed. The consequence is worth stating: on a machine nobody starts or inspects, a list change reaches the task book only at the next `start`. 界線 3 and 界線 5.
|
||||
- **`stop` clearing the heartbeat and removing the schedule is not a breach of 界線 5「不刪除狀態檔」.** That limit protects state that records work — the task book, worktrees, wiki pages — from a background process nobody is watching. The heartbeat records one fact only, "the last patrol round finished", and the schedule entry is what keeps rounds running, so a `stop` that leaves either behind leaves a lie behind. Clearing both is the whole job of `stop`, and they are the only deletions any operation here performs, both of them entries this skill installed itself. `stop` touches nothing under `tasks/`, nobody else's cron entry, no worktree and no wiki page. Do not "restore" this limit later by taking either removal out of `stop`.
|
||||
|
||||
@@ -313,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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -331,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.
|
||||
|
||||
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=` and `contents_file=`. 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.
|
||||
|
||||
@@ -341,15 +362,21 @@ One round: read five sources, record the result, then beat. Everything before th
|
||||
|
||||
3. **Rebuild `MONITOR_{HASH}` from its three blocks.** Hand it to `jsc-gitea:wiki` with page type `MONITOR`, so the repo is the one `gitea.sh wiki-repo MONITOR` resolves — `JSC_WIKI_REPO_MONITOR`, then `JSC_WIKI_REPO`, then exit 3. This is the content page and it stays in that repo; only the directory page of step 4 moved to the `CONTENTS` repo, and neither chain ever falls back to the other. Read the page back first, then build the new body out of what came back plus this round's files, in this order and with nothing else on the page:
|
||||
|
||||
**The three blocks are joined by `{CURRENT}/jsc-assist/tools/patrol.sh rebuild --old {the file the old page was read into}`, never by hand.** It prints `rebuilt_file=` — put that file. It also prints `old_format=`, `blocks=`, `summary_rows=` and `old_rows_kept=`; carry those into the report. Exit 6 means an argument was rejected or the old-page file is not there, exit 5 a scratch file could not be written; both take step 3's abort row.
|
||||
|
||||
Joining them by hand is what this replaces, and the reason is worth keeping. The join is a fixed shape, so leaving it to the caller meant a fresh agent picked its own tools for it every round: one round reached for `awk`, was refused by the permission gate twice, worked around it because somebody was there to watch, and said in its own report that the same refusal would hang an unattended round — with the round lock already taken. The scratch directory had accumulated 124 files by then, named `newpage.md`, `new-page.md`, `new_page.md`, `page-new.md` and so on: the same join, redone every round, under a different set of names. **A step with one correct shape does not belong in a prompt.** What the script joins:
|
||||
|
||||
| Block | Where it comes from |
|
||||
| --- | --- |
|
||||
| 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived |
|
||||
| 本頁基本資料 | the old page, byte for byte from its heading to the line before 最新一輪. Never rewritten, never re-derived — the script does not even normalise its trailing blank lines |
|
||||
| 最新一輪 | the whole content of `latest_file`, replacing the old block entirely |
|
||||
| 近 24 輪摘要 | `summary_file`, which already holds the heading, the five-column table header (`巡檢時間`、`本輪判定`、`各項成敗`、`待人處理`、`警示來源`) and this round's row; then the old table's data rows in their old order underneath, cut so the table holds at most 24 rows |
|
||||
|
||||
An old-format page — per-round sections stacked up, no 最新一輪 heading — is detected by the script itself, which keeps the basic-data block, drops the stacked sections, starts the table with this round's row, and prints `old_format=yes converted=1`. Report that the page was converted.
|
||||
|
||||
**Verify the page's links before the write.** List every link the rebuilt body carries — the ones the latest-round block brought in, and any that survived in the block carried over from the old page — and run `{CURRENT}/jsc-gitea/tools/link-check.sh` over the whole list. Exit 0 is the only result that permits the write. On exit 1 report the `DEAD` lines verbatim, then run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}` and stop: a round that writes a dead link records a false trail nobody can follow back. Exits 2, 3 and 7 take the same abort, each reported by the rule B table above. A body carrying no link at all needs no call — say so in the report rather than claiming a check that never ran.
|
||||
|
||||
Put the whole page. An old-format page — per-round sections stacked up, no summary table — has no rows to carry over: keep its `本頁基本資料` block, drop the stacked sections, let the table start with this round's row, and say in the report that the page was converted. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **No record, no heartbeat**, and that verdict belongs to this step alone: the round's result lives on this page, so a repo this step cannot resolve leaves the round with nowhere to be recorded. Step 4 is judged on its own terms. Completion condition: `link-check.sh` exited 0 over the body's links or the body carried none, the put or the create returned success, and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code.
|
||||
Put the whole page. Only exit 4 from the read permits creating the page instead, and then the body is the whole content of `newpage_file`, which already carries all three blocks. Exit 7 and exit 8 mean the old content is unknown: create nothing, write nothing — rebuilding a page from an unknown original throws the summary table away. On any write failure — including exit 3 with no wiki repo configured for `MONITOR`, which the patrol cannot ask about — run `{CURRENT}/jsc-assist/tools/patrol.sh abort --round {round}`, report the code, and stop. **When the write did not fail but was refused — the permission layer stopped the command before it ran — the report MUST carry the command line verbatim, exactly as it was submitted.** A refusal has no exit code and no stderr from the tool, so the command text is the only thing that can be held against the allow list, and the allow list matches the text as written. A report that says only 「the write was refused」 cannot be acted on: this happened, and the round that hit it named neither the command nor the tool, so every obvious suspect had to be ruled out one at a time and the actual one was never found. Quote the whole line — the environment-variable prefix included, since that counts as part of what the rule is matched against — and say which step submitted it. **No record, no heartbeat**, and that verdict belongs to this step alone: the round's result lives on this page, so a repo this step cannot resolve leaves the round with nowhere to be recorded. Step 4 is judged on its own terms. Completion condition: `link-check.sh` exited 0 over the body's links or the body carried none, the put or the create returned success, and the page holds exactly three blocks with the summary table at 24 rows or fewer and this round's row on top, or the abort ran and the round was reported as unrecorded with its exit code.
|
||||
|
||||
4. **Update this machine's block in `MONITOR_CONTENTS`, through `jsc-gitea/tools/wiki-contents.sh`.** That page is a directory every machine writes to, and it lives in the repo `gitea.sh wiki-repo CONTENTS` resolves — `JSC_WIKI_REPO_CONTENTS`, then `JSC_WIKI_REPO`, then exit 3, and never a fallback to `JSC_WIKI_REPO_MONITOR`. The page carries no table: it is an H1, a `>` preamble, and then one H2 block per machine — the heading is that machine's monitor page name, and the fields are one `- {name}:{value}` bullet each underneath. The script owns the read-match-write of one block, so never read this page and rebuild it by hand, never write it through `jsc-gitea:wiki`, and never rebuild it the way step 3 rebuilds the content page — every other block here belongs to a machine that is not this one, and one careless whole-page write deletes their records.
|
||||
|
||||
@@ -377,13 +404,17 @@ 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.
|
||||
|
||||
5. **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.
|
||||
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. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL.
|
||||
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.
|
||||
|
||||
7. **Report the round.** Print the round verdict and, when it is `警示`, the `warn_sources=` text that says why — a round can read all five sources and still come out `警示`, and that column is the only place the reason appears; then one line per item with its `status=` and, for a failure, its `note=`; the monitor page name, the link-check verdict for each of the two writes — passed, skipped for a body with no link, or refused with its exit code and its `DEAD` lines — and the directory entry as `updated`, `added`, or not written with the exit code and the reason; whether the heartbeat was written; and, when `lock_broken=1`, that the previous round's lock was taken over because it had aged past the TTL.
|
||||
|
||||
**The event numbers get their own line, and the unpaired starts get their own list.** Print `events_total=` and `events_bad=` as this round's event count and its non-`ok` count, then every non-`ok` event with its `kind`, `name`, `status`, `exit` and `detail`, then — separately, never folded into the same list — every start with no matching end, by `name` and `session`. A non-zero `events_unpaired=` is the round's most important finding: each row is a skill run that started and never reached its closing step. Say `events_rotated=` too when it is `rotated` or `failed`. When `item=D-11` failed, say the source could not be read rather than reporting zero events — zero read events and zero existing events look identical in a report and mean opposite things.
|
||||
|
||||
Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on.
|
||||
**The two reminder counts are reported as two numbers and never merged into one.** Print `reminders=` as the entries a person can act on and `reminders_builtin=` as the delegation list's own items, each with the word that says which it is — a report saying "eight reminders queued" describes an eight-line problem for the reader when every one of the eight was waiting on plumbing nobody has wired. The queue writer splits the kinds and whatever prints the queue honours the split; a report that adds them back together throws that away at the last step, which is where the flattening happened in practice. Say what the built-in count is waiting on: an action of `remind` with no entry point yet, so it comes due again next round.
|
||||
|
||||
**Then report step 5 in its own block**, because it is the only place the task book's own work is accounted for: how many entries were due, how many ran, how many came back clean and how many failed, then every `failed=` row by name with the first line of its command's output, every `held=` row with what could not be given a target, and every `write_failed=` row as an entry that ran without being recorded. Say plainly that a held entry's history was left untouched. Close with the 待人處理 rows from the latest-round block, verbatim, and nothing else — the patrol names an entry point and stops there. Completion condition: all five items appear in the report, the event count, the non-`ok` count and the unpaired starts are stated, the two reminder counts appear as two separate numbers, the heartbeat outcome is stated as written or not written, and no suggestion in 待人處理 was acted on.
|
||||
|
||||
## status
|
||||
|
||||
@@ -417,7 +448,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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
+163
-23
@@ -8,8 +8,9 @@
|
||||
# [--created {ISO 時間}] [--last-run {ISO 時間}] [--fail-count {數字}]
|
||||
# [--now {epoch 秒}]
|
||||
#
|
||||
# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期。
|
||||
# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。
|
||||
# scan 一輪一次:比對狀態快照算出本輪新事件、推進快照與事件計數,再逐筆判到期與逾期。
|
||||
# events 唯讀預覽:只比對、只印,一律不推進快照,也不判到期。
|
||||
# overdue 只判逾期,唯讀:一份快照都不推進,一個事件都不比。狀態查詢那一路走這一個。
|
||||
# next 純算:給一組欄位算出 next_run,不讀待辦簿、不碰快照、不發事件。
|
||||
#
|
||||
# 結束碼:
|
||||
@@ -17,7 +18,8 @@
|
||||
# 1 至少一個狀態來源存在卻讀不到。**結果照樣印得出來**,只是那一個來源本輪不判事件、
|
||||
# 快照那一段原樣留著,呼叫端要把它標成警示
|
||||
# 2 欄位值不合法或算不出來:trigger 或 recur 解不開、時間字串解不出 epoch、間隔寫錯、
|
||||
# --now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,其餘各筆照判
|
||||
# due 欄的截止時間解不開、--now 不是非負整數。scan 遇到這種待辦只把那一筆標成判不了,
|
||||
# 其餘各筆照判
|
||||
# 3 快照或事件計數換不上去。算是算出來了,但下一輪會拿同一份舊快照再比一次,於是同一批
|
||||
# 事件會被判第二次。這一碼要吵出來,不能當成成功
|
||||
# 4 待辦簿目錄不存在或零筆,這一輪沒有任何一筆可判。不是失敗,但呼叫端要知道「沒判過」
|
||||
@@ -139,8 +141,13 @@
|
||||
#
|
||||
# --- 這一支不判什麼 ---
|
||||
#
|
||||
# 不判逾期(due 欄有沒有過),不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。
|
||||
# 它只回答兩個問題:本輪有哪些新事件、哪幾筆現在到期。做不做、怎麼做、失敗怎麼記,都在呼叫端。
|
||||
# 不執行任何一筆待辦,不改任何一筆的 state,不送提醒到前景,不碰 wiki。
|
||||
# 它只回答三個問題:本輪有哪些新事件、哪幾筆現在到期、哪幾筆逾期了。做不做、怎麼做、失敗怎麼記,
|
||||
# 都在呼叫端。
|
||||
#
|
||||
# 到期與逾期分兩欄,不合成一個判定:到期是「該跑了」,逾期是「due 欄那個截止時間過了」。
|
||||
# 一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期(它沒有截止時間);一筆有截止時間的交辦
|
||||
# 可能早就逾期,卻因為排定點還沒到而判成「還沒到」。合成一個值就會把這兩種情況混成同一格。
|
||||
#
|
||||
# 環境變數:
|
||||
# JSC_HOME 助理狀態檔的根目錄,預設 ~/.jsc。要是連 HOME 也沒有就回 6,不猜
|
||||
@@ -195,8 +202,9 @@ note() { printf '[jsc][助理到期判定]:%s\n' "$1" >&2; }
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run]
|
||||
due.sh events [--now epoch 秒]
|
||||
usage: due.sh scan [--out 目錄] [--now epoch 秒] [--dry-run]
|
||||
due.sh events [--now epoch 秒]
|
||||
due.sh overdue [--now epoch 秒]
|
||||
due.sh next --trigger at:...|after:... --recur once|every:間隔
|
||||
[--created ISO 時間] [--last-run ISO 時間] [--fail-count 數字]
|
||||
[--now epoch 秒]
|
||||
@@ -869,15 +877,51 @@ kv_get() { # $1=檔案 $2=鍵
|
||||
# 不寫成 key=value:cron 式子與標題裡有空白,key=value 的那一行沒辦法只靠空白切回來,切錯就是
|
||||
# recur 只剩「cron:0」那半截,而那半截看起來還很像一個完整的值。值一律折過,裡面不會有定位
|
||||
# 字元也不會有換行,所以定位字元切得準。
|
||||
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' \
|
||||
row_out() { # 十二個欄位,順序如上
|
||||
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 "$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() {
|
||||
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。"; }
|
||||
if [ ! -d "$TASKS_DIR" ]; then
|
||||
RC_EMPTY=4
|
||||
@@ -898,11 +942,28 @@ scan_tasks() {
|
||||
_cr=$(kv_get "$_f" created)
|
||||
_lr=$(kv_get "$_f" last_run)
|
||||
_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
|
||||
# done 與 paused 一律不判。paused 是人設的,助理判它到期只會讓人以為它還在跑。
|
||||
# done 與 paused 一律不判到期。
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -914,7 +975,7 @@ scan_tasks() {
|
||||
unwired) T_UNWIRED=$(( T_UNWIRED + 1 )) ;;
|
||||
esac
|
||||
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
|
||||
[ "$T_TOTAL" -eq 0 ] && {
|
||||
RC_EMPTY=4
|
||||
@@ -931,7 +992,7 @@ compose_md() {
|
||||
printf '事件靠比對狀態快照偵測:每一輪讀那幾支產生者留下的狀態,跟上一輪的快照比,有差別就算事件發生。快照在 `%s`,事件計數在 `%s`。\n\n' \
|
||||
"$PREV" "$SEEN"
|
||||
printf '**兩輪之間發生又消失的事件會漏掉。** 一支工作包在同一個巡檢週期之內鎖上又合併、一個工作階段在同一個週期之內開又關,這一節都看不到——看到的只有兩張快照的差別。往後誰假設「事件不會漏」就會出錯,而那種錯是無聲的:那筆待辦看起來就是還沒到期。\n\n'
|
||||
printf '這一節不判逾期(`due` 欄有沒有過),也不執行任何一筆。到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n'
|
||||
printf '到期與逾期是兩件事,這一節兩件都判:到期是「該跑了」,逾期是「`due` 欄那個截止時間過了」。一筆七天一次的檢查項天天都可能到期,卻永遠不會逾期;一筆有截止時間的交辦可能早就逾期,卻因為排定點還沒到而判成還沒到。這一節不執行任何一筆——到期的那幾筆要不要做、怎麼做,在呼叫端。\n\n'
|
||||
printf '| 項目 | 內容 |\n'
|
||||
printf '| --- | --- |\n'
|
||||
printf '| 判定時間 | %s |\n' "$(epoch_to_iso "$NOW")"
|
||||
@@ -943,10 +1004,12 @@ compose_md() {
|
||||
printf '| 本輪新事件 | %s 種 |\n' "$EV_NEW"
|
||||
printf '| 待辦簿筆數 | %s |\n' "$T_TOTAL"
|
||||
printf '| 到期 | %s |\n' "$T_DUE"
|
||||
printf '| 逾期 | %s |\n' "$T_OVERDUE"
|
||||
printf '| 還沒到 | %s |\n' "$T_WAIT"
|
||||
printf '| 判不了(欄位不合法) | %s |\n' "$T_INVALID"
|
||||
printf '| 判不了(來源還沒接線) | %s |\n' "$T_UNWIRED"
|
||||
printf '| 不判(state 不是 pending) | %s |\n' "$T_SKIP"
|
||||
[ "$T_DUEBAD" -gt 0 ] && printf '| `due` 欄解不開 | %s |\n' "$T_DUEBAD"
|
||||
|
||||
printf '\n#### 狀態來源\n\n'
|
||||
printf '| 來源 | 狀態 | 筆數 | 說明 |\n'
|
||||
@@ -975,19 +1038,36 @@ compose_md() {
|
||||
printf '本輪沒有新事件。\n'
|
||||
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'
|
||||
if [ -s "$ROWS" ]; then
|
||||
printf '| id | 判定 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n'
|
||||
printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- |\n'
|
||||
printf '| id | 判定 | 逾期 | 標題 | 動作 | trigger | recur | next_run | 重新武裝 | 為什麼 |\n'
|
||||
printf '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |\n'
|
||||
# 這一段交給 awk 一次做完,不用殼的 while read 逐列切。殼的 read 拿定位字元當分隔時會
|
||||
# 把連續的定位字元併成一個——定位字元算 IFS 的空白字元——於是空欄位整個消失,後面每一欄
|
||||
# 都往前挪一格,印出來的表格看起來完整,值卻全錯位。awk 的 -F'\t' 不併,空欄位就是空欄位。
|
||||
awk -F'\t' '
|
||||
function d(s) { return s == "" ? "-" : s }
|
||||
function esc(s) { gsub(/\|/, "\\|", s); return s }
|
||||
NF >= 11 && $1 != "" {
|
||||
printf "| `%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))
|
||||
NF >= 12 && $1 != "" {
|
||||
printf "| `%s` | %s | %s | %s | %s | `%s` | `%s` | %s | %s | %s |\n", \
|
||||
$1, $2, d($12), esc(d($11)), esc(d($5)), d($6), d($7), d($8), d($9), esc(d($10))
|
||||
}' "$ROWS"
|
||||
else
|
||||
printf '待辦簿零筆,或目錄不存在。這一輪沒有任何一筆可判——「沒判過」不等於「都沒到期」。\n'
|
||||
@@ -1042,9 +1122,9 @@ cmd_scan() {
|
||||
# stdout 上的 task= 那幾行是給人看的。trigger、recur、why、title 四欄都可能帶空白,所以這
|
||||
# 幾行切不回欄位——要逐筆取值的呼叫端讀 rows_file 那個定位字元分隔的檔案,不要切這幾行。
|
||||
if [ -s "$ROWS" ]; then
|
||||
awk -F'\t' 'NF >= 11 && $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", \
|
||||
$1, $2, $3, $4, $5, $8, $9, $6, $7, $10, $11
|
||||
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 overdue=%s why=%s title=%s\n", \
|
||||
$1, $2, $3, $4, $5, $8, $9, $6, $7, $12, $10, $11
|
||||
}' "$ROWS"
|
||||
fi
|
||||
printf 'tasks_total=%s\n' "$T_TOTAL"
|
||||
@@ -1053,8 +1133,19 @@ cmd_scan() {
|
||||
printf 'tasks_invalid=%s\n' "$T_INVALID"
|
||||
printf 'tasks_unwired=%s\n' "$T_UNWIRED"
|
||||
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_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 'prev_file=%s\n' "$PREV"
|
||||
printf 'seen_file=%s\n' "$SEEN"
|
||||
@@ -1062,6 +1153,53 @@ cmd_scan() {
|
||||
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() {
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -1149,6 +1287,7 @@ FIRST_RUN=0
|
||||
EV_NAMES=''
|
||||
EV_NEW=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:-}"
|
||||
[ -n "$CMD" ] || usage
|
||||
@@ -1156,6 +1295,7 @@ shift
|
||||
case "$CMD" in
|
||||
scan) cmd_scan "$@" ;;
|
||||
events) cmd_events "$@" ;;
|
||||
overdue) cmd_overdue "$@" ;;
|
||||
next) cmd_next "$@" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
+193
-4
@@ -148,6 +148,9 @@
|
||||
# warn_sources= 本輪的警示來源,以「、」分隔;沒有警示就是「無」。各項全過卻判成警示
|
||||
# 時,原因只寫在這裡
|
||||
# tasks_total= tasks_failing= 待辦簿筆數與連續失敗筆數,供目錄頁那一個區塊與摘要用
|
||||
# tasks_overdue= 逾期筆數(截止時間已經過了),取自到期判定那一支;判不出來時是減號
|
||||
# reminders= reminders_builtin= reminders_file= 提醒佇列逐筆點名的筆數、只算總數的內建項
|
||||
# 筆數,與佇列路徑。工作階段開始那一支 hook 讀它
|
||||
# tasks_due= 本輪到期的筆數;到期判定那一支失敗時為空
|
||||
# events_new= 本輪偵測到的新事件種類數;到期判定那一支失敗時為空
|
||||
# due_status= due_rc= 到期判定那一支的結果與結束碼
|
||||
@@ -205,9 +208,15 @@ WARN=0
|
||||
DUE_STATUS=fail
|
||||
DUE_RC=0
|
||||
DUE_MD=''
|
||||
DUE_ROWS=''
|
||||
DUE_NOTE=''
|
||||
DUE_TASKS=''
|
||||
DUE_EVENTS=''
|
||||
DUE_OVERDUE=''
|
||||
FAILING_LINES=''
|
||||
REMINDERS=0
|
||||
REMINDERS_BUILTIN=0
|
||||
REMINDERS_FILE=''
|
||||
|
||||
# 這支腳本是不是從 $JSC_HOME/current 那一組路徑被叫起來的。不是就大聲警告,但照跑。
|
||||
# 只警告、不中止是刻意的取捨:從工作樹直接跑腳本是開發時的正當用法,中止會把那條路擋掉;
|
||||
@@ -237,6 +246,7 @@ usage() {
|
||||
usage: patrol.sh collect [--out 目錄] [--trigger 排程|事件|手動]
|
||||
patrol.sh finish --round 輪次代號 [--out 目錄] [--dry-run]
|
||||
patrol.sh abort --round 輪次代號 [--out 目錄]
|
||||
patrol.sh rebuild --old 舊頁檔 [--out 目錄]
|
||||
EOF
|
||||
exit 6
|
||||
}
|
||||
@@ -873,7 +883,7 @@ d11() {
|
||||
# --- 待辦簿筆數(只供目錄頁那一個區塊用)---
|
||||
|
||||
count_tasks() {
|
||||
TASKS_TOTAL=0; TASKS_FAILING=0
|
||||
TASKS_TOTAL=0; TASKS_FAILING=0; FAILING_LINES=''
|
||||
_d="$STATE_DIR/tasks"
|
||||
[ -d "$_d" ] && [ -r "$_d" ] || return 0
|
||||
for _f in "$_d"/*; do
|
||||
@@ -881,11 +891,104 @@ count_tasks() {
|
||||
TASKS_TOTAL=$(( TASKS_TOTAL + 1 ))
|
||||
_fc=$(sed -n 's/^fail_count=//p' "$_f" 2>/dev/null | head -n1)
|
||||
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
|
||||
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,理由寫在那一支的檔頭:
|
||||
@@ -911,8 +1014,14 @@ due_scan() {
|
||||
JSC_HOME="$JSC_HOME" sh "$_due" scan --out "$RD/due" >"$RD/due.out" 2>"$RD/due.err" </dev/null || _rc=$?
|
||||
DUE_RC="$_rc"
|
||||
DUE_MD=$(sed -n 's/^due_file=//p' "$RD/due.out" 2>/dev/null | head -n1)
|
||||
# 機器可讀的那一份也要轉出去。判到期與執行是兩支腳本,中間靠這個檔案交棒;不轉出去
|
||||
# 呼叫端就只能靠預設路徑猜,而那個預設指向的是上一次有人手動跑 scan 留下的檔案。
|
||||
# 實測踩過:那份舊清單在機器上放了六小時,執行那一支每一輪都對它動手,跑的是一筆已經
|
||||
# 被移除的待辦,而且看起來完全正常——舊清單當時是對的,內容剛好沒變。
|
||||
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_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
|
||||
0) DUE_STATUS=ok ;;
|
||||
4)
|
||||
@@ -990,8 +1099,19 @@ compose() {
|
||||
cat "$RD/d07.md"; printf '\n'
|
||||
if [ -n "$DUE_MD" ] && [ -s "$DUE_MD" ]; then
|
||||
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"
|
||||
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
|
||||
printf '### 待辦簿到期與逾期\n\n'
|
||||
printf '**這一輪判不出到期。** 到期判定那一支(`tools/due.sh`)%s。本輪待辦簿共 %s 筆,其中 %s 筆 `fail_count` 大於 0。這一節沒有判定結果**不代表沒有任何一筆到期**,要逐筆看就跑 `/jsc-assist:assistant status`。\n\n' \
|
||||
@@ -1078,7 +1198,7 @@ compose() {
|
||||
CMD="${1:-}"
|
||||
[ -n "$CMD" ] || usage
|
||||
shift
|
||||
case "$CMD" in collect|finish|abort) ;; *) usage ;; esac
|
||||
case "$CMD" in collect|finish|abort|rebuild) ;; *) usage ;; esac
|
||||
|
||||
OUT=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
@@ -1087,9 +1207,11 @@ while [ "$#" -gt 0 ]; do
|
||||
--trigger) [ "$#" -ge 2 ] || usage; TRIGGER="$2"; shift 2 ;;
|
||||
--round) [ "$#" -ge 2 ] || usage; ROUND="$2"; shift 2 ;;
|
||||
--dry-run) DRYRUN=1; shift ;;
|
||||
--old) [ "$#" -ge 2 ] || usage; OLD_PAGE="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
OLD_PAGE="${OLD_PAGE:-}"
|
||||
[ -n "$OUT" ] && RD="$OUT"
|
||||
|
||||
case "$CMD" in
|
||||
@@ -1120,6 +1242,18 @@ case "$CMD" in
|
||||
d11
|
||||
count_tasks
|
||||
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 "$D11_STATUS"
|
||||
|
||||
@@ -1150,10 +1284,15 @@ case "$CMD" in
|
||||
printf 'tasks_total=%s\n' "$TASKS_TOTAL"
|
||||
printf 'tasks_failing=%s\n' "$TASKS_FAILING"
|
||||
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 'due_status=%s\n' "$DUE_STATUS"
|
||||
printf 'due_rc=%s\n' "$DUE_RC"
|
||||
printf 'due_file=%s\n' "$DUE_MD"
|
||||
printf 'due_rows_file=%s\n' "${DUE_ROWS:-}"
|
||||
printf 'pending=%s\n' "$PEND_COUNT"
|
||||
printf 'events_total=%s\n' "$EV_TOTAL"
|
||||
printf 'events_bad=%s\n' "$EV_BAD"
|
||||
@@ -1204,4 +1343,54 @@ case "$CMD" in
|
||||
printf 'heartbeat=not-written\n'
|
||||
lock_release
|
||||
exit 0 ;;
|
||||
|
||||
# 拿舊頁重組出要寫回去的整頁。三塊的接法固定,所以由這一支做完,呼叫端只要「讀回舊頁、
|
||||
# 叫這一支、把結果寫回去」三步。
|
||||
#
|
||||
# 為什麼要有這個子命令:接三塊這件事本來留給呼叫端,而呼叫端是一個代理人,於是它每一輪
|
||||
# 臨場挑工具——實測某一輪挑了 awk,兩次都被權限擋掉;那一輪有人在場所以它改用別的方式繞
|
||||
# 過去了,並且自己指出無人值守那一輪遇到同一個擋法會直接卡住,而鎖在第一步就取走了。
|
||||
# 更根本的是暫存目錄實測累積了 124 個檔案,名字是 newpage.md、new-page.md、new_page.md、
|
||||
# page-new.md 這種——同一件事每輪重做一次,每輪換一組檔名。規格說那裡只該有五個檔案。
|
||||
# 接法寫死在這裡之後,那一步不再需要任何通用文字工具,也就沒有東西可以被擋。
|
||||
rebuild)
|
||||
[ -n "$OLD_PAGE" ] || usage
|
||||
[ -f "$OLD_PAGE" ] || die 6 "舊頁檔讀不到:$OLD_PAGE。這一支不自己讀 wiki,舊頁要由呼叫端先取下來。"
|
||||
for _f in "$RD/latest.md" "$RD/summary.md"; do
|
||||
[ -f "$_f" ] || die 6 "$_f 不在。rebuild 要接的是同一輪 collect 產出的那幾塊,先跑 collect。"
|
||||
done
|
||||
_out="$RD/rebuilt.md"
|
||||
# 舊頁那一塊「本頁基本資料」原樣留著,從它的標題到「最新一輪」前一行為止。整頁沒有
|
||||
# 「最新一輪」那個標題時視為舊格式:基本資料留著、堆疊的各輪小節丟掉,摘要表從本輪開始。
|
||||
if grep -q '^## 最新一輪' "$OLD_PAGE" 2>/dev/null; then
|
||||
awk '/^## 最新一輪/{exit} {print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。"
|
||||
printf 'old_format=no\n'
|
||||
else
|
||||
awk '/^## /{n++} n<=1{print}' "$OLD_PAGE" >"$RD/rb-head.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-head.md。"
|
||||
printf 'old_format=yes converted=1\n'
|
||||
fi
|
||||
# 舊表的資料列照原順序留在本輪那一列底下,整張表截到 24 列。摘要那一塊已經含標題、
|
||||
# 表頭與本輪那一列,所以這裡只補舊列。
|
||||
awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{print}' "$OLD_PAGE" >"$RD/rb-rows.md" 2>/dev/null || die 5 "暫存檔寫不進去:$RD/rb-rows.md。"
|
||||
_new_rows=$(awk '/^\| 2/{n++} END{print n+0}' "$RD/summary.md")
|
||||
_keep=$((24 - _new_rows))
|
||||
[ "$_keep" -lt 0 ] && _keep=0
|
||||
# 基本資料那一塊逐字照抄,連尾端空行都不動——技能本文寫的是「逐字保留」,剝掉再補一個
|
||||
# 雖然內容沒少,那句話就變成假的了。只在它沒有以空行收尾時補一個分隔,免得下一塊的
|
||||
# 標題黏在表格最後一列後面。
|
||||
_tail=$(awk 'END{print (NF==0 ? "blank" : "text")}' "$RD/rb-head.md" 2>/dev/null)
|
||||
{
|
||||
cat "$RD/rb-head.md"
|
||||
[ "$_tail" = "blank" ] || printf '\n'
|
||||
cat "$RD/latest.md"
|
||||
printf '\n'
|
||||
cat "$RD/summary.md"
|
||||
[ "$_keep" -gt 0 ] && head -n "$_keep" "$RD/rb-rows.md"
|
||||
} >"$_out" 2>/dev/null || die 5 "暫存檔寫不進去:$_out。"
|
||||
printf 'rebuilt_file=%s\n' "$_out"
|
||||
printf 'blocks=%s summary_rows=%s old_rows_kept=%s\n' \
|
||||
"$(grep -c '^## ' "$_out")" \
|
||||
"$(awk '/^## 近 24 輪摘要/{f=1;next} f && /^\| 2/{n++} END{print n+0}' "$_out")" \
|
||||
"$([ "$_keep" -gt 0 ] && awk 'END{print NR+0}' "$RD/rb-rows.md" | awk -v k="$_keep" '{print ($1<k?$1:k)}' || echo 0)"
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
Executable
+475
@@ -0,0 +1,475 @@
|
||||
#!/usr/bin/env sh
|
||||
# run-due.sh — 把到期的內建檢查項真的跑一遍,成敗回寫待辦簿。
|
||||
#
|
||||
# 用法:
|
||||
# run-due.sh run [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}] [--dry-run]
|
||||
# run-due.sh plan [--rows {檔案}] [--root {字面絕對根目錄}] [--now {epoch 秒}]
|
||||
#
|
||||
# run 逐筆執行並回寫待辦簿。--dry-run 只印要跑什麼、不執行也不回寫
|
||||
# plan 等同 run --dry-run,另取一個名字是為了讓唯讀那一路在指令列上看得出來
|
||||
#
|
||||
# 結束碼:
|
||||
# 0 這一輪跑完了。零筆到期、每一筆都代不出目標,都算跑完
|
||||
# 1 至少一支指令回非零。那一筆已經記成失敗、失敗次數加一,其餘各筆照跑
|
||||
# 2 到期清單讀不到或欄位對不上,**一筆都沒跑**。判到期那一支沒跑過,或它的輸出換了格式
|
||||
# 4 至少一筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆
|
||||
# 5 檔案系統失敗:暫存檔寫不進去
|
||||
# 6 用法錯誤:不認得的子命令或選項、選項缺值、--root 不是絕對路徑
|
||||
# 同時命中好幾碼時,回報順序是 5、2、4、1:前面的蓋掉後面的。4 排在 1 前面是因為
|
||||
# 「跑了但沒記到」會讓同一件事每輪重跑,比「跑了而且失敗」更需要人知道。
|
||||
#
|
||||
# --- 這一支只跑指令型,不叫技能也不送提醒 ---
|
||||
#
|
||||
# 待辦簿的動作欄有三種值:一行指令、技能名、remind。這一支只跑第一種。
|
||||
# 技能名那一種不跑的理由:叫用一整支技能的代價與風險都大得多——它會寫 wiki、開 PR、改檔案,
|
||||
# 而這一支跑在沒有人看的那一輪裡。唯讀盤點指令失敗最多就是回一個非零碼,那一筆記一次失敗;
|
||||
# 一支技能中途失敗可能留下寫到一半的頁或開錯的 PR。兩種代價不同,就該分兩次判、分兩次接。
|
||||
# remind 那一種不跑的理由更直接:它本來就沒有東西可跑,提醒怎麼送到前景是另一件待辦。
|
||||
#
|
||||
# --- 只跑內建項,使用者交辦的一律不碰 ---
|
||||
#
|
||||
# 只有 spec_key 非空的那幾筆會被執行,也就是依委派清單種入的內建項。
|
||||
# 理由是那幾筆的指令經過種入那一支的檢核:路徑一定是 {root}/jsc-{domain}/ 開頭、不含金錢符號
|
||||
# 與波浪號、代入點只認得三個、腳本一定在這台機器上。使用者親手登錄的那幾筆沒有經過那道關,
|
||||
# 動作欄想寫什麼都行,在無人值守那一輪把它送進殼是另一回事,要另外判。
|
||||
# 執行這一刻再驗一次同樣那幾項,不因為種入時驗過就省掉:待辦檔是純文字,中間可能被改。
|
||||
#
|
||||
# --- 權限閘門與這一支的關係,講白 ---
|
||||
#
|
||||
# 這一支自己執行那些指令,所以權限層只看得到 run-due.sh 這一條指令,看不到裡面跑了什麼。
|
||||
# 那不是繞過閘門,是閘門本來就不是沙箱:它是一份「可信入口」的白名單,而清單上的每一支腳本
|
||||
# 本來就做得了它該做的事——巡檢那一支會寫 wiki 頁,部署那一支會改整台機器的外掛。
|
||||
# 防線改由三層擋:一、只跑 spec_key 非空的內建項,那幾筆的來源是版本控管、要走 PR 的委派清單;
|
||||
# 二、執行前再驗一次指令形狀,不信任待辦檔的內容;三、代不出來的目標一律不跑。
|
||||
# 這一段寫在這裡是因為下一個維護的人一定會問,而「為什麼不會被擋」跟「為什麼不需要被擋」
|
||||
# 是兩個不同的答案,後者才是對的那一個。
|
||||
#
|
||||
# --- 代不出目標不算失敗 ---
|
||||
#
|
||||
# {cli} 由偵測到的 CLI 代號代入,{repo} 由 scan-repos.sh 掃到的存取庫工作目錄代入。
|
||||
# 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run,
|
||||
# 也不加失敗次數**。
|
||||
# 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數
|
||||
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
|
||||
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
|
||||
#
|
||||
# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支
|
||||
# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到
|
||||
# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。
|
||||
set -u
|
||||
|
||||
usage() {
|
||||
echo 'usage: run-due.sh {run|plan} [--rows 檔案] [--root 絕對路徑] [--now epoch] [--dry-run]' >&2
|
||||
exit 6
|
||||
}
|
||||
|
||||
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; }
|
||||
warn() { printf '[jsc][助理執行][WARN]:%s\n' "$*" >&2; }
|
||||
|
||||
[ "$#" -ge 1 ] || usage
|
||||
MODE=$1; shift
|
||||
case "$MODE" in
|
||||
run) DRYRUN=0 ;;
|
||||
plan) DRYRUN=1 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
ROWS=''
|
||||
OPT_ROOT=''
|
||||
NOW=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--rows) [ "$#" -ge 2 ] || usage; ROWS=$2; shift 2 ;;
|
||||
--root) [ "$#" -ge 2 ] || usage; OPT_ROOT=$2; shift 2 ;;
|
||||
--now) [ "$#" -ge 2 ] || usage; NOW=$2; shift 2 ;;
|
||||
--dry-run) DRYRUN=1; shift ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$OPT_ROOT" in
|
||||
''|/*) ;;
|
||||
*) die 6 "--root 要給字面絕對路徑,收到的是「$OPT_ROOT」。" ;;
|
||||
esac
|
||||
|
||||
# 助理狀態目錄。判到期那一支把 rows.txt 放在這底下,兩支要對得上同一個位置。
|
||||
JSC_HOME_RESOLVED=${JSC_HOME:-${HOME:-}/.jsc}
|
||||
case "$JSC_HOME_RESOLVED" in
|
||||
/*) ;;
|
||||
*) die 6 'JSC_HOME 與 HOME 都解不出絕對路徑,找不到助理狀態目錄。' ;;
|
||||
esac
|
||||
# --rows 沒有預設值,一律要指定。
|
||||
#
|
||||
# 原本有預設,指向助理狀態目錄底下那一份。那個預設是這一支上線之後最嚴重的一個缺陷:
|
||||
# 判到期那一支是被巡檢用 --out 叫的,輸出寫進那一輪自己的暫存目錄,跟這個預設不是同一個
|
||||
# 位置。於是這一支每一輪讀的都是「上一次有人手動跑 scan 留下的那一份」。實測那份清單在
|
||||
# 機器上放了六小時,這一支每一輪都對它動手,還跑了一筆已經被移除的待辦。
|
||||
# 而它看起來完全正常——那份舊清單產生的當下是對的,內容剛好沒變。**錯了六小時才顯形。**
|
||||
#
|
||||
# 所以不留預設。少帶這個選項就回用法錯誤,吵一次總比安靜地對舊資料動手好。呼叫端要拿
|
||||
# collect 印的 due_rows_file= 餵進來,那是同一輪產生的那一份。
|
||||
if [ -z "$ROWS" ]; then
|
||||
die 6 '沒有帶 --rows。這一支不猜到期清單的位置:判到期那一支的輸出寫在叫它的人指定的目錄裡,猜一個預設就會讀到別人留下的舊檔案,而舊檔案讀起來跟新的一模一樣。請把 collect 印的 due_rows_file= 餵進來。'
|
||||
fi
|
||||
|
||||
[ -f "$ROWS" ] || die 2 "到期清單讀不到:$ROWS。請先跑 due.sh scan——沒跑過判定,跟「都沒到期」不是同一件事。"
|
||||
|
||||
TAB=$(printf '\t')
|
||||
TMPD=$(mktemp -d 2>/dev/null) || die 5 '暫存目錄建不起來。'
|
||||
trap 'rm -rf "$TMPD"' EXIT
|
||||
|
||||
[ -n "$NOW" ] || NOW=$(date -u +%s)
|
||||
NOW_ISO=$(date -u -d "@$NOW" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null) \
|
||||
|| NOW_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# --- 找同一組工具 ---
|
||||
|
||||
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
|
||||
ROOT="$OPT_ROOT"
|
||||
# 沒帶 --root 就從自己的位置往上推兩層。呼叫端該餵進來,這只是開發時跑得動的退路。
|
||||
[ -n "$ROOT" ] || ROOT=$(CDPATH= cd -- "$HERE/../.." 2>/dev/null && pwd -L) || ROOT=''
|
||||
|
||||
find_tool() { # $1=domain $2=相對路徑
|
||||
for _d in "jsc-$1" "$1"; do
|
||||
[ -n "$ROOT" ] && [ -f "$ROOT/$_d/$2" ] && { printf '%s' "$ROOT/$_d/$2"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# 清單太舊就拒跑。判到期與執行之間隔著呼叫端,那一段有可能斷掉——collect 失敗了而那一輪
|
||||
# 照樣往下走,或呼叫端餵進上一輪的路徑。兩種都會讓這一支對著一份不描述現況的清單動手,
|
||||
# 而那正是它上線之後六小時裡在做的事。
|
||||
# 門檻取心跳的過期門檻:那是這台機器認定「一輪跑完的紀錄還算新鮮」的長度,一份比它還舊的
|
||||
# 到期清單本來就不可能是這一輪產生的。讀不到門檻就退回 300 秒,跟心跳那一支的預設一致。
|
||||
_ttl=''
|
||||
if _hb=$(find_tool hooks hooks/heartbeat.sh 2>/dev/null); then
|
||||
_ttl=$("$_hb" report 2>/dev/null | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p' | head -n1)
|
||||
fi
|
||||
case "${_ttl:-}" in
|
||||
''|*[!0-9]*) _ttl=300 ;;
|
||||
esac
|
||||
_mtime=$(date -r "$ROWS" +%s 2>/dev/null) || _mtime=''
|
||||
if [ -n "$_mtime" ]; then
|
||||
_age=$((NOW - _mtime))
|
||||
if [ "$_age" -gt "$_ttl" ]; then
|
||||
die 2 "到期清單是 $_age 秒前產生的,超過門檻 $_ttl 秒,這一支不對它動手:$ROWS。一份比心跳門檻還舊的清單不可能描述這一輪,照著跑會動到已經不存在的待辦。請確認判到期那一步這一輪真的跑成功了,再把它印的路徑餵進來。"
|
||||
fi
|
||||
fi
|
||||
|
||||
TASKS_SH="$HERE/tasks.sh"
|
||||
[ -f "$TASKS_SH" ] || TASKS_SH=$(find_tool assist tools/tasks.sh) \
|
||||
|| die 2 '找不到 tasks.sh,成敗沒有地方回寫。待辦簿只有一個寫入者,缺了它這一輪不該跑。'
|
||||
DUE_SH="$HERE/due.sh"
|
||||
[ -f "$DUE_SH" ] || DUE_SH=$(find_tool assist tools/due.sh) || DUE_SH=''
|
||||
|
||||
# --- {cli} 的代入來源 ---
|
||||
|
||||
# 偵測到的 CLI 代號,一行一個。取不到就當成代不出來,帶 {cli} 的那幾筆這一輪跳過。
|
||||
CLIS="$TMPD/clis.txt"
|
||||
: >"$CLIS"
|
||||
if _det=$(find_tool cli tools/detect-clis.sh); then
|
||||
"$_det" 2>/dev/null | awk -F"$TAB" 'NF>0 && $1 != "" {print $1}' >"$CLIS" 2>/dev/null || : >"$CLIS"
|
||||
fi
|
||||
N_CLI=$(awk 'END{print NR+0}' "$CLIS")
|
||||
|
||||
# 跟排程條目安裝當下記下的那一份比對。少了哪一支要點名,不是只印個數字。
|
||||
#
|
||||
# 為什麼要比:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的 PATH 是
|
||||
# 安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——實測有一支裝在帶行程編號的多殼層目錄,
|
||||
# 重開機之後那條路徑就不在了。
|
||||
# 沒有基準的後果不是報錯,是安靜地少做一支:這一輪照樣把偵測到的每一支都跑過、照樣全部
|
||||
# 成功、照樣回報「都跑過了」。實測那一輪報「對 4 支 CLI 都跑過,全部成功」,而機器上裝了
|
||||
# 5 支——報告讀起來完全正常。
|
||||
# 只報不修:那條路徑本質上是暫時的,真正的解法是那支 CLI 該裝在穩定位置,或者重跑一次
|
||||
# 排程安裝重新拍一份快照。兩件都不是這一支該替人決定的。
|
||||
CLI_MISSING=''
|
||||
if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
|
||||
for _want in $JSC_ASSIST_CLIS; do
|
||||
grep -qxF "$_want" "$CLIS" 2>/dev/null || CLI_MISSING="${CLI_MISSING:+$CLI_MISSING、}$_want"
|
||||
done
|
||||
fi
|
||||
|
||||
# --- {repo} 的代入來源 ---
|
||||
|
||||
# 掃到的存取庫工作目錄,一行一條。掃描規則、掃描起點與排除清單全在 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"
|
||||
}
|
||||
|
||||
# --- 判斷動作是哪一種 ---
|
||||
|
||||
# 技能名的形狀是 jsc-{domain}:{技能名}。比對整串,不用前綴比對:一行以 jsc- 開頭的指令
|
||||
# 路徑不該被當成技能名。
|
||||
is_skill_name() { # $1=action
|
||||
case "$1" in
|
||||
jsc-*:*)
|
||||
case "$1" in
|
||||
*' '*|*/*) return 1 ;;
|
||||
esac
|
||||
return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# 執行前再驗一次指令形狀。種入那一支驗過同樣幾項,這裡不省:待辦檔是純文字,中間可能被改。
|
||||
# 驗不過就當成代不出來,跳過不跑,不記失敗——這一筆的內容有問題,那是清單或待辦檔的事。
|
||||
cmd_shape_ok() { # $1=指令
|
||||
case "$1" in
|
||||
*'$'*|*'`'*|*'~'*) BAD_WHY='指令裡有金錢符號、反引號或波浪號'; return 1 ;;
|
||||
*';'*|*'|'*|*'&'*) BAD_WHY='指令裡有分號、管線或連接符號,一筆內建項只該是一行單一指令'; return 1 ;;
|
||||
esac
|
||||
# 路徑一定要落在這一輪的根目錄底下。字面絕對路徑是權限層唯一認得的形狀,也是唯一
|
||||
# 看得出「這支腳本是不是我們自己的」的形狀。
|
||||
case "$1" in
|
||||
*"$ROOT"/*) ;;
|
||||
*) BAD_WHY="指令裡沒有這一輪的根目錄 $ROOT"; return 1 ;;
|
||||
esac
|
||||
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
|
||||
RC_CMD=0; RC_WRITE=0
|
||||
|
||||
# 欄位順序取自 due.sh 印的 rows_columns=。這裡寫死同一個順序,換了就對不上——所以先驗一次
|
||||
# 欄位數,對不上一筆都不跑,而不是照舊讀進錯的欄位。
|
||||
_cols=$(awk -F"$TAB" 'NF>1{print NF; exit}' "$ROWS" 2>/dev/null)
|
||||
case "${_cols:-0}" in
|
||||
12) ;;
|
||||
0) note '到期清單是空的,這一輪沒有任何一筆要跑。'; _cols=12 ;;
|
||||
*) die 2 "到期清單的欄位數是 ${_cols},這一支認得的是 12 欄。判到期那一支的輸出格式換過了,先對齊再跑——照舊讀下去會把指令讀成別的欄位。第 12 欄是逾期判定,跟這一支要跑什麼無關,但欄位數不對就代表兩支不是同一版,那時候前 11 欄的順序也不能假設還是原來那樣。" ;;
|
||||
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
|
||||
[ -n "${c_id:-}" ] || continue
|
||||
[ "${c_verdict:-}" = "due" ] || continue
|
||||
N_DUE=$((N_DUE + 1))
|
||||
|
||||
# 只跑內建項。使用者交辦的那幾筆沒有經過種入那一支的檢核,動作欄想寫什麼都行。
|
||||
_spec=$(sed -n 's/^spec_key=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
|
||||
if [ -z "$_spec" ]; then
|
||||
N_SKIP=$((N_SKIP + 1))
|
||||
printf 'skip=%s reason=不是委派清單種入的內建項,這一支不跑使用者交辦的那幾筆\n' "$c_id"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 三種動作,這一輪只跑第一種。
|
||||
if [ "$c_action" = "remind" ]; then
|
||||
N_SKIP=$((N_SKIP + 1))
|
||||
printf 'skip=%s spec=%s reason=動作是只提醒,沒有東西可跑\n' "$c_id" "$_spec"
|
||||
continue
|
||||
fi
|
||||
if is_skill_name "$c_action"; then
|
||||
N_SKIP=$((N_SKIP + 1))
|
||||
printf 'skip=%s spec=%s reason=動作是技能名,叫用整支技能這一批還沒接 action=%s\n' \
|
||||
"$c_id" "$_spec" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
BAD_WHY=''
|
||||
if ! cmd_shape_ok "$c_action"; then
|
||||
N_HELD=$((N_HELD + 1))
|
||||
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$BAD_WHY" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 代入點展開:一個目標一行,寫進暫存檔。
|
||||
_targets="$TMPD/targets.$c_id"
|
||||
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
|
||||
_hold=''
|
||||
# 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。
|
||||
_repovals="$REPOS"
|
||||
case "$c_action" in
|
||||
*'{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
|
||||
if [ -z "$_hold" ]; then
|
||||
# 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {repo} 與 {cli} 時,目標數是兩邊
|
||||
# 的乘積——三個存取庫乘五支 CLI 就是十五個目標,一個目標跑一次。
|
||||
printf '%s\n' "$c_action" >"$TMPD/x0.$c_id"
|
||||
expand_over '{repo}' "$_repovals" "$TMPD/x0.$c_id" "$TMPD/x1.$c_id" \
|
||||
|| die 5 "暫存檔寫不進去:$TMPD/x1.$c_id。"
|
||||
expand_over '{cli}' "$CLIS" "$TMPD/x1.$c_id" "$_targets" \
|
||||
|| die 5 "暫存檔寫不進去:$_targets。"
|
||||
# 代入之後再驗一次禁止字元。種入那一支與上面的 cmd_shape_ok 驗的是還沒代入的字面,
|
||||
# 代進去的值是這一輪現場算出來的——路徑與 CLI 代號都有可能帶進新的字元,而帶進來的
|
||||
# 那一刻就是送進殼的前一刻。
|
||||
if grep -q '[$`;|&~]' "$_targets" 2>/dev/null; then
|
||||
_hold='代入之後出現金錢符號、反引號、分號、管線、連接符號或波浪號,代入來源有問題'
|
||||
: >"$_targets"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$_hold" ]; then
|
||||
N_HELD=$((N_HELD + 1))
|
||||
printf 'held=%s spec=%s reason=%s action=%s\n' "$c_id" "$_spec" "$_hold" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 代完之後還留著大括號就是還有認不得的代入點。原樣送進殼會跑到一個沒有人寫過的地方。
|
||||
if grep -q '[{}]' "$_targets" 2>/dev/null; then
|
||||
N_HELD=$((N_HELD + 1))
|
||||
printf 'held=%s spec=%s reason=代完之後還留著大括號,認不得的代入點 action=%s\n' \
|
||||
"$c_id" "$_spec" "$c_action"
|
||||
continue
|
||||
fi
|
||||
|
||||
_n=$(awk 'END{print NR+0}' "$_targets")
|
||||
if [ "$DRYRUN" -eq 1 ]; then
|
||||
N_RUN=$((N_RUN + 1))
|
||||
printf 'would_run=%s spec=%s targets=%s\n' "$c_id" "$_spec" "$_n"
|
||||
while IFS= read -r _t; do printf ' target=%s\n' "$_t"; done <"$_targets"
|
||||
continue
|
||||
fi
|
||||
|
||||
# 真的跑。一個目標一次,全部成功這一筆才算成功——一支 CLI 接線壞掉就是一筆要人看的發現。
|
||||
N_RUN=$((N_RUN + 1))
|
||||
_entry_rc=0
|
||||
_first_err=''
|
||||
while IFS= read -r _t; do
|
||||
[ -n "$_t" ] || continue
|
||||
_out=$(sh -c "$_t" </dev/null 2>&1); _rc=$?
|
||||
if [ "$_rc" -eq 0 ]; then
|
||||
printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t"
|
||||
else
|
||||
_entry_rc=1
|
||||
[ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | clip 160)
|
||||
printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \
|
||||
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | clip 160)"
|
||||
fi
|
||||
done <"$_targets"
|
||||
|
||||
# 回寫。下一次什麼時候到期由判定那一支算,這一支不自己算——兩邊各算一次就會漂移。
|
||||
if [ "$_entry_rc" -eq 0 ]; then
|
||||
_next=''
|
||||
if [ -n "$DUE_SH" ]; then
|
||||
_next=$("$DUE_SH" next --trigger "$c_trigger" --recur "$c_recur" \
|
||||
--last-run "$NOW_ISO" --now "$NOW" 2>/dev/null | sed -n 's/^next_run=//p' | head -n1)
|
||||
fi
|
||||
if [ -n "$_next" ]; then
|
||||
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" --next-run "$_next" >/dev/null 2>&1
|
||||
else
|
||||
"$TASKS_SH" done "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1
|
||||
fi
|
||||
_wrc=$?
|
||||
if [ "$_wrc" -eq 0 ]; then
|
||||
N_OK=$((N_OK + 1))
|
||||
printf 'done=%s spec=%s targets=%s next_run=%s\n' "$c_id" "$_spec" "$_n" "${_next:--}"
|
||||
else
|
||||
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
|
||||
printf 'write_failed=%s spec=%s op=done rc=%s\n' "$c_id" "$_spec" "$_wrc"
|
||||
fi
|
||||
else
|
||||
RC_CMD=1
|
||||
"$TASKS_SH" fail "$c_id" --last-run "$NOW_ISO" >/dev/null 2>&1; _wrc=$?
|
||||
if [ "$_wrc" -eq 0 ]; then
|
||||
N_FAIL=$((N_FAIL + 1))
|
||||
printf 'failed=%s spec=%s targets=%s first_error=%s\n' "$c_id" "$_spec" "$_n" "${_first_err:--}"
|
||||
else
|
||||
N_WRITE_BAD=$((N_WRITE_BAD + 1)); RC_WRITE=1
|
||||
printf 'write_failed=%s spec=%s op=fail rc=%s\n' "$c_id" "$_spec" "$_wrc"
|
||||
fi
|
||||
fi
|
||||
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 repos=%s repo_scan=%s\n' \
|
||||
"$([ "$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_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_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。"
|
||||
[ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。"
|
||||
[ "$RC_WRITE" -ne 0 ] && warn "有 $N_WRITE_BAD 筆的回寫失敗。指令跑過了,但待辦簿沒記到,下一輪會再跑一次同一筆,逐筆印在上面的 write_failed= 那幾行,各自帶了 tasks.sh 的結束碼。"
|
||||
[ "$RC_CMD" -ne 0 ] && warn "有 $N_FAIL 筆的指令回非零,已經記成失敗、失敗次數加一。逐筆印在上面的 failed= 與 target_fail= 那幾行。"
|
||||
|
||||
[ "$RC_WRITE" -eq 0 ] || exit 4
|
||||
[ "$RC_CMD" -eq 0 ] || exit 1
|
||||
exit 0
|
||||
Executable
+299
@@ -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
|
||||
+126
-3
@@ -125,6 +125,9 @@
|
||||
# JSC_WIKI_REPO_{TYPE} 各頁型的 wiki 存取庫。已設定的全部快照進條目。名單是當下從
|
||||
# 環境撈出來的,不寫死,所以新增頁型自動涵蓋,這支不必跟著改。
|
||||
# 目錄頁那一支專用變數也在裡面
|
||||
# JSC_ASSIST_SCAN_ROOT 存取庫掃描的起點。install 當下快照進條目;沒有它,帶 {repo}
|
||||
# 代入點的內建項每一輪都代不出目標
|
||||
# JSC_ASSIST_SCAN_EXCLUDE 存取庫掃描的排除清單。install 當下快照進條目
|
||||
set -u
|
||||
|
||||
MARK_PREFIX='# jsc-assist:assistant'
|
||||
@@ -133,6 +136,7 @@ STATE_DIR="$JSC_HOME/assistant"
|
||||
CURRENT="$JSC_HOME/current"
|
||||
LOG="$STATE_DIR/schedule.log"
|
||||
CRONTAB_CMD="${JSC_ASSIST_CRONTAB_CMD:-crontab}"
|
||||
CRON_LINE_MAX="${JSC_ASSIST_CRON_LINE_MAX:-1000}"
|
||||
TASK_PREFIX='jsc-assist-assistant'
|
||||
|
||||
DRYRUN=0
|
||||
@@ -336,7 +340,20 @@ patrol_command() {
|
||||
# 不寫死是刻意的:頁型會增加,目錄頁後來也分了自己的存取庫變數,寫死清單就要跟著改一次,
|
||||
# 而漏掉的那一個在排程那一輪是空值,wiki 寫不成、心跳不寫,外面只看得到心跳過期。
|
||||
snapshot_names() {
|
||||
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
|
||||
# PATH 也要快照。cron 給的 PATH 只有 /usr/bin 與 /bin,而這台機器的 CLI 執行檔多半裝在
|
||||
# 家目錄底下,所以那一輪偵測 CLI 會一支都找不到——而且是**回結束碼 0、印出空清單**,
|
||||
# 不是失敗。看起來像「這台機器沒裝 CLI」,實際上是 PATH 上找不到。
|
||||
# 2026-09-04 實測踩到:無人值守那一輪 clis=0,帶 {cli} 代入點的內建項全部代不出目標,
|
||||
# 而同一支腳本在有人的 shell 裡回五支。
|
||||
# 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己
|
||||
# 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份
|
||||
# 路徑清單,而清單會過期。
|
||||
# 掃描起點與排除清單也要快照。存取庫掃描那一支刻意不猜掃描起點,沒有值就整個代入點
|
||||
# 代不出來,帶 {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 \
|
||||
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
|
||||
| sort -u
|
||||
@@ -346,6 +363,56 @@ snapshot_names() {
|
||||
# 整條 cron 指令切斷。
|
||||
shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
|
||||
# 條目要帶的 PATH。不是整條照抄——這台機器實測 2160 字元,去重又去掉不存在的那幾段之後
|
||||
# 還有 1332 字元,而 crontab 一行有長度上限,照抄會直接回「command too long」而裝不起來。
|
||||
# 實測踩過:加上整條 PATH 之後 install 回 4,錯誤訊息是 crontab 自己吐的。
|
||||
#
|
||||
# 只帶放得下 CLI 執行檔的那幾個目錄,這台機器實測是兩個、合起來 70 字元。這樣夠用的理由是
|
||||
# 這一條要解決的問題很窄:cron 只給 /usr/bin 與 /bin,而偵測 CLI 那一支靠 PATH 找執行檔,
|
||||
# 找不到就回結束碼 0 加一份空清單——看起來像「這台機器沒裝 CLI」。巡檢要跑的指令本身走的是
|
||||
# 字面絕對路徑,不靠這一條。
|
||||
#
|
||||
# 一個代價要講白:有些 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),那種路徑
|
||||
# 下一次開機就不在了。這裡不特別過濾——過濾要認得每一種安裝方式,那份名單會過期得比路徑
|
||||
# 還快。重跑一次 install 就會重新拍一份,而條目本來就是安裝當下的快照。
|
||||
cli_path() {
|
||||
_dirs=''
|
||||
for _c in claude codex copilot agy kiro-cli; do
|
||||
_abs=$(command -v "$_c" 2>/dev/null) || continue
|
||||
case "$_abs" in /*) ;; *) continue ;; esac
|
||||
_d=$(dirname -- "$_abs")
|
||||
case ":$_dirs:" in
|
||||
*":$_d:"*) ;;
|
||||
*) _dirs="${_dirs:+$_dirs:}$_d" ;;
|
||||
esac
|
||||
done
|
||||
# 收尾接上 cron 本來就會給的那兩段,免得只剩自訂目錄而找不到系統工具。
|
||||
printf '%s' "${_dirs:+$_dirs:}/usr/bin:/bin"
|
||||
}
|
||||
|
||||
# 條目要記下安裝當下偵測到哪幾支 CLI,作為往後每一輪的參照基準。
|
||||
#
|
||||
# 為什麼需要這個基準:偵測那一支純看 PATH,分不出「裝了但執行檔不在 PATH 上」。而條目帶的
|
||||
# PATH 是安裝當下拍的,其中某些 CLI 裝在會過期的目錄底下——這台機器實測就有一支裝在帶行程
|
||||
# 編號的多殼層目錄,重開機之後那條路徑就不在了。
|
||||
# 少了基準的後果不是報錯,是安靜地少做一支:那一輪照樣把偵測到的每一支都跑過、照樣全部
|
||||
# 成功、照樣回報「都跑過了」,因為它不知道該有幾支。實測那一輪報「對 4 支 CLI 都跑過,
|
||||
# 全部成功」,而機器上裝了 5 支。
|
||||
# 記下來之後,讀的那一邊才有東西可比,也才答得出「該不該重跑一次 install」。
|
||||
# 判法照 cli_path() 那一套:逐支用 command -v 試,不叫外部腳本。兩個理由——這一支在 install
|
||||
# 那一刻本來就已經用同一套判過一次(為了組 PATH),再叫一支等於同一件事兩套實作;而且那一支
|
||||
# 只看 PATH,跟這裡拿得到的資訊一樣多,多繞一層沒有換到任何東西。
|
||||
cli_codes() {
|
||||
_codes=''
|
||||
for _pair in claude:claude codex:codex copilot:copilot antigravity:agy kiro:kiro-cli; do
|
||||
_code=${_pair%%:*}; _bin=${_pair#*:}
|
||||
_abs=$(command -v "$_bin" 2>/dev/null) || continue
|
||||
case "$_abs" in /*) ;; *) continue ;; esac
|
||||
_codes="${_codes:+$_codes }$_code"
|
||||
done
|
||||
printf '%s' "$_codes"
|
||||
}
|
||||
|
||||
# 條目的指令前綴:固定三個旗標,加上這一刻的環境快照。沒設定的變數直接跳過,不寫空值——
|
||||
# 寫 NAME='' 進去,讀的人分不出是「刻意設成空」還是「安裝時忘了設」。
|
||||
# JSC_GITEA_CONFIRM=yes 是必要的:寫入確認只認 tty,排程那一輪沒有 tty,不帶這個旗標監控頁
|
||||
@@ -357,7 +424,7 @@ shq() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"; }
|
||||
# 中止的唯一證據」。不設這個變數,兩半就都落在 CLI 自己給的代號上。
|
||||
# 2026-09-04 實測:一台機器累積了 91 筆假中止,其中 90 筆是同一支被每輪叫用的技能。
|
||||
env_prefix() { # 順便把快照到的變數名稱寫進 $1,供輸出列出名稱(只有名稱,不含值)
|
||||
_p='JSC_CLI=cron JSC_GITEA_CONFIRM=yes'
|
||||
_p="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
|
||||
_names=''
|
||||
for _n in $(snapshot_names); do
|
||||
eval "_v=\${$_n:-}"
|
||||
@@ -459,7 +526,7 @@ trap 'rm -rf "$TMPD"' EXIT
|
||||
# 巡檢指令與環境快照在這裡就解出來。放進 cron_entry 再解的話,那支是在命令替換的子行程裡
|
||||
# 跑,判不出 CLI 時 die 只結束子行程,主流程會帶著空指令繼續往下裝。
|
||||
PATROL_RESOLVED=''
|
||||
ENV_PREFIX='JSC_CLI=cron JSC_GITEA_CONFIRM=yes'
|
||||
ENV_PREFIX="PATH=$(shq "$(cli_path)") JSC_ASSIST_CLIS=$(shq "$(cli_codes)") JSC_CLI=cron JSC_GITEA_CONFIRM=yes"
|
||||
SNAPSHOT_NAMES=''
|
||||
case "$CMD:$JOBS" in
|
||||
install:*patrol*)
|
||||
@@ -498,8 +565,12 @@ print_allow_rules() {
|
||||
# 代跑(那是已放行指令的子行程,不會再問一次),但收尾那一筆 skill-end 是技能自己用 Bash 叫的,
|
||||
# 那一次就要這一條規則。少了它,那一輪會停在最後一步的權限詢問,而排程那一輪沒有人可以按
|
||||
# 同意:那一輪的收尾事件寫不出去,start 永遠配不到 end,下一輪就把一輪其實做完的巡檢報成中止。
|
||||
# run-due.sh 是巡檢那一輪真的執行到期內建項的那一支,技能自己用 Bash 叫它。少了這一條,
|
||||
# 那一輪會停在那一步的權限詢問,而鎖已經握在手上:下一輪跟著讓開,一直到鎖逾時為止。
|
||||
# 它自己跑的那幾支盤點指令是它的子行程,不會再問一次,所以只要放行它自己這一條。
|
||||
for _s in "$ROOT/jsc-assist/tools/schedule.sh" \
|
||||
"$ROOT/jsc-assist/tools/patrol.sh" \
|
||||
"$ROOT/jsc-assist/tools/run-due.sh" \
|
||||
"$ROOT/jsc-hooks/hooks/heartbeat.sh" \
|
||||
"$ROOT/jsc-hooks/tools/report-status.sh" \
|
||||
"$ROOT/jsc-gitea/tools/gitea.sh" \
|
||||
@@ -508,12 +579,42 @@ print_allow_rules() {
|
||||
printf 'allow_rule=Bash(%s:*)\n' "$_s"
|
||||
printf 'allow_rule=Bash(sh %s:*)\n' "$_s"
|
||||
printf 'allow_rule=Bash(bash %s:*)\n' "$_s"
|
||||
_printed="${_printed:-} ${_s#"$ROOT/"}"
|
||||
done
|
||||
# 檔案規則寫的是解出來的絕對路徑,不是 $JSC_HOME/assistant:設定檔不展開變數,寫變數名
|
||||
# 等於這條規則永遠比對不到。
|
||||
printf 'allow_rule=Read(%s/**)\n' "$STATE_DIR"
|
||||
printf 'allow_rule=Edit(%s/**)\n' "$STATE_DIR"
|
||||
printf 'allow_rule=Skill(jsc-gitea:wiki)\n'
|
||||
|
||||
# 上面那份清單是寫死的,而技能本文哪天多叫一支腳本,這裡不會自己知道。漏一支的代價不是
|
||||
# 報錯,是那一輪停在權限詢問、鎖握在手上,接下來每一輪都讓開到鎖逾時為止——查起來只看得到
|
||||
# 「心跳過期」。所以這裡回頭掃一次技能本文,把它叫的每一支腳本跟上面印過的比對。
|
||||
# 只警告不中止:本文的寫法可能變,比對規則跟不上時不該連排程都裝不起來。
|
||||
# 目錄名以 jsc-assist 為準,找不到才退回不帶前綴的 assist:安裝版面是前者,開發用的並排
|
||||
# 工作目錄版面是後者。只認一種的話,在另一種版面底下這道檢核會被 [ -f ] 靜靜跳過——
|
||||
# 一道「找不到就當通過」的檢核,跟沒有那道檢核是同一件事。
|
||||
_skill=''
|
||||
for _cand in "$ROOT/jsc-assist/skills/assistant/SKILL.md" \
|
||||
"$ROOT/assist/skills/assistant/SKILL.md"; do
|
||||
[ -f "$_cand" ] && { _skill="$_cand"; break; }
|
||||
done
|
||||
if [ -z "$_skill" ]; then
|
||||
note "找不到技能本文,這一次沒有比對「本文叫得到的腳本有沒有全部放行」。找過 $ROOT/jsc-assist 與 $ROOT/assist 兩處。規則清單照樣印在上面,但少一支就是那一輪停在權限詢問,請自己核對一次。"
|
||||
else
|
||||
_missing=''
|
||||
# 只掃 patrol 那一節。規則防的是無人值守那一輪,而 start 與 status 兩個操作都有人在現場,
|
||||
# 它們叫的腳本跳一次權限詢問有人按得掉。整份掃的話那幾支會每次都被報成缺規則,而一份
|
||||
# 每次都有雜訊的警告,看的人第二次就會跳過它——那時候真的漏一支就沒有人看得到了。
|
||||
for _w in $(awk '/^## patrol$/{f=1;next} f && /^## /{exit} f' "$_skill" \
|
||||
| sed -n 's|.*{CURRENT}/\(jsc-[a-z-]*/[a-z-]*/[a-z-]*\.sh\).*|\1|p' | sort -u); do
|
||||
case " $_printed " in
|
||||
*" $_w "*) ;;
|
||||
*) _missing="${_missing:+$_missing、}$_w" ;;
|
||||
esac
|
||||
done
|
||||
[ -z "$_missing" ] || note "技能本文叫得到、但上面的規則沒有涵蓋的腳本:$_missing。那一輪叫到它就會停在權限詢問,而鎖已經握在手上,接下來每一輪都會讓開到鎖逾時。請把這幾支補進這一支腳本的規則清單,再重跑一次 install。"
|
||||
fi
|
||||
}
|
||||
|
||||
# 巡檢那一輪只用 $JSC_HOME/current 那一組連結叫工具,連結不在就是那一輪一定失敗。這裡只查
|
||||
@@ -616,6 +717,28 @@ crontab_install() {
|
||||
printf '\n' >>"$_new"
|
||||
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
|
||||
for _job in $JOBS; do
|
||||
printf 'dryrun=crontab job=%s entry=%s\n' "$_job" "$(cron_entry "$_job" | mask_secret)"
|
||||
|
||||
Reference in New Issue
Block a user