@delegance/claude-autopilot 5.2.2 → 6.2.2
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 +1027 -1
- package/README.md +104 -17
- package/dist/src/adapters/council/claude.js +2 -1
- package/dist/src/adapters/council/openai.js +14 -7
- 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/generic.d.ts +39 -0
- package/dist/src/adapters/deploy/generic.js +98 -0
- package/dist/src/adapters/deploy/index.d.ts +15 -0
- package/dist/src/adapters/deploy/index.js +78 -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 +221 -0
- package/dist/src/adapters/deploy/types.js +15 -0
- package/dist/src/adapters/deploy/vercel.d.ts +143 -0
- package/dist/src/adapters/deploy/vercel.js +426 -0
- package/dist/src/adapters/pricing.d.ts +36 -0
- package/dist/src/adapters/pricing.js +40 -0
- package/dist/src/adapters/review-engine/claude.js +2 -1
- package/dist/src/adapters/review-engine/codex.js +12 -8
- package/dist/src/adapters/review-engine/gemini.js +2 -1
- package/dist/src/adapters/review-engine/openai-compatible.js +2 -1
- package/dist/src/adapters/sdk-loader.d.ts +15 -0
- package/dist/src/adapters/sdk-loader.js +77 -0
- package/dist/src/cli/autopilot.d.ts +71 -0
- package/dist/src/cli/autopilot.js +735 -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/deploy.d.ts +71 -0
- package/dist/src/cli/deploy.js +539 -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 +400 -0
- package/dist/src/cli/implement.d.ts +91 -0
- package/dist/src/cli/implement.js +196 -0
- package/dist/src/cli/index.js +784 -222
- 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 +102 -1
- 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/scan.d.ts +93 -0
- package/dist/src/cli/scan.js +166 -40
- 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 +43 -0
- package/dist/src/core/config/schema.js +25 -0
- package/dist/src/core/config/types.d.ts +17 -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 +12 -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/detector-rules.js +6 -0
- package/dist/src/core/migrate/schema-validator.js +22 -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 +504 -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 +100 -0
- package/dist/src/core/run-state/resolve-engine.js +190 -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 +73 -0
- package/dist/src/core/run-state/run-phase-with-lifecycle.js +186 -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 +278 -0
- package/dist/src/core/run-state/types.js +13 -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/package.json +9 -5
- package/scripts/autoregress.ts +3 -2
- package/skills/claude-autopilot.md +1 -1
- package/skills/make-interfaces-feel-better/SKILL.md +104 -0
- package/skills/migrate/SKILL.md +193 -47
- 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,902 @@
|
|
|
1
|
+
// src/cli/runs.ts
|
|
2
|
+
//
|
|
3
|
+
// v6 Phase 3 — user-facing CLI surface over the Phase 1 (persistence) and
|
|
4
|
+
// Phase 2 (phase wrapper) APIs. Six verbs, all read-only or scoped-delete:
|
|
5
|
+
//
|
|
6
|
+
// runs list — wrap listRuns; --status filter; newest-first
|
|
7
|
+
// runs show <id> — render state.json + tail of events.ndjson
|
|
8
|
+
// runs gc — wrap gcRuns; default 30d cutoff; confirmation
|
|
9
|
+
// runs delete <id> — explicit single-run delete, terminal-status only
|
|
10
|
+
// run resume <id> — LOOKUP-ONLY: identify nextPhase + decision
|
|
11
|
+
// runs doctor — replay events vs. state.json; report drift; --fix
|
|
12
|
+
//
|
|
13
|
+
// Phase 3 is read/inspect + GC. Actual phase execution on resume lands in
|
|
14
|
+
// Phase 6+; here `run resume` just answers "what would happen if I resumed?".
|
|
15
|
+
// This is documented in the function body and in `runs resume --help` text.
|
|
16
|
+
//
|
|
17
|
+
// Spec: docs/specs/v6-run-state-engine.md "Resume command", "CLI `--json`
|
|
18
|
+
// mode", "Migration path". `--json` envelope shape in Phase 3 is the v1
|
|
19
|
+
// surface; strict stdout/stderr channel discipline lands in Phase 5.
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import * as readline from 'node:readline';
|
|
23
|
+
import { GuardrailError } from "../core/errors.js";
|
|
24
|
+
import { foldEvents, readEvents } from "../core/run-state/events.js";
|
|
25
|
+
import { acquireRunLock } from "../core/run-state/lock.js";
|
|
26
|
+
import { decideReplay } from "../core/run-state/replay-decision.js";
|
|
27
|
+
import { readStateSnapshot, statePath, writeStateSnapshot } from "../core/run-state/state.js";
|
|
28
|
+
import { isValidULID } from "../core/run-state/ulid.js";
|
|
29
|
+
import { gcRuns, listRuns, rebuildIndex, runDirFor, runsRoot, } from "../core/run-state/runs.js";
|
|
30
|
+
// ----------------------------------------------------------------------------
|
|
31
|
+
// Shared envelope shape for --json output. Phase 3 keeps the surface minimal;
|
|
32
|
+
// strict stdout/stderr discipline + per-command schema validation lands in
|
|
33
|
+
// Phase 5 (see spec "CLI --json mode + strict channel discipline").
|
|
34
|
+
// ----------------------------------------------------------------------------
|
|
35
|
+
const ENVELOPE_SCHEMA_VERSION = 1;
|
|
36
|
+
/** Validate that `runId` is a ULID. Throws GuardrailError(invalid_config) if
|
|
37
|
+
* not — keeps the surface uniform across verbs that take a runId. */
|
|
38
|
+
function assertValidRunId(runId) {
|
|
39
|
+
if (!runId) {
|
|
40
|
+
throw new GuardrailError('a run id is required', {
|
|
41
|
+
code: 'invalid_config',
|
|
42
|
+
provider: 'runs-cli',
|
|
43
|
+
details: { runId },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (!isValidULID(runId)) {
|
|
47
|
+
throw new GuardrailError(`run id is not a valid ULID: ${runId}`, {
|
|
48
|
+
code: 'invalid_config',
|
|
49
|
+
provider: 'runs-cli',
|
|
50
|
+
details: { runId },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** Format a GuardrailError into a one-line `[code] message` string. */
|
|
55
|
+
function formatErr(err) {
|
|
56
|
+
if (err instanceof GuardrailError)
|
|
57
|
+
return `${err.code}: ${err.message}`;
|
|
58
|
+
if (err instanceof Error)
|
|
59
|
+
return err.message;
|
|
60
|
+
return String(err);
|
|
61
|
+
}
|
|
62
|
+
/** Convert a result + json-mode flag into the final envelope when --json is
|
|
63
|
+
* set. The envelope is emitted as a single line on stdout; stderr lines from
|
|
64
|
+
* the result are dropped under --json (Phase 3 limitation; Phase 5 will route
|
|
65
|
+
* them as NDJSON events). */
|
|
66
|
+
function maybeEnvelope(command, json, result, payload) {
|
|
67
|
+
if (!json)
|
|
68
|
+
return result;
|
|
69
|
+
const envelope = {
|
|
70
|
+
schema_version: ENVELOPE_SCHEMA_VERSION,
|
|
71
|
+
command,
|
|
72
|
+
status: result.exit === 0 ? 'pass' : 'fail',
|
|
73
|
+
exit: result.exit,
|
|
74
|
+
...payload,
|
|
75
|
+
};
|
|
76
|
+
return { exit: result.exit, stdout: [JSON.stringify(envelope)], stderr: [] };
|
|
77
|
+
}
|
|
78
|
+
const VALID_STATUS_FILTERS = new Set([
|
|
79
|
+
'pending',
|
|
80
|
+
'running',
|
|
81
|
+
'paused',
|
|
82
|
+
'success',
|
|
83
|
+
'failed',
|
|
84
|
+
'aborted',
|
|
85
|
+
]);
|
|
86
|
+
/** `runs list` — newest-first listing. Optional --status filter narrows to
|
|
87
|
+
* one RunStatus. `--json` emits an envelope; text mode prints a tight
|
|
88
|
+
* table. */
|
|
89
|
+
export async function runRunsList(opts) {
|
|
90
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
91
|
+
const json = !!opts.json;
|
|
92
|
+
// The status filter shares the spec's RunStatus shape but the CLI accepts
|
|
93
|
+
// a couple of common shorthands. We map "running" to 'running' (literal),
|
|
94
|
+
// "completed" to 'success' (the spec's terminal-success status), and
|
|
95
|
+
// "failed" to 'failed'. Anything else is rejected before we list.
|
|
96
|
+
let statusFilter;
|
|
97
|
+
if (opts.status) {
|
|
98
|
+
const s = opts.status.toLowerCase();
|
|
99
|
+
if (s === 'completed' || s === 'complete')
|
|
100
|
+
statusFilter = 'success';
|
|
101
|
+
else if (VALID_STATUS_FILTERS.has(s))
|
|
102
|
+
statusFilter = s;
|
|
103
|
+
else {
|
|
104
|
+
const err = new GuardrailError(`--status must be one of: pending, running, paused, completed, failed, aborted (got "${opts.status}")`, { code: 'invalid_config', provider: 'runs-cli', details: { status: opts.status } });
|
|
105
|
+
const result = {
|
|
106
|
+
exit: 1,
|
|
107
|
+
stdout: [],
|
|
108
|
+
stderr: [`[claude-autopilot] runs list: ${formatErr(err)}`],
|
|
109
|
+
};
|
|
110
|
+
return maybeEnvelope('runs list', json, result, {
|
|
111
|
+
error: formatErr(err),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let entries;
|
|
116
|
+
try {
|
|
117
|
+
entries = listRuns(cwd, { rebuild: true });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
const result = {
|
|
121
|
+
exit: 1,
|
|
122
|
+
stdout: [],
|
|
123
|
+
stderr: [`[claude-autopilot] runs list: ${formatErr(err)}`],
|
|
124
|
+
};
|
|
125
|
+
return maybeEnvelope('runs list', json, result, { error: formatErr(err) });
|
|
126
|
+
}
|
|
127
|
+
if (statusFilter) {
|
|
128
|
+
entries = entries.filter(e => e.status === statusFilter);
|
|
129
|
+
}
|
|
130
|
+
if (json) {
|
|
131
|
+
return maybeEnvelope('runs list', true, { exit: 0, stdout: [], stderr: [] }, { runs: entries, count: entries.length, ...(statusFilter ? { statusFilter } : {}) });
|
|
132
|
+
}
|
|
133
|
+
if (entries.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
exit: 0,
|
|
136
|
+
stdout: ['No runs.' + (statusFilter ? ` (filtered to status="${statusFilter}")` : '')],
|
|
137
|
+
stderr: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Tight text table. Columns: runId | status | startedAt | cost | lastPhase
|
|
141
|
+
const lines = [];
|
|
142
|
+
lines.push(formatRunRow('runId', 'status', 'started', 'cost', 'lastPhase'));
|
|
143
|
+
lines.push(formatRunRow('-----', '------', '-------', '----', '---------'));
|
|
144
|
+
for (const e of entries) {
|
|
145
|
+
lines.push(formatRunRow(e.runId, e.status + (e.recovered ? '*' : ''), e.startedAt, `$${e.totalCostUSD.toFixed(2)}`, e.lastPhase ?? '-'));
|
|
146
|
+
}
|
|
147
|
+
if (entries.some(e => e.recovered)) {
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('* state recovered from events.ndjson — run `claude-autopilot runs doctor` to inspect');
|
|
150
|
+
}
|
|
151
|
+
return { exit: 0, stdout: lines, stderr: [] };
|
|
152
|
+
}
|
|
153
|
+
const COL_RUNID = 28;
|
|
154
|
+
const COL_STATUS = 11;
|
|
155
|
+
const COL_STARTED = 26;
|
|
156
|
+
const COL_COST = 9;
|
|
157
|
+
function pad(s, n) {
|
|
158
|
+
if (s.length >= n)
|
|
159
|
+
return s + ' ';
|
|
160
|
+
return s + ' '.repeat(n - s.length);
|
|
161
|
+
}
|
|
162
|
+
function formatRunRow(runId, status, startedAt, cost, lastPhase) {
|
|
163
|
+
return (pad(runId, COL_RUNID) +
|
|
164
|
+
pad(status, COL_STATUS) +
|
|
165
|
+
pad(startedAt, COL_STARTED) +
|
|
166
|
+
pad(cost, COL_COST) +
|
|
167
|
+
lastPhase);
|
|
168
|
+
}
|
|
169
|
+
/** `runs show <id>` — render state.json (or replay if missing) plus, with
|
|
170
|
+
* --events, the tail of events.ndjson. JSON mode bundles state + events into
|
|
171
|
+
* the envelope. */
|
|
172
|
+
export async function runRunsShow(opts) {
|
|
173
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
174
|
+
const json = !!opts.json;
|
|
175
|
+
const tail = opts.eventsTail ?? 20;
|
|
176
|
+
try {
|
|
177
|
+
assertValidRunId(opts.runId);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
const result = {
|
|
181
|
+
exit: 1,
|
|
182
|
+
stdout: [],
|
|
183
|
+
stderr: [`[claude-autopilot] runs show: ${formatErr(err)}`],
|
|
184
|
+
};
|
|
185
|
+
return maybeEnvelope('runs show', json, result, { error: formatErr(err) });
|
|
186
|
+
}
|
|
187
|
+
const runDir = runDirFor(cwd, opts.runId);
|
|
188
|
+
if (!fs.existsSync(runDir)) {
|
|
189
|
+
const err = new GuardrailError(`run not found: ${opts.runId}`, {
|
|
190
|
+
code: 'not_found',
|
|
191
|
+
provider: 'runs-cli',
|
|
192
|
+
details: { runId: opts.runId, runDir },
|
|
193
|
+
});
|
|
194
|
+
const result = {
|
|
195
|
+
exit: 1,
|
|
196
|
+
stdout: [],
|
|
197
|
+
stderr: [`[claude-autopilot] runs show: ${formatErr(err)}`],
|
|
198
|
+
};
|
|
199
|
+
return maybeEnvelope('runs show', json, result, { error: formatErr(err), runId: opts.runId });
|
|
200
|
+
}
|
|
201
|
+
// Read state.json — if missing/corrupt, fall back to in-memory replay so
|
|
202
|
+
// we never modify the run during a read-only show.
|
|
203
|
+
let state = null;
|
|
204
|
+
let recovered = false;
|
|
205
|
+
try {
|
|
206
|
+
state = readStateSnapshot(runDir);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
recovered = true;
|
|
210
|
+
}
|
|
211
|
+
if (!state) {
|
|
212
|
+
try {
|
|
213
|
+
const { events: replayEvents } = readEvents(runDir);
|
|
214
|
+
state = foldEvents(runDir, replayEvents);
|
|
215
|
+
recovered = true;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
const result = {
|
|
219
|
+
exit: 1,
|
|
220
|
+
stdout: [],
|
|
221
|
+
stderr: [`[claude-autopilot] runs show: ${formatErr(err)}`],
|
|
222
|
+
};
|
|
223
|
+
return maybeEnvelope('runs show', json, result, {
|
|
224
|
+
error: formatErr(err),
|
|
225
|
+
runId: opts.runId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Optional event tail (always read so JSON mode can include it; text mode
|
|
230
|
+
// prints only when --events is set).
|
|
231
|
+
let tailEvents = [];
|
|
232
|
+
if (opts.events || json) {
|
|
233
|
+
try {
|
|
234
|
+
tailEvents = readEvents(runDir, { tail }).events;
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
// Mid-log corruption — surface as warning, keep the snapshot.
|
|
238
|
+
tailEvents = [];
|
|
239
|
+
if (!json) {
|
|
240
|
+
return {
|
|
241
|
+
exit: 1,
|
|
242
|
+
stdout: [],
|
|
243
|
+
stderr: [`[claude-autopilot] runs show: events.ndjson corrupt — ${formatErr(err)}`],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (json) {
|
|
249
|
+
return maybeEnvelope('runs show', true, { exit: 0, stdout: [], stderr: [] }, {
|
|
250
|
+
runId: opts.runId,
|
|
251
|
+
state,
|
|
252
|
+
recovered,
|
|
253
|
+
events: tailEvents,
|
|
254
|
+
eventsTail: tail,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
// Text mode — header + phase checklist.
|
|
258
|
+
const lines = [];
|
|
259
|
+
lines.push(`run ${state.runId} status=${state.status}${recovered ? ' (recovered)' : ''}`);
|
|
260
|
+
lines.push(` started: ${state.startedAt}`);
|
|
261
|
+
if (state.endedAt)
|
|
262
|
+
lines.push(` ended: ${state.endedAt}`);
|
|
263
|
+
lines.push(` cost: $${state.totalCostUSD.toFixed(4)}`);
|
|
264
|
+
lines.push(` cwd: ${state.cwd || '(unknown)'}`);
|
|
265
|
+
lines.push('');
|
|
266
|
+
lines.push('phases:');
|
|
267
|
+
for (const p of state.phases) {
|
|
268
|
+
lines.push(formatPhaseRow(p, p.index === state.currentPhaseIdx));
|
|
269
|
+
if (p.lastError) {
|
|
270
|
+
lines.push(` error: ${p.lastError}`);
|
|
271
|
+
}
|
|
272
|
+
if (p.externalRefs.length > 0) {
|
|
273
|
+
for (const r of p.externalRefs) {
|
|
274
|
+
lines.push(` ref: ${r.kind}=${r.id}${r.url ? ` (${r.url})` : ''}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (opts.events) {
|
|
279
|
+
lines.push('');
|
|
280
|
+
lines.push(`events (last ${tailEvents.length}):`);
|
|
281
|
+
for (const ev of tailEvents) {
|
|
282
|
+
lines.push(` ${ev.seq.toString().padStart(4)} ${ev.ts} ${ev.event}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return { exit: 0, stdout: lines, stderr: [] };
|
|
286
|
+
}
|
|
287
|
+
function statusGlyph(status) {
|
|
288
|
+
switch (status) {
|
|
289
|
+
case 'succeeded':
|
|
290
|
+
return '[x]';
|
|
291
|
+
case 'failed':
|
|
292
|
+
return '[!]';
|
|
293
|
+
case 'running':
|
|
294
|
+
return '[>]';
|
|
295
|
+
case 'aborted':
|
|
296
|
+
return '[-]';
|
|
297
|
+
case 'skipped':
|
|
298
|
+
return '[~]';
|
|
299
|
+
case 'pending':
|
|
300
|
+
default:
|
|
301
|
+
return '[ ]';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function formatPhaseRow(p, isCurrent) {
|
|
305
|
+
const arrow = isCurrent ? ' <-' : '';
|
|
306
|
+
const cost = `$${p.costUSD.toFixed(4)}`;
|
|
307
|
+
const dur = p.durationMs !== undefined ? `${p.durationMs}ms` : '-';
|
|
308
|
+
return ` ${statusGlyph(p.status)} ${p.name.padEnd(14)} ${cost.padEnd(10)} ${dur.padEnd(8)} attempts=${p.attempts}${arrow}`;
|
|
309
|
+
}
|
|
310
|
+
/** `runs gc` — wraps gcRuns with confirmation. Default cutoff 30 days. With
|
|
311
|
+
* --dry-run, lists what would be removed without touching disk. */
|
|
312
|
+
export async function runRunsGc(opts) {
|
|
313
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
314
|
+
const json = !!opts.json;
|
|
315
|
+
const olderThanDays = opts.olderThanDays ?? 30;
|
|
316
|
+
if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
|
|
317
|
+
const err = new GuardrailError(`--older-than-days must be a non-negative number (got ${olderThanDays})`, { code: 'invalid_config', provider: 'runs-cli', details: { olderThanDays } });
|
|
318
|
+
const result = {
|
|
319
|
+
exit: 1,
|
|
320
|
+
stdout: [],
|
|
321
|
+
stderr: [`[claude-autopilot] runs gc: ${formatErr(err)}`],
|
|
322
|
+
};
|
|
323
|
+
return maybeEnvelope('runs gc', json, result, { error: formatErr(err) });
|
|
324
|
+
}
|
|
325
|
+
// Always start with a dry-run pass so we can preview + ask.
|
|
326
|
+
const preview = gcRuns(cwd, { olderThanDays, dryRun: true });
|
|
327
|
+
if (preview.deleted.length === 0) {
|
|
328
|
+
const result = {
|
|
329
|
+
exit: 0,
|
|
330
|
+
stdout: [
|
|
331
|
+
`runs gc: nothing to delete (cutoff ${olderThanDays} days; ${preview.kept.length} kept, ${preview.skippedUnsafe.length} skipped unsafe)`,
|
|
332
|
+
],
|
|
333
|
+
stderr: [],
|
|
334
|
+
};
|
|
335
|
+
return maybeEnvelope('runs gc', json, result, {
|
|
336
|
+
olderThanDays,
|
|
337
|
+
candidates: [],
|
|
338
|
+
deleted: [],
|
|
339
|
+
kept: preview.kept,
|
|
340
|
+
skippedUnsafe: preview.skippedUnsafe,
|
|
341
|
+
dryRun: true,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
if (opts.dryRun) {
|
|
345
|
+
const lines = [
|
|
346
|
+
`runs gc (dry-run): would delete ${preview.deleted.length} run(s)`,
|
|
347
|
+
...preview.deleted.map(id => ` - ${id}`),
|
|
348
|
+
`kept ${preview.kept.length}, skipped unsafe ${preview.skippedUnsafe.length}`,
|
|
349
|
+
];
|
|
350
|
+
return maybeEnvelope('runs gc', json, { exit: 0, stdout: lines, stderr: [] }, {
|
|
351
|
+
olderThanDays,
|
|
352
|
+
candidates: preview.deleted,
|
|
353
|
+
deleted: [],
|
|
354
|
+
kept: preview.kept,
|
|
355
|
+
skippedUnsafe: preview.skippedUnsafe,
|
|
356
|
+
dryRun: true,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
// Confirmation. --yes skips; --json implies non-interactive — we require
|
|
360
|
+
// --yes there to avoid blocking a CI invocation.
|
|
361
|
+
if (!opts.yes) {
|
|
362
|
+
if (json || !process.stdin.isTTY) {
|
|
363
|
+
const err = new GuardrailError(`non-interactive: pass --yes to confirm deletion of ${preview.deleted.length} run(s)`, {
|
|
364
|
+
code: 'invalid_config',
|
|
365
|
+
provider: 'runs-cli',
|
|
366
|
+
details: { candidates: preview.deleted },
|
|
367
|
+
});
|
|
368
|
+
const result = {
|
|
369
|
+
exit: 1,
|
|
370
|
+
stdout: [],
|
|
371
|
+
stderr: [`[claude-autopilot] runs gc: ${formatErr(err)}`],
|
|
372
|
+
};
|
|
373
|
+
return maybeEnvelope('runs gc', json, result, {
|
|
374
|
+
error: formatErr(err),
|
|
375
|
+
candidates: preview.deleted,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
const confirmed = await confirmInteractive(`Delete ${preview.deleted.length} run(s) older than ${olderThanDays} days? [y/N] `);
|
|
379
|
+
if (!confirmed) {
|
|
380
|
+
return {
|
|
381
|
+
exit: 0,
|
|
382
|
+
stdout: ['runs gc: aborted'],
|
|
383
|
+
stderr: [],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Real pass.
|
|
388
|
+
const real = gcRuns(cwd, { olderThanDays });
|
|
389
|
+
const lines = [
|
|
390
|
+
`runs gc: deleted ${real.deleted.length} run(s)`,
|
|
391
|
+
...real.deleted.map(id => ` - ${id}`),
|
|
392
|
+
`kept ${real.kept.length}, skipped unsafe ${real.skippedUnsafe.length}`,
|
|
393
|
+
];
|
|
394
|
+
return maybeEnvelope('runs gc', json, { exit: 0, stdout: lines, stderr: [] }, {
|
|
395
|
+
olderThanDays,
|
|
396
|
+
deleted: real.deleted,
|
|
397
|
+
kept: real.kept,
|
|
398
|
+
skippedUnsafe: real.skippedUnsafe,
|
|
399
|
+
dryRun: false,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async function confirmInteractive(prompt) {
|
|
403
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
404
|
+
try {
|
|
405
|
+
const answer = await new Promise(resolve => rl.question(prompt, resolve));
|
|
406
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
rl.close();
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const TERMINAL_STATUSES = new Set(['success', 'failed', 'aborted']);
|
|
413
|
+
/** `runs delete <id>` — explicit single-run delete. Refuses non-terminal
|
|
414
|
+
* status without --force; refuses if the run lock is currently held by
|
|
415
|
+
* another writer.
|
|
416
|
+
*
|
|
417
|
+
* We acquire the lock for the duration of the delete so we never race a
|
|
418
|
+
* concurrent writer. Lock acquisition uses a tiny timeout — we want
|
|
419
|
+
* fail-fast over blocking. */
|
|
420
|
+
export async function runRunsDelete(opts) {
|
|
421
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
422
|
+
const json = !!opts.json;
|
|
423
|
+
try {
|
|
424
|
+
assertValidRunId(opts.runId);
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
const result = {
|
|
428
|
+
exit: 1,
|
|
429
|
+
stdout: [],
|
|
430
|
+
stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
|
|
431
|
+
};
|
|
432
|
+
return maybeEnvelope('runs delete', json, result, { error: formatErr(err) });
|
|
433
|
+
}
|
|
434
|
+
const runDir = runDirFor(cwd, opts.runId);
|
|
435
|
+
if (!fs.existsSync(runDir)) {
|
|
436
|
+
const err = new GuardrailError(`run not found: ${opts.runId}`, {
|
|
437
|
+
code: 'not_found',
|
|
438
|
+
provider: 'runs-cli',
|
|
439
|
+
details: { runId: opts.runId, runDir },
|
|
440
|
+
});
|
|
441
|
+
const result = {
|
|
442
|
+
exit: 1,
|
|
443
|
+
stdout: [],
|
|
444
|
+
stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
|
|
445
|
+
};
|
|
446
|
+
return maybeEnvelope('runs delete', json, result, {
|
|
447
|
+
error: formatErr(err),
|
|
448
|
+
runId: opts.runId,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
// Refuse if non-terminal without --force.
|
|
452
|
+
let status = 'unknown';
|
|
453
|
+
try {
|
|
454
|
+
const snap = readStateSnapshot(runDir);
|
|
455
|
+
if (snap)
|
|
456
|
+
status = snap.status;
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// Treat corrupt as unknown — we still let --force win below.
|
|
460
|
+
}
|
|
461
|
+
if (!opts.force && status !== 'unknown' && !TERMINAL_STATUSES.has(status)) {
|
|
462
|
+
const err = new GuardrailError(`run ${opts.runId} status=${status} is not terminal — refusing delete without --force`, {
|
|
463
|
+
code: 'invalid_config',
|
|
464
|
+
provider: 'runs-cli',
|
|
465
|
+
details: { runId: opts.runId, status },
|
|
466
|
+
});
|
|
467
|
+
const result = {
|
|
468
|
+
exit: 1,
|
|
469
|
+
stdout: [],
|
|
470
|
+
stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
|
|
471
|
+
};
|
|
472
|
+
return maybeEnvelope('runs delete', json, result, {
|
|
473
|
+
error: formatErr(err),
|
|
474
|
+
runId: opts.runId,
|
|
475
|
+
status,
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
// Lock acquisition. If the lock is held we surface lock_held.
|
|
479
|
+
let lock;
|
|
480
|
+
try {
|
|
481
|
+
lock = await acquireRunLock(runDir, { retries: 0 });
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
const result = {
|
|
485
|
+
exit: 1,
|
|
486
|
+
stdout: [],
|
|
487
|
+
stderr: [`[claude-autopilot] runs delete: ${formatErr(err)}`],
|
|
488
|
+
};
|
|
489
|
+
return maybeEnvelope('runs delete', json, result, {
|
|
490
|
+
error: formatErr(err),
|
|
491
|
+
runId: opts.runId,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
try {
|
|
495
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
await lock.release().catch(() => { });
|
|
499
|
+
const result = {
|
|
500
|
+
exit: 1,
|
|
501
|
+
stdout: [],
|
|
502
|
+
stderr: [`[claude-autopilot] runs delete: rm failed: ${formatErr(err)}`],
|
|
503
|
+
};
|
|
504
|
+
return maybeEnvelope('runs delete', json, result, {
|
|
505
|
+
error: formatErr(err),
|
|
506
|
+
runId: opts.runId,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
// Refresh the index — best-effort.
|
|
510
|
+
try {
|
|
511
|
+
rebuildIndex(cwd);
|
|
512
|
+
}
|
|
513
|
+
catch { /* index is cache */ }
|
|
514
|
+
// The lock handle's underlying file is gone — release() will no-op gracefully.
|
|
515
|
+
await lock.release().catch(() => { });
|
|
516
|
+
return maybeEnvelope('runs delete', json, {
|
|
517
|
+
exit: 0,
|
|
518
|
+
stdout: [`runs delete: removed ${opts.runId}`],
|
|
519
|
+
stderr: [],
|
|
520
|
+
}, { runId: opts.runId, deleted: true, status });
|
|
521
|
+
}
|
|
522
|
+
/** `run resume <id>` — Phase 3 LOOKUP ONLY.
|
|
523
|
+
*
|
|
524
|
+
* This verb identifies which phase a future resume would pick up from and
|
|
525
|
+
* the decision the engine would make per the spec's idempotency table. It
|
|
526
|
+
* does NOT execute the phase — that wires in Phase 6+ once the budget
|
|
527
|
+
* enforcer (Phase 4) and the JSON event stream (Phase 5) are in place.
|
|
528
|
+
*
|
|
529
|
+
* Decision rules (mirror `runPhase` in src/core/run-state/phase-runner.ts):
|
|
530
|
+
* - already-complete : run.status === 'success' or every phase succeeded
|
|
531
|
+
* - skip-idempotent : nextPhase has a prior phase.success AND idempotent
|
|
532
|
+
* - needs-human : nextPhase has a prior phase.success AND side-effects
|
|
533
|
+
* - retry : default (no prior success — first attempt or retry
|
|
534
|
+
* of a failed attempt) */
|
|
535
|
+
export async function runRunResume(opts) {
|
|
536
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
537
|
+
const json = !!opts.json;
|
|
538
|
+
try {
|
|
539
|
+
assertValidRunId(opts.runId);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
const result = {
|
|
543
|
+
exit: 1,
|
|
544
|
+
stdout: [],
|
|
545
|
+
stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
|
|
546
|
+
};
|
|
547
|
+
return maybeEnvelope('run resume', json, result, { error: formatErr(err) });
|
|
548
|
+
}
|
|
549
|
+
const runDir = runDirFor(cwd, opts.runId);
|
|
550
|
+
if (!fs.existsSync(runDir)) {
|
|
551
|
+
const err = new GuardrailError(`run not found: ${opts.runId}`, {
|
|
552
|
+
code: 'not_found',
|
|
553
|
+
provider: 'runs-cli',
|
|
554
|
+
details: { runId: opts.runId, runDir },
|
|
555
|
+
});
|
|
556
|
+
const result = {
|
|
557
|
+
exit: 1,
|
|
558
|
+
stdout: [],
|
|
559
|
+
stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
|
|
560
|
+
};
|
|
561
|
+
return maybeEnvelope('run resume', json, result, {
|
|
562
|
+
error: formatErr(err),
|
|
563
|
+
runId: opts.runId,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Always replay events in-memory so the lookup is cheap and never mutates.
|
|
567
|
+
let state;
|
|
568
|
+
try {
|
|
569
|
+
const fromSnap = readStateSnapshot(runDir);
|
|
570
|
+
if (fromSnap)
|
|
571
|
+
state = fromSnap;
|
|
572
|
+
else
|
|
573
|
+
state = foldEvents(runDir, readEvents(runDir).events);
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
// Fall back to events replay; if THAT fails, surface.
|
|
577
|
+
try {
|
|
578
|
+
state = foldEvents(runDir, readEvents(runDir).events);
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
const result = {
|
|
582
|
+
exit: 1,
|
|
583
|
+
stdout: [],
|
|
584
|
+
stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
|
|
585
|
+
};
|
|
586
|
+
return maybeEnvelope('run resume', json, result, {
|
|
587
|
+
error: formatErr(err),
|
|
588
|
+
runId: opts.runId,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const lookup = computeResumeLookup(state, opts.fromPhase);
|
|
593
|
+
// Validate --from-phase if provided.
|
|
594
|
+
if (opts.fromPhase && !state.phases.some(p => p.name === opts.fromPhase)) {
|
|
595
|
+
const err = new GuardrailError(`--from-phase "${opts.fromPhase}" is not a phase of run ${opts.runId}`, {
|
|
596
|
+
code: 'invalid_config',
|
|
597
|
+
provider: 'runs-cli',
|
|
598
|
+
details: { fromPhase: opts.fromPhase, phases: state.phases.map(p => p.name) },
|
|
599
|
+
});
|
|
600
|
+
const result = {
|
|
601
|
+
exit: 1,
|
|
602
|
+
stdout: [],
|
|
603
|
+
stderr: [`[claude-autopilot] run resume: ${formatErr(err)}`],
|
|
604
|
+
};
|
|
605
|
+
return maybeEnvelope('run resume', json, result, {
|
|
606
|
+
error: formatErr(err),
|
|
607
|
+
runId: opts.runId,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
if (json) {
|
|
611
|
+
return maybeEnvelope('run resume', true, { exit: 0, stdout: [], stderr: [] }, {
|
|
612
|
+
...lookup,
|
|
613
|
+
lookupOnly: true,
|
|
614
|
+
note: 'Phase 3 of v6 is lookup-only. Execution wires in Phase 6+.',
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const lines = [];
|
|
618
|
+
lines.push(`run ${lookup.runId} status=${lookup.status}`);
|
|
619
|
+
lines.push(` currentPhase: ${lookup.currentPhase ?? '(none)'}`);
|
|
620
|
+
lines.push(` nextPhase: ${lookup.nextPhase ?? '(none)'}`);
|
|
621
|
+
lines.push(` decision: ${lookup.decision}`);
|
|
622
|
+
lines.push(` reason: ${lookup.reason}`);
|
|
623
|
+
if (lookup.externalRefs.length > 0) {
|
|
624
|
+
lines.push(' externalRefs:');
|
|
625
|
+
for (const r of lookup.externalRefs) {
|
|
626
|
+
lines.push(` ${r.kind}=${r.id}${r.url ? ` (${r.url})` : ''}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
lines.push('');
|
|
630
|
+
lines.push('NOTE: this is a lookup-only verb in v6 Phase 3.');
|
|
631
|
+
lines.push(' Actual phase execution wires in Phase 6+. Use it to confirm');
|
|
632
|
+
lines.push(' the engine would do the right thing before that lands.');
|
|
633
|
+
return { exit: 0, stdout: lines, stderr: [] };
|
|
634
|
+
}
|
|
635
|
+
/** Pure projection over a RunState that decides the next phase + replay rule.
|
|
636
|
+
* Exported for tests. */
|
|
637
|
+
export function computeResumeLookup(state, fromPhase) {
|
|
638
|
+
const externalRefs = [];
|
|
639
|
+
for (const p of state.phases)
|
|
640
|
+
externalRefs.push(...p.externalRefs);
|
|
641
|
+
// Already-complete short-circuit: every phase succeeded OR run.status is
|
|
642
|
+
// success. Either condition is enough.
|
|
643
|
+
if (state.status === 'success' || state.phases.every(p => p.status === 'succeeded')) {
|
|
644
|
+
const last = state.phases[state.phases.length - 1];
|
|
645
|
+
return {
|
|
646
|
+
runId: state.runId,
|
|
647
|
+
status: state.status,
|
|
648
|
+
currentPhase: last?.name ?? null,
|
|
649
|
+
nextPhase: null,
|
|
650
|
+
decision: 'already-complete',
|
|
651
|
+
reason: 'all phases succeeded — nothing to resume',
|
|
652
|
+
externalRefs,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// Find the resume target. Either the explicit --from-phase or the first
|
|
656
|
+
// non-succeeded phase by index.
|
|
657
|
+
let target;
|
|
658
|
+
if (fromPhase) {
|
|
659
|
+
target = state.phases.find(p => p.name === fromPhase);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
target = state.phases.find(p => p.status !== 'succeeded');
|
|
663
|
+
}
|
|
664
|
+
if (!target) {
|
|
665
|
+
return {
|
|
666
|
+
runId: state.runId,
|
|
667
|
+
status: state.status,
|
|
668
|
+
currentPhase: null,
|
|
669
|
+
nextPhase: null,
|
|
670
|
+
decision: 'already-complete',
|
|
671
|
+
reason: 'no resumable phase identified',
|
|
672
|
+
externalRefs,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
const currentName = state.phases[state.currentPhaseIdx]?.name ?? null;
|
|
676
|
+
// Phase 6 — delegate to the canonical decideReplay() so the CLI
|
|
677
|
+
// prediction matches what runPhase will actually do. This is "lookup
|
|
678
|
+
// mode" — we pass an empty readbacks array, which (per the matrix)
|
|
679
|
+
// collapses every prior-success-with-side-effects case to needs-human
|
|
680
|
+
// because we can't perform a live readback from inside the CLI lookup.
|
|
681
|
+
// That's the right answer: surface the question to the user before
|
|
682
|
+
// actual execution. The CLI prediction's `skip-idempotent` /
|
|
683
|
+
// `already-complete` decisions are convenience aliases over decideReplay's
|
|
684
|
+
// `skip-already-applied` so existing consumers keep their vocabulary.
|
|
685
|
+
const hasPriorSuccess = target.status === 'succeeded';
|
|
686
|
+
const decision = decideReplay({
|
|
687
|
+
phaseName: target.name,
|
|
688
|
+
hasPriorSuccess,
|
|
689
|
+
priorAttempts: target.attempts,
|
|
690
|
+
idempotent: target.idempotent,
|
|
691
|
+
hasSideEffects: target.hasSideEffects,
|
|
692
|
+
externalRefs: target.externalRefs,
|
|
693
|
+
readbacks: [], // pure-state lookup; live readbacks happen inside runPhase
|
|
694
|
+
forceReplay: false,
|
|
695
|
+
});
|
|
696
|
+
// Bugbot LOW (PR #91): exhaustive switch — no `default` branch so TypeScript
|
|
697
|
+
// catches a missing case at compile time if a new ReplayDecisionKind variant
|
|
698
|
+
// is added. The `never` assignment is the standard pattern (mirrors the
|
|
699
|
+
// foldEvents switch in events.ts).
|
|
700
|
+
let mappedDecision;
|
|
701
|
+
switch (decision.decision) {
|
|
702
|
+
case 'retry':
|
|
703
|
+
mappedDecision = 'retry';
|
|
704
|
+
break;
|
|
705
|
+
case 'needs-human':
|
|
706
|
+
case 'abort':
|
|
707
|
+
mappedDecision = 'needs-human';
|
|
708
|
+
break;
|
|
709
|
+
case 'skip-already-applied':
|
|
710
|
+
// Map to the existing CLI vocabulary: idempotent phases keep their
|
|
711
|
+
// skip-idempotent label; everything else surfaces as already-complete
|
|
712
|
+
// so existing CLI consumers don't need to learn a new verb.
|
|
713
|
+
mappedDecision = target.idempotent ? 'skip-idempotent' : 'already-complete';
|
|
714
|
+
break;
|
|
715
|
+
default: {
|
|
716
|
+
const _exhaustive = decision.decision;
|
|
717
|
+
throw new Error(`unreachable ReplayDecisionKind: ${String(_exhaustive)}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
runId: state.runId,
|
|
722
|
+
status: state.status,
|
|
723
|
+
currentPhase: currentName,
|
|
724
|
+
nextPhase: target.name,
|
|
725
|
+
decision: mappedDecision,
|
|
726
|
+
reason: decision.reason,
|
|
727
|
+
externalRefs: target.externalRefs.length > 0 ? target.externalRefs : externalRefs,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
/** `runs doctor` — replay events.ndjson per run, compare against state.json,
|
|
731
|
+
* report drift. With --fix, rewrite state.json from the replay where drift
|
|
732
|
+
* exists.
|
|
733
|
+
*
|
|
734
|
+
* Drift categories:
|
|
735
|
+
* snapshot-vs-replay : both readable but disagree on a key field
|
|
736
|
+
* snapshot-missing : state.json absent, replay successful
|
|
737
|
+
* snapshot-corrupt : state.json present but unparseable
|
|
738
|
+
* events-corrupt : events.ndjson can't be folded (bigger problem)
|
|
739
|
+
*/
|
|
740
|
+
export async function runRunsDoctor(opts) {
|
|
741
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
742
|
+
const json = !!opts.json;
|
|
743
|
+
const root = runsRoot(cwd);
|
|
744
|
+
if (!fs.existsSync(root)) {
|
|
745
|
+
return maybeEnvelope('runs doctor', json, { exit: 0, stdout: ['runs doctor: no runs directory.'], stderr: [] }, { runs: [] });
|
|
746
|
+
}
|
|
747
|
+
// Decide the run set.
|
|
748
|
+
let runIds;
|
|
749
|
+
if (opts.runId) {
|
|
750
|
+
try {
|
|
751
|
+
assertValidRunId(opts.runId);
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
const result = {
|
|
755
|
+
exit: 1,
|
|
756
|
+
stdout: [],
|
|
757
|
+
stderr: [`[claude-autopilot] runs doctor: ${formatErr(err)}`],
|
|
758
|
+
};
|
|
759
|
+
return maybeEnvelope('runs doctor', json, result, { error: formatErr(err) });
|
|
760
|
+
}
|
|
761
|
+
runIds = [opts.runId];
|
|
762
|
+
}
|
|
763
|
+
else {
|
|
764
|
+
runIds = fs
|
|
765
|
+
.readdirSync(root, { withFileTypes: true })
|
|
766
|
+
.filter(d => d.isDirectory())
|
|
767
|
+
.map(d => d.name)
|
|
768
|
+
.filter(isValidULID);
|
|
769
|
+
}
|
|
770
|
+
const reports = [];
|
|
771
|
+
let driftCount = 0;
|
|
772
|
+
for (const runId of runIds) {
|
|
773
|
+
const runDir = path.join(root, runId);
|
|
774
|
+
if (!fs.existsSync(runDir)) {
|
|
775
|
+
reports.push({ runId, drift: 'snapshot-missing', details: 'run dir not found' });
|
|
776
|
+
driftCount += 1;
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
let snapshot = null;
|
|
780
|
+
let snapErr = null;
|
|
781
|
+
try {
|
|
782
|
+
snapshot = readStateSnapshot(runDir);
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
snapErr = formatErr(err);
|
|
786
|
+
}
|
|
787
|
+
let replayed = null;
|
|
788
|
+
let replayErr = null;
|
|
789
|
+
try {
|
|
790
|
+
const evRead = readEvents(runDir);
|
|
791
|
+
replayed = foldEvents(runDir, evRead.events);
|
|
792
|
+
}
|
|
793
|
+
catch (err) {
|
|
794
|
+
replayErr = formatErr(err);
|
|
795
|
+
}
|
|
796
|
+
if (replayErr) {
|
|
797
|
+
reports.push({ runId, drift: 'events-corrupt', details: replayErr });
|
|
798
|
+
driftCount += 1;
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (!snapshot && !snapErr) {
|
|
802
|
+
// snapshot missing
|
|
803
|
+
reports.push({ runId, drift: 'snapshot-missing', details: 'state.json absent' });
|
|
804
|
+
driftCount += 1;
|
|
805
|
+
if (opts.fix && replayed) {
|
|
806
|
+
try {
|
|
807
|
+
writeStateSnapshot(runDir, replayed);
|
|
808
|
+
reports[reports.length - 1].fixed = true;
|
|
809
|
+
}
|
|
810
|
+
catch (err) {
|
|
811
|
+
reports[reports.length - 1].details = `fix failed: ${formatErr(err)}`;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (snapErr) {
|
|
817
|
+
reports.push({ runId, drift: 'snapshot-corrupt', details: snapErr });
|
|
818
|
+
driftCount += 1;
|
|
819
|
+
if (opts.fix && replayed) {
|
|
820
|
+
try {
|
|
821
|
+
writeStateSnapshot(runDir, replayed);
|
|
822
|
+
reports[reports.length - 1].fixed = true;
|
|
823
|
+
}
|
|
824
|
+
catch (err) {
|
|
825
|
+
reports[reports.length - 1].details = `fix failed: ${formatErr(err)}`;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
// Both readable — compare key fields.
|
|
831
|
+
const drift = diffStates(snapshot, replayed);
|
|
832
|
+
if (drift) {
|
|
833
|
+
reports.push({ runId, drift: 'snapshot-vs-replay', details: drift });
|
|
834
|
+
driftCount += 1;
|
|
835
|
+
if (opts.fix && replayed) {
|
|
836
|
+
try {
|
|
837
|
+
writeStateSnapshot(runDir, replayed);
|
|
838
|
+
reports[reports.length - 1].fixed = true;
|
|
839
|
+
}
|
|
840
|
+
catch (err) {
|
|
841
|
+
reports[reports.length - 1].details = `${drift}; fix failed: ${formatErr(err)}`;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
reports.push({ runId, drift: 'none' });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const exit = driftCount > 0 && !opts.fix ? 1 : 0;
|
|
850
|
+
if (json) {
|
|
851
|
+
return maybeEnvelope('runs doctor', true, { exit, stdout: [], stderr: [] }, { runs: reports, driftCount, fixApplied: !!opts.fix });
|
|
852
|
+
}
|
|
853
|
+
const lines = [];
|
|
854
|
+
if (reports.length === 0) {
|
|
855
|
+
lines.push('runs doctor: no runs found.');
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
for (const r of reports) {
|
|
859
|
+
const tag = r.drift === 'none' ? 'OK' : r.drift.toUpperCase();
|
|
860
|
+
const fixedNote = r.fixed ? ' (fixed)' : '';
|
|
861
|
+
lines.push(` ${tag.padEnd(20)} ${r.runId}${fixedNote}${r.details ? ` — ${r.details}` : ''}`);
|
|
862
|
+
}
|
|
863
|
+
lines.push('');
|
|
864
|
+
lines.push(`runs doctor: ${reports.length} run(s) checked, ${driftCount} drift finding(s)`);
|
|
865
|
+
if (driftCount > 0 && !opts.fix) {
|
|
866
|
+
lines.push(' hint: re-run with --fix to rewrite state.json from events.ndjson');
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
return { exit, stdout: lines, stderr: [] };
|
|
870
|
+
}
|
|
871
|
+
/** Diff two RunStates on key fields. Returns a one-line description of the
|
|
872
|
+
* first divergence or null if equivalent. */
|
|
873
|
+
function diffStates(a, b) {
|
|
874
|
+
if (a.runId !== b.runId)
|
|
875
|
+
return `runId mismatch (${a.runId} vs ${b.runId})`;
|
|
876
|
+
if (a.status !== b.status)
|
|
877
|
+
return `status mismatch (${a.status} vs ${b.status})`;
|
|
878
|
+
if (a.lastEventSeq !== b.lastEventSeq) {
|
|
879
|
+
return `lastEventSeq mismatch (${a.lastEventSeq} vs ${b.lastEventSeq})`;
|
|
880
|
+
}
|
|
881
|
+
// Cost compared with a small epsilon for float jitter.
|
|
882
|
+
if (Math.abs(a.totalCostUSD - b.totalCostUSD) > 1e-9) {
|
|
883
|
+
return `totalCostUSD mismatch (${a.totalCostUSD} vs ${b.totalCostUSD})`;
|
|
884
|
+
}
|
|
885
|
+
if (a.phases.length !== b.phases.length) {
|
|
886
|
+
return `phase count mismatch (${a.phases.length} vs ${b.phases.length})`;
|
|
887
|
+
}
|
|
888
|
+
for (let i = 0; i < a.phases.length; i++) {
|
|
889
|
+
const pa = a.phases[i];
|
|
890
|
+
const pb = b.phases[i];
|
|
891
|
+
if (pa.status !== pb.status) {
|
|
892
|
+
return `phases[${i}] (${pa.name}) status mismatch (${pa.status} vs ${pb.status})`;
|
|
893
|
+
}
|
|
894
|
+
if (pa.attempts !== pb.attempts) {
|
|
895
|
+
return `phases[${i}] (${pa.name}) attempts mismatch (${pa.attempts} vs ${pb.attempts})`;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return null;
|
|
899
|
+
}
|
|
900
|
+
// `statePath` is re-exported for convenience to keep CLI imports tidy.
|
|
901
|
+
export { statePath };
|
|
902
|
+
//# sourceMappingURL=runs.js.map
|