@codedology/code-forge 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +13 -0
  3. package/docs/README.md +1 -0
  4. package/docs/reference/blocks.json +96 -1
  5. package/docs/reference/config.md +2 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +7 -0
  8. package/skill/SKILL.md +1 -0
  9. package/skill/references/adapters/claude-code.md +14 -0
  10. package/skill/references/autopilot.md +48 -0
  11. package/skill/references/code.md +1 -1
  12. package/skill/references/decisions.md +2 -0
  13. package/skill/references/harden.md +1 -1
  14. package/skill/references/plan.md +2 -0
  15. package/skill/references/review.md +2 -2
  16. package/skill/scripts/forge +1 -1
  17. package/src/autopilot/actions.mjs +491 -0
  18. package/src/autopilot/binnacle.mjs +494 -0
  19. package/src/autopilot/delegate.mjs +422 -0
  20. package/src/autopilot/grant.mjs +354 -0
  21. package/src/autopilot/limits.mjs +847 -0
  22. package/src/autopilot/scopes.mjs +95 -0
  23. package/src/cli/author.mjs +1 -2
  24. package/src/cli/autopilot.mjs +500 -0
  25. package/src/cli/block.mjs +40 -8
  26. package/src/cli/facts.mjs +1 -2
  27. package/src/cli/jev.mjs +1 -2
  28. package/src/cli/logs.mjs +4 -142
  29. package/src/cli/plan.mjs +1 -2
  30. package/src/cli/run.mjs +18 -60
  31. package/src/cli/s2.mjs +1 -2
  32. package/src/cli/spawn.mjs +1 -2
  33. package/src/config/closed-book.mjs +20 -3
  34. package/src/config/load.mjs +15 -0
  35. package/src/doctor/probes.mjs +34 -11
  36. package/src/engines/builders/claude.mjs +3 -3
  37. package/src/engines/builders/codex.mjs +6 -5
  38. package/src/engines/builders/grok.mjs +3 -3
  39. package/src/engines/builders/validate-params.mjs +3 -3
  40. package/src/ledger/report.mjs +2 -0
  41. package/src/review/fixloop.mjs +69 -5
  42. package/src/review/gate-check.mjs +156 -6
  43. package/src/session/spawn.mjs +68 -8
  44. package/src/state/run.mjs +8 -1
  45. package/src/util/breadcrumbs.mjs +1 -0
  46. package/src/util/forbidden.mjs +10 -0
  47. package/src/util/gh.mjs +175 -0
  48. package/src/worker/engine.mjs +8 -4
  49. package/src/worker/loop.mjs +42 -0
@@ -0,0 +1,847 @@
1
+ /**
2
+ * Autopilot limits (issue #5, plan autopilot §1, block B48): the budget stop per category and the
3
+ * owner's expiring approvals.
4
+ *
5
+ * Budget per category. A grant's `--budget review=<usd>,coding=<usd>` caps and `--stop-at` ratio
6
+ * are metered at the single budget gate of `spawnSession` (after the provider slot, under the
7
+ * run's budget lock, like B41). A session's category comes from its role ({@link ROLE_CATEGORY}:
8
+ * coder → coding, every other role → review). While a grant is active and caps the category:
9
+ * spent in the category since the grant started + the running sessions' reservations in the
10
+ * category ≥ stop_at × cap ⇒ the session is refused with {@link pausedMessage}, the grant is
11
+ * paused for that category (`grant.paused[category]`) and ONE signed `autopilot.pause` row is
12
+ * written per category per grant. `grantFor` then answers `paused` for the category's scopes
13
+ * (`SCOPE_CATEGORY`). A paused category stays paused for the rest of the grant: the owner decides.
14
+ * No grant (or no cap for the category) ⇒ nothing changes.
15
+ *
16
+ * Expiring approvals. `autopilot approve --key <dot.path> --value <json> --until <time>` (the owner,
17
+ * at a terminal) writes the workspace `.code-forge.yml` and reloads the run through the same path
18
+ * as `run reload` ({@link reloadWorkspace} → `reloadRun`), records the old value in the run record
19
+ * and writes one signed `autopilot.approve` row (values redacted). {@link checkApprovals} is the
20
+ * lazy restore, run by every `autopilot` command, every `block` command and every worker drain
21
+ * ({@link expiryChecks}): an approval past its `until` gets its old value written back, a reload,
22
+ * and ONE signed `autopilot.restore` row — unless the workspace value was changed by hand since
23
+ * (it no longer equals the approved value): then nothing is overwritten and ONE signed
24
+ * `autopilot.restore_skipped` row leaves it to the owner. Fixed keys (`IMMUTABLE_KEYS`) are refused.
25
+ *
26
+ * Limit keys. While a grant is active, a reload that changes a limit key (`isLimitKey`: a key in
27
+ * `LIMIT_KEYS`, under one, or a parent of one) is refused — unless the change is exactly the one
28
+ * an approval (or its restore) makes: {@link limitGuard} gets that change as an internal `allow`,
29
+ * never from a user flag.
30
+ */
31
+
32
+ import { chmod, mkdir, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
33
+ import { readdirSync } from 'node:fs';
34
+ import { randomBytes } from 'node:crypto';
35
+ import path from 'node:path';
36
+ import { parseDocument } from 'yaml';
37
+ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, slugFor } from '../config/load.mjs';
38
+ import { loadSeenInCache } from '../config/refresh.mjs';
39
+ import { validateConfig } from '../config/validate.mjs';
40
+ import { hasCliOnPath } from '../cli/validate.mjs';
41
+ import { isSpendRow, reservedUsd, reserveBudget, roundUsd, rowUsd } from '../ledger/spend.mjs';
42
+ import { appendRow } from '../ledger/write.mjs';
43
+ import { canonicalJSON, changedKeyPaths, IMMUTABLE_KEYS } from '../state/config-snapshot.mjs';
44
+ import { assertRunId, runsDir, StateError } from '../state/paths.mjs';
45
+ import { readRun, reloadRun, saveRun, withRunLock, writeSigned } from '../state/run.mjs';
46
+ import { redact, writeSafe } from '../util/redact.mjs';
47
+ import { pendingTickets, queueDir } from '../worker/queue.mjs';
48
+ import { repoRootOf, WorkerError } from '../worker/ticket.mjs';
49
+ import { checkExpiry, grantState, parseUntil } from './grant.mjs';
50
+ import { BUDGET_CATEGORIES, isLimitKey } from './scopes.mjs';
51
+
52
+ /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
53
+ /** @typedef {import('./grant.mjs').Grant} Grant */
54
+
55
+ /* ------------------------------------------------------------------ budget per category -- */
56
+
57
+ /** The budget category of each session role (author and facts count as review). */
58
+ export const ROLE_CATEGORY = Object.freeze({ coder: 'coding', reviewer: 'review', judge: 'review', s2: 'review', delegate: 'review', author: 'review', facts: 'review' });
59
+
60
+ /** @param {unknown} role @returns {string | null} the role's category, null for a row without a known role. */
61
+ export const categoryOfRole = (role) => (typeof role === 'string' && Object.hasOwn(ROLE_CATEGORY, role) ? /** @type {Record<string, string>} */ (ROLE_CATEGORY)[role] : null);
62
+
63
+ /**
64
+ * The refusal text at the stop ratio.
65
+ * @param {string} category @param {number} spent - spent + reserved in the category.
66
+ * @param {number} cap @param {number} ratio
67
+ */
68
+ export const pausedMessage = (category, spent, cap, ratio) => `autopilot paused: ${category} spend ${spent.toFixed(2)} of ${cap.toFixed(2)} reached the stop at ${ratio}; waiting for the owner`;
69
+
70
+ /** The fail-closed refusal when a capped category's spend cannot be read. @param {string} category */
71
+ export const categoryUnreadableMessage = (category) => `autopilot: the ${category} spend cannot be read; not starting a session`;
72
+
73
+
74
+ /** The reservation key of a category (B41's reservation map, keyed apart from the run's own). @param {string} run @param {string} category */
75
+ export const categoryReservationKey = (run, category) => `autopilot:${category}:${run}`;
76
+
77
+ /**
78
+ * USD spent by `runId` in `category` since `since` (rows with an unknown price count 0 and are
79
+ * counted in `unknown`; a row without a readable `ts` counts — it cannot be dated, so it is not
80
+ * left out). Spend rows only (`isSpendRow`); a row whose role has no category is not counted.
81
+ * @param {Array<Record<string, any>>} rows @param {string} runId @param {string} category @param {string} since
82
+ * @returns {{usd: number, unknown: number}}
83
+ */
84
+ export function categorySpend(rows, runId, category, since) {
85
+ const from = Date.parse(since);
86
+ let usd = 0;
87
+ let unknown = 0;
88
+ for (const row of rows) {
89
+ if (!isSpendRow(row) || row.run !== runId || categoryOfRole(row.role) !== category) continue;
90
+ const t = typeof row.ts === 'string' ? Date.parse(row.ts) : Number.NaN;
91
+ if (Number.isFinite(t) && Number.isFinite(from) && t < from) continue;
92
+ const cost = rowUsd(row);
93
+ if (cost === null) unknown += 1;
94
+ else usd += cost;
95
+ }
96
+ return { usd: roundUsd(usd), unknown };
97
+ }
98
+
99
+ /**
100
+ * Spend per budget category since the grant started (for `autopilot status`).
101
+ * @param {Array<Record<string, any>>} rows @param {string} runId @param {Grant} grant
102
+ * @returns {Record<string, number>}
103
+ */
104
+ export function spendByCategory(rows, runId, grant) {
105
+ /** @type {Record<string, number>} */
106
+ const out = {};
107
+ for (const category of BUDGET_CATEGORIES) out[category] = categorySpend(rows, runId, category, grant.started_at).usd;
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * @typedef {{category: string, cap: number, stopAt: number, grantId: string, startedAt: string, project: string, paused: boolean, rowPending: boolean}} CategoryPlan
113
+ */
114
+
115
+ /** The refusal when the run record cannot be read: the autopilot budget is unknown. */
116
+ export const RECORD_UNKNOWN_MESSAGE = 'autopilot: the run record cannot be read, so the autopilot budget is unknown; not starting a session';
117
+
118
+ /**
119
+ * A known failure to read a run record: a `StateError`, a JSON parse error, or a file-system
120
+ * error (an `E…` code). Anything else (a TypeError, …) is a bug and propagates.
121
+ * @param {unknown} thrown @returns {boolean}
122
+ */
123
+ const isKnownReadFailure = (thrown) =>
124
+ thrown instanceof StateError || thrown instanceof SyntaxError || (thrown instanceof Error && typeof (/** @type {any} */ (thrown).code) === 'string' && /^E[A-Z]+$/.test(/** @type {any} */ (thrown).code));
125
+
126
+ /**
127
+ * What the spawn gate must meter for a session of `role` in `run` at `now`: null when the run has
128
+ * no record, no grant, an inactive grant (or run), or no cap for the role's category.
129
+ * `spawnSession` reads it once outside its budget lock (to decide whether to lock at all) and
130
+ * AGAIN under the lock, so a session sees a pause the previous one wrote.
131
+ * @param {string} run @param {string} role @param {Date} now
132
+ * @returns {Promise<CategoryPlan | null>}
133
+ * @throws {StateError} `record-unknown` when the record exists but cannot be read (a known read
134
+ * failure); any other error propagates. Accepted on purpose (fail closed): an unreadable record
135
+ * refuses the session EVEN for a run without a grant — the record is what says whether a grant
136
+ * exists, so without it "no grant" cannot be known.
137
+ */
138
+ export async function categoryPlan(run, role, now) {
139
+ /** @type {Record<string, any>} */
140
+ let record;
141
+ try {
142
+ record = await readRun(run);
143
+ } catch (thrown) {
144
+ if (thrown instanceof StateError && (thrown.code === 'no-run' || thrown.code === 'bad-run-id')) return null;
145
+ if (isKnownReadFailure(thrown)) throw new StateError('record-unknown', RECORD_UNKNOWN_MESSAGE);
146
+ throw thrown;
147
+ }
148
+ const grant = record.autopilot;
149
+ if (!grant || typeof grant !== 'object' || record.status !== 'active' || grantState(grant, now) !== 'active') return null;
150
+ const category = categoryOfRole(role);
151
+ const cap = category === null ? undefined : grant.caps?.[category];
152
+ if (category === null || typeof cap !== 'number' || !(cap > 0)) return null;
153
+ const stopAt = typeof grant.stop_at === 'number' && grant.stop_at > 0 && grant.stop_at <= 1 ? grant.stop_at : 1;
154
+ const pause = grant.paused && Object.hasOwn(grant.paused, category) ? grant.paused[category] : null;
155
+ return { category, cap, stopAt, grantId: grant.grant_id, startedAt: grant.started_at, project: record.project, paused: pause !== null, rowPending: pause?.row_pending === true };
156
+ }
157
+
158
+ /**
159
+ * The category check proper, called by `spawnSession`'s budget gate UNDER the run's budget lock
160
+ * (after the `budget.usd` check, with a plan read under that lock). Refuses at the stop ratio (and
161
+ * records the pause), else reserves `estimate` in the category until the session row is written.
162
+ * A paused category whose pause row is still pending gets the row written now.
163
+ * @param {{
164
+ * plan: CategoryPlan, run: string, role: string, block?: string | null,
165
+ * readRows: (() => Promise<Array<Record<string, any>>>) | null, writeRow: WriteRow | null,
166
+ * stderr: {write: (s: string) => unknown}, now: Date, estimate: number,
167
+ * }} opts
168
+ * @returns {Promise<{refused: Record<string, any>, release?: undefined} | {refused: null, release: () => void}>}
169
+ */
170
+ export async function categoryGate({ plan, run, role, block = null, readRows, writeRow, stderr, now, estimate }) {
171
+ const { category, cap, stopAt } = plan;
172
+ /** @param {string} message @param {Record<string, any>} extra */
173
+ const refuse = (message, extra) => {
174
+ writeSafe(stderr, `code-forge: ${message}\n`);
175
+ return { refused: { status: 'unavailable', reason: 'autopilot-paused', message, answer: null, category, cap_usd: cap, ...extra } };
176
+ };
177
+ /** @type {Array<Record<string, any>> | null} */
178
+ let rows = null;
179
+ try {
180
+ const got = readRows ? await readRows() : null;
181
+ rows = Array.isArray(got) ? got : null;
182
+ } catch {
183
+ rows = null;
184
+ }
185
+ if (rows === null) return refuse(categoryUnreadableMessage(category), { spent_usd: null });
186
+ const key = categoryReservationKey(run, category);
187
+ const spent = categorySpend(rows, run, category, plan.startedAt).usd;
188
+ const reserved = reservedUsd(key);
189
+ const total = roundUsd(spent + reserved);
190
+ if (plan.paused || total >= roundUsd(stopAt * cap)) {
191
+ if (!plan.paused || plan.rowPending) await recordPause({ plan, run, role, block, spent, reserved, writeRow, now });
192
+ return refuse(pausedMessage(category, total, cap, stopAt), { spent_usd: spent, reserved_usd: reserved });
193
+ }
194
+ return { refused: null, release: reserveBudget(key, estimate) };
195
+ }
196
+
197
+ /**
198
+ * Pause the grant for the category and write its ONE signed `autopilot.pause` row, under the run
199
+ * lock (re-checked there, so racing sessions write one row). The paused state is saved FIRST
200
+ * (`row_pending: true`, with the row's fields), then the row is written and the pending mark
201
+ * cleared. A row that cannot be written leaves the category paused with the row pending: the next
202
+ * session's check writes it (one row in the end).
203
+ * @param {{plan: CategoryPlan, run: string, role: string, block: string | null, spent: number, reserved: number, writeRow: WriteRow | null, now: Date}} o
204
+ * @returns {Promise<boolean>} whether this call wrote the row.
205
+ */
206
+ async function recordPause({ plan, run, role, block, spent, reserved, writeRow, now }) {
207
+ const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: plan.project }));
208
+ return withRunLock(run, async () => {
209
+ const record = await readRun(run);
210
+ const grant = record.autopilot;
211
+ if (!grant || grant.grant_id !== plan.grantId) return false;
212
+ let pause = grant.paused && Object.hasOwn(grant.paused, plan.category) ? grant.paused[plan.category] : null;
213
+ if (pause !== null && pause.row_pending !== true) return false; // paused and recorded already
214
+ if (pause === null) {
215
+ pause = { at: now.toISOString(), spent_usd: spent, reserved_usd: reserved, cap_usd: plan.cap, stop_at: plan.stopAt, role, block, row_pending: true };
216
+ grant.paused = { ...(grant.paused ?? {}), [plan.category]: pause };
217
+ await saveRun(record); // the pause holds even if the row cannot be written
218
+ }
219
+ try {
220
+ await writeSigned(run, write, {
221
+ event: 'autopilot.pause',
222
+ grant_id: plan.grantId,
223
+ category: plan.category,
224
+ spent_usd: pause.spent_usd,
225
+ reserved_usd: pause.reserved_usd,
226
+ cap_usd: pause.cap_usd,
227
+ stop_at: pause.stop_at,
228
+ role: pause.role,
229
+ block: pause.block,
230
+ ts: pause.at,
231
+ });
232
+ } catch {
233
+ return false; // still pending: the next check writes it
234
+ }
235
+ pause.row_pending = false;
236
+ await saveRun(record);
237
+ return true;
238
+ });
239
+ }
240
+
241
+ /* -------------------------------------------------------------------------- limit guard -- */
242
+
243
+ /** The refusal of a limit-key change while a grant is active. @param {string} key */
244
+ export const limitRefusal = (key) => `autopilot is active: ${key} is a limit; only the owner can change it with code-forge autopilot approve`;
245
+
246
+ /**
247
+ * @typedef {{set: unknown} | {remove: true, prune: number}} KeyChange - `prune`: how many empty
248
+ * parent mappings (created by the approval) to remove after the key.
249
+ * @typedef {{segs: string[], change: KeyChange}} AllowedChange
250
+ */
251
+
252
+ /** @param {unknown} v @returns {v is Record<string, any>} */
253
+ const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
254
+
255
+ /** A plain object (prototype `Object.prototype` or null): the only kind a key path descends into. @param {unknown} v @returns {v is Record<string, any>} */
256
+ const isPlainObject = (v) => isMapping(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
257
+
258
+ /** Key segments that could reach an object's prototype: refused in every key path. */
259
+ export const FORBIDDEN_SEGMENTS = Object.freeze(['__proto__', 'constructor', 'prototype']);
260
+
261
+ /** The refusal of a key path holding a forbidden segment. */
262
+ export const FORBIDDEN_SEGMENT_MESSAGE = '--key must not contain __proto__, constructor or prototype';
263
+
264
+ /**
265
+ * Refuse a key path with a prototype segment (every caller, not only the flag parser).
266
+ * @param {string[]} segs @returns {string[]}
267
+ * @throws {StateError} `usage`
268
+ */
269
+ export function assertSafeSegments(segs) {
270
+ if (segs.some((seg) => FORBIDDEN_SEGMENTS.includes(seg))) throw new StateError('usage', FORBIDDEN_SEGMENT_MESSAGE);
271
+ return segs;
272
+ }
273
+
274
+ /**
275
+ * `obj` (a plain config) with `change` applied at `segs`, as a fresh copy.
276
+ * @param {Record<string, any>} obj @param {string[]} segs @param {KeyChange} change
277
+ */
278
+ export function withChange(obj, segs, change) {
279
+ assertSafeSegments(segs);
280
+ const copy = structuredClone(obj);
281
+ /** @type {Record<string, any>[]} */
282
+ const chain = [copy];
283
+ let cur = copy;
284
+ for (const seg of segs.slice(0, -1)) {
285
+ // descend only into an OWN plain-object property; anything else is replaced by a fresh object
286
+ let next = Object.hasOwn(cur, seg) ? cur[seg] : undefined;
287
+ if (!isPlainObject(next)) {
288
+ if ('remove' in change) return copy;
289
+ next = {};
290
+ Object.defineProperty(cur, seg, { value: next, enumerable: true, writable: true, configurable: true });
291
+ }
292
+ cur = next;
293
+ chain.push(cur);
294
+ }
295
+ const last = segs[segs.length - 1];
296
+ if ('set' in change) {
297
+ Object.defineProperty(cur, last, { value: structuredClone(change.set), enumerable: true, writable: true, configurable: true });
298
+ return copy;
299
+ }
300
+ if (Object.hasOwn(cur, last)) delete cur[last];
301
+ for (let i = 0; i < change.prune; i += 1) {
302
+ const depth = segs.length - 1 - i; // chain[depth] is the parent mapping of segs[depth]
303
+ if (depth < 1 || Object.keys(chain[depth]).length > 0) break;
304
+ delete chain[depth - 1][segs[depth - 1]];
305
+ }
306
+ return copy;
307
+ }
308
+
309
+ /**
310
+ * `cfg` without the key at `segs` and without the mappings on its path that are left empty.
311
+ * @param {Record<string, any>} cfg @param {string[]} segs
312
+ */
313
+ const withoutKey = (cfg, segs) => withChange(cfg, segs, { remove: true, prune: segs.length - 1 });
314
+
315
+ /**
316
+ * The `reloadRun` guard: while the record's grant is active, any changed limit key is refused —
317
+ * except at the key `allow` names (an approval or its restore; internal, never a flag).
318
+ *
319
+ * Which view is compared: BOTH sides are the migrated config — the run's snapshot (`before`) and
320
+ * the freshly loaded file (`after`) — with the allowed key (and the parents left empty by removing
321
+ * it) taken out of both. So the guard never compares the key's VALUE: whatever migration or a
322
+ * default fills there in the snapshot cannot make an approval's restore look like a second change,
323
+ * while ANY other difference (a hand edit made meanwhile) is still caught. The approval's old
324
+ * value / absence / prune count come from the raw file on purpose: the raw file is what the
325
+ * restore writes back. A key that migration MOVES elsewhere would show up as another change and
326
+ * be refused (fail closed).
327
+ * @param {AllowedChange | null} allow @param {Date} now
328
+ */
329
+ export function limitGuard(allow, now) {
330
+ /** @type {(record: Record<string, any>, before: Record<string, any> | undefined, after: Record<string, any>, changed: string[] | null) => void} */
331
+ return (record, before, after, changed) => {
332
+ const grant = record.autopilot;
333
+ if (!grant || typeof grant !== 'object' || grantState(grant, now) !== 'active') return;
334
+ let paths = changed;
335
+ if (allow !== null && before !== undefined) paths = changedKeyPaths(withoutKey(before, allow.segs), withoutKey(after, allow.segs));
336
+ const hit = paths === null ? 'the whole config' : paths.find((p) => isLimitKey(p));
337
+ if (hit !== undefined) throw new StateError('autopilot-limit', limitRefusal(hit));
338
+ };
339
+ }
340
+
341
+ /* -------------------------------------------------------------------- workspace reload -- */
342
+
343
+ /**
344
+ * @typedef {{
345
+ * ok: boolean, error?: 'not-found' | 'read-error' | 'parse-error' | 'migrate-error' | 'invalid', line?: string, code?: string, errors?: Array<{rule: string, path?: string}>,
346
+ * changed?: string[] | null, oldHash?: string | null, newHash?: string, pinned?: number, rowError?: string | null,
347
+ * }} ReloadOutcome - `ok: true` carries the `reloadRun` result; `ok: false` the `error` (with `line`
348
+ * for a parse error, `errors` for an invalid config).
349
+ */
350
+
351
+ /**
352
+ * The `run reload` path (B35), shared by the verb, `autopilot approve` and the lazy restore: load
353
+ * and validate the workspace config, pin the queue's pending tickets, `reloadRun` with the
354
+ * autopilot {@link limitGuard} — ALWAYS: every caller (the `run reload` verb included) goes through
355
+ * the guard. `allow` is internal: only `approve` and its restore pass it; the verb never does.
356
+ * @param {{runId: string, allow?: AllowedChange | null, writeRow?: WriteRow, now?: Date}} opts
357
+ * @returns {Promise<ReloadOutcome>}
358
+ * @throws {StateError} `run-ended`, `immutable-key`, `autopilot-limit`, `queue-unreadable`, …
359
+ */
360
+ export async function reloadWorkspace({ runId, allow = null, writeRow, now = new Date() }) {
361
+ const { project, workspace, status } = await readRun(runId);
362
+ if (status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
363
+ const loaded = await loadProjectConfig(workspace);
364
+ if (!loaded.ok || !loaded.config) {
365
+ if (loaded.error === 'not-found') return { ok: false, error: 'not-found' };
366
+ if (loaded.error === 'parse-error') {
367
+ // never the parser's message: it can quote a line of the file
368
+ const line = /\(line (\d+), column \d+\)$/.exec(loaded.message ?? '')?.[1];
369
+ return { ok: false, error: 'parse-error', ...(line ? { line } : {}) };
370
+ }
371
+ if (loaded.error === 'migrate-error') return { ok: false, error: 'migrate-error' };
372
+ // a read error: the errno code only, never the OS text (it carries the full path)
373
+ const code = /could not read .*: (E[A-Z]+)$/.exec(loaded.message ?? '')?.[1];
374
+ return { ok: false, error: 'read-error', ...(code ? { code } : {}) };
375
+ }
376
+ const result = validateConfig(loaded.config, { seenInCache: await loadSeenInCache(), hasCliOnPath });
377
+ if (!result.valid) return { ok: false, error: 'invalid', errors: result.errors.map((e) => ({ rule: e.rule, ...(typeof e.path === 'string' && e.path.length > 0 ? { path: e.path } : {}) })) };
378
+ /** @type {string | null} */
379
+ let repoRoot = null;
380
+ try {
381
+ repoRoot = await repoRootOf(workspace);
382
+ } catch (thrown) {
383
+ if (!(thrown instanceof WorkerError && thrown.code === 'no-repo')) throw thrown;
384
+ // no repository: there is no review queue to pin
385
+ }
386
+ const readPending = () => {
387
+ if (repoRoot === null) return [];
388
+ try {
389
+ readdirSync(queueDir(repoRoot));
390
+ } catch (thrown) {
391
+ if (thrown?.code === 'ENOENT') return [];
392
+ throw new StateError('queue-unreadable', 'cannot read the review queue; nothing changed');
393
+ }
394
+ return pendingTickets(repoRoot);
395
+ };
396
+ const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project }));
397
+ const reloaded = await reloadRun({ runId, config: loaded.config, readPending, effectiveSlug: slugFor(loaded.config, workspace), writeRow: write, now, guard: limitGuard(allow, now) });
398
+ return { ok: true, ...reloaded };
399
+ }
400
+
401
+ /** A failed reload as one plain line (rule ids and key paths only). @param {ReloadOutcome} res */
402
+ export function reloadFailureText(res) {
403
+ if (res.ok) return '';
404
+ if (res.error === 'not-found') return `no ${DEFAULT_CONFIG_FILENAME} in the workspace`;
405
+ if (res.error === 'read-error') return `${DEFAULT_CONFIG_FILENAME} could not be read${res.code ? ` (${res.code})` : ''}`;
406
+ if (res.error === 'parse-error') return `the config could not be parsed (${DEFAULT_CONFIG_FILENAME}${res.line ? `:${res.line}` : ''})`;
407
+ if (res.error === 'migrate-error') return `${DEFAULT_CONFIG_FILENAME} could not be migrated to the current format`;
408
+ return `${DEFAULT_CONFIG_FILENAME} would be invalid (${(res.errors ?? []).map((e) => `[${e.rule}]${e.path ? ` ${e.path}` : ''}`).join(', ')})`;
409
+ }
410
+
411
+ /* ------------------------------------------------------------------ expiring approvals -- */
412
+
413
+ /**
414
+ * @typedef {{
415
+ * approval_id: string, key: string, new: unknown, old: unknown, old_absent: boolean, prune: number,
416
+ * until: string, approved_at: string, status: 'active' | 'restored' | 'restore_skipped', ended_at: string | null,
417
+ * finish_pending?: {status: 'restored' | 'restore_skipped', row: Record<string, any>},
418
+ * restoring?: boolean,
419
+ * }} Approval - stored in the run record (outside the workspace, 0600) under `autopilot_approvals`.
420
+ */
421
+
422
+ const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
423
+ const LOCK_TIMEOUT_MS = 10000;
424
+ const LOCK_POLL_MS = 25;
425
+
426
+ /** @param {string} message */
427
+ const usage = (message) => new StateError('usage', message);
428
+
429
+ /**
430
+ * `--key`: a dotted path of plain segments (letters, digits, `_`, `-`).
431
+ * @param {unknown} key @returns {string[]}
432
+ */
433
+ export function parseKeyPath(key) {
434
+ if (typeof key !== 'string' || key.length === 0) throw usage('--key is required: a dotted config key path, e.g. review.max_rounds_per_file');
435
+ const segs = key.split('.');
436
+ if (!segs.every((s) => KEY_SEGMENT.test(s))) throw usage('--key must be a dotted path of plain segments (letters, digits, _ and -), e.g. review.max_rounds_per_file');
437
+ return assertSafeSegments(segs);
438
+ }
439
+
440
+ /**
441
+ * The fixed key `key` would touch (equal, under, or a parent of one), or null.
442
+ * @param {string} key @returns {string | null}
443
+ */
444
+ export function immutableHit(key) {
445
+ return Object.keys(IMMUTABLE_KEYS).find((k) => key === k || key.startsWith(`${k}.`) || k.startsWith(`${key}.`)) ?? null;
446
+ }
447
+
448
+ /** @param {string} a @param {string} b @returns {boolean} the two key paths overlap (equal, or one under the other). */
449
+ const overlaps = (a, b) => a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
450
+
451
+ /** @param {Record<string, any>} record @returns {Approval[]} */
452
+ const approvalsOf = (record) => (Array.isArray(record.autopilot_approvals) ? record.autopilot_approvals : []);
453
+
454
+ /** @param {Record<string, any>} record @param {{writeRow?: WriteRow}} deps @returns {WriteRow} */
455
+ const writerFor = (record, deps) => deps.writeRow ?? ((row) => appendRow(row, { slug: record.project }));
456
+
457
+ /**
458
+ * Validate an `approve` before anyone is asked: key, fixed keys, the JSON value, `--until`, an
459
+ * active run and no active approval on an overlapping key. Nothing is written.
460
+ * @param {{runId: string, key: unknown, value: unknown, until: unknown, now: Date}} input
461
+ * @returns {Promise<{key: string, segs: string[], value: unknown, until: string}>}
462
+ * @throws {StateError} `usage`, `immutable-key`, `run-ended`, `approval-active`
463
+ */
464
+ export async function validateApproval({ runId, key, value, until, now }) {
465
+ const segs = parseKeyPath(key);
466
+ const keyPath = segs.join('.');
467
+ const fixed = immutableHit(keyPath);
468
+ if (fixed !== null) throw new StateError('immutable-key', `${keyPath} cannot be approved: ${fixed} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (fixed)]})`);
469
+ if (typeof value !== 'string') throw usage('--value is required: a JSON value, e.g. 4, true, "text" or {"a": 1}');
470
+ /** @type {unknown} */
471
+ let parsed;
472
+ try {
473
+ parsed = JSON.parse(value);
474
+ } catch {
475
+ throw usage('--value must be JSON, e.g. 4, true, "text" or {"a": 1}'); // never the value itself
476
+ }
477
+ const untilIso = parseUntil(until, now);
478
+ const record = await readRun(runId);
479
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
480
+ const clash = approvalsOf(record).find((a) => a.status === 'active' && overlaps(a.key, keyPath));
481
+ if (clash) throw new StateError('approval-active', `an approval for ${clash.key} is active until ${clash.until}; it must end first`);
482
+ return { key: keyPath, segs, value: parsed, until: untilIso };
483
+ }
484
+
485
+ /**
486
+ * Serialize approvals and restores of one run across processes (a `mkdir` lock next to the run
487
+ * record). It wraps `reloadRun`, which takes the run lock itself, so it is a lock of its own.
488
+ * @template T @param {string} runId @param {() => Promise<T>} fn @returns {Promise<T>}
489
+ */
490
+ async function withApprovalsLock(runId, fn) {
491
+ await mkdir(runsDir(), { recursive: true, mode: 0o700 });
492
+ const lock = path.join(runsDir(), `${assertRunId(runId)}.approvals.lock`);
493
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
494
+ for (;;) {
495
+ try {
496
+ await mkdir(lock);
497
+ break;
498
+ } catch (err) {
499
+ if (err.code !== 'EEXIST') throw err;
500
+ if (Date.now() > deadline) throw new StateError('locked', `run ${runId}'s approvals are locked (${lock}); remove it if no forge verb is running`);
501
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
502
+ }
503
+ }
504
+ let failed = false;
505
+ try {
506
+ return await fn();
507
+ } catch (thrown) {
508
+ failed = true;
509
+ throw thrown;
510
+ } finally {
511
+ try {
512
+ await rmdir(lock);
513
+ } catch (rmError) {
514
+ // never mask fn's own error; a lock left behind after a success is reported
515
+ if (!failed) throw rmError; // eslint-disable-line no-unsafe-finally
516
+ }
517
+ }
518
+ }
519
+
520
+ /**
521
+ * The workspace config as an editable YAML document.
522
+ * @param {string} workspace
523
+ * @returns {Promise<{file: string, text: string, doc: import('yaml').Document, js: Record<string, any>}>}
524
+ */
525
+ async function readWorkspace(workspace) {
526
+ const file = path.join(workspace, DEFAULT_CONFIG_FILENAME);
527
+ let text;
528
+ try {
529
+ text = await readFile(file, 'utf8');
530
+ } catch (err) {
531
+ if (err.code === 'ENOENT') throw new StateError('no-config', `no ${DEFAULT_CONFIG_FILENAME} in the workspace`);
532
+ throw new StateError('config-read', `${DEFAULT_CONFIG_FILENAME} could not be read${typeof err.code === 'string' ? ` (${err.code})` : ''}`);
533
+ }
534
+ const doc = parseDocument(text, { prettyErrors: false });
535
+ const js = doc.errors.length === 0 ? doc.toJS() : null;
536
+ if (!isMapping(js)) throw new StateError('config-parse', `${DEFAULT_CONFIG_FILENAME} could not be parsed`);
537
+ return { file, text, doc, js };
538
+ }
539
+
540
+ /**
541
+ * Atomic text write that keeps the file's mode (a new file gets the default 0644); the temp file is
542
+ * removed when the write or the rename fails.
543
+ * @param {string} file @param {string} text
544
+ */
545
+ async function writeText(file, text) {
546
+ let mode = 0o644;
547
+ try {
548
+ mode = (await stat(file)).mode & 0o7777;
549
+ } catch {
550
+ // no file yet: the default mode
551
+ }
552
+ const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
553
+ try {
554
+ await writeFile(tmp, text, { mode });
555
+ await chmod(tmp, mode); // the umask may have narrowed it
556
+ await rename(tmp, file);
557
+ } catch (thrown) {
558
+ await unlink(tmp).catch(() => null);
559
+ throw thrown;
560
+ }
561
+ }
562
+
563
+ /**
564
+ * @param {Record<string, any>} js @param {string[]} segs
565
+ * @returns {{present: boolean, value: unknown, missing: number}} `missing`: how many trailing parent
566
+ * mappings do not exist yet (they are created by a set).
567
+ */
568
+ function valueAt(js, segs) {
569
+ assertSafeSegments(segs);
570
+ /** @type {unknown} */
571
+ let cur = js;
572
+ for (let i = 0; i < segs.length; i += 1) {
573
+ if (!isPlainObject(cur) || !Object.hasOwn(cur, segs[i])) return { present: false, value: undefined, missing: isPlainObject(cur) ? segs.length - 1 - i : 0 };
574
+ cur = cur[segs[i]];
575
+ }
576
+ return { present: true, value: cur, missing: 0 };
577
+ }
578
+
579
+ /**
580
+ * Apply `change` to the YAML document (comments and layout elsewhere kept).
581
+ * @param {import('yaml').Document} doc @param {string[]} segs @param {KeyChange} change
582
+ */
583
+ function editDoc(doc, segs, change) {
584
+ assertSafeSegments(segs);
585
+ try {
586
+ if ('set' in change) {
587
+ doc.setIn(segs, doc.createNode(change.set));
588
+ return;
589
+ }
590
+ doc.deleteIn(segs);
591
+ for (let i = 0; i < change.prune; i += 1) {
592
+ const parent = segs.slice(0, segs.length - 1 - i);
593
+ if (parent.length === 0) break;
594
+ const node = /** @type {any} */ (doc.getIn(parent, true));
595
+ if (!node || !Array.isArray(node.items) || node.items.length > 0) break;
596
+ doc.deleteIn(parent);
597
+ }
598
+ } catch {
599
+ throw usage(`${segs.join('.')} cannot be set: a parent of it is not a mapping in ${DEFAULT_CONFIG_FILENAME}`);
600
+ }
601
+ }
602
+
603
+ /** The change that puts an approval's old value back. @param {Approval} a @returns {KeyChange} */
604
+ const revertChange = (a) => (a.old_absent ? { remove: true, prune: a.prune } : { set: a.old });
605
+
606
+ /**
607
+ * `autopilot approve` after the owner confirmed: write the workspace config, reload (the approved
608
+ * change is the one limit change the guard lets through), then — under the run lock — write the
609
+ * signed `autopilot.approve` row and store the approval. Any failure puts the file back (and,
610
+ * after a reload, reloads the old value) and throws: nothing is half-approved.
611
+ * @param {{runId: string, key: string, segs: string[], value: unknown, until: string, now?: Date, writeRow?: WriteRow, approvalId?: string}} opts
612
+ * @returns {Promise<Approval>}
613
+ */
614
+ export async function approveChange({ runId, key, segs, value, until, now = new Date(), writeRow, approvalId }) {
615
+ return withApprovalsLock(runId, async () => {
616
+ const record = await readRun(runId);
617
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
618
+ const clash = approvalsOf(record).find((a) => a.status === 'active' && overlaps(a.key, key));
619
+ if (clash) throw new StateError('approval-active', `an approval for ${clash.key} is active until ${clash.until}; it must end first`);
620
+ const ws = await readWorkspace(record.workspace);
621
+ const before = valueAt(ws.js, segs);
622
+ if (before.present && canonicalJSON(before.value) === canonicalJSON(value)) throw new StateError('no-change', `${key} already has that value in ${DEFAULT_CONFIG_FILENAME}; nothing to approve`);
623
+ /** @type {Approval} */
624
+ const approval = {
625
+ approval_id: approvalId ?? `apr-${randomBytes(4).toString('hex')}`,
626
+ key,
627
+ new: value,
628
+ old: before.present ? before.value : null,
629
+ old_absent: !before.present,
630
+ prune: before.present ? 0 : before.missing,
631
+ until,
632
+ approved_at: now.toISOString(),
633
+ status: 'active',
634
+ ended_at: null,
635
+ };
636
+ editDoc(ws.doc, segs, { set: value });
637
+ await writeText(ws.file, String(ws.doc));
638
+ /** @type {ReloadOutcome} */
639
+ let res;
640
+ try {
641
+ res = await reloadWorkspace({ runId, allow: { segs, change: { set: value } }, writeRow, now });
642
+ } catch (thrown) {
643
+ await writeText(ws.file, ws.text);
644
+ throw thrown;
645
+ }
646
+ if (!res.ok) {
647
+ await writeText(ws.file, ws.text);
648
+ throw new StateError('config-invalid', `${reloadFailureText(res)} — nothing changed`);
649
+ }
650
+ try {
651
+ await withRunLock(runId, async () => {
652
+ const fresh = await readRun(runId);
653
+ await writeSigned(runId, writerFor(fresh, { writeRow }), {
654
+ event: 'autopilot.approve',
655
+ approval_id: approval.approval_id,
656
+ key,
657
+ old: approval.old_absent ? null : redact(approval.old),
658
+ old_absent: approval.old_absent,
659
+ new: redact(value),
660
+ until,
661
+ new_hash: res.ok ? res.newHash : null,
662
+ ts: now.toISOString(),
663
+ });
664
+ fresh.autopilot_approvals = [...approvalsOf(fresh), approval];
665
+ await saveRun(fresh);
666
+ });
667
+ } catch (thrown) {
668
+ // the row or the record failed: undo the change so nothing stands unrecorded
669
+ await writeText(ws.file, ws.text);
670
+ await reloadWorkspace({ runId, allow: { segs, change: revertChange(approval) }, writeRow, now }).catch(() => null);
671
+ throw thrown;
672
+ }
673
+ return approval;
674
+ });
675
+ }
676
+
677
+ /**
678
+ * @typedef {{restored: string[], skipped: string[], failed: Array<{approval_id: string, reason: string}>}} ApprovalCheck
679
+ */
680
+
681
+ /**
682
+ * The lazy restore: every active approval of the run whose `until` is at or before `now` is
683
+ * restored (old value written back, reload, ONE signed `autopilot.restore` row) — or, when the
684
+ * workspace value no longer equals the approved value (changed by hand), left alone with ONE
685
+ * signed `autopilot.restore_skipped` row. A restore that cannot load or reload the config puts the
686
+ * file back and stays active (reported in `failed`; the next check tries again). For a run that
687
+ * has ended the old value is written back without a reload. Crash safety: `restoring: true` is
688
+ * saved on the approval BEFORE the file is touched; a later check that finds it finishes the
689
+ * restore (no rewrite when the file already holds the old value) and never reads it as a change
690
+ * by hand.
691
+ * @param {string} runId
692
+ * @param {Date | (() => Date)} [clock] - a clock is read only when the run has an active approval
693
+ * (a caller's fake clock is not advanced by a run without any).
694
+ * @param {{writeRow?: WriteRow}} [deps]
695
+ * @returns {Promise<ApprovalCheck>}
696
+ */
697
+ export async function checkApprovals(runId, clock = new Date(), deps = {}) {
698
+ /** @type {ApprovalCheck} */
699
+ const out = { restored: [], skipped: [], failed: [] };
700
+ /** @type {Record<string, any>} */
701
+ let record;
702
+ try {
703
+ record = await readRun(runId);
704
+ } catch (thrown) {
705
+ if (thrown instanceof StateError && (thrown.code === 'no-run' || thrown.code === 'bad-run-id')) return out;
706
+ throw thrown;
707
+ }
708
+ if (!approvalsOf(record).some((a) => a.status === 'active')) return out;
709
+ const now = typeof clock === 'function' ? clock() : clock;
710
+ /** @param {Approval} a */
711
+ const due = (a) => a.status === 'active' && now.getTime() >= Date.parse(a.until);
712
+ if (!approvalsOf(record).some(due)) return out;
713
+ return withApprovalsLock(runId, async () => {
714
+ const fresh = await readRun(runId);
715
+ for (const approval of approvalsOf(fresh).filter(due)) {
716
+ /**
717
+ * Write the signed row, then end the approval. When the row cannot be written the approval
718
+ * stays active with the finished step recorded (`finish_pending`): the next check writes the
719
+ * row without touching the file again.
720
+ * @param {'restored' | 'restore_skipped'} status @param {Record<string, any>} row
721
+ * @returns {Promise<boolean>} whether the row was written now
722
+ */
723
+ const finish = (status, row) =>
724
+ withRunLock(runId, async () => {
725
+ const latest = await readRun(runId);
726
+ const stored = approvalsOf(latest).find((a) => a.approval_id === approval.approval_id);
727
+ if (!stored || stored.status !== 'active') return false;
728
+ const pending = stored.finish_pending ?? { status, row: { approval_id: approval.approval_id, key: approval.key, until: approval.until, ...row, ts: now.toISOString() } };
729
+ try {
730
+ await writeSigned(runId, writerFor(latest, deps), pending.row);
731
+ } catch {
732
+ stored.finish_pending = pending;
733
+ await saveRun(latest);
734
+ return false;
735
+ }
736
+ delete stored.finish_pending;
737
+ stored.status = pending.status;
738
+ stored.ended_at = pending.row.ts;
739
+ await saveRun(latest);
740
+ return true;
741
+ });
742
+ /** @param {'restored' | 'restore_skipped'} status @param {Record<string, any>} row */
743
+ const report = async (status, row) => {
744
+ if (await finish(status, row)) (status === 'restored' ? out.restored : out.skipped).push(approval.approval_id);
745
+ else out.failed.push({ approval_id: approval.approval_id, reason: 'restore-incomplete' });
746
+ };
747
+ if (approval.finish_pending) {
748
+ // the file step is done; only the signed row is missing
749
+ await report(approval.finish_pending.status, {});
750
+ continue;
751
+ }
752
+ /** @type {string[]} */
753
+ let segs;
754
+ try {
755
+ segs = assertSafeSegments(approval.key.split('.'));
756
+ } catch {
757
+ out.failed.push({ approval_id: approval.approval_id, reason: 'unsafe key' });
758
+ continue;
759
+ }
760
+ /** @type {Awaited<ReturnType<typeof readWorkspace>>} */
761
+ let ws;
762
+ try {
763
+ ws = await readWorkspace(fresh.workspace);
764
+ } catch (thrown) {
765
+ out.failed.push({ approval_id: approval.approval_id, reason: thrown?.message ?? String(thrown) });
766
+ continue;
767
+ }
768
+ const current = valueAt(ws.js, segs);
769
+ const holdsOld = approval.old_absent ? !current.present : current.present && canonicalJSON(current.value) === canonicalJSON(approval.old);
770
+ if (approval.restoring !== true) {
771
+ if (!current.present || canonicalJSON(current.value) !== canonicalJSON(approval.new)) {
772
+ await report('restore_skipped', { event: 'autopilot.restore_skipped', reason: 'changed-by-hand' });
773
+ continue;
774
+ }
775
+ // crash safety: the restore is recorded as started BEFORE the file is touched, so a check
776
+ // after a crash finishes it instead of reading the old value as a change by hand
777
+ await withRunLock(runId, async () => {
778
+ const latest = await readRun(runId);
779
+ const stored = approvalsOf(latest).find((a) => a.approval_id === approval.approval_id);
780
+ if (stored) stored.restoring = true;
781
+ await saveRun(latest);
782
+ });
783
+ }
784
+ const change = revertChange(approval);
785
+ if (!holdsOld) {
786
+ try {
787
+ editDoc(ws.doc, segs, change);
788
+ await writeText(ws.file, String(ws.doc));
789
+ } catch (thrown) {
790
+ out.failed.push({ approval_id: approval.approval_id, reason: thrown?.message ?? String(thrown) });
791
+ continue;
792
+ }
793
+ }
794
+ /** @type {string | null} */
795
+ let newHash = null;
796
+ if (fresh.status === 'active') {
797
+ /** @type {string | null} */
798
+ let failure = null;
799
+ try {
800
+ const res = await reloadWorkspace({ runId, allow: { segs, change }, writeRow: deps.writeRow, now });
801
+ if (res.ok) newHash = res.newHash;
802
+ else failure = reloadFailureText(res);
803
+ } catch (thrown) {
804
+ failure = thrown?.message ?? String(thrown);
805
+ }
806
+ if (failure !== null) {
807
+ await writeText(ws.file, ws.text);
808
+ out.failed.push({ approval_id: approval.approval_id, reason: failure });
809
+ continue;
810
+ }
811
+ }
812
+ await report('restored', { event: 'autopilot.restore', restored: approval.old_absent ? null : redact(approval.old), old_absent: approval.old_absent, reloaded: fresh.status === 'active', new_hash: newHash });
813
+ }
814
+ return out;
815
+ });
816
+ }
817
+
818
+ /**
819
+ * The lazy checks every `block` command and every worker drain run (and every `autopilot`
820
+ * command, which runs the grant expiry itself): the grant's expiry, then the approvals' restore.
821
+ * @param {string} runId @param {Date} [now] @param {{writeRow?: WriteRow}} [deps]
822
+ * @returns {Promise<ApprovalCheck>}
823
+ */
824
+ export async function expiryChecks(runId, now = new Date(), deps = {}) {
825
+ try {
826
+ await checkExpiry(runId, now, deps);
827
+ } catch (thrown) {
828
+ if (!(thrown instanceof StateError && (thrown.code === 'no-run' || thrown.code === 'bad-run-id'))) throw thrown;
829
+ }
830
+ return checkApprovals(runId, now, deps);
831
+ }
832
+
833
+ /**
834
+ * `ApprovalCheck` as stderr lines (ids and key paths only), for the CLI callers.
835
+ * @param {ApprovalCheck} res @returns {string}
836
+ */
837
+ export function approvalCheckText(res) {
838
+ return [
839
+ ...res.restored.map((id) => `autopilot: approval ${id} expired; the old value is back\n`),
840
+ ...res.skipped.map((id) => `autopilot: approval ${id} expired but its key was changed by hand; left for the owner\n`),
841
+ ...res.failed.map((f) =>
842
+ f.reason === 'restore-incomplete'
843
+ ? `autopilot: WARN approval ${f.approval_id}: its signed row could not be written yet (restore-incomplete); the next check writes it\n`
844
+ : `autopilot: WARN approval ${f.approval_id} could not be restored yet (${f.reason})\n`,
845
+ ),
846
+ ].join('');
847
+ }