@codedology/code-forge 0.5.1 → 0.6.1
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.
- package/CHANGELOG.md +59 -0
- package/README.md +39 -2
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +127 -1
- package/docs/reference/config.md +2 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +7 -0
- package/skill/SKILL.md +2 -0
- package/skill/references/adapters/claude-code.md +14 -0
- package/skill/references/autopilot.md +48 -0
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +3 -1
- package/skill/references/harden.md +1 -1
- package/skill/references/ledger.md +2 -0
- package/skill/references/plan.md +6 -4
- package/skill/references/review.md +2 -2
- package/skill/references/rules-core.md +4 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/plan.md +4 -3
- package/src/autopilot/actions.mjs +491 -0
- package/src/autopilot/binnacle.mjs +494 -0
- package/src/autopilot/delegate.mjs +422 -0
- package/src/autopilot/grant.mjs +354 -0
- package/src/autopilot/limits.mjs +848 -0
- package/src/autopilot/scopes.mjs +95 -0
- package/src/cli/author.mjs +7 -5
- package/src/cli/autopilot.mjs +500 -0
- package/src/cli/block.mjs +40 -8
- package/src/cli/facts.mjs +7 -6
- package/src/cli/jev.mjs +10 -6
- package/src/cli/logs.mjs +4 -142
- package/src/cli/plan.mjs +18 -7
- package/src/cli/run.mjs +20 -61
- package/src/cli/s2.mjs +5 -4
- package/src/cli/spawn.mjs +5 -4
- package/src/config/closed-book.mjs +20 -3
- package/src/config/load.mjs +113 -1
- package/src/doctor/probes.mjs +34 -11
- package/src/engines/builders/claude.mjs +3 -3
- package/src/engines/builders/codex.mjs +6 -5
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/builders/validate-params.mjs +3 -3
- package/src/ledger/report.mjs +2 -0
- package/src/review/fixloop.mjs +69 -5
- package/src/review/gate-check.mjs +156 -6
- package/src/session/facts.mjs +291 -73
- package/src/session/plan-check.mjs +75 -6
- package/src/session/spawn.mjs +68 -8
- package/src/state/run.mjs +8 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +10 -0
- package/src/util/gh.mjs +175 -0
- package/src/worker/engine.mjs +8 -4
- package/src/worker/loop.mjs +42 -0
|
@@ -0,0 +1,848 @@
|
|
|
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
|
+
// the run record's workspace IS the project root (`run start` records `projectRootFor`, B50)
|
|
364
|
+
const loaded = await loadProjectConfig(workspace);
|
|
365
|
+
if (!loaded.ok || !loaded.config) {
|
|
366
|
+
if (loaded.error === 'not-found') return { ok: false, error: 'not-found' };
|
|
367
|
+
if (loaded.error === 'parse-error') {
|
|
368
|
+
// never the parser's message: it can quote a line of the file
|
|
369
|
+
const line = /\(line (\d+), column \d+\)$/.exec(loaded.message ?? '')?.[1];
|
|
370
|
+
return { ok: false, error: 'parse-error', ...(line ? { line } : {}) };
|
|
371
|
+
}
|
|
372
|
+
if (loaded.error === 'migrate-error') return { ok: false, error: 'migrate-error' };
|
|
373
|
+
// a read error: the errno code only, never the OS text (it carries the full path)
|
|
374
|
+
const code = /could not read .*: (E[A-Z]+)$/.exec(loaded.message ?? '')?.[1];
|
|
375
|
+
return { ok: false, error: 'read-error', ...(code ? { code } : {}) };
|
|
376
|
+
}
|
|
377
|
+
const result = validateConfig(loaded.config, { seenInCache: await loadSeenInCache(), hasCliOnPath });
|
|
378
|
+
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 } : {}) })) };
|
|
379
|
+
/** @type {string | null} */
|
|
380
|
+
let repoRoot = null;
|
|
381
|
+
try {
|
|
382
|
+
repoRoot = await repoRootOf(workspace);
|
|
383
|
+
} catch (thrown) {
|
|
384
|
+
if (!(thrown instanceof WorkerError && thrown.code === 'no-repo')) throw thrown;
|
|
385
|
+
// no repository: there is no review queue to pin
|
|
386
|
+
}
|
|
387
|
+
const readPending = () => {
|
|
388
|
+
if (repoRoot === null) return [];
|
|
389
|
+
try {
|
|
390
|
+
readdirSync(queueDir(repoRoot));
|
|
391
|
+
} catch (thrown) {
|
|
392
|
+
if (thrown?.code === 'ENOENT') return [];
|
|
393
|
+
throw new StateError('queue-unreadable', 'cannot read the review queue; nothing changed');
|
|
394
|
+
}
|
|
395
|
+
return pendingTickets(repoRoot);
|
|
396
|
+
};
|
|
397
|
+
const write = writeRow ?? ((/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project }));
|
|
398
|
+
const reloaded = await reloadRun({ runId, config: loaded.config, readPending, effectiveSlug: slugFor(loaded.config, workspace), writeRow: write, now, guard: limitGuard(allow, now) });
|
|
399
|
+
return { ok: true, ...reloaded };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A failed reload as one plain line (rule ids and key paths only). @param {ReloadOutcome} res */
|
|
403
|
+
export function reloadFailureText(res) {
|
|
404
|
+
if (res.ok) return '';
|
|
405
|
+
if (res.error === 'not-found') return `no ${DEFAULT_CONFIG_FILENAME} in the workspace`;
|
|
406
|
+
if (res.error === 'read-error') return `${DEFAULT_CONFIG_FILENAME} could not be read${res.code ? ` (${res.code})` : ''}`;
|
|
407
|
+
if (res.error === 'parse-error') return `the config could not be parsed (${DEFAULT_CONFIG_FILENAME}${res.line ? `:${res.line}` : ''})`;
|
|
408
|
+
if (res.error === 'migrate-error') return `${DEFAULT_CONFIG_FILENAME} could not be migrated to the current format`;
|
|
409
|
+
return `${DEFAULT_CONFIG_FILENAME} would be invalid (${(res.errors ?? []).map((e) => `[${e.rule}]${e.path ? ` ${e.path}` : ''}`).join(', ')})`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* ------------------------------------------------------------------ expiring approvals -- */
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @typedef {{
|
|
416
|
+
* approval_id: string, key: string, new: unknown, old: unknown, old_absent: boolean, prune: number,
|
|
417
|
+
* until: string, approved_at: string, status: 'active' | 'restored' | 'restore_skipped', ended_at: string | null,
|
|
418
|
+
* finish_pending?: {status: 'restored' | 'restore_skipped', row: Record<string, any>},
|
|
419
|
+
* restoring?: boolean,
|
|
420
|
+
* }} Approval - stored in the run record (outside the workspace, 0600) under `autopilot_approvals`.
|
|
421
|
+
*/
|
|
422
|
+
|
|
423
|
+
const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
|
|
424
|
+
const LOCK_TIMEOUT_MS = 10000;
|
|
425
|
+
const LOCK_POLL_MS = 25;
|
|
426
|
+
|
|
427
|
+
/** @param {string} message */
|
|
428
|
+
const usage = (message) => new StateError('usage', message);
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* `--key`: a dotted path of plain segments (letters, digits, `_`, `-`).
|
|
432
|
+
* @param {unknown} key @returns {string[]}
|
|
433
|
+
*/
|
|
434
|
+
export function parseKeyPath(key) {
|
|
435
|
+
if (typeof key !== 'string' || key.length === 0) throw usage('--key is required: a dotted config key path, e.g. review.max_rounds_per_file');
|
|
436
|
+
const segs = key.split('.');
|
|
437
|
+
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');
|
|
438
|
+
return assertSafeSegments(segs);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The fixed key `key` would touch (equal, under, or a parent of one), or null.
|
|
443
|
+
* @param {string} key @returns {string | null}
|
|
444
|
+
*/
|
|
445
|
+
export function immutableHit(key) {
|
|
446
|
+
return Object.keys(IMMUTABLE_KEYS).find((k) => key === k || key.startsWith(`${k}.`) || k.startsWith(`${key}.`)) ?? null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** @param {string} a @param {string} b @returns {boolean} the two key paths overlap (equal, or one under the other). */
|
|
450
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
|
|
451
|
+
|
|
452
|
+
/** @param {Record<string, any>} record @returns {Approval[]} */
|
|
453
|
+
const approvalsOf = (record) => (Array.isArray(record.autopilot_approvals) ? record.autopilot_approvals : []);
|
|
454
|
+
|
|
455
|
+
/** @param {Record<string, any>} record @param {{writeRow?: WriteRow}} deps @returns {WriteRow} */
|
|
456
|
+
const writerFor = (record, deps) => deps.writeRow ?? ((row) => appendRow(row, { slug: record.project }));
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Validate an `approve` before anyone is asked: key, fixed keys, the JSON value, `--until`, an
|
|
460
|
+
* active run and no active approval on an overlapping key. Nothing is written.
|
|
461
|
+
* @param {{runId: string, key: unknown, value: unknown, until: unknown, now: Date}} input
|
|
462
|
+
* @returns {Promise<{key: string, segs: string[], value: unknown, until: string}>}
|
|
463
|
+
* @throws {StateError} `usage`, `immutable-key`, `run-ended`, `approval-active`
|
|
464
|
+
*/
|
|
465
|
+
export async function validateApproval({ runId, key, value, until, now }) {
|
|
466
|
+
const segs = parseKeyPath(key);
|
|
467
|
+
const keyPath = segs.join('.');
|
|
468
|
+
const fixed = immutableHit(keyPath);
|
|
469
|
+
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)]})`);
|
|
470
|
+
if (typeof value !== 'string') throw usage('--value is required: a JSON value, e.g. 4, true, "text" or {"a": 1}');
|
|
471
|
+
/** @type {unknown} */
|
|
472
|
+
let parsed;
|
|
473
|
+
try {
|
|
474
|
+
parsed = JSON.parse(value);
|
|
475
|
+
} catch {
|
|
476
|
+
throw usage('--value must be JSON, e.g. 4, true, "text" or {"a": 1}'); // never the value itself
|
|
477
|
+
}
|
|
478
|
+
const untilIso = parseUntil(until, now);
|
|
479
|
+
const record = await readRun(runId);
|
|
480
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
481
|
+
const clash = approvalsOf(record).find((a) => a.status === 'active' && overlaps(a.key, keyPath));
|
|
482
|
+
if (clash) throw new StateError('approval-active', `an approval for ${clash.key} is active until ${clash.until}; it must end first`);
|
|
483
|
+
return { key: keyPath, segs, value: parsed, until: untilIso };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Serialize approvals and restores of one run across processes (a `mkdir` lock next to the run
|
|
488
|
+
* record). It wraps `reloadRun`, which takes the run lock itself, so it is a lock of its own.
|
|
489
|
+
* @template T @param {string} runId @param {() => Promise<T>} fn @returns {Promise<T>}
|
|
490
|
+
*/
|
|
491
|
+
async function withApprovalsLock(runId, fn) {
|
|
492
|
+
await mkdir(runsDir(), { recursive: true, mode: 0o700 });
|
|
493
|
+
const lock = path.join(runsDir(), `${assertRunId(runId)}.approvals.lock`);
|
|
494
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
495
|
+
for (;;) {
|
|
496
|
+
try {
|
|
497
|
+
await mkdir(lock);
|
|
498
|
+
break;
|
|
499
|
+
} catch (err) {
|
|
500
|
+
if (err.code !== 'EEXIST') throw err;
|
|
501
|
+
if (Date.now() > deadline) throw new StateError('locked', `run ${runId}'s approvals are locked (${lock}); remove it if no forge verb is running`);
|
|
502
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
let failed = false;
|
|
506
|
+
try {
|
|
507
|
+
return await fn();
|
|
508
|
+
} catch (thrown) {
|
|
509
|
+
failed = true;
|
|
510
|
+
throw thrown;
|
|
511
|
+
} finally {
|
|
512
|
+
try {
|
|
513
|
+
await rmdir(lock);
|
|
514
|
+
} catch (rmError) {
|
|
515
|
+
// never mask fn's own error; a lock left behind after a success is reported
|
|
516
|
+
if (!failed) throw rmError; // eslint-disable-line no-unsafe-finally
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The workspace config as an editable YAML document.
|
|
523
|
+
* @param {string} workspace
|
|
524
|
+
* @returns {Promise<{file: string, text: string, doc: import('yaml').Document, js: Record<string, any>}>}
|
|
525
|
+
*/
|
|
526
|
+
async function readWorkspace(workspace) {
|
|
527
|
+
const file = path.join(workspace, DEFAULT_CONFIG_FILENAME);
|
|
528
|
+
let text;
|
|
529
|
+
try {
|
|
530
|
+
text = await readFile(file, 'utf8');
|
|
531
|
+
} catch (err) {
|
|
532
|
+
if (err.code === 'ENOENT') throw new StateError('no-config', `no ${DEFAULT_CONFIG_FILENAME} in the workspace`);
|
|
533
|
+
throw new StateError('config-read', `${DEFAULT_CONFIG_FILENAME} could not be read${typeof err.code === 'string' ? ` (${err.code})` : ''}`);
|
|
534
|
+
}
|
|
535
|
+
const doc = parseDocument(text, { prettyErrors: false });
|
|
536
|
+
const js = doc.errors.length === 0 ? doc.toJS() : null;
|
|
537
|
+
if (!isMapping(js)) throw new StateError('config-parse', `${DEFAULT_CONFIG_FILENAME} could not be parsed`);
|
|
538
|
+
return { file, text, doc, js };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Atomic text write that keeps the file's mode (a new file gets the default 0644); the temp file is
|
|
543
|
+
* removed when the write or the rename fails.
|
|
544
|
+
* @param {string} file @param {string} text
|
|
545
|
+
*/
|
|
546
|
+
async function writeText(file, text) {
|
|
547
|
+
let mode = 0o644;
|
|
548
|
+
try {
|
|
549
|
+
mode = (await stat(file)).mode & 0o7777;
|
|
550
|
+
} catch {
|
|
551
|
+
// no file yet: the default mode
|
|
552
|
+
}
|
|
553
|
+
const tmp = `${file}.${process.pid}.${randomBytes(3).toString('hex')}.tmp`;
|
|
554
|
+
try {
|
|
555
|
+
await writeFile(tmp, text, { mode });
|
|
556
|
+
await chmod(tmp, mode); // the umask may have narrowed it
|
|
557
|
+
await rename(tmp, file);
|
|
558
|
+
} catch (thrown) {
|
|
559
|
+
await unlink(tmp).catch(() => null);
|
|
560
|
+
throw thrown;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* @param {Record<string, any>} js @param {string[]} segs
|
|
566
|
+
* @returns {{present: boolean, value: unknown, missing: number}} `missing`: how many trailing parent
|
|
567
|
+
* mappings do not exist yet (they are created by a set).
|
|
568
|
+
*/
|
|
569
|
+
function valueAt(js, segs) {
|
|
570
|
+
assertSafeSegments(segs);
|
|
571
|
+
/** @type {unknown} */
|
|
572
|
+
let cur = js;
|
|
573
|
+
for (let i = 0; i < segs.length; i += 1) {
|
|
574
|
+
if (!isPlainObject(cur) || !Object.hasOwn(cur, segs[i])) return { present: false, value: undefined, missing: isPlainObject(cur) ? segs.length - 1 - i : 0 };
|
|
575
|
+
cur = cur[segs[i]];
|
|
576
|
+
}
|
|
577
|
+
return { present: true, value: cur, missing: 0 };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Apply `change` to the YAML document (comments and layout elsewhere kept).
|
|
582
|
+
* @param {import('yaml').Document} doc @param {string[]} segs @param {KeyChange} change
|
|
583
|
+
*/
|
|
584
|
+
function editDoc(doc, segs, change) {
|
|
585
|
+
assertSafeSegments(segs);
|
|
586
|
+
try {
|
|
587
|
+
if ('set' in change) {
|
|
588
|
+
doc.setIn(segs, doc.createNode(change.set));
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
doc.deleteIn(segs);
|
|
592
|
+
for (let i = 0; i < change.prune; i += 1) {
|
|
593
|
+
const parent = segs.slice(0, segs.length - 1 - i);
|
|
594
|
+
if (parent.length === 0) break;
|
|
595
|
+
const node = /** @type {any} */ (doc.getIn(parent, true));
|
|
596
|
+
if (!node || !Array.isArray(node.items) || node.items.length > 0) break;
|
|
597
|
+
doc.deleteIn(parent);
|
|
598
|
+
}
|
|
599
|
+
} catch {
|
|
600
|
+
throw usage(`${segs.join('.')} cannot be set: a parent of it is not a mapping in ${DEFAULT_CONFIG_FILENAME}`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The change that puts an approval's old value back. @param {Approval} a @returns {KeyChange} */
|
|
605
|
+
const revertChange = (a) => (a.old_absent ? { remove: true, prune: a.prune } : { set: a.old });
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* `autopilot approve` after the owner confirmed: write the workspace config, reload (the approved
|
|
609
|
+
* change is the one limit change the guard lets through), then — under the run lock — write the
|
|
610
|
+
* signed `autopilot.approve` row and store the approval. Any failure puts the file back (and,
|
|
611
|
+
* after a reload, reloads the old value) and throws: nothing is half-approved.
|
|
612
|
+
* @param {{runId: string, key: string, segs: string[], value: unknown, until: string, now?: Date, writeRow?: WriteRow, approvalId?: string}} opts
|
|
613
|
+
* @returns {Promise<Approval>}
|
|
614
|
+
*/
|
|
615
|
+
export async function approveChange({ runId, key, segs, value, until, now = new Date(), writeRow, approvalId }) {
|
|
616
|
+
return withApprovalsLock(runId, async () => {
|
|
617
|
+
const record = await readRun(runId);
|
|
618
|
+
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
619
|
+
const clash = approvalsOf(record).find((a) => a.status === 'active' && overlaps(a.key, key));
|
|
620
|
+
if (clash) throw new StateError('approval-active', `an approval for ${clash.key} is active until ${clash.until}; it must end first`);
|
|
621
|
+
const ws = await readWorkspace(record.workspace);
|
|
622
|
+
const before = valueAt(ws.js, segs);
|
|
623
|
+
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`);
|
|
624
|
+
/** @type {Approval} */
|
|
625
|
+
const approval = {
|
|
626
|
+
approval_id: approvalId ?? `apr-${randomBytes(4).toString('hex')}`,
|
|
627
|
+
key,
|
|
628
|
+
new: value,
|
|
629
|
+
old: before.present ? before.value : null,
|
|
630
|
+
old_absent: !before.present,
|
|
631
|
+
prune: before.present ? 0 : before.missing,
|
|
632
|
+
until,
|
|
633
|
+
approved_at: now.toISOString(),
|
|
634
|
+
status: 'active',
|
|
635
|
+
ended_at: null,
|
|
636
|
+
};
|
|
637
|
+
editDoc(ws.doc, segs, { set: value });
|
|
638
|
+
await writeText(ws.file, String(ws.doc));
|
|
639
|
+
/** @type {ReloadOutcome} */
|
|
640
|
+
let res;
|
|
641
|
+
try {
|
|
642
|
+
res = await reloadWorkspace({ runId, allow: { segs, change: { set: value } }, writeRow, now });
|
|
643
|
+
} catch (thrown) {
|
|
644
|
+
await writeText(ws.file, ws.text);
|
|
645
|
+
throw thrown;
|
|
646
|
+
}
|
|
647
|
+
if (!res.ok) {
|
|
648
|
+
await writeText(ws.file, ws.text);
|
|
649
|
+
throw new StateError('config-invalid', `${reloadFailureText(res)} — nothing changed`);
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
await withRunLock(runId, async () => {
|
|
653
|
+
const fresh = await readRun(runId);
|
|
654
|
+
await writeSigned(runId, writerFor(fresh, { writeRow }), {
|
|
655
|
+
event: 'autopilot.approve',
|
|
656
|
+
approval_id: approval.approval_id,
|
|
657
|
+
key,
|
|
658
|
+
old: approval.old_absent ? null : redact(approval.old),
|
|
659
|
+
old_absent: approval.old_absent,
|
|
660
|
+
new: redact(value),
|
|
661
|
+
until,
|
|
662
|
+
new_hash: res.ok ? res.newHash : null,
|
|
663
|
+
ts: now.toISOString(),
|
|
664
|
+
});
|
|
665
|
+
fresh.autopilot_approvals = [...approvalsOf(fresh), approval];
|
|
666
|
+
await saveRun(fresh);
|
|
667
|
+
});
|
|
668
|
+
} catch (thrown) {
|
|
669
|
+
// the row or the record failed: undo the change so nothing stands unrecorded
|
|
670
|
+
await writeText(ws.file, ws.text);
|
|
671
|
+
await reloadWorkspace({ runId, allow: { segs, change: revertChange(approval) }, writeRow, now }).catch(() => null);
|
|
672
|
+
throw thrown;
|
|
673
|
+
}
|
|
674
|
+
return approval;
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* @typedef {{restored: string[], skipped: string[], failed: Array<{approval_id: string, reason: string}>}} ApprovalCheck
|
|
680
|
+
*/
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The lazy restore: every active approval of the run whose `until` is at or before `now` is
|
|
684
|
+
* restored (old value written back, reload, ONE signed `autopilot.restore` row) — or, when the
|
|
685
|
+
* workspace value no longer equals the approved value (changed by hand), left alone with ONE
|
|
686
|
+
* signed `autopilot.restore_skipped` row. A restore that cannot load or reload the config puts the
|
|
687
|
+
* file back and stays active (reported in `failed`; the next check tries again). For a run that
|
|
688
|
+
* has ended the old value is written back without a reload. Crash safety: `restoring: true` is
|
|
689
|
+
* saved on the approval BEFORE the file is touched; a later check that finds it finishes the
|
|
690
|
+
* restore (no rewrite when the file already holds the old value) and never reads it as a change
|
|
691
|
+
* by hand.
|
|
692
|
+
* @param {string} runId
|
|
693
|
+
* @param {Date | (() => Date)} [clock] - a clock is read only when the run has an active approval
|
|
694
|
+
* (a caller's fake clock is not advanced by a run without any).
|
|
695
|
+
* @param {{writeRow?: WriteRow}} [deps]
|
|
696
|
+
* @returns {Promise<ApprovalCheck>}
|
|
697
|
+
*/
|
|
698
|
+
export async function checkApprovals(runId, clock = new Date(), deps = {}) {
|
|
699
|
+
/** @type {ApprovalCheck} */
|
|
700
|
+
const out = { restored: [], skipped: [], failed: [] };
|
|
701
|
+
/** @type {Record<string, any>} */
|
|
702
|
+
let record;
|
|
703
|
+
try {
|
|
704
|
+
record = await readRun(runId);
|
|
705
|
+
} catch (thrown) {
|
|
706
|
+
if (thrown instanceof StateError && (thrown.code === 'no-run' || thrown.code === 'bad-run-id')) return out;
|
|
707
|
+
throw thrown;
|
|
708
|
+
}
|
|
709
|
+
if (!approvalsOf(record).some((a) => a.status === 'active')) return out;
|
|
710
|
+
const now = typeof clock === 'function' ? clock() : clock;
|
|
711
|
+
/** @param {Approval} a */
|
|
712
|
+
const due = (a) => a.status === 'active' && now.getTime() >= Date.parse(a.until);
|
|
713
|
+
if (!approvalsOf(record).some(due)) return out;
|
|
714
|
+
return withApprovalsLock(runId, async () => {
|
|
715
|
+
const fresh = await readRun(runId);
|
|
716
|
+
for (const approval of approvalsOf(fresh).filter(due)) {
|
|
717
|
+
/**
|
|
718
|
+
* Write the signed row, then end the approval. When the row cannot be written the approval
|
|
719
|
+
* stays active with the finished step recorded (`finish_pending`): the next check writes the
|
|
720
|
+
* row without touching the file again.
|
|
721
|
+
* @param {'restored' | 'restore_skipped'} status @param {Record<string, any>} row
|
|
722
|
+
* @returns {Promise<boolean>} whether the row was written now
|
|
723
|
+
*/
|
|
724
|
+
const finish = (status, row) =>
|
|
725
|
+
withRunLock(runId, async () => {
|
|
726
|
+
const latest = await readRun(runId);
|
|
727
|
+
const stored = approvalsOf(latest).find((a) => a.approval_id === approval.approval_id);
|
|
728
|
+
if (!stored || stored.status !== 'active') return false;
|
|
729
|
+
const pending = stored.finish_pending ?? { status, row: { approval_id: approval.approval_id, key: approval.key, until: approval.until, ...row, ts: now.toISOString() } };
|
|
730
|
+
try {
|
|
731
|
+
await writeSigned(runId, writerFor(latest, deps), pending.row);
|
|
732
|
+
} catch {
|
|
733
|
+
stored.finish_pending = pending;
|
|
734
|
+
await saveRun(latest);
|
|
735
|
+
return false;
|
|
736
|
+
}
|
|
737
|
+
delete stored.finish_pending;
|
|
738
|
+
stored.status = pending.status;
|
|
739
|
+
stored.ended_at = pending.row.ts;
|
|
740
|
+
await saveRun(latest);
|
|
741
|
+
return true;
|
|
742
|
+
});
|
|
743
|
+
/** @param {'restored' | 'restore_skipped'} status @param {Record<string, any>} row */
|
|
744
|
+
const report = async (status, row) => {
|
|
745
|
+
if (await finish(status, row)) (status === 'restored' ? out.restored : out.skipped).push(approval.approval_id);
|
|
746
|
+
else out.failed.push({ approval_id: approval.approval_id, reason: 'restore-incomplete' });
|
|
747
|
+
};
|
|
748
|
+
if (approval.finish_pending) {
|
|
749
|
+
// the file step is done; only the signed row is missing
|
|
750
|
+
await report(approval.finish_pending.status, {});
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
/** @type {string[]} */
|
|
754
|
+
let segs;
|
|
755
|
+
try {
|
|
756
|
+
segs = assertSafeSegments(approval.key.split('.'));
|
|
757
|
+
} catch {
|
|
758
|
+
out.failed.push({ approval_id: approval.approval_id, reason: 'unsafe key' });
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
/** @type {Awaited<ReturnType<typeof readWorkspace>>} */
|
|
762
|
+
let ws;
|
|
763
|
+
try {
|
|
764
|
+
ws = await readWorkspace(fresh.workspace);
|
|
765
|
+
} catch (thrown) {
|
|
766
|
+
out.failed.push({ approval_id: approval.approval_id, reason: thrown?.message ?? String(thrown) });
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const current = valueAt(ws.js, segs);
|
|
770
|
+
const holdsOld = approval.old_absent ? !current.present : current.present && canonicalJSON(current.value) === canonicalJSON(approval.old);
|
|
771
|
+
if (approval.restoring !== true) {
|
|
772
|
+
if (!current.present || canonicalJSON(current.value) !== canonicalJSON(approval.new)) {
|
|
773
|
+
await report('restore_skipped', { event: 'autopilot.restore_skipped', reason: 'changed-by-hand' });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
// crash safety: the restore is recorded as started BEFORE the file is touched, so a check
|
|
777
|
+
// after a crash finishes it instead of reading the old value as a change by hand
|
|
778
|
+
await withRunLock(runId, async () => {
|
|
779
|
+
const latest = await readRun(runId);
|
|
780
|
+
const stored = approvalsOf(latest).find((a) => a.approval_id === approval.approval_id);
|
|
781
|
+
if (stored) stored.restoring = true;
|
|
782
|
+
await saveRun(latest);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
const change = revertChange(approval);
|
|
786
|
+
if (!holdsOld) {
|
|
787
|
+
try {
|
|
788
|
+
editDoc(ws.doc, segs, change);
|
|
789
|
+
await writeText(ws.file, String(ws.doc));
|
|
790
|
+
} catch (thrown) {
|
|
791
|
+
out.failed.push({ approval_id: approval.approval_id, reason: thrown?.message ?? String(thrown) });
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/** @type {string | null} */
|
|
796
|
+
let newHash = null;
|
|
797
|
+
if (fresh.status === 'active') {
|
|
798
|
+
/** @type {string | null} */
|
|
799
|
+
let failure = null;
|
|
800
|
+
try {
|
|
801
|
+
const res = await reloadWorkspace({ runId, allow: { segs, change }, writeRow: deps.writeRow, now });
|
|
802
|
+
if (res.ok) newHash = res.newHash;
|
|
803
|
+
else failure = reloadFailureText(res);
|
|
804
|
+
} catch (thrown) {
|
|
805
|
+
failure = thrown?.message ?? String(thrown);
|
|
806
|
+
}
|
|
807
|
+
if (failure !== null) {
|
|
808
|
+
await writeText(ws.file, ws.text);
|
|
809
|
+
out.failed.push({ approval_id: approval.approval_id, reason: failure });
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
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 });
|
|
814
|
+
}
|
|
815
|
+
return out;
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* The lazy checks every `block` command and every worker drain run (and every `autopilot`
|
|
821
|
+
* command, which runs the grant expiry itself): the grant's expiry, then the approvals' restore.
|
|
822
|
+
* @param {string} runId @param {Date} [now] @param {{writeRow?: WriteRow}} [deps]
|
|
823
|
+
* @returns {Promise<ApprovalCheck>}
|
|
824
|
+
*/
|
|
825
|
+
export async function expiryChecks(runId, now = new Date(), deps = {}) {
|
|
826
|
+
try {
|
|
827
|
+
await checkExpiry(runId, now, deps);
|
|
828
|
+
} catch (thrown) {
|
|
829
|
+
if (!(thrown instanceof StateError && (thrown.code === 'no-run' || thrown.code === 'bad-run-id'))) throw thrown;
|
|
830
|
+
}
|
|
831
|
+
return checkApprovals(runId, now, deps);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* `ApprovalCheck` as stderr lines (ids and key paths only), for the CLI callers.
|
|
836
|
+
* @param {ApprovalCheck} res @returns {string}
|
|
837
|
+
*/
|
|
838
|
+
export function approvalCheckText(res) {
|
|
839
|
+
return [
|
|
840
|
+
...res.restored.map((id) => `autopilot: approval ${id} expired; the old value is back\n`),
|
|
841
|
+
...res.skipped.map((id) => `autopilot: approval ${id} expired but its key was changed by hand; left for the owner\n`),
|
|
842
|
+
...res.failed.map((f) =>
|
|
843
|
+
f.reason === 'restore-incomplete'
|
|
844
|
+
? `autopilot: WARN approval ${f.approval_id}: its signed row could not be written yet (restore-incomplete); the next check writes it\n`
|
|
845
|
+
: `autopilot: WARN approval ${f.approval_id} could not be restored yet (${f.reason})\n`,
|
|
846
|
+
),
|
|
847
|
+
].join('');
|
|
848
|
+
}
|