@delegance/claude-autopilot 5.5.2 → 7.2.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.
- package/CHANGELOG.md +1776 -6
- package/README.md +65 -1
- package/bin/_launcher.js +38 -23
- package/dist/src/adapters/council/openai.js +12 -6
- package/dist/src/adapters/deploy/_http.d.ts +43 -0
- package/dist/src/adapters/deploy/_http.js +99 -0
- package/dist/src/adapters/deploy/fly.d.ts +206 -0
- package/dist/src/adapters/deploy/fly.js +696 -0
- package/dist/src/adapters/deploy/index.d.ts +2 -0
- package/dist/src/adapters/deploy/index.js +33 -0
- package/dist/src/adapters/deploy/render.d.ts +181 -0
- package/dist/src/adapters/deploy/render.js +550 -0
- package/dist/src/adapters/deploy/types.d.ts +67 -3
- package/dist/src/adapters/deploy/vercel.d.ts +17 -1
- package/dist/src/adapters/deploy/vercel.js +29 -49
- package/dist/src/adapters/pricing.d.ts +36 -0
- package/dist/src/adapters/pricing.js +40 -0
- package/dist/src/adapters/review-engine/codex.js +10 -7
- package/dist/src/cli/autopilot.d.ts +75 -0
- package/dist/src/cli/autopilot.js +750 -0
- package/dist/src/cli/brainstorm.d.ts +23 -0
- package/dist/src/cli/brainstorm.js +131 -0
- package/dist/src/cli/costs.d.ts +15 -1
- package/dist/src/cli/costs.js +99 -10
- package/dist/src/cli/dashboard/index.d.ts +5 -0
- package/dist/src/cli/dashboard/index.js +49 -0
- package/dist/src/cli/dashboard/login.d.ts +22 -0
- package/dist/src/cli/dashboard/login.js +260 -0
- package/dist/src/cli/dashboard/logout.d.ts +12 -0
- package/dist/src/cli/dashboard/logout.js +45 -0
- package/dist/src/cli/dashboard/status.d.ts +30 -0
- package/dist/src/cli/dashboard/status.js +65 -0
- package/dist/src/cli/dashboard/upload.d.ts +16 -0
- package/dist/src/cli/dashboard/upload.js +48 -0
- package/dist/src/cli/deploy.d.ts +3 -3
- package/dist/src/cli/deploy.js +34 -9
- package/dist/src/cli/engine-flag-deprecation.d.ts +14 -0
- package/dist/src/cli/engine-flag-deprecation.js +20 -0
- package/dist/src/cli/fix.d.ts +18 -0
- package/dist/src/cli/fix.js +105 -11
- package/dist/src/cli/help-text.d.ts +52 -0
- package/dist/src/cli/help-text.js +416 -0
- package/dist/src/cli/implement.d.ts +91 -0
- package/dist/src/cli/implement.js +196 -0
- package/dist/src/cli/index.d.ts +2 -1
- package/dist/src/cli/index.js +774 -245
- package/dist/src/cli/json-envelope.d.ts +187 -0
- package/dist/src/cli/json-envelope.js +270 -0
- package/dist/src/cli/json-mode.d.ts +33 -0
- package/dist/src/cli/json-mode.js +201 -0
- package/dist/src/cli/migrate.d.ts +111 -0
- package/dist/src/cli/migrate.js +305 -0
- package/dist/src/cli/plan.d.ts +81 -0
- package/dist/src/cli/plan.js +149 -0
- package/dist/src/cli/pr.d.ts +106 -0
- package/dist/src/cli/pr.js +191 -19
- package/dist/src/cli/preflight.js +26 -0
- package/dist/src/cli/review.d.ts +27 -0
- package/dist/src/cli/review.js +126 -0
- package/dist/src/cli/runs-watch-renderer.d.ts +45 -0
- package/dist/src/cli/runs-watch-renderer.js +275 -0
- package/dist/src/cli/runs-watch.d.ts +41 -0
- package/dist/src/cli/runs-watch.js +395 -0
- package/dist/src/cli/runs.d.ts +122 -0
- package/dist/src/cli/runs.js +902 -0
- package/dist/src/cli/scaffold.d.ts +39 -0
- package/dist/src/cli/scaffold.js +287 -0
- package/dist/src/cli/scan.d.ts +93 -0
- package/dist/src/cli/scan.js +166 -40
- package/dist/src/cli/setup.d.ts +30 -0
- package/dist/src/cli/setup.js +137 -0
- package/dist/src/cli/spec.d.ts +66 -0
- package/dist/src/cli/spec.js +132 -0
- package/dist/src/cli/validate.d.ts +29 -0
- package/dist/src/cli/validate.js +131 -0
- package/dist/src/core/config/schema.d.ts +9 -0
- package/dist/src/core/config/schema.js +7 -0
- package/dist/src/core/config/types.d.ts +11 -0
- package/dist/src/core/council/runner.d.ts +10 -1
- package/dist/src/core/council/runner.js +25 -3
- package/dist/src/core/council/types.d.ts +7 -0
- package/dist/src/core/errors.d.ts +1 -1
- package/dist/src/core/errors.js +11 -0
- package/dist/src/core/logging/redaction.d.ts +13 -0
- package/dist/src/core/logging/redaction.js +20 -0
- package/dist/src/core/migrate/schema-validator.js +15 -1
- package/dist/src/core/phases/static-rules.d.ts +5 -1
- package/dist/src/core/phases/static-rules.js +2 -5
- package/dist/src/core/run-state/budget.d.ts +88 -0
- package/dist/src/core/run-state/budget.js +141 -0
- package/dist/src/core/run-state/cli-internal.d.ts +21 -0
- package/dist/src/core/run-state/cli-internal.js +174 -0
- package/dist/src/core/run-state/events.d.ts +59 -0
- package/dist/src/core/run-state/events.js +512 -0
- package/dist/src/core/run-state/lock.d.ts +61 -0
- package/dist/src/core/run-state/lock.js +206 -0
- package/dist/src/core/run-state/phase-context.d.ts +60 -0
- package/dist/src/core/run-state/phase-context.js +108 -0
- package/dist/src/core/run-state/phase-registry.d.ts +137 -0
- package/dist/src/core/run-state/phase-registry.js +162 -0
- package/dist/src/core/run-state/phase-runner.d.ts +80 -0
- package/dist/src/core/run-state/phase-runner.js +447 -0
- package/dist/src/core/run-state/provider-readback.d.ts +130 -0
- package/dist/src/core/run-state/provider-readback.js +426 -0
- package/dist/src/core/run-state/replay-decision.d.ts +69 -0
- package/dist/src/core/run-state/replay-decision.js +144 -0
- package/dist/src/core/run-state/resolve-engine.d.ts +45 -0
- package/dist/src/core/run-state/resolve-engine.js +74 -0
- package/dist/src/core/run-state/resume-preflight.d.ts +66 -0
- package/dist/src/core/run-state/resume-preflight.js +116 -0
- package/dist/src/core/run-state/run-phase-with-lifecycle.d.ts +69 -0
- package/dist/src/core/run-state/run-phase-with-lifecycle.js +193 -0
- package/dist/src/core/run-state/runs.d.ts +57 -0
- package/dist/src/core/run-state/runs.js +288 -0
- package/dist/src/core/run-state/snapshot.d.ts +14 -0
- package/dist/src/core/run-state/snapshot.js +114 -0
- package/dist/src/core/run-state/state.d.ts +40 -0
- package/dist/src/core/run-state/state.js +164 -0
- package/dist/src/core/run-state/types.d.ts +284 -0
- package/dist/src/core/run-state/types.js +19 -0
- package/dist/src/core/run-state/ulid.d.ts +11 -0
- package/dist/src/core/run-state/ulid.js +95 -0
- package/dist/src/core/schema-alignment/extractor/index.d.ts +1 -1
- package/dist/src/core/schema-alignment/extractor/index.js +2 -2
- package/dist/src/core/schema-alignment/extractor/prisma.d.ts +13 -1
- package/dist/src/core/schema-alignment/extractor/prisma.js +65 -10
- package/dist/src/core/schema-alignment/git-history.d.ts +19 -0
- package/dist/src/core/schema-alignment/git-history.js +53 -0
- package/dist/src/core/static-rules/rules/brand-tokens.js +2 -2
- package/dist/src/core/static-rules/rules/schema-alignment.js +14 -4
- package/dist/src/dashboard/auto-upload.d.ts +26 -0
- package/dist/src/dashboard/auto-upload.js +107 -0
- package/dist/src/dashboard/config.d.ts +22 -0
- package/dist/src/dashboard/config.js +109 -0
- package/dist/src/dashboard/upload/canonical.d.ts +3 -0
- package/dist/src/dashboard/upload/canonical.js +16 -0
- package/dist/src/dashboard/upload/chain.d.ts +9 -0
- package/dist/src/dashboard/upload/chain.js +27 -0
- package/dist/src/dashboard/upload/snapshot.d.ts +23 -0
- package/dist/src/dashboard/upload/snapshot.js +66 -0
- package/dist/src/dashboard/upload/uploader.d.ts +54 -0
- package/dist/src/dashboard/upload/uploader.js +330 -0
- package/package.json +19 -3
- package/scripts/autoregress.ts +1 -1
- package/scripts/test-runner.mjs +4 -0
- package/skills/claude-autopilot.md +1 -1
- package/skills/make-interfaces-feel-better/SKILL.md +104 -0
- package/skills/simplify-ui/SKILL.md +103 -0
- package/skills/ui/SKILL.md +117 -0
- package/skills/ui-ux-pro-max/SKILL.md +90 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// src/core/run-state/runs.ts
|
|
2
|
+
//
|
|
3
|
+
// Top-level Run State Engine helpers — createRun, listRuns, gcRuns. These
|
|
4
|
+
// are the entry points the (yet-to-be-built) phase wrapper, CLI, and budget
|
|
5
|
+
// enforcer will call. Phase 1 ships only the data layer; later phases build
|
|
6
|
+
// on top.
|
|
7
|
+
//
|
|
8
|
+
// Spec: docs/specs/v6-run-state-engine.md "State on disk", "Run lifecycle",
|
|
9
|
+
// "Resume command".
|
|
10
|
+
import * as fs from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
12
|
+
import { ulid, decodeTime } from "./ulid.js";
|
|
13
|
+
import { acquireRunLock } from "./lock.js";
|
|
14
|
+
import { appendEvent, foldEvents, readEvents, stateToIndexEntry } from "./events.js";
|
|
15
|
+
import { writeStateSnapshot } from "./state.js";
|
|
16
|
+
import { RUN_STATE_SCHEMA_VERSION, } from "./types.js";
|
|
17
|
+
const CACHE_DIR = '.guardrail-cache';
|
|
18
|
+
const RUNS_DIR = 'runs';
|
|
19
|
+
const INDEX_FILE = 'index.json';
|
|
20
|
+
export function runsRoot(cwd) {
|
|
21
|
+
return path.join(cwd, CACHE_DIR, RUNS_DIR);
|
|
22
|
+
}
|
|
23
|
+
export function indexPath(cwd) {
|
|
24
|
+
return path.join(runsRoot(cwd), INDEX_FILE);
|
|
25
|
+
}
|
|
26
|
+
export function runDirFor(cwd, runId) {
|
|
27
|
+
return path.join(runsRoot(cwd), runId);
|
|
28
|
+
}
|
|
29
|
+
/** Create a fresh run directory, acquire its advisory lock, write the
|
|
30
|
+
* initial state.json, and emit the `run.start` event.
|
|
31
|
+
*
|
|
32
|
+
* Throws GuardrailError(lock_held) if a stale lock exists for the freshly-
|
|
33
|
+
* generated runId — extremely unlikely (ULIDs are unique) but possible if
|
|
34
|
+
* two parallel invocations on the same OS clock collide on a leftover dir
|
|
35
|
+
* on disk. Caller can simply retry. */
|
|
36
|
+
export async function createRun(opts) {
|
|
37
|
+
if (!Array.isArray(opts.phases) || opts.phases.length === 0) {
|
|
38
|
+
throw new Error('createRun: phases[] must be a non-empty array');
|
|
39
|
+
}
|
|
40
|
+
const runId = ulid();
|
|
41
|
+
const runDir = runDirFor(opts.cwd, runId);
|
|
42
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
43
|
+
// Acquire BEFORE first event write so writerId is well-defined.
|
|
44
|
+
const lock = await acquireRunLock(runDir);
|
|
45
|
+
// Seed the state snapshot first (with no events yet) so that even a crash
|
|
46
|
+
// before run.start lands leaves a recoverable artifact.
|
|
47
|
+
const startedAt = new Date(decodeTime(runId)).toISOString();
|
|
48
|
+
const initialState = {
|
|
49
|
+
schema_version: RUN_STATE_SCHEMA_VERSION,
|
|
50
|
+
runId,
|
|
51
|
+
startedAt,
|
|
52
|
+
status: 'pending',
|
|
53
|
+
phases: opts.phases.map((name, idx) => ({
|
|
54
|
+
schema_version: RUN_STATE_SCHEMA_VERSION,
|
|
55
|
+
name,
|
|
56
|
+
index: idx,
|
|
57
|
+
status: 'pending',
|
|
58
|
+
idempotent: false,
|
|
59
|
+
hasSideEffects: false,
|
|
60
|
+
costUSD: 0,
|
|
61
|
+
attempts: 0,
|
|
62
|
+
artifacts: [],
|
|
63
|
+
externalRefs: [],
|
|
64
|
+
})),
|
|
65
|
+
currentPhaseIdx: 0,
|
|
66
|
+
totalCostUSD: 0,
|
|
67
|
+
lastEventSeq: 0,
|
|
68
|
+
writerId: lock.writerId,
|
|
69
|
+
cwd: opts.cwd,
|
|
70
|
+
...(opts.config !== undefined ? { config: opts.config } : {}),
|
|
71
|
+
};
|
|
72
|
+
writeStateSnapshot(runDir, initialState);
|
|
73
|
+
// Emit run.start. The appender owns the seq counter.
|
|
74
|
+
const startEvent = appendEvent(runDir, {
|
|
75
|
+
event: 'run.start',
|
|
76
|
+
phases: opts.phases,
|
|
77
|
+
...(opts.config !== undefined ? { config: opts.config } : {}),
|
|
78
|
+
}, { writerId: lock.writerId, runId });
|
|
79
|
+
// Refresh the snapshot to reflect lastEventSeq=1.
|
|
80
|
+
initialState.lastEventSeq = startEvent.seq;
|
|
81
|
+
writeStateSnapshot(runDir, initialState);
|
|
82
|
+
// Refresh the index (best-effort — index is a pure cache).
|
|
83
|
+
try {
|
|
84
|
+
rebuildIndex(opts.cwd);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Index failure shouldn't block the run.
|
|
88
|
+
}
|
|
89
|
+
return { runId, runDir, state: initialState, lock };
|
|
90
|
+
}
|
|
91
|
+
// ----------------------------------------------------------------------------
|
|
92
|
+
// Listing + indexing.
|
|
93
|
+
// ----------------------------------------------------------------------------
|
|
94
|
+
function readIndex(cwd) {
|
|
95
|
+
const p = indexPath(cwd);
|
|
96
|
+
if (!fs.existsSync(p))
|
|
97
|
+
return null;
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null; // treat unreadable index as missing — it's a cache
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function writeIndex(cwd, index) {
|
|
106
|
+
fs.mkdirSync(runsRoot(cwd), { recursive: true });
|
|
107
|
+
fs.writeFileSync(indexPath(cwd), JSON.stringify(index, null, 2), 'utf8');
|
|
108
|
+
}
|
|
109
|
+
/** Rebuild index.json from each run dir's state.json (or replayed state if
|
|
110
|
+
* the snapshot is missing / corrupt). Newest-first ordering by ULID. */
|
|
111
|
+
export function rebuildIndex(cwd) {
|
|
112
|
+
const root = runsRoot(cwd);
|
|
113
|
+
const entries = [];
|
|
114
|
+
if (!fs.existsSync(root)) {
|
|
115
|
+
const empty = { schema_version: RUN_STATE_SCHEMA_VERSION, runs: [] };
|
|
116
|
+
writeIndex(cwd, empty);
|
|
117
|
+
return empty;
|
|
118
|
+
}
|
|
119
|
+
const dirents = fs.readdirSync(root, { withFileTypes: true });
|
|
120
|
+
for (const d of dirents) {
|
|
121
|
+
if (!d.isDirectory())
|
|
122
|
+
continue;
|
|
123
|
+
const runId = d.name;
|
|
124
|
+
const runDir = path.join(root, runId);
|
|
125
|
+
let state;
|
|
126
|
+
let recovered = false;
|
|
127
|
+
try {
|
|
128
|
+
// We don't hold the lock during a list — listing is read-only and
|
|
129
|
+
// races with a concurrent writer are tolerated (we may briefly read
|
|
130
|
+
// a stale snapshot, which is fine). For replay-recovery we DO need
|
|
131
|
+
// a writerId, but only if the snapshot is bad; if so the run isn't
|
|
132
|
+
// healthy anyway, and we use a synthetic writerId so we never
|
|
133
|
+
// mutate the run's events.ndjson during a list operation.
|
|
134
|
+
// Instead of recoverState (which writes events) we just replay
|
|
135
|
+
// in-memory.
|
|
136
|
+
const fromEvents = readEvents(runDir);
|
|
137
|
+
// Build a fresh snapshot if state.json is missing or unreadable.
|
|
138
|
+
// Use the project-internal file paths to avoid pulling readState
|
|
139
|
+
// here just to throw.
|
|
140
|
+
const stateFilePath = path.join(runDir, 'state.json');
|
|
141
|
+
if (fs.existsSync(stateFilePath)) {
|
|
142
|
+
try {
|
|
143
|
+
state = JSON.parse(fs.readFileSync(stateFilePath, 'utf8'));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// fall through to replay
|
|
147
|
+
recovered = true;
|
|
148
|
+
// Replay needs the events; if the events are also corrupt we
|
|
149
|
+
// surface the error via skip.
|
|
150
|
+
state = replayInMemory(runDir, fromEvents.events);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
recovered = true;
|
|
155
|
+
state = replayInMemory(runDir, fromEvents.events);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// Corrupt run dir — skip from the index entirely. `runs doctor`
|
|
160
|
+
// (Phase 3) will surface these.
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
entries.push(stateToIndexEntry(state, recovered));
|
|
164
|
+
}
|
|
165
|
+
// ULIDs are sortable; we want NEWEST first → reverse-sort by runId.
|
|
166
|
+
entries.sort((a, b) => (a.runId < b.runId ? 1 : a.runId > b.runId ? -1 : 0));
|
|
167
|
+
const index = { schema_version: RUN_STATE_SCHEMA_VERSION, runs: entries };
|
|
168
|
+
writeIndex(cwd, index);
|
|
169
|
+
return index;
|
|
170
|
+
}
|
|
171
|
+
/** In-memory replay used by rebuildIndex / listRuns — does NOT write to disk
|
|
172
|
+
* or emit events. Lets us pass pre-fetched events so we don't double-read
|
|
173
|
+
* the file. */
|
|
174
|
+
function replayInMemory(runDir, events) {
|
|
175
|
+
return foldEvents(runDir, events);
|
|
176
|
+
}
|
|
177
|
+
/** List all runs, newest-first. Lazily rebuilds index.json if missing. */
|
|
178
|
+
export function listRuns(cwd, opts = {}) {
|
|
179
|
+
if (opts.rebuild)
|
|
180
|
+
return rebuildIndex(cwd).runs;
|
|
181
|
+
const idx = readIndex(cwd);
|
|
182
|
+
if (idx)
|
|
183
|
+
return idx.runs;
|
|
184
|
+
return rebuildIndex(cwd).runs;
|
|
185
|
+
}
|
|
186
|
+
/** Delete completed runs older than N days. Honors the spec's symlink
|
|
187
|
+
* safety: uses lstat so we never traverse a symlink out of the runs/
|
|
188
|
+
* tree. */
|
|
189
|
+
export function gcRuns(cwd, opts) {
|
|
190
|
+
const root = runsRoot(cwd);
|
|
191
|
+
const result = { deleted: [], kept: [], skippedUnsafe: [] };
|
|
192
|
+
if (!fs.existsSync(root))
|
|
193
|
+
return result;
|
|
194
|
+
const cutoff = (opts.now ?? Date.now()) - opts.olderThanDays * 86_400_000;
|
|
195
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
196
|
+
for (const d of entries) {
|
|
197
|
+
if (d.name === INDEX_FILE)
|
|
198
|
+
continue;
|
|
199
|
+
const runId = d.name;
|
|
200
|
+
const runDir = path.join(root, runId);
|
|
201
|
+
// Symlinks (whether to dirs or files) are flagged unsafe. Dirent's
|
|
202
|
+
// isDirectory() returns FALSE for a symlink even if the target is a
|
|
203
|
+
// directory, which matches our policy here — we only operate on real
|
|
204
|
+
// dirs that lstat agrees are not links.
|
|
205
|
+
if (d.isSymbolicLink()) {
|
|
206
|
+
result.skippedUnsafe.push(runId);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (!d.isDirectory())
|
|
210
|
+
continue;
|
|
211
|
+
let lst;
|
|
212
|
+
try {
|
|
213
|
+
lst = fs.lstatSync(runDir);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
result.skippedUnsafe.push(runId);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!lst.isDirectory() || lst.isSymbolicLink()) {
|
|
220
|
+
result.skippedUnsafe.push(runId);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Read state to decide. If unreadable, skip — `runs doctor` will deal.
|
|
224
|
+
let state = null;
|
|
225
|
+
try {
|
|
226
|
+
const sp = path.join(runDir, 'state.json');
|
|
227
|
+
if (fs.existsSync(sp)) {
|
|
228
|
+
state = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// fall through
|
|
233
|
+
}
|
|
234
|
+
if (!state) {
|
|
235
|
+
// Defensive: try to derive from ULID alone for "old enough" check.
|
|
236
|
+
// If runId isn't a ULID we treat it as suspicious and skip.
|
|
237
|
+
let createdMs;
|
|
238
|
+
try {
|
|
239
|
+
createdMs = decodeTime(runId);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
result.skippedUnsafe.push(runId);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (createdMs >= cutoff) {
|
|
246
|
+
result.kept.push(runId);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Fall through — eligible for delete.
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const terminal = state.status === 'success' || state.status === 'failed' || state.status === 'aborted';
|
|
253
|
+
if (!terminal) {
|
|
254
|
+
result.kept.push(runId);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
const endMs = state.endedAt ? Date.parse(state.endedAt) : Date.parse(state.startedAt);
|
|
258
|
+
if (Number.isFinite(endMs) && endMs >= cutoff) {
|
|
259
|
+
result.kept.push(runId);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (opts.dryRun) {
|
|
264
|
+
result.deleted.push(runId);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
// Defense in depth: refuse to recurse out via a symlink hidden inside.
|
|
269
|
+
// fs.rmSync with `force: true, recursive: true` handles dirs but
|
|
270
|
+
// also follows nothing — it doesn't traverse symlinks for deletion
|
|
271
|
+
// boundaries (it deletes the link, not the target).
|
|
272
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
273
|
+
result.deleted.push(runId);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
result.skippedUnsafe.push(runId);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Refresh the index after a real GC pass.
|
|
280
|
+
if (!opts.dryRun && result.deleted.length > 0) {
|
|
281
|
+
try {
|
|
282
|
+
rebuildIndex(cwd);
|
|
283
|
+
}
|
|
284
|
+
catch { /* index is cache */ }
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
//# sourceMappingURL=runs.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type PhaseSnapshot } from './types.ts';
|
|
2
|
+
export declare function phasesDir(runDir: string): string;
|
|
3
|
+
export declare function phaseSnapshotPath(runDir: string, phaseName: string): string;
|
|
4
|
+
/** Write a per-phase snapshot atomically. Identical sequence to
|
|
5
|
+
* state.json:
|
|
6
|
+
* open(tmp, 'w') → write → fsync(fd) → close → rename → fsync(dirfd).
|
|
7
|
+
*
|
|
8
|
+
* Any pre-existing snapshot is left untouched until the rename, so a crash
|
|
9
|
+
* mid-write leaves the previous snapshot intact. */
|
|
10
|
+
export declare function writePhaseSnapshot(runDir: string, snapshot: PhaseSnapshot): void;
|
|
11
|
+
/** Read a per-phase snapshot. Returns null if missing. Throws
|
|
12
|
+
* GuardrailError(corrupted_state) if it's present-but-unparseable. */
|
|
13
|
+
export declare function readPhaseSnapshot(runDir: string, phaseName: string): PhaseSnapshot | null;
|
|
14
|
+
//# sourceMappingURL=snapshot.d.ts.map
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// src/core/run-state/snapshot.ts
|
|
2
|
+
//
|
|
3
|
+
// Atomic per-phase snapshot writer/reader. Each phase, after run, gets a
|
|
4
|
+
// `phases/<name>.json` artifact mirroring the corresponding entry in
|
|
5
|
+
// state.json. Writes use the same tmp+rename+fsync protocol as state.json so
|
|
6
|
+
// a crash mid-write never leaves a half-baked phase snapshot on disk.
|
|
7
|
+
//
|
|
8
|
+
// Phase 1 left this as a TODO; Phase 2 fills it in to back the lifecycle
|
|
9
|
+
// wrapper (`runPhase`).
|
|
10
|
+
//
|
|
11
|
+
// Spec: docs/specs/v6-run-state-engine.md "State on disk" — `phases/<name>.json`.
|
|
12
|
+
import * as fs from 'node:fs';
|
|
13
|
+
import * as path from 'node:path';
|
|
14
|
+
import { GuardrailError } from "../errors.js";
|
|
15
|
+
const PHASES_DIR = 'phases';
|
|
16
|
+
export function phasesDir(runDir) {
|
|
17
|
+
return path.join(runDir, PHASES_DIR);
|
|
18
|
+
}
|
|
19
|
+
export function phaseSnapshotPath(runDir, phaseName) {
|
|
20
|
+
return path.join(phasesDir(runDir), `${sanitizePhaseFilename(phaseName)}.json`);
|
|
21
|
+
}
|
|
22
|
+
/** Reject filename characters that would escape `phases/`. Phase names are
|
|
23
|
+
* caller-supplied strings; we bound them to a safe charset rather than
|
|
24
|
+
* letting `..` / path separators sneak in.
|
|
25
|
+
*
|
|
26
|
+
* Allowed: ASCII alphanumerics, dash, underscore, dot. Anything else is
|
|
27
|
+
* rejected with a typed error so callers can correct the call-site rather
|
|
28
|
+
* than silently producing a write to `../somewhere`. */
|
|
29
|
+
function sanitizePhaseFilename(phaseName) {
|
|
30
|
+
if (!phaseName || typeof phaseName !== 'string') {
|
|
31
|
+
throw new GuardrailError(`phase snapshot: name must be a non-empty string`, { code: 'invalid_config', provider: 'run-state', details: { phaseName } });
|
|
32
|
+
}
|
|
33
|
+
if (!/^[A-Za-z0-9._-]+$/.test(phaseName)) {
|
|
34
|
+
throw new GuardrailError(`phase snapshot: name "${phaseName}" contains unsupported characters`, { code: 'invalid_config', provider: 'run-state', details: { phaseName } });
|
|
35
|
+
}
|
|
36
|
+
return phaseName;
|
|
37
|
+
}
|
|
38
|
+
/** Write a per-phase snapshot atomically. Identical sequence to
|
|
39
|
+
* state.json:
|
|
40
|
+
* open(tmp, 'w') → write → fsync(fd) → close → rename → fsync(dirfd).
|
|
41
|
+
*
|
|
42
|
+
* Any pre-existing snapshot is left untouched until the rename, so a crash
|
|
43
|
+
* mid-write leaves the previous snapshot intact. */
|
|
44
|
+
export function writePhaseSnapshot(runDir, snapshot) {
|
|
45
|
+
const dir = phasesDir(runDir);
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
const target = phaseSnapshotPath(runDir, snapshot.name);
|
|
48
|
+
const tmp = `${target}.tmp`;
|
|
49
|
+
const data = JSON.stringify(snapshot, null, 2);
|
|
50
|
+
const fd = fs.openSync(tmp, 'w');
|
|
51
|
+
let wroteOk = false;
|
|
52
|
+
try {
|
|
53
|
+
fs.writeSync(fd, data);
|
|
54
|
+
fs.fsyncSync(fd);
|
|
55
|
+
wroteOk = true;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
fs.closeSync(fd);
|
|
59
|
+
if (!wroteOk) {
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(tmp);
|
|
62
|
+
}
|
|
63
|
+
catch { /* ignore */ }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
fs.renameSync(tmp, target);
|
|
67
|
+
// Best-effort dir fsync for rename durability. Same EISDIR/EPERM/ENOTSUP
|
|
68
|
+
// tolerance as state.ts (tmpfs / SMB / Windows quirks).
|
|
69
|
+
try {
|
|
70
|
+
const dirFd = fs.openSync(dir, 'r');
|
|
71
|
+
try {
|
|
72
|
+
fs.fsyncSync(dirFd);
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
fs.closeSync(dirFd);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const code = err.code;
|
|
80
|
+
if (code !== 'EISDIR' && code !== 'EPERM' && code !== 'ENOTSUP') {
|
|
81
|
+
throw new GuardrailError(`phase snapshot: dir fsync failed: ${err.message}`, {
|
|
82
|
+
code: 'corrupted_state',
|
|
83
|
+
provider: 'run-state',
|
|
84
|
+
details: { runDir, phaseName: snapshot.name, errno: code },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Read a per-phase snapshot. Returns null if missing. Throws
|
|
90
|
+
* GuardrailError(corrupted_state) if it's present-but-unparseable. */
|
|
91
|
+
export function readPhaseSnapshot(runDir, phaseName) {
|
|
92
|
+
const p = phaseSnapshotPath(runDir, phaseName);
|
|
93
|
+
if (!fs.existsSync(p))
|
|
94
|
+
return null;
|
|
95
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
96
|
+
if (!raw) {
|
|
97
|
+
throw new GuardrailError(`phase snapshot: empty file ${p}`, {
|
|
98
|
+
code: 'corrupted_state',
|
|
99
|
+
provider: 'run-state',
|
|
100
|
+
details: { runDir, phaseName },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(raw);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw new GuardrailError(`phase snapshot: corrupt JSON: ${err.message}`, {
|
|
108
|
+
code: 'corrupted_state',
|
|
109
|
+
provider: 'run-state',
|
|
110
|
+
details: { runDir, phaseName, error: err.message },
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type RunState, type WriterId } from './types.ts';
|
|
2
|
+
/** Lowest `schema_version` value this binary can replay. Bump only on a
|
|
3
|
+
* major release that drops support for a prior wire shape. */
|
|
4
|
+
export declare const RUN_STATE_MIN_SUPPORTED_SCHEMA_VERSION: 1;
|
|
5
|
+
/** Highest `schema_version` value this binary can replay. Always equal to
|
|
6
|
+
* the writer's `RUN_STATE_SCHEMA_VERSION` — the writer never produces a
|
|
7
|
+
* newer shape than the reader on the same binary. */
|
|
8
|
+
export declare const RUN_STATE_MAX_SUPPORTED_SCHEMA_VERSION: 2;
|
|
9
|
+
export declare function statePath(runDir: string): string;
|
|
10
|
+
/** Write the snapshot atomically. Sequence:
|
|
11
|
+
* open(tmp, 'w') → write → fsync(fd) → close → rename → fsync(dirfd).
|
|
12
|
+
*
|
|
13
|
+
* If any step fails, the tmp file is best-effort-cleaned. The pre-existing
|
|
14
|
+
* state.json is untouched until the rename, so a crash anywhere before
|
|
15
|
+
* rename leaves the previous snapshot intact. */
|
|
16
|
+
export declare function writeStateSnapshot(runDir: string, state: RunState): void;
|
|
17
|
+
/** Read the snapshot. Returns null if missing. Throws GuardrailError(
|
|
18
|
+
* corrupted_state) if it's present but unparseable — recoverState() handles
|
|
19
|
+
* the fallback to events-replay. */
|
|
20
|
+
export declare function readStateSnapshot(runDir: string): RunState | null;
|
|
21
|
+
export interface RecoverStateOptions {
|
|
22
|
+
/** Writer that will own the recovery's `index.rebuilt` event. The
|
|
23
|
+
* caller must already hold the run's advisory lock. */
|
|
24
|
+
writerId: WriterId;
|
|
25
|
+
/** runId override; defaults to basename(runDir). */
|
|
26
|
+
runId?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface RecoverStateResult {
|
|
29
|
+
state: RunState;
|
|
30
|
+
/** True if recovery actually re-derived the snapshot (vs. just reading
|
|
31
|
+
* a healthy one). */
|
|
32
|
+
recovered: boolean;
|
|
33
|
+
/** When `recovered === true`, the cause that triggered the rebuild. */
|
|
34
|
+
cause?: 'missing' | 'corrupt';
|
|
35
|
+
}
|
|
36
|
+
/** Open-or-recover the snapshot. If state.json is missing or corrupt, fall
|
|
37
|
+
* back to events.ndjson replay, persist the result, and emit
|
|
38
|
+
* `index.rebuilt`. The caller MUST already hold the advisory lock. */
|
|
39
|
+
export declare function recoverState(runDir: string, opts: RecoverStateOptions): RecoverStateResult;
|
|
40
|
+
//# sourceMappingURL=state.d.ts.map
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// src/core/run-state/state.ts
|
|
2
|
+
//
|
|
3
|
+
// Atomic snapshot writer for state.json. Persistence protocol per spec:
|
|
4
|
+
// 1. write to state.json.tmp
|
|
5
|
+
// 2. fsync(file) on the tmp
|
|
6
|
+
// 3. rename tmp → state.json
|
|
7
|
+
// 4. fsync(dir) so the rename is durable
|
|
8
|
+
//
|
|
9
|
+
// readStateSnapshot() returns null if the snapshot is missing (the canonical
|
|
10
|
+
// "fresh run" state) and throws if it's present-but-corrupt. recoverState()
|
|
11
|
+
// is the resilience entry point — it falls back to events.ndjson replay if
|
|
12
|
+
// the snapshot is unreadable, then rewrites a clean snapshot and emits an
|
|
13
|
+
// `index.rebuilt` event.
|
|
14
|
+
//
|
|
15
|
+
// Spec: docs/specs/v6-run-state-engine.md "Persistence protocol — Durable
|
|
16
|
+
// append", "Failure modes the user should never have to debug".
|
|
17
|
+
import * as fs from 'node:fs';
|
|
18
|
+
import * as path from 'node:path';
|
|
19
|
+
import { GuardrailError } from "../errors.js";
|
|
20
|
+
import { appendEvent, replayState } from "./events.js";
|
|
21
|
+
import { RUN_STATE_SCHEMA_VERSION } from "./types.js";
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// v6.2.2 — cache contract version policy (per spec / codex WARNING #1).
|
|
24
|
+
//
|
|
25
|
+
// `replayState()` uses these bounds to reject run dirs whose `schema_version`
|
|
26
|
+
// is outside the supported window. Strict equality would block resume across
|
|
27
|
+
// rolling deploys / mixed binary fleets — the window allows additive minor
|
|
28
|
+
// schema bumps to ship without breaking forward-read on older readers, and a
|
|
29
|
+
// future major (v7) resets `MIN_SUPPORTED` to break with the past explicitly.
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/** Lowest `schema_version` value this binary can replay. Bump only on a
|
|
32
|
+
* major release that drops support for a prior wire shape. */
|
|
33
|
+
export const RUN_STATE_MIN_SUPPORTED_SCHEMA_VERSION = 1;
|
|
34
|
+
/** Highest `schema_version` value this binary can replay. Always equal to
|
|
35
|
+
* the writer's `RUN_STATE_SCHEMA_VERSION` — the writer never produces a
|
|
36
|
+
* newer shape than the reader on the same binary. */
|
|
37
|
+
export const RUN_STATE_MAX_SUPPORTED_SCHEMA_VERSION = RUN_STATE_SCHEMA_VERSION;
|
|
38
|
+
const STATE_FILE = 'state.json';
|
|
39
|
+
const STATE_TMP = 'state.json.tmp';
|
|
40
|
+
export function statePath(runDir) {
|
|
41
|
+
return path.join(runDir, STATE_FILE);
|
|
42
|
+
}
|
|
43
|
+
function tmpPath(runDir) {
|
|
44
|
+
return path.join(runDir, STATE_TMP);
|
|
45
|
+
}
|
|
46
|
+
/** Write the snapshot atomically. Sequence:
|
|
47
|
+
* open(tmp, 'w') → write → fsync(fd) → close → rename → fsync(dirfd).
|
|
48
|
+
*
|
|
49
|
+
* If any step fails, the tmp file is best-effort-cleaned. The pre-existing
|
|
50
|
+
* state.json is untouched until the rename, so a crash anywhere before
|
|
51
|
+
* rename leaves the previous snapshot intact. */
|
|
52
|
+
export function writeStateSnapshot(runDir, state) {
|
|
53
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
54
|
+
const data = JSON.stringify(state, null, 2);
|
|
55
|
+
const tmp = tmpPath(runDir);
|
|
56
|
+
const target = statePath(runDir);
|
|
57
|
+
const fd = fs.openSync(tmp, 'w');
|
|
58
|
+
let wroteOk = false;
|
|
59
|
+
try {
|
|
60
|
+
fs.writeSync(fd, data);
|
|
61
|
+
fs.fsyncSync(fd);
|
|
62
|
+
wroteOk = true;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
fs.closeSync(fd);
|
|
66
|
+
if (!wroteOk) {
|
|
67
|
+
try {
|
|
68
|
+
fs.unlinkSync(tmp);
|
|
69
|
+
}
|
|
70
|
+
catch { /* ignore */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
fs.renameSync(tmp, target);
|
|
74
|
+
// fsync the parent directory so the rename is durable on power-loss.
|
|
75
|
+
// On Linux/macOS this is best-effort (ENOTSUP on some filesystems for
|
|
76
|
+
// dir fds); we swallow expected platform-specific failures so a
|
|
77
|
+
// working-directory on tmpfs / SMB / etc. doesn't break the writer.
|
|
78
|
+
try {
|
|
79
|
+
const dirFd = fs.openSync(runDir, 'r');
|
|
80
|
+
try {
|
|
81
|
+
fs.fsyncSync(dirFd);
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
fs.closeSync(dirFd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
const code = err.code;
|
|
89
|
+
// EISDIR happens on some Windows configs where opening a dir for read
|
|
90
|
+
// isn't permitted; EPERM/ENOTSUP on certain FS. We don't escalate —
|
|
91
|
+
// the rename itself is atomic; dir-fsync is a defense-in-depth.
|
|
92
|
+
if (code !== 'EISDIR' && code !== 'EPERM' && code !== 'ENOTSUP') {
|
|
93
|
+
// Anything else, surface as warning via a thrown GuardrailError so
|
|
94
|
+
// callers can decide. We choose `corrupted_state` as the closest
|
|
95
|
+
// category since the snapshot may not have been durably committed.
|
|
96
|
+
throw new GuardrailError(`state.json: dir fsync failed: ${err.message}`, {
|
|
97
|
+
code: 'corrupted_state',
|
|
98
|
+
provider: 'run-state',
|
|
99
|
+
details: { runDir, errno: code },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Read the snapshot. Returns null if missing. Throws GuardrailError(
|
|
105
|
+
* corrupted_state) if it's present but unparseable — recoverState() handles
|
|
106
|
+
* the fallback to events-replay. */
|
|
107
|
+
export function readStateSnapshot(runDir) {
|
|
108
|
+
const p = statePath(runDir);
|
|
109
|
+
if (!fs.existsSync(p))
|
|
110
|
+
return null;
|
|
111
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
112
|
+
if (!raw) {
|
|
113
|
+
throw new GuardrailError(`state.json: empty file`, {
|
|
114
|
+
code: 'corrupted_state',
|
|
115
|
+
provider: 'run-state',
|
|
116
|
+
details: { runDir },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(raw);
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
throw new GuardrailError(`state.json: corrupt JSON: ${err.message}`, {
|
|
124
|
+
code: 'corrupted_state',
|
|
125
|
+
provider: 'run-state',
|
|
126
|
+
details: { runDir, error: err.message },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Open-or-recover the snapshot. If state.json is missing or corrupt, fall
|
|
131
|
+
* back to events.ndjson replay, persist the result, and emit
|
|
132
|
+
* `index.rebuilt`. The caller MUST already hold the advisory lock. */
|
|
133
|
+
export function recoverState(runDir, opts) {
|
|
134
|
+
let cause = null;
|
|
135
|
+
let snapshot = null;
|
|
136
|
+
try {
|
|
137
|
+
snapshot = readStateSnapshot(runDir);
|
|
138
|
+
if (!snapshot)
|
|
139
|
+
cause = 'missing';
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
if (err instanceof GuardrailError && err.code === 'corrupted_state') {
|
|
143
|
+
cause = 'corrupt';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!cause && snapshot) {
|
|
150
|
+
return { state: snapshot, recovered: false };
|
|
151
|
+
}
|
|
152
|
+
// Recovery path. Replay first, then persist, then emit event in that
|
|
153
|
+
// order — emitting the event before persisting would leave a record of
|
|
154
|
+
// a recovery that didn't actually land if we crash between the two.
|
|
155
|
+
const replayed = replayState(runDir);
|
|
156
|
+
writeStateSnapshot(runDir, replayed);
|
|
157
|
+
appendEvent(runDir, { event: 'index.rebuilt', cause: cause }, { writerId: opts.writerId, ...(opts.runId !== undefined ? { runId: opts.runId } : {}) });
|
|
158
|
+
return {
|
|
159
|
+
state: replayed,
|
|
160
|
+
recovered: true,
|
|
161
|
+
cause: cause,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=state.js.map
|