@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,426 @@
|
|
|
1
|
+
// src/core/run-state/provider-readback.ts
|
|
2
|
+
//
|
|
3
|
+
// v6 Phase 6 — pluggable provider read-back layer.
|
|
4
|
+
//
|
|
5
|
+
// When the run-state engine resumes a run that has prior `phase.success` +
|
|
6
|
+
// side-effects + persisted `externalRefs`, the replay decision (see
|
|
7
|
+
// `replay-decision.ts`) is NOT pure — it MUST consult the platform of record
|
|
8
|
+
// to confirm the ref is still live and in the expected state. e.g. for a
|
|
9
|
+
// `github-pr` ref we ask `gh pr view <id> --json state` and inspect
|
|
10
|
+
// open / closed / merged. For a `deploy` ref we ask the adapter's `status()`.
|
|
11
|
+
//
|
|
12
|
+
// This file is the seam: a `ProviderReadback` interface, a registry mapping
|
|
13
|
+
// `ExternalRef.kind` to an implementation, and the built-in readbacks for
|
|
14
|
+
// github / vercel / fly / render / supabase. Each readback FAILS CLOSED — any
|
|
15
|
+
// throw or unrecognized response shape is recorded as
|
|
16
|
+
// `existsOnPlatform: false, currentState: 'unknown'`. Callers (the replay
|
|
17
|
+
// decision matrix) treat unknown-state as `needs-human` so we never quietly
|
|
18
|
+
// overwrite or duplicate a side effect on a missing/stale ref.
|
|
19
|
+
//
|
|
20
|
+
// Spec: docs/specs/v6-run-state-engine.md "Idempotency rules + external
|
|
21
|
+
// operation ledger (Codex CRITICAL #2)" — replay decision is "persisted refs
|
|
22
|
+
// + a provider read-back check (e.g., 'is PR #123 still open?')".
|
|
23
|
+
import { runSafe } from "../shell.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Wrapping helper — guarantees the fail-closed contract regardless of impl.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
/** Wrap a readback so that any throw collapses to the unknown-state result.
|
|
28
|
+
* All built-in readbacks below opt into this; external implementations are
|
|
29
|
+
* free to use it too. Centralizes the fail-closed invariant. */
|
|
30
|
+
function failClosed(name, ref, fn) {
|
|
31
|
+
return fn().catch(() => unknownResult(ref, { readback: name, threw: true }));
|
|
32
|
+
}
|
|
33
|
+
/** Build a fail-closed result for a ref that the readback couldn't verify. */
|
|
34
|
+
function unknownResult(ref, metadata) {
|
|
35
|
+
return {
|
|
36
|
+
refKind: ref.kind,
|
|
37
|
+
refId: ref.id,
|
|
38
|
+
existsOnPlatform: false,
|
|
39
|
+
currentState: 'unknown',
|
|
40
|
+
...(metadata ? { metadata } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const defaultGhRunner = (args) => runSafe('gh', args, { timeout: 30000 });
|
|
44
|
+
export function makeGithubReadback(opts = {}) {
|
|
45
|
+
const gh = opts.gh ?? defaultGhRunner;
|
|
46
|
+
return {
|
|
47
|
+
name: 'github',
|
|
48
|
+
handles: ['github-pr', 'github-comment', 'git-remote-push'],
|
|
49
|
+
verifyRef: (ref) => failClosed('github', ref, async () => {
|
|
50
|
+
if (ref.kind === 'github-pr')
|
|
51
|
+
return verifyGithubPr(gh, ref);
|
|
52
|
+
if (ref.kind === 'github-comment')
|
|
53
|
+
return verifyGithubComment(gh, ref);
|
|
54
|
+
if (ref.kind === 'git-remote-push')
|
|
55
|
+
return verifyGitRemotePush(gh, ref);
|
|
56
|
+
return unknownResult(ref, { readback: 'github', reason: 'unsupported-kind' });
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async function verifyGithubPr(gh, ref) {
|
|
61
|
+
// `gh pr view <id> --json state,url,title,merged` — single deterministic
|
|
62
|
+
// call. PR ID may be a bare number ("99") or a full URL.
|
|
63
|
+
const out = gh(['pr', 'view', ref.id, '--json', 'state,url,title,merged']);
|
|
64
|
+
if (out === null)
|
|
65
|
+
return unknownResult(ref, { readback: 'github-pr', reason: 'gh-cli-failed' });
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = JSON.parse(out);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return unknownResult(ref, { readback: 'github-pr', reason: 'unparseable-json' });
|
|
72
|
+
}
|
|
73
|
+
// Map gh's state vocabulary onto ours. gh returns OPEN | CLOSED | MERGED.
|
|
74
|
+
// `merged: true` overrides — a closed-merged PR is "merged", not "closed".
|
|
75
|
+
let currentState;
|
|
76
|
+
if (parsed.merged === true || parsed.state === 'MERGED')
|
|
77
|
+
currentState = 'merged';
|
|
78
|
+
else if (parsed.state === 'OPEN')
|
|
79
|
+
currentState = 'open';
|
|
80
|
+
else if (parsed.state === 'CLOSED')
|
|
81
|
+
currentState = 'closed';
|
|
82
|
+
else
|
|
83
|
+
currentState = 'unknown';
|
|
84
|
+
return {
|
|
85
|
+
refKind: ref.kind,
|
|
86
|
+
refId: ref.id,
|
|
87
|
+
existsOnPlatform: true,
|
|
88
|
+
currentState,
|
|
89
|
+
metadata: {
|
|
90
|
+
readback: 'github-pr',
|
|
91
|
+
...(parsed.url ? { url: parsed.url } : {}),
|
|
92
|
+
...(parsed.title ? { title: parsed.title } : {}),
|
|
93
|
+
rawState: parsed.state,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function verifyGithubComment(gh, ref) {
|
|
98
|
+
// gh doesn't have a clean per-comment-ID lookup — we use `gh api` against
|
|
99
|
+
// the issues comments endpoint. Comment IDs are integers; if the ref id is
|
|
100
|
+
// qualified as `<repo>:<id>` we split, else we rely on cwd's repo context.
|
|
101
|
+
let endpoint;
|
|
102
|
+
if (ref.id.includes(':')) {
|
|
103
|
+
const [repo, commentId] = ref.id.split(':', 2);
|
|
104
|
+
endpoint = `/repos/${repo}/issues/comments/${commentId}`;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
endpoint = `/repos/{owner}/{repo}/issues/comments/${ref.id}`;
|
|
108
|
+
}
|
|
109
|
+
const out = gh(['api', endpoint]);
|
|
110
|
+
if (out === null) {
|
|
111
|
+
// gh api returns non-zero on 404. Treat as does-not-exist (which is
|
|
112
|
+
// distinct from unknown — a deleted comment is meaningful: replay would
|
|
113
|
+
// create a new comment, so the prior ref is no longer authoritative).
|
|
114
|
+
return {
|
|
115
|
+
refKind: ref.kind,
|
|
116
|
+
refId: ref.id,
|
|
117
|
+
existsOnPlatform: false,
|
|
118
|
+
currentState: 'closed',
|
|
119
|
+
metadata: { readback: 'github-comment', reason: 'gh-api-failed-or-404' },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(out);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return unknownResult(ref, { readback: 'github-comment', reason: 'unparseable-json' });
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
refKind: ref.kind,
|
|
131
|
+
refId: ref.id,
|
|
132
|
+
existsOnPlatform: typeof parsed.id === 'number',
|
|
133
|
+
currentState: typeof parsed.id === 'number' ? 'open' : 'unknown',
|
|
134
|
+
metadata: {
|
|
135
|
+
readback: 'github-comment',
|
|
136
|
+
...(parsed.html_url ? { url: parsed.html_url } : {}),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
async function verifyGitRemotePush(gh, ref) {
|
|
141
|
+
// For a git-remote-push ref the id is the commit SHA. We confirm it exists
|
|
142
|
+
// on the remote by asking gh for the commit. Treat "not found" as
|
|
143
|
+
// does-not-exist (rebased away), distinct from unknown (auth/network).
|
|
144
|
+
// gh api format: /repos/{owner}/{repo}/commits/<sha>.
|
|
145
|
+
const out = gh(['api', `/repos/{owner}/{repo}/commits/${ref.id}`]);
|
|
146
|
+
if (out === null) {
|
|
147
|
+
return {
|
|
148
|
+
refKind: ref.kind,
|
|
149
|
+
refId: ref.id,
|
|
150
|
+
existsOnPlatform: false,
|
|
151
|
+
currentState: 'closed',
|
|
152
|
+
metadata: { readback: 'git-remote-push', reason: 'gh-api-failed-or-404' },
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
let parsed;
|
|
156
|
+
try {
|
|
157
|
+
parsed = JSON.parse(out);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return unknownResult(ref, { readback: 'git-remote-push', reason: 'unparseable-json' });
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
refKind: ref.kind,
|
|
164
|
+
refId: ref.id,
|
|
165
|
+
existsOnPlatform: typeof parsed.sha === 'string',
|
|
166
|
+
currentState: typeof parsed.sha === 'string' ? 'live' : 'unknown',
|
|
167
|
+
metadata: {
|
|
168
|
+
readback: 'git-remote-push',
|
|
169
|
+
...(parsed.html_url ? { url: parsed.html_url } : {}),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
let deployAdapterResolver = null;
|
|
174
|
+
/** Register a resolver that maps a provider name (e.g. "vercel") to a
|
|
175
|
+
* status-fetcher. The CLI wires this from `src/adapters/deploy/index.ts`
|
|
176
|
+
* during boot; tests inject mocks directly. */
|
|
177
|
+
export function registerDeployAdapterResolver(resolver) {
|
|
178
|
+
deployAdapterResolver = resolver;
|
|
179
|
+
}
|
|
180
|
+
/** Reset the registered resolver. Test-only seam. */
|
|
181
|
+
export function __resetDeployAdapterResolver() {
|
|
182
|
+
deployAdapterResolver = null;
|
|
183
|
+
}
|
|
184
|
+
export function makeDeployReadback(name, providers) {
|
|
185
|
+
return {
|
|
186
|
+
name,
|
|
187
|
+
handles: ['deploy', 'rollback-target'],
|
|
188
|
+
providers,
|
|
189
|
+
verifyRef: (ref) => failClosed(name, ref, async () => {
|
|
190
|
+
const provider = ref.provider ?? null;
|
|
191
|
+
if (!provider || !providers.includes(provider)) {
|
|
192
|
+
return unknownResult(ref, {
|
|
193
|
+
readback: name,
|
|
194
|
+
reason: 'provider-mismatch',
|
|
195
|
+
refProvider: provider,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (!deployAdapterResolver) {
|
|
199
|
+
return unknownResult(ref, {
|
|
200
|
+
readback: name,
|
|
201
|
+
reason: 'no-adapter-resolver-registered',
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const fetcher = deployAdapterResolver(provider);
|
|
205
|
+
if (!fetcher) {
|
|
206
|
+
return unknownResult(ref, {
|
|
207
|
+
readback: name,
|
|
208
|
+
reason: 'adapter-not-resolvable',
|
|
209
|
+
provider,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const r = await fetcher.status({ deployId: ref.id });
|
|
213
|
+
// Map adapter status → ReadbackState. The adapter contract returns
|
|
214
|
+
// 'pass'|'fail'|'in-progress'|'fail_rolled_back'|'fail_rollback_failed'.
|
|
215
|
+
let currentState;
|
|
216
|
+
switch (r.status) {
|
|
217
|
+
case 'pass':
|
|
218
|
+
currentState = 'live';
|
|
219
|
+
break;
|
|
220
|
+
case 'fail':
|
|
221
|
+
case 'fail_rollback_failed':
|
|
222
|
+
currentState = 'failed';
|
|
223
|
+
break;
|
|
224
|
+
case 'fail_rolled_back':
|
|
225
|
+
currentState = 'rolled-back';
|
|
226
|
+
break;
|
|
227
|
+
case 'in-progress':
|
|
228
|
+
currentState = 'open';
|
|
229
|
+
break;
|
|
230
|
+
default:
|
|
231
|
+
currentState = 'unknown';
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
refKind: ref.kind,
|
|
235
|
+
refId: ref.id,
|
|
236
|
+
existsOnPlatform: true,
|
|
237
|
+
currentState,
|
|
238
|
+
metadata: {
|
|
239
|
+
readback: name,
|
|
240
|
+
provider,
|
|
241
|
+
rawStatus: r.status,
|
|
242
|
+
...(r.deployUrl ? { deployUrl: r.deployUrl } : {}),
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
let migrationStateFetcher = null;
|
|
249
|
+
/** Register the migration-state fetcher used by the supabase readback.
|
|
250
|
+
* CLI boot wires this; tests inject directly. */
|
|
251
|
+
export function registerMigrationStateFetcher(fetcher) {
|
|
252
|
+
migrationStateFetcher = fetcher;
|
|
253
|
+
}
|
|
254
|
+
export function __resetMigrationStateFetcher() {
|
|
255
|
+
migrationStateFetcher = null;
|
|
256
|
+
}
|
|
257
|
+
let migrationBatchFetcher = null;
|
|
258
|
+
/** Register the `migration-batch` fetcher. The CLI boot wires this from the
|
|
259
|
+
* per-skill adapter; tests inject mocks directly. */
|
|
260
|
+
export function registerMigrationBatchFetcher(fetcher) {
|
|
261
|
+
migrationBatchFetcher = fetcher;
|
|
262
|
+
}
|
|
263
|
+
export function __resetMigrationBatchFetcher() {
|
|
264
|
+
migrationBatchFetcher = null;
|
|
265
|
+
}
|
|
266
|
+
export function makeSupabaseReadback() {
|
|
267
|
+
return {
|
|
268
|
+
name: 'supabase',
|
|
269
|
+
handles: ['migration-version', 'migration-batch'],
|
|
270
|
+
verifyRef: (ref) => failClosed('supabase', ref, async () => {
|
|
271
|
+
if (ref.kind === 'migration-batch')
|
|
272
|
+
return verifyMigrationBatch(ref);
|
|
273
|
+
// migration-version
|
|
274
|
+
if (!migrationStateFetcher) {
|
|
275
|
+
return unknownResult(ref, {
|
|
276
|
+
readback: 'supabase',
|
|
277
|
+
reason: 'no-migration-state-fetcher-registered',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const result = await migrationStateFetcher.fetch(ref.id);
|
|
281
|
+
if (!result) {
|
|
282
|
+
return unknownResult(ref, {
|
|
283
|
+
readback: 'supabase',
|
|
284
|
+
reason: 'migration-state-fetch-failed-or-not-found',
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
refKind: ref.kind,
|
|
289
|
+
refId: ref.id,
|
|
290
|
+
existsOnPlatform: true,
|
|
291
|
+
currentState: result.applied ? 'live' : 'open',
|
|
292
|
+
metadata: {
|
|
293
|
+
readback: 'supabase',
|
|
294
|
+
applied: result.applied,
|
|
295
|
+
...(result.appliedAt ? { appliedAt: result.appliedAt } : {}),
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
async function verifyMigrationBatch(ref) {
|
|
302
|
+
if (!migrationBatchFetcher) {
|
|
303
|
+
return unknownResult(ref, {
|
|
304
|
+
readback: 'supabase',
|
|
305
|
+
reason: 'no-migration-batch-fetcher-registered',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const result = await migrationBatchFetcher.fetch(ref.id);
|
|
309
|
+
if (!result) {
|
|
310
|
+
return unknownResult(ref, {
|
|
311
|
+
readback: 'supabase',
|
|
312
|
+
reason: 'migration-batch-fetch-failed-or-not-found',
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
if (result.planned.length === 0) {
|
|
316
|
+
// A planned-empty batch is degenerate — no work to verify against. Treat
|
|
317
|
+
// it as merged (skip-already-applied) rather than unknown so a batch ref
|
|
318
|
+
// emitted before the dispatcher discovered "nothing to do" doesn't
|
|
319
|
+
// wedge the resume preflight on needs-human. The post-effect ref set is
|
|
320
|
+
// also empty in this case, so the orchestrator's "all post-effect refs
|
|
321
|
+
// merged/live" check naturally short-circuits to skip.
|
|
322
|
+
return {
|
|
323
|
+
refKind: ref.kind,
|
|
324
|
+
refId: ref.id,
|
|
325
|
+
existsOnPlatform: true,
|
|
326
|
+
currentState: 'merged',
|
|
327
|
+
metadata: {
|
|
328
|
+
readback: 'supabase',
|
|
329
|
+
plannedCount: 0,
|
|
330
|
+
appliedCount: 0,
|
|
331
|
+
erroredCount: 0,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
let appliedCount = 0;
|
|
336
|
+
let pendingCount = 0;
|
|
337
|
+
let erroredCount = 0;
|
|
338
|
+
for (const item of result.planned) {
|
|
339
|
+
if (item.state === 'applied')
|
|
340
|
+
appliedCount++;
|
|
341
|
+
else if (item.state === 'pending')
|
|
342
|
+
pendingCount++;
|
|
343
|
+
else if (item.state === 'errored')
|
|
344
|
+
erroredCount++;
|
|
345
|
+
}
|
|
346
|
+
let currentState;
|
|
347
|
+
if (erroredCount > 0)
|
|
348
|
+
currentState = 'failed';
|
|
349
|
+
else if (pendingCount === 0)
|
|
350
|
+
currentState = 'merged';
|
|
351
|
+
else
|
|
352
|
+
currentState = 'open';
|
|
353
|
+
return {
|
|
354
|
+
refKind: ref.kind,
|
|
355
|
+
refId: ref.id,
|
|
356
|
+
existsOnPlatform: true,
|
|
357
|
+
currentState,
|
|
358
|
+
metadata: {
|
|
359
|
+
readback: 'supabase',
|
|
360
|
+
plannedCount: result.planned.length,
|
|
361
|
+
appliedCount,
|
|
362
|
+
pendingCount,
|
|
363
|
+
erroredCount,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Registry — first-match-wins lookup keyed on ExternalRefKind.
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
/** Default built-in registry. Order matters: first readback whose `handles`
|
|
371
|
+
* contains the ref kind wins. Callers may swap individual entries via
|
|
372
|
+
* `setProviderReadbacks` (test-only seam). */
|
|
373
|
+
function buildDefaultRegistry() {
|
|
374
|
+
return [
|
|
375
|
+
makeGithubReadback(),
|
|
376
|
+
makeDeployReadback('vercel', ['vercel']),
|
|
377
|
+
makeDeployReadback('fly', ['fly']),
|
|
378
|
+
makeDeployReadback('render', ['render']),
|
|
379
|
+
makeSupabaseReadback(),
|
|
380
|
+
];
|
|
381
|
+
}
|
|
382
|
+
let providerReadbacks = buildDefaultRegistry();
|
|
383
|
+
/** Live registry — exposed as a getter so tests / callers can introspect. */
|
|
384
|
+
export function getProviderReadbacks() {
|
|
385
|
+
return providerReadbacks;
|
|
386
|
+
}
|
|
387
|
+
/** Replace the registry (test seam). Pass null to reset to defaults. */
|
|
388
|
+
export function setProviderReadbacks(list) {
|
|
389
|
+
providerReadbacks = list === null ? buildDefaultRegistry() : list;
|
|
390
|
+
}
|
|
391
|
+
/** Look up the readback that handles a given ref. Two-pass match: first try
|
|
392
|
+
* a strict (kind + provider) match so multiple readbacks sharing a kind
|
|
393
|
+
* (vercel/fly/render all on `deploy`) don't shadow each other; then fall
|
|
394
|
+
* back to a kind-only match for readbacks that don't declare a provider
|
|
395
|
+
* allowlist (e.g. the github readback handles `github-pr` regardless of
|
|
396
|
+
* ref.provider). Returns null if no registered readback claims this ref —
|
|
397
|
+
* caller treats null as "no readback available, route to needs-human".
|
|
398
|
+
*
|
|
399
|
+
* Bugbot MEDIUM (PR #91): without provider-aware matching, the first deploy
|
|
400
|
+
* readback registered (vercel) won every `deploy`/`rollback-target` lookup
|
|
401
|
+
* and the fly/render readbacks were dead code. */
|
|
402
|
+
export function readbackForRef(ref) {
|
|
403
|
+
if (ref.provider) {
|
|
404
|
+
for (const rb of providerReadbacks) {
|
|
405
|
+
if (rb.handles.includes(ref.kind) && rb.providers?.includes(ref.provider))
|
|
406
|
+
return rb;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
for (const rb of providerReadbacks) {
|
|
410
|
+
if (rb.handles.includes(ref.kind) && !rb.providers)
|
|
411
|
+
return rb;
|
|
412
|
+
}
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
/** Verify a list of refs in parallel. Returns one ReadbackResult per ref.
|
|
416
|
+
* Refs without a registered readback get an unknown-state result so the
|
|
417
|
+
* decision matrix can attribute the gap. Order is preserved. */
|
|
418
|
+
export async function verifyRefs(refs) {
|
|
419
|
+
return Promise.all(refs.map(async (ref) => {
|
|
420
|
+
const rb = readbackForRef(ref);
|
|
421
|
+
if (!rb)
|
|
422
|
+
return unknownResult(ref, { reason: 'no-readback-registered' });
|
|
423
|
+
return rb.verifyRef(ref);
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
//# sourceMappingURL=provider-readback.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ExternalRef } from './types.ts';
|
|
2
|
+
import type { ReadbackResult } from './provider-readback.ts';
|
|
3
|
+
/** Decision the engine should take when replaying / resuming a phase. */
|
|
4
|
+
export type ReplayDecisionKind =
|
|
5
|
+
/** Run the phase body. Default for fresh attempts and post-failure retries. */
|
|
6
|
+
'retry'
|
|
7
|
+
/** Don't run; treat as already-done. Engine returns prior output / snapshot. */
|
|
8
|
+
| 'skip-already-applied'
|
|
9
|
+
/** Don't run; can't safely decide. Engine emits phase.needs-human + throws. */
|
|
10
|
+
| 'needs-human'
|
|
11
|
+
/** Don't run; explicit user/CI signal to give up. Engine throws abort code. */
|
|
12
|
+
| 'abort';
|
|
13
|
+
export interface ReplayDecision {
|
|
14
|
+
decision: ReplayDecisionKind;
|
|
15
|
+
/** Single-line human-readable explanation. Embedded into needs-human events
|
|
16
|
+
* and surface in `runs resume` output. */
|
|
17
|
+
reason: string;
|
|
18
|
+
/** External refs the decision considered. Echoed back so CI/humans can
|
|
19
|
+
* inspect them without re-reading the events log. */
|
|
20
|
+
refsConsulted: ExternalRef[];
|
|
21
|
+
/** Per-ref readback results. Empty array when the decision was made
|
|
22
|
+
* without consulting readbacks (e.g. retry on no-prior-success). */
|
|
23
|
+
readbacksConsulted: ReadbackResult[];
|
|
24
|
+
}
|
|
25
|
+
/** Inputs to `decideReplay`. All fields required so callers can't accidentally
|
|
26
|
+
* drop a signal. Keep in lockstep with runPhase's gating logic. */
|
|
27
|
+
export interface ReplayDecisionInput {
|
|
28
|
+
/** Phase name — for the reason string only; no behavior depends on it. */
|
|
29
|
+
phaseName: string;
|
|
30
|
+
/** True iff prior `phase.success` event exists for this phaseIdx. */
|
|
31
|
+
hasPriorSuccess: boolean;
|
|
32
|
+
/** Total attempts recorded in state.json for this phaseIdx (failed +
|
|
33
|
+
* succeeded). Used only for the `reason` string when there's no prior
|
|
34
|
+
* success but priorAttempts > 0 — distinguishes "first attempt" from
|
|
35
|
+
* "post-failure retry" so users running `runs resume` get an accurate
|
|
36
|
+
* description. No behavior depends on this; it's a presentation field.
|
|
37
|
+
* Defaults to 0 when omitted (Bugbot LOW PR #91 fold-in). */
|
|
38
|
+
priorAttempts?: number;
|
|
39
|
+
/** Mirrors RunPhase.idempotent declared at registration. */
|
|
40
|
+
idempotent: boolean;
|
|
41
|
+
/** Mirrors RunPhase.hasSideEffects declared at registration. */
|
|
42
|
+
hasSideEffects: boolean;
|
|
43
|
+
/** All externalRefs persisted for this phaseIdx across prior attempts. */
|
|
44
|
+
externalRefs: ExternalRef[];
|
|
45
|
+
/** Readback results, one per externalRef in the same order. May be empty
|
|
46
|
+
* when the caller is doing pure-state lookup (CLI `runs resume`) — in
|
|
47
|
+
* that case any side-effect-phase prior success collapses to needs-human
|
|
48
|
+
* because we have no live confirmation. */
|
|
49
|
+
readbacks: ReadbackResult[];
|
|
50
|
+
/** When true the user/CI explicitly asked to override needs-human. The
|
|
51
|
+
* engine emits a `replay.override` event; this function flips the
|
|
52
|
+
* decision to retry regardless of state. */
|
|
53
|
+
forceReplay: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** Decide what to do with a (re-)attempt of a phase. Pure; safe to call
|
|
56
|
+
* during CLI lookup AND inside runPhase. The decision matrix mirrors the
|
|
57
|
+
* spec's per-phase replay table:
|
|
58
|
+
*
|
|
59
|
+
* | prior success | idempotent | sideEffects | refs | readback all valid | -> decision |
|
|
60
|
+
* | no | - | - | - | - | retry |
|
|
61
|
+
* | yes | yes | - | - | - | skip |
|
|
62
|
+
* | yes | no | no | - | - | skip |
|
|
63
|
+
* | yes | no | yes | empty | - | needs-human |
|
|
64
|
+
* | yes | no | yes | non-empty | all valid | skip |
|
|
65
|
+
* | yes | no | yes | non-empty | any missing/stale | needs-human |
|
|
66
|
+
*
|
|
67
|
+
* forceReplay = true overrides everything → retry. */
|
|
68
|
+
export declare function decideReplay(input: ReplayDecisionInput): ReplayDecision;
|
|
69
|
+
//# sourceMappingURL=replay-decision.d.ts.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// src/core/run-state/replay-decision.ts
|
|
2
|
+
//
|
|
3
|
+
// v6 Phase 6 — pure decision function for "should this phase replay?".
|
|
4
|
+
//
|
|
5
|
+
// Inputs are the persisted facts of a prior phase attempt (success count,
|
|
6
|
+
// idempotent / hasSideEffects declarations, externalRefs) plus the live
|
|
7
|
+
// readback results from `provider-readback.ts`. Output is one of four
|
|
8
|
+
// decisions, plus the refs + readbacks the decision was based on so callers
|
|
9
|
+
// can surface them in `phase.needs-human` events for human triage.
|
|
10
|
+
//
|
|
11
|
+
// This file is deliberately pure: it does NOT execute readbacks itself
|
|
12
|
+
// (caller passes them in), it does NOT consult disk, it does NOT throw on
|
|
13
|
+
// any input shape. Easy to unit-test exhaustively against the spec's
|
|
14
|
+
// per-phase replay table.
|
|
15
|
+
//
|
|
16
|
+
// Spec: docs/specs/v6-run-state-engine.md "Idempotency rules + external
|
|
17
|
+
// operation ledger (Codex CRITICAL #2)" — the replay matrix.
|
|
18
|
+
/** Decide what to do with a (re-)attempt of a phase. Pure; safe to call
|
|
19
|
+
* during CLI lookup AND inside runPhase. The decision matrix mirrors the
|
|
20
|
+
* spec's per-phase replay table:
|
|
21
|
+
*
|
|
22
|
+
* | prior success | idempotent | sideEffects | refs | readback all valid | -> decision |
|
|
23
|
+
* | no | - | - | - | - | retry |
|
|
24
|
+
* | yes | yes | - | - | - | skip |
|
|
25
|
+
* | yes | no | no | - | - | skip |
|
|
26
|
+
* | yes | no | yes | empty | - | needs-human |
|
|
27
|
+
* | yes | no | yes | non-empty | all valid | skip |
|
|
28
|
+
* | yes | no | yes | non-empty | any missing/stale | needs-human |
|
|
29
|
+
*
|
|
30
|
+
* forceReplay = true overrides everything → retry. */
|
|
31
|
+
export function decideReplay(input) {
|
|
32
|
+
const refsConsulted = [...input.externalRefs];
|
|
33
|
+
const readbacksConsulted = [...input.readbacks];
|
|
34
|
+
// Override path — caller already gated this on user/CI consent. Engine
|
|
35
|
+
// emits replay.override on this branch.
|
|
36
|
+
if (input.forceReplay) {
|
|
37
|
+
return {
|
|
38
|
+
decision: 'retry',
|
|
39
|
+
reason: `forceReplay override: ${input.phaseName} will re-execute despite prior state`,
|
|
40
|
+
refsConsulted,
|
|
41
|
+
readbacksConsulted,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// No prior success → fresh attempt or post-failure retry. Always safe.
|
|
45
|
+
if (!input.hasPriorSuccess) {
|
|
46
|
+
const priorAttempts = input.priorAttempts ?? 0;
|
|
47
|
+
const reason = priorAttempts > 0
|
|
48
|
+
? `${input.phaseName} previous attempt(s) failed (${priorAttempts}) — retry safe`
|
|
49
|
+
: `${input.phaseName} has no prior success — first attempt`;
|
|
50
|
+
return {
|
|
51
|
+
decision: 'retry',
|
|
52
|
+
reason,
|
|
53
|
+
refsConsulted,
|
|
54
|
+
readbacksConsulted: [],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// Prior success + declared idempotent → safe to short-circuit. The phase
|
|
58
|
+
// contract promises the prior output is durable / retrievable.
|
|
59
|
+
if (input.idempotent) {
|
|
60
|
+
return {
|
|
61
|
+
decision: 'skip-already-applied',
|
|
62
|
+
reason: `${input.phaseName} previously succeeded and is idempotent — replay short-circuits`,
|
|
63
|
+
refsConsulted,
|
|
64
|
+
readbacksConsulted: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Prior success + no side effects → still safe to skip. The phase
|
|
68
|
+
// produced no observable platform state; replay would just re-do the
|
|
69
|
+
// identical no-side-effect work.
|
|
70
|
+
if (!input.hasSideEffects) {
|
|
71
|
+
return {
|
|
72
|
+
decision: 'skip-already-applied',
|
|
73
|
+
reason: `${input.phaseName} previously succeeded with no side effects — skip-already-applied`,
|
|
74
|
+
refsConsulted,
|
|
75
|
+
readbacksConsulted: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// Prior success + side effects + no refs → we can't reach the platform of
|
|
79
|
+
// record to confirm anything. Bubble to a human; the spec is explicit
|
|
80
|
+
// that missing refs always route to needs-human.
|
|
81
|
+
if (input.externalRefs.length === 0) {
|
|
82
|
+
return {
|
|
83
|
+
decision: 'needs-human',
|
|
84
|
+
reason: `${input.phaseName} previously succeeded with side effects but recorded no externalRefs — cannot verify, needs human review`,
|
|
85
|
+
refsConsulted,
|
|
86
|
+
readbacksConsulted: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Prior success + side effects + refs but no readbacks supplied (CLI
|
|
90
|
+
// lookup mode): we must NOT silently skip. Surface as needs-human so the
|
|
91
|
+
// CLI prediction matches what runPhase will do under live conditions.
|
|
92
|
+
if (input.readbacks.length === 0) {
|
|
93
|
+
return {
|
|
94
|
+
decision: 'needs-human',
|
|
95
|
+
reason: `${input.phaseName} previously succeeded with side effects; no live readback was performed — needs human review (or pass --force-replay)`,
|
|
96
|
+
refsConsulted,
|
|
97
|
+
readbacksConsulted: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Refs + readbacks both present — adjudicate per readback validity.
|
|
101
|
+
const stale = readbacksConsulted.filter(rb => !isReadbackValid(rb));
|
|
102
|
+
if (stale.length > 0) {
|
|
103
|
+
const summary = stale
|
|
104
|
+
.map(rb => `${rb.refKind}=${rb.refId} state=${rb.currentState}`)
|
|
105
|
+
.join(', ');
|
|
106
|
+
return {
|
|
107
|
+
decision: 'needs-human',
|
|
108
|
+
reason: `${input.phaseName} previously succeeded but ${stale.length} ref(s) are stale or missing on the platform: ${summary}`,
|
|
109
|
+
refsConsulted,
|
|
110
|
+
readbacksConsulted,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
decision: 'skip-already-applied',
|
|
115
|
+
reason: `${input.phaseName} previously succeeded; all ${readbacksConsulted.length} platform ref(s) verified live — skip-already-applied`,
|
|
116
|
+
refsConsulted,
|
|
117
|
+
readbacksConsulted,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** A readback is "valid" — i.e. authorizes a skip-already-applied — when the
|
|
121
|
+
* platform confirms the ref still exists AND its current state is one of
|
|
122
|
+
* the "still represents the prior side effect" set. The deny-set:
|
|
123
|
+
* - 'closed' / 'rolled-back' / 'failed' → side effect was reverted;
|
|
124
|
+
* replaying would create a new artifact.
|
|
125
|
+
* - 'unknown' → fail-closed; we can't make a confident assertion.
|
|
126
|
+
* Anything else (open / merged / live) is treated as "ref still represents
|
|
127
|
+
* the prior side effect" — replay would be a duplicate. */
|
|
128
|
+
function isReadbackValid(rb) {
|
|
129
|
+
if (!rb.existsOnPlatform)
|
|
130
|
+
return false;
|
|
131
|
+
switch (rb.currentState) {
|
|
132
|
+
case 'open':
|
|
133
|
+
case 'merged':
|
|
134
|
+
case 'live':
|
|
135
|
+
return true;
|
|
136
|
+
case 'closed':
|
|
137
|
+
case 'rolled-back':
|
|
138
|
+
case 'failed':
|
|
139
|
+
case 'unknown':
|
|
140
|
+
default:
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=replay-decision.js.map
|