存取庫掃描接上 {repo} 代入點 #41
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills",
|
||||
"jsc": {
|
||||
|
||||
@@ -43,7 +43,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
|
||||
|
||||
| 檔案 | 用途 |
|
||||
| --- | --- |
|
||||
| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL` 與已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`。`JSC_WIKI_REPO` 系列含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`:監控頁 `MONITOR_{HASH}` 與目錄頁 `MONITOR_CONTENTS` 分屬不同存取庫,兩支變數都要帶。名單是安裝當下從環境撈出所有已設定的,不寫死,所以新增的頁型變數自動涵蓋,這支不必跟著改——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。安裝當下把解好的字面根目錄寫進條目的提示文字(`工具根目錄={絕對路徑}`)並印成 `patrol_root=`:那一輪自己解不出根目錄,只能從提示文字拿,拿不到就停下回報;自訂巡檢指令沒帶這一段只警告、不中止。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動 |
|
||||
| `tools/schedule.sh` | 助理系統排程的安裝、移除與查現況。三個子命令 `install`、`remove`、`status`,只裝 `patrol` 這一筆——心跳由巡檢自己寫,`install heartbeat` 一律回 6,舊版遺留的心跳條目由 `install patrol` 順手清掉。巡檢週期由心跳的過期門檻算出來(`2 × 週期 × 60 < 門檻`,再取能整除一小時的分鐘數):門檻 300 秒是每 2 分鐘一輪,門檻 1800 秒是每 12 分鐘一輪。Linux、WSL 與 macOS 走 crontab,Windows 走 schtasks。條目行尾帶固定標記 `# jsc-assist:assistant {工作}`,只動自己那一筆,別人的排程一行都不碰。條目自己把環境帶齊:CLI 用 `command -v` 解成絕對路徑、安裝當下把 `GITEA_HOST`、`GITEA_TOKEN`、`JSC_HOME`、`JSC_ASSISTANT_HEARTBEAT_TTL`、存取庫掃描的 `JSC_ASSIST_SCAN_ROOT` 與 `JSC_ASSIST_SCAN_EXCLUDE`,以及已設定的 `JSC_WIKI_REPO` 系列快照進條目、自帶 `JSC_GITEA_CONFIRM=yes`。`JSC_WIKI_REPO` 系列含內容頁的 `JSC_WIKI_REPO_MONITOR` 與目錄頁的 `JSC_WIKI_REPO_CONTENTS`:監控頁 `MONITOR_{HASH}` 與目錄頁 `MONITOR_CONTENTS` 分屬不同存取庫,兩支變數都要帶。名單是安裝當下從環境撈出所有已設定的,不寫死,所以新增的頁型變數自動涵蓋,這支不必跟著改——cron 的 PATH 很短、不讀設定檔、也沒有 tty。印出條目時金鑰一律遮掉,條目本身含金鑰快照,crontab 檔案要保持只有本人讀得到,變數改過要重跑一次 install。安裝當下把解好的字面根目錄寫進條目的提示文字(`工具根目錄={絕對路徑}`)並印成 `patrol_root=`:那一輪自己解不出根目錄,只能從提示文字拿,拿不到就停下回報;自訂巡檢指令沒帶這一段只警告、不中止。裝完會檢查排程服務在不在跑,沒跑就回 1——WSL 預設不啟動 cron;也會檢查 `$JSC_HOME/current` 那組連結在不在、印出這一輪要開的 allow 規則,連結不在只警告、不代建。`--dry-run` 只印組出來的條目與寫回後的內容,什麼都不動。條目長度兩個模式都量,印成 `entry_len=`:cron 一行有長度上限,超過就整批寫不進去,而那個限制是 `crontab` 自己在寫入那一刻才擋,`--dry-run` 那一路根本不碰它——實測踩過一次,預演全綠、安裝回「command too long」 |
|
||||
| `tools/patrol.sh` | 一輪巡檢的收攏與收口。三個子命令:`collect` 取鎖、讀五項來源、組出監控頁的「最新一輪」與「近 24 輪摘要」兩塊、本輪的摘要列與目錄頁那一個區塊(區塊的 H2 標題是內容頁頁名 `MONITOR_{HASH}`,upsert 拿標題當鍵;「監控頁」那一條是連結,網址留佔位,等監控頁寫成之後由呼叫端用 `gitea.sh wiki-url` 的絕對網址換掉);`finish` 在監控頁寫成之後才寫心跳、換上用量快照、放掉鎖;`abort` 只放掉鎖,不寫心跳。各項來源各自獨立,一項失敗其餘各項照跑,失敗那一項在頁上寫明是「這一項失敗」而不是沒資料。執行狀態事件那一項由 `collect` 自己叫 `jsc-hooks/tools/report-status.sh` 排空再輪替,把非 ok 的事件與「有 start 沒有配對 end」的技能彙整成頁上那一節;`drain` 是消耗性讀取,所以只由這支跑,且它失敗一律不中止那一輪。整輪拿一把目錄鎖,上一輪還在跑就回 4 讓開;鎖逾時(門檻取心跳門檻)會被下一輪搶回來,並在頁上記一筆。`version-guard.sh report` 回「查詢失敗」時照原字抄,不補查、不美化 |
|
||||
| `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 才寫;但那一條含主機位址與網址編碼,會變,所以不當鍵 |
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-assist",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_{HASH} wiki 頁)",
|
||||
"skills": "./skills/",
|
||||
"jsc": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -76,6 +76,7 @@ Every tool below is addressed through `{CURRENT}/{plugin}`, with `{CURRENT}` sta
|
||||
| the task book, the only writer there is | `{CURRENT}/jsc-assist/tools/tasks.sh` |
|
||||
| the 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` |
|
||||
@@ -185,7 +186,7 @@ Four properties of that script matter enough to state here, because a report tha
|
||||
- **A written entry is not a running entry.** WSL does not start cron by default, and this is the machine's most likely state. Exit 1 from `install` means the entry is on disk and will never fire. Report that as a failure of the start, name `sudo service cron start`, and say it has to be run again after every WSL restart. Never soften exit 1 into "scheduling is set up".
|
||||
- **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.
|
||||
|
||||
@@ -263,7 +264,7 @@ The delegation list holds one row per jsc skill and records whether that skill c
|
||||
|
||||
**A `probe` that cannot be substituted falls back to `remind` and is reported, never dropped.** The path is not `{root}/jsc-{domain}/...`, the command carries a `$` or a `~`, a brace other than the three known holes survived, the root could not be resolved, or the script is not on this machine: each prints `probe_bad=` and the entry is still seeded, as a reminder. Not seeding it would mean removing it on `apply`, so one mistyped cell upstream would delete an entry carrying its own `last_run` and `fail_count` history.
|
||||
|
||||
**`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** `run-due.sh` is what executes one, and it is the only thing that does: `tasks.sh` stores the value, `due.sh` prints it, `status` and the monitor page print it. That tool substitutes `{cli}` from `jsc-cli/tools/detect-clis.sh` and runs once per target, with the targets' results together counting as the entry's one success or failure. `{repo}` has no source yet — the repository scan is not built — so an entry carrying that hole is held, reported, and left with its `last_run` and `fail_count` untouched. **Holding is not failing**: an entry that cannot be given a target did nothing wrong, and recording it as a failure grows a counter nobody can bring down by fixing that entry, which is exactly what that counter exists to make visible.
|
||||
**`{root}` is substituted here; `{cli}` and `{repo}` are deliberately left in the value.** The CLI list has to be detected and the repositories have to be scanned, and neither is known at seeding time. Expanding into several entries instead would give one `spec_key` several files — the reconcile treats that as `dup=` and refuses to touch any of them — and would go stale the moment a CLI is installed or a repository cloned, with re-expansion costing the history it just protected. So the hole stays, and one rule pays for it: **an `action` containing a brace is not yet a runnable command and must never be executed as written.** `run-due.sh` is what executes one, and it is the only thing that does: `tasks.sh` stores the value, `due.sh` prints it, `status` and the monitor page print it. That tool substitutes `{cli}` from `jsc-cli/tools/detect-clis.sh` and `{repo}` from `jsc-assist/tools/scan-repos.sh`, then runs once per target, with the targets' results together counting as the entry's one success or failure. An entry carrying both holes runs once per pair — three repositories and five CLIs is fifteen targets. An entry whose `repo=` field names a repository is given that one repository only, matched against the scan by working directory or by `{owner}/{repo}`; a name the scan did not find is held rather than widened back to every repository, because that entry asked for one target. **The scan refuses to guess its starting point**: without `JSC_ASSIST_SCAN_ROOT` it reports that and nothing else, because guessing one layer too high turns the working directory into the home directory and then into the whole machine, and the commands would run there with nobody watching. So an entry carrying `{repo}` on a machine where that variable never reached the scheduled entry is held with the scan's own words as the reason — that is what tells a person which variable to set and that a fresh `schedule.sh install patrol` is what carries it into the entry. **Holding is not failing**: an entry that cannot be given a target did nothing wrong, and recording it as a failure grows a counter nobody can bring down by fixing that entry, which is exactly what that counter exists to make visible.
|
||||
|
||||
**A `pending` row stays a reminder but is never reported as an ordinary one.** The reason lives in the report, as a `pending=` line, and nothing is written into the task file. Putting the marker in the title would change the `id` and cut that entry's history, and the drift check compares `kind`, `action`, `trigger` and `recur` but not the title, so a stale marker would never be caught; adding a sixteenth key would change the task book's fixed storage format for a piece of upstream prose the task book has no way to edit later. `status` runs `plan`, so `pending=`, `held=` and `drift=` all reach a human in the same place.
|
||||
|
||||
@@ -329,7 +330,7 @@ That property holds only while nothing fakes a heartbeat. **`write` is called by
|
||||
|
||||
3. **Confirm the heartbeat.** Run `{CURRENT}/jsc-hooks/hooks/heartbeat.sh report` and read its `state=`, `ts=`, `ttl=`, `pid=`, `cli=`, `session=` and `file=` fields. `state=fresh` is the expected result. Any other state right after a successful round means something rewrote or removed the file in between: report the state, the path and that the heartbeat did not survive its own write, and do not claim a started assistant. Completion condition: the report line was read and either `state=fresh` was recorded with its seven fields, or the mismatch was reported.
|
||||
|
||||
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.
|
||||
|
||||
@@ -399,7 +400,7 @@ One round: read five sources, record the result, then beat. Everything before th
|
||||
|
||||
Completion condition: `link-check.sh` exited 0 over the block's URL and the script exited 0 with exactly one `## MONITOR_{HASH}` block on the page carrying this round's values, or exit 3 from the upsert or a non-zero `link-check.sh` was reported as an unwritten directory entry and the round carried on, or one of the other non-zero codes — `wiki-url`'s included — was reported after the abort ran.
|
||||
|
||||
5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was.
|
||||
5. **Run the built-in check items that are due.** Run `{CURRENT}/jsc-assist/tools/run-due.sh run --root {CURRENT} --rows {the `due_rows_file=` step 1 printed}`. **That path is not optional and there is no default.** The judging step writes its output into the directory whoever called it chose, so a default would point somewhere else — and it did: the tool shipped with one, and every round read a file a person had left behind by running the judge by hand. That file sat on this machine for 67 hours while each round acted on it, one round even running an entry that had already been removed. Nothing looked wrong, because the stale list had been correct when it was written and its contents happened not to change. Passing the path makes each round say which round's data it is acting on; the tool also refuses a list older than the heartbeat TTL, because a list older than that cannot describe this round. This is the one step of the round that changes something outside the round's own files, and it is deliberately narrow: it runs only the entries whose `action` is a command and whose `spec_key` is set, so a reminder, a skill name and anything a person entered by hand are all left alone. Judge the exit code by the run-due.sh table, and keep every `done=`, `failed=`, `held=`, `skip=`, `write_failed=`, `target_ok=` and `target_fail=` line plus the summary counts for the report. **`cli_missing=` is the one to read carefully.** It names the CLIs the scheduled entry recorded at install time that this round could not detect, and it is the only place that gap shows: every target that was detected still ran, still passed, and the round still reports "all of them ran", because without that comparison nothing knows how many there should have been. One round reported four CLIs all passing on a machine with five installed. The usual cause is a CLI whose executable sits in a directory that expires — one here lives under a process-numbered multishell path — while the entry's `PATH` was snapshotted at install time. Report the named CLIs, say that a clean pass is not the same as a full pass, and name re-running the schedule install as what refreshes the snapshot. **`repos=` and `repo_scan=` are the same kind of reading for `{repo}`.** `repos=0` with `repo_scan=rc3` means the scan had no starting point, so every entry carrying `{repo}` was held this round — report the variable to set, not "there is nothing to inventory". `repo_scan=rc1` means the scan handed over what it could and named the rest: each `repo_scan_note=` line is a repository that exists on this machine and was **not** inventoried this round, either because its path carries a space or a shell metacharacter or because it has no `origin` to compute a `REPO_{HASH}` page name from. Those lines go into the report as findings; a round that prints only the count reads as a full sweep. **No exit code from this step stops the round.** Exit 1 means an entry's command failed and that entry now carries one more failure — that is a finding, not a broken round; exit 4 means a write-back failed, so the same entry will run again next round, which is worth saying out loud; exit 2, 5 and 6 mean nothing ran, and the round still has a result to record. Completion condition: the exit code and the summary counts are recorded, and step 6 was reached whatever that code was.
|
||||
|
||||
6. **Write the heartbeat.** Run `{CURRENT}/jsc-assist/tools/patrol.sh finish --round {round}`. This is the last step for a reason: it is the only thing that turns a fresh heartbeat into a true statement. Judge the exit code by the patrol.sh table — 2, 4 and 5 all mean the round is recorded but unproven, and each has its own report line there. Completion condition: `finish` exited 0, or the failure was reported as "recorded but no heartbeat" with its code.
|
||||
|
||||
|
||||
+122
-24
@@ -46,12 +46,16 @@
|
||||
#
|
||||
# --- 代不出目標不算失敗 ---
|
||||
#
|
||||
# {cli} 由偵測到的 CLI 代號代入,{repo} 由掃到的存取庫工作目錄代入。後者的掃描還沒做出來,
|
||||
# 所以帶 {repo} 的那幾筆這一輪代不出目標。處置是印一行 held= 就跳過,**不動那一筆的
|
||||
# last_run,也不加失敗次數**。
|
||||
# 那一筆沒有做錯任何事:代不出目標是這一支還缺一塊,記成失敗會讓一個沒有人修得動的計數
|
||||
# {cli} 由偵測到的 CLI 代號代入,{repo} 由 scan-repos.sh 掃到的存取庫工作目錄代入。
|
||||
# 兩個代入點都代得出來才跑;任一個代不出來就印一行 held= 跳過,**不動那一筆的 last_run,
|
||||
# 也不加失敗次數**。
|
||||
# 那一筆沒有做錯任何事:代不出目標是環境還缺一塊,記成失敗會讓一個沒有人修得動的計數
|
||||
# 一路往上爬,而那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。
|
||||
# 把「還沒接上」記成「壞掉」,等於用假的壞掉把真的壞掉蓋掉。
|
||||
#
|
||||
# {repo} 代不出來最常見的成因是 JSC_ASSIST_SCAN_ROOT 沒設。那不是預設值漏填,是掃描那一支
|
||||
# 刻意不猜:猜錯掃描起點的後果是在猜錯的那些目錄底下跑指令,而那一輪沒有人看得到它跑到
|
||||
# 哪裡去了。所以這裡照它回的話原樣記進 held= 的理由欄,人才看得出要去設哪一個變數。
|
||||
set -u
|
||||
|
||||
usage() {
|
||||
@@ -60,6 +64,16 @@ usage() {
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
@@ -186,9 +200,52 @@ if [ -n "${JSC_ASSIST_CLIS:-}" ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# {repo} 的代入來源還沒做出來。這裡不猜一個掃描規則頂替:猜錯就是在整台機器上跑指令,
|
||||
# 而那一輪沒有人看得到它跑到哪裡去了。
|
||||
REPO_READY=0
|
||||
# --- {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=\)/repo_scan_note=\1/p' \
|
||||
"$TMPD/scan.out" "$TMPD/scan.err" 2>/dev/null
|
||||
fi
|
||||
N_REPOS=$(awk 'END{print NR+0}' "$REPOS")
|
||||
if [ -z "$REPO_WHY" ] && [ "$N_REPOS" -eq 0 ]; then
|
||||
REPO_WHY='存取庫掃描一個都沒掃到,{repo} 代不出目標'
|
||||
fi
|
||||
|
||||
# 這一筆綁的存取庫對出一條掃到的路徑。綁的值可以是工作目錄路徑,也可以是 {owner}/{repo}——
|
||||
# 待辦簿的欄位說明只寫「這一筆綁哪一個存取庫」,兩種寫法都有人會填,所以兩種都認。
|
||||
# 對不出來一律回失敗,不退回「全部存取庫」:那一筆指名了一個目標,代成全部就是跑了
|
||||
# 十九個沒有人要它跑的地方。
|
||||
repo_path_of() { # $1=綁定值
|
||||
awk -F"$TAB" -v want="$1" '$1==want || $2==want {print $1; found=1; exit} END{exit !found}' "$REPOMAP"
|
||||
}
|
||||
|
||||
# --- 判斷動作是哪一種 ---
|
||||
|
||||
@@ -221,6 +278,25 @@ cmd_shape_ok() { # $1=指令
|
||||
return 0
|
||||
}
|
||||
|
||||
# 把一個代入點換成清單裡的每一個值,一個值一行。清單是空的又剛好命中代入點,那一行就
|
||||
# 整行消失——所以呼叫端要先確定清單非空,別靠這裡回錯:目標數變成 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
|
||||
@@ -272,23 +348,42 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
|
||||
_targets="$TMPD/targets.$c_id"
|
||||
: >"$_targets" 2>/dev/null || die 5 "暫存檔寫不進去:$_targets。"
|
||||
_hold=''
|
||||
# 沒綁存取庫的那一筆代成掃到的每一個;綁了的只代那一個。
|
||||
_repovals="$REPOS"
|
||||
case "$c_action" in
|
||||
*'{repo}'*)
|
||||
[ "$REPO_READY" -eq 1 ] || _hold='存取庫掃描還沒做出來,{repo} 代不出目標' ;;
|
||||
if [ -n "$REPO_WHY" ]; then
|
||||
_hold="$REPO_WHY"
|
||||
else
|
||||
_bind=$(sed -n 's/^repo=//p' "$JSC_HOME_RESOLVED/assistant/tasks/$c_id" 2>/dev/null | head -n1)
|
||||
if [ -n "$_bind" ]; then
|
||||
if _bp=$(repo_path_of "$_bind"); then
|
||||
_repovals="$TMPD/repoval.$c_id"
|
||||
printf '%s\n' "$_bp" >"$_repovals" 2>/dev/null || die 5 "暫存檔寫不進去:$_repovals。"
|
||||
else
|
||||
_hold="這一筆綁的存取庫「$_bind」這一輪沒掃到,代不出目標"
|
||||
fi
|
||||
fi
|
||||
fi ;;
|
||||
esac
|
||||
case "$c_action" in
|
||||
*'{cli}'*) [ "$N_CLI" -gt 0 ] || _hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標' ;;
|
||||
esac
|
||||
if [ -z "$_hold" ]; then
|
||||
case "$c_action" in
|
||||
*'{cli}'*)
|
||||
if [ "$N_CLI" -eq 0 ]; then
|
||||
_hold='這台機器偵測不到任何一支 CLI,{cli} 代不出目標'
|
||||
else
|
||||
while IFS= read -r _c; do
|
||||
[ -n "$_c" ] || continue
|
||||
printf '%s\n' "$(printf '%s' "$c_action" | sed "s|{cli}|$_c|g")" >>"$_targets"
|
||||
done <"$CLIS"
|
||||
fi ;;
|
||||
*) printf '%s\n' "$c_action" >>"$_targets" ;;
|
||||
esac
|
||||
# 兩個代入點分兩段代,一段一個暫存檔。同一筆同時帶 {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))
|
||||
@@ -323,9 +418,9 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
|
||||
printf 'target_ok=%s rc=0 cmd=%s\n' "$c_id" "$_t"
|
||||
else
|
||||
_entry_rc=1
|
||||
[ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | head -n1 | cut -c1-160)
|
||||
[ -n "$_first_err" ] || _first_err=$(printf '%s' "$_out" | clip 160)
|
||||
printf 'target_fail=%s rc=%s cmd=%s detail=%s\n' \
|
||||
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | head -n1 | cut -c1-160)"
|
||||
"$c_id" "$_rc" "$_t" "$(printf '%s' "$_out" | clip 160)"
|
||||
fi
|
||||
done <"$_targets"
|
||||
|
||||
@@ -362,10 +457,13 @@ while IFS="$TAB" read -r c_id c_verdict c_state c_kind c_action c_trigger c_recu
|
||||
fi
|
||||
done <"$ROWS"
|
||||
|
||||
printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s\n' \
|
||||
printf 'mode=%s rows=%s root=%s due=%s ran=%s ok=%s failed=%s held=%s skipped=%s write_failed=%s clis=%s cli_missing=%s repos=%s repo_scan=%s\n' \
|
||||
"$([ "$DRYRUN" -eq 1 ] && echo plan || echo run)" "$ROWS" "${ROOT:--}" \
|
||||
"$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}"
|
||||
"$N_DUE" "$N_RUN" "$N_OK" "$N_FAIL" "$N_HELD" "$N_SKIP" "$N_WRITE_BAD" "$N_CLI" "${CLI_MISSING:--}" \
|
||||
"$N_REPOS" "$REPO_SCAN"
|
||||
|
||||
[ -n "$REPO_WHY" ] && note "存取庫清單這一輪是空的:$REPO_WHY。帶 {repo} 代入點的那幾筆全部跳過,逐筆印在上面的 held= 那幾行。"
|
||||
[ "$REPO_SCAN" = 'rc1' ] && note "存取庫掃描回 1:掃到的存取庫裡有幾個對不出 REPO_{HASH} 盤點頁頁名,或路徑帶了空白與殼層特殊字元而被拒。交出來的那幾條照用,被拒的那幾個這一輪沒有被盤點到——理由逐個印在掃描那一支的 rejected= 與 unnamed= 那幾行。"
|
||||
[ -n "$CLI_MISSING" ] && warn "排程條目記著這台機器有 $JSC_ASSIST_CLIS,但這一輪只偵測到 $N_CLI 支,少了:$CLI_MISSING。帶 {cli} 代入點的那幾筆這一輪少跑了那幾支,而且**全部成功也不代表全部跑過**。成因通常是那支 CLI 裝在會過期的目錄底下(例如帶行程編號的多殼層目錄),條目帶的 PATH 是安裝當下拍的,那條路徑現在不在了。重跑一次 schedule.sh install patrol 會重新拍一份快照;那支 CLI 反覆消失就要考慮把它裝到穩定位置。"
|
||||
[ "$N_HELD" -gt 0 ] && note "有 $N_HELD 筆代不出目標或指令形狀不對,這一輪跳過,逐筆印在上面的 held= 那幾行。**那幾筆的執行紀錄與失敗次數一個字都沒動**——代不出目標不是那一筆做錯了什麼,記成失敗會讓一個沒有人修得動的計數一路往上爬。"
|
||||
[ "$N_SKIP" -gt 0 ] && note "有 $N_SKIP 筆這一批不跑:動作是只提醒的、動作是技能名的、還有不是內建項的,逐筆印在上面的 skip= 那幾行。"
|
||||
|
||||
Executable
+273
@@ -0,0 +1,273 @@
|
||||
#!/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=
|
||||
# 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
|
||||
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
|
||||
|
||||
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"
|
||||
|
||||
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
|
||||
if [ "$N_REJ" -gt 0 ] || [ "$N_UNNAMED" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
+29
-1
@@ -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
|
||||
@@ -344,7 +348,12 @@ snapshot_names() {
|
||||
# 巡檢那一輪要跑的指令本身走的是字面絕對路徑、不靠 PATH;靠 PATH 的是那幾支腳本自己
|
||||
# 呼叫的外部程式,所以修在條目這一層,不是逐支腳本各自去猜安裝路徑——猜就要維護一份
|
||||
# 路徑清單,而清單會過期。
|
||||
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL
|
||||
# 掃描起點與排除清單也要快照。存取庫掃描那一支刻意不猜掃描起點,沒有值就整個代入點
|
||||
# 代不出來,帶 {repo} 的內建項每一輪都會被跳過——而那一輪只會印一行 held=,看起來像
|
||||
# 「這一批還沒接上」,不像「有一個變數沒進條目」。排除清單少了同樣要命:殼裡排除掉的
|
||||
# 那幾個目錄,在排程那一輪會被當成要盤點的存取庫。
|
||||
printf '%s\n' GITEA_HOST GITEA_TOKEN JSC_HOME JSC_ASSISTANT_HEARTBEAT_TTL \
|
||||
JSC_ASSIST_SCAN_ROOT JSC_ASSIST_SCAN_EXCLUDE
|
||||
env 2>/dev/null \
|
||||
| sed -n 's/^\(JSC_WIKI_REPO\)=.*/\1/p; s/^\(JSC_WIKI_REPO_[A-Za-z0-9_]*\)=.*/\1/p' \
|
||||
| sort -u
|
||||
@@ -708,6 +717,25 @@ 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
|
||||
if [ "$_len" -ge $((CRON_LINE_MAX - 100)) ]; then
|
||||
warn "$_job 的條目有 $_len 個位元組,距離 cron 一行的上限 $CRON_LINE_MAX 不到 100。再多加一個環境變數快照就會裝不進去,而那一次的錯誤訊息只會說 command too long。"
|
||||
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