@catalyst-cloud/cli 0.16.14 → 0.16.16
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 +8 -0
- package/dist/args.js +15 -0
- package/dist/cli.js +8 -0
- package/dist/onboard-next.js +21 -1
- package/dist/onboard-runner-custody.js +48 -11
- package/dist/onboard-runner.js +65 -6
- package/dist/seat.js +45 -0
- package/dist/stage.js +824 -0
- package/dist/transport.js +13 -2
- package/dist/write.js +49 -7
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/writing-to-linear.md +2 -2
- package/skills/catalyst-linear/scripts/label.mjs +6 -5
- package/skills/catalyst-onboard/SKILL.md +7 -6
- package/skills/catalyst-onboard/references/connecting-this-machine.md +1 -0
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +2 -2
- package/skills/whats-happening/scripts/snapshot.mjs +128 -34
package/dist/stage.js
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
// A bare local seat holds one cloud phase across CLI invocations. The cloud owns the phase graph;
|
|
2
|
+
// this client only publishes through a live, fenced local-phase claim.
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { hostname } from "node:os";
|
|
5
|
+
import { mkdirSync, openSync, readFileSync, renameSync, closeSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { flagString, positionals } from "./args.js";
|
|
8
|
+
import { configDirFor, requireConfig, } from "./config.js";
|
|
9
|
+
import { loadContract } from "./contract.js";
|
|
10
|
+
import { stageIdForSlot, teamForTicket } from "./contract.js";
|
|
11
|
+
import { CliError, UsageError } from "./errors.js";
|
|
12
|
+
import { bearerFor } from "./oauth.js";
|
|
13
|
+
import { apiClient } from "./transport.js";
|
|
14
|
+
import { postAgent, resolveIssue } from "./write.js";
|
|
15
|
+
const names = {
|
|
16
|
+
claim: "/local-phase/claim",
|
|
17
|
+
renew: "/local-phase/renew",
|
|
18
|
+
release: "/local-phase/release",
|
|
19
|
+
artifact: "/api/v1/artifacts/publish",
|
|
20
|
+
object: "/api/v1/artifacts/object",
|
|
21
|
+
coordination: "/api/v1/coordination/publish",
|
|
22
|
+
};
|
|
23
|
+
const artifactNames = {
|
|
24
|
+
intake: "intake.json",
|
|
25
|
+
research: "research.md",
|
|
26
|
+
plan: "plan.md",
|
|
27
|
+
implement: "implement.md",
|
|
28
|
+
validate: "validation.md",
|
|
29
|
+
pr: "pr.md",
|
|
30
|
+
merge: "merge.md",
|
|
31
|
+
remediate: "remediation.json",
|
|
32
|
+
};
|
|
33
|
+
const slotForPhase = {
|
|
34
|
+
validate: "verify",
|
|
35
|
+
merge: "pr",
|
|
36
|
+
};
|
|
37
|
+
function statePath(home, ticket) {
|
|
38
|
+
return join(configDirFor(home), "local-stages", `${ticket}.json`);
|
|
39
|
+
}
|
|
40
|
+
async function withStageLock(home, ticket, action) {
|
|
41
|
+
const path = `${statePath(home, ticket)}.lock`;
|
|
42
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
const owner = randomUUID();
|
|
44
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
45
|
+
try {
|
|
46
|
+
const fd = openSync(path, "wx", 0o600);
|
|
47
|
+
try {
|
|
48
|
+
writeFileSync(fd, JSON.stringify({ owner, pid: process.pid }));
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
closeSync(fd);
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
return await action();
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
try {
|
|
58
|
+
if (JSON.parse(readFileSync(path, "utf8")).owner === owner)
|
|
59
|
+
unlinkSync(path);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// A missing or replaced lock cannot be safely removed by this invocation.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err.code !== "EEXIST")
|
|
68
|
+
throw err;
|
|
69
|
+
let staleOwner;
|
|
70
|
+
let stalePid;
|
|
71
|
+
let stalePartial;
|
|
72
|
+
try {
|
|
73
|
+
const holder = JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
if (typeof holder.owner === "string" &&
|
|
75
|
+
Number.isSafeInteger(holder.pid) &&
|
|
76
|
+
holder.pid > 0) {
|
|
77
|
+
try {
|
|
78
|
+
process.kill(holder.pid, 0);
|
|
79
|
+
}
|
|
80
|
+
catch (probe) {
|
|
81
|
+
if (probe.code === "ESRCH") {
|
|
82
|
+
staleOwner = holder.owner;
|
|
83
|
+
stalePid = holder.pid;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// An interrupted write has no PID to probe. A fresh partial write may still have an
|
|
90
|
+
// active owner, while an older inode can be recovered under the same exclusive guard.
|
|
91
|
+
try {
|
|
92
|
+
const stat = statSync(path);
|
|
93
|
+
if (Date.now() - stat.mtimeMs > 5_000)
|
|
94
|
+
stalePartial = {
|
|
95
|
+
dev: stat.dev,
|
|
96
|
+
ino: stat.ino,
|
|
97
|
+
mtimeMs: stat.mtimeMs,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// The lock disappeared while we inspected it.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if ((staleOwner !== undefined || stalePartial !== undefined) &&
|
|
105
|
+
attempt === 0) {
|
|
106
|
+
// Only one stale-lock recoverer may inspect and remove the old inode at a time. Without
|
|
107
|
+
// this second exclusive lock, two readers can both see a dead PID and the second can
|
|
108
|
+
// unlink the first reader's newly acquired live lock.
|
|
109
|
+
const recoveryPath = `${path}.recovery`;
|
|
110
|
+
let recoveryFd;
|
|
111
|
+
try {
|
|
112
|
+
recoveryFd = openSync(recoveryPath, "wx", 0o600);
|
|
113
|
+
}
|
|
114
|
+
catch (recoveryError) {
|
|
115
|
+
if (recoveryError.code === "EEXIST") {
|
|
116
|
+
try {
|
|
117
|
+
// A process can die between the exclusive create and writing its owner record.
|
|
118
|
+
// Leave a fresh partial write alone; an old one is safe to recover on retry.
|
|
119
|
+
const recoveryStat = statSync(recoveryPath);
|
|
120
|
+
let recoverer;
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(readFileSync(recoveryPath, "utf8"));
|
|
123
|
+
if (parsed !== null &&
|
|
124
|
+
typeof parsed === "object" &&
|
|
125
|
+
!Array.isArray(parsed) &&
|
|
126
|
+
typeof parsed.owner === "string" &&
|
|
127
|
+
Number.isSafeInteger(parsed.pid) &&
|
|
128
|
+
parsed.pid > 0)
|
|
129
|
+
recoverer = parsed;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// A killed writer can leave nonempty, incomplete JSON.
|
|
133
|
+
}
|
|
134
|
+
if (recoverer === undefined &&
|
|
135
|
+
Date.now() - recoveryStat.mtimeMs > 5_000) {
|
|
136
|
+
const current = statSync(recoveryPath);
|
|
137
|
+
if (current.dev === recoveryStat.dev &&
|
|
138
|
+
current.ino === recoveryStat.ino &&
|
|
139
|
+
current.mtimeMs === recoveryStat.mtimeMs)
|
|
140
|
+
unlinkSync(recoveryPath);
|
|
141
|
+
}
|
|
142
|
+
if (recoverer !== undefined) {
|
|
143
|
+
try {
|
|
144
|
+
process.kill(recoverer.pid, 0);
|
|
145
|
+
}
|
|
146
|
+
catch (probe) {
|
|
147
|
+
if (probe.code === "ESRCH") {
|
|
148
|
+
const current = JSON.parse(readFileSync(recoveryPath, "utf8"));
|
|
149
|
+
if (current.owner === recoverer.owner &&
|
|
150
|
+
current.pid === recoverer.pid)
|
|
151
|
+
unlinkSync(recoveryPath);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// A partial or replaced recovery lock belongs to another invocation.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
throw new CliError(`another stage command is recovering the lock for ${ticket}; retry when it finishes`, "stage-locked");
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(recoveryFd, JSON.stringify({ owner, pid: process.pid }));
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
closeSync(recoveryFd);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
if (stalePartial !== undefined) {
|
|
170
|
+
const stat = statSync(path);
|
|
171
|
+
if (stat.dev === stalePartial.dev &&
|
|
172
|
+
stat.ino === stalePartial.ino &&
|
|
173
|
+
stat.mtimeMs === stalePartial.mtimeMs &&
|
|
174
|
+
Date.now() - stat.mtimeMs > 5_000)
|
|
175
|
+
unlinkSync(path);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const current = JSON.parse(readFileSync(path, "utf8"));
|
|
179
|
+
if (current.owner === staleOwner && current.pid === stalePid) {
|
|
180
|
+
try {
|
|
181
|
+
process.kill(stalePid, 0);
|
|
182
|
+
}
|
|
183
|
+
catch (probe) {
|
|
184
|
+
if (probe.code === "ESRCH")
|
|
185
|
+
unlinkSync(path);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
unlinkSync(recoveryPath);
|
|
192
|
+
}
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
throw new CliError(`another stage command is active for ${ticket}; retry when it finishes`, "stage-locked");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
throw new CliError(`cannot lock ${ticket}`, "stage-locked");
|
|
199
|
+
}
|
|
200
|
+
function saveState(home, state) {
|
|
201
|
+
const path = statePath(home, state.ticket);
|
|
202
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
203
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
204
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
205
|
+
try {
|
|
206
|
+
writeFileSync(fd, JSON.stringify(state) + "\n");
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
closeSync(fd);
|
|
210
|
+
}
|
|
211
|
+
renameSync(temp, path);
|
|
212
|
+
}
|
|
213
|
+
function readState(ctx, cfg, ticket, phase) {
|
|
214
|
+
let state;
|
|
215
|
+
try {
|
|
216
|
+
state = JSON.parse(readFileSync(statePath(ctx.home, ticket), "utf8"));
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new CliError(`no local stage claim for ${ticket}; run catalyst stage start ${ticket} ${phase}`, "stage-state");
|
|
220
|
+
}
|
|
221
|
+
if (state.version !== 1 ||
|
|
222
|
+
state.ticket !== ticket ||
|
|
223
|
+
state.phase !== phase ||
|
|
224
|
+
state.account !== cfg.account ||
|
|
225
|
+
state.baseUrl !== cfg.baseUrl)
|
|
226
|
+
throw new CliError("local stage claim does not match this ticket, phase, or workspace login", "stage-state");
|
|
227
|
+
return state;
|
|
228
|
+
}
|
|
229
|
+
function routes(doc) {
|
|
230
|
+
const discovered = { ...names };
|
|
231
|
+
for (const key of ["claim", "renew", "release"]) {
|
|
232
|
+
const suffix = names[key];
|
|
233
|
+
const path = doc.routes.find((r) => r.method === "POST" && r.path.endsWith(suffix))?.path;
|
|
234
|
+
if (!path)
|
|
235
|
+
throw new CliError(`tenant contract has no POST ${suffix} route; update the cloud`, "stage-route");
|
|
236
|
+
discovered[key] = path;
|
|
237
|
+
}
|
|
238
|
+
return discovered;
|
|
239
|
+
}
|
|
240
|
+
async function request(ctx, cfg, path, options) {
|
|
241
|
+
const url = new URL(path, cfg.baseUrl);
|
|
242
|
+
url.searchParams.set("account", cfg.account);
|
|
243
|
+
for (const [key, value] of Object.entries(options.query ?? {}))
|
|
244
|
+
url.searchParams.set(key, String(value));
|
|
245
|
+
const headers = {
|
|
246
|
+
authorization: `Bearer ${await bearerFor(ctx, cfg)}`,
|
|
247
|
+
};
|
|
248
|
+
if (options.credential)
|
|
249
|
+
headers["x-catalyst-lease-credential"] = options.credential;
|
|
250
|
+
if (options.body !== undefined)
|
|
251
|
+
headers["content-type"] = "application/json";
|
|
252
|
+
if (options.bytes !== undefined)
|
|
253
|
+
headers["content-type"] = options.contentType ?? "application/octet-stream";
|
|
254
|
+
const res = await ctx.fetch(url, {
|
|
255
|
+
method: options.method ?? "POST",
|
|
256
|
+
headers,
|
|
257
|
+
body: options.bytes ??
|
|
258
|
+
(options.body === undefined ? undefined : JSON.stringify(options.body)),
|
|
259
|
+
signal: AbortSignal.timeout(options.bytes === undefined ? 30_000 : 120_000),
|
|
260
|
+
});
|
|
261
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
262
|
+
let body;
|
|
263
|
+
try {
|
|
264
|
+
body = JSON.parse(bytes.toString("utf8"));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
body = undefined;
|
|
268
|
+
}
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
const reason = body && typeof body === "object"
|
|
271
|
+
? JSON.stringify(body)
|
|
272
|
+
: bytes.toString("utf8").slice(0, 200);
|
|
273
|
+
if (res.status === 403 &&
|
|
274
|
+
body &&
|
|
275
|
+
typeof body === "object" &&
|
|
276
|
+
body.reason === "invalid-lease-credential")
|
|
277
|
+
throw new CliError(`${options.method ?? "POST"} ${path} refused a stale or superseded lease; publish nothing`, "stage-dispossessed", 2, 403);
|
|
278
|
+
throw new CliError(`${options.method ?? "POST"} ${path} failed (${res.status}): ${reason}`, "stage-http", 2, res.status);
|
|
279
|
+
}
|
|
280
|
+
return { body, bytes, status: res.status };
|
|
281
|
+
}
|
|
282
|
+
function record(state, kind, summary, artifactKey) {
|
|
283
|
+
return {
|
|
284
|
+
host: state.node,
|
|
285
|
+
event_id: `stage-${state.ticket}-${state.phase}-${state.nonce}-${kind}`,
|
|
286
|
+
event_name: `phase.${state.phase}.${kind}`,
|
|
287
|
+
ts: new Date().toISOString(),
|
|
288
|
+
attributes: {
|
|
289
|
+
ticket: state.ticket,
|
|
290
|
+
phase: state.phase,
|
|
291
|
+
nonce: state.nonce,
|
|
292
|
+
node: state.node,
|
|
293
|
+
...(summary ? { summary } : {}),
|
|
294
|
+
...(kind === "failed"
|
|
295
|
+
? {
|
|
296
|
+
failureMode: "phase_failed",
|
|
297
|
+
...(summary ? { failureDetail: summary } : {}),
|
|
298
|
+
...(artifactKey ? { artifactRef: artifactKey } : {}),
|
|
299
|
+
}
|
|
300
|
+
: artifactKey
|
|
301
|
+
? { artifactKey }
|
|
302
|
+
: {}),
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
async function publishEvent(ctx, cfg, state, kind, summary, artifactKey) {
|
|
307
|
+
const result = (await request(ctx, cfg, state.routes.coordination, {
|
|
308
|
+
credential: state.credential,
|
|
309
|
+
body: { records: [record(state, kind, summary, artifactKey)] },
|
|
310
|
+
})).body;
|
|
311
|
+
// A retry after a lost response can reach the same deterministic event ID again.
|
|
312
|
+
if (result?.accepted !== 1 && result?.accepted !== 0)
|
|
313
|
+
throw new CliError(`cloud did not accept ${kind} event`, "stage-publish");
|
|
314
|
+
}
|
|
315
|
+
async function moveCardToStage(ctx, cfg, doc, state) {
|
|
316
|
+
const api = apiClient(cfg, ctx);
|
|
317
|
+
const issue = await resolveIssue(api, state.ticket);
|
|
318
|
+
const stageId = stageIdForSlot(teamForTicket(doc, issue.identifier), slotForPhase[state.phase] ?? state.phase);
|
|
319
|
+
const result = await postAgent(api, doc, "issue-state", {
|
|
320
|
+
issueId: issue.id,
|
|
321
|
+
stateId: stageId,
|
|
322
|
+
});
|
|
323
|
+
if (result?.outcome !== "succeeded")
|
|
324
|
+
throw new CliError(`card move to ${state.phase} was not confirmed: ${result?.reason ?? result?.outcome ?? "missing outcome"}`, "stage-card-move");
|
|
325
|
+
}
|
|
326
|
+
async function publishArtifact(ctx, cfg, state, name, bytes, contentType) {
|
|
327
|
+
const result = (await request(ctx, cfg, state.routes.artifact, {
|
|
328
|
+
credential: state.credential,
|
|
329
|
+
query: { ticket: state.ticket, phase: state.phase, name },
|
|
330
|
+
bytes,
|
|
331
|
+
contentType,
|
|
332
|
+
})).body;
|
|
333
|
+
if (!result?.key || !Number.isSafeInteger(result.bytes))
|
|
334
|
+
throw new CliError(`artifact ${name} publish returned no key or byte count`, "stage-publish");
|
|
335
|
+
if (result.bytes !== Buffer.byteLength(bytes))
|
|
336
|
+
throw new CliError(`artifact ${name} publish acknowledged ${result.bytes} bytes; sent ${Buffer.byteLength(bytes)}`, "stage-publish");
|
|
337
|
+
return result.key;
|
|
338
|
+
}
|
|
339
|
+
async function release(ctx, cfg, state, outcome, reason) {
|
|
340
|
+
const result = (await request(ctx, cfg, state.routes.release, {
|
|
341
|
+
credential: state.credential,
|
|
342
|
+
body: {
|
|
343
|
+
ticket: state.ticket,
|
|
344
|
+
phase: state.phase,
|
|
345
|
+
nonce: state.nonce,
|
|
346
|
+
runtime: state.runtime,
|
|
347
|
+
machine: state.machine,
|
|
348
|
+
...(outcome ? { outcome } : {}),
|
|
349
|
+
...(reason ? { reason } : {}),
|
|
350
|
+
},
|
|
351
|
+
})).body;
|
|
352
|
+
if (result?.released !== true)
|
|
353
|
+
throw new CliError(`stage release was not confirmed: ${result?.refusal ?? "unknown"}; lease proof retained`, "stage-release");
|
|
354
|
+
try {
|
|
355
|
+
unlinkSync(statePath(ctx.home, state.ticket));
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
if (err.code !== "ENOENT")
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
async function finishTerminalRelease(ctx, cfg, state, kind) {
|
|
363
|
+
try {
|
|
364
|
+
await release(ctx, cfg, state);
|
|
365
|
+
}
|
|
366
|
+
catch (releaseError) {
|
|
367
|
+
if (!(releaseError instanceof CliError &&
|
|
368
|
+
releaseError.code === "stage-dispossessed"))
|
|
369
|
+
throw new CliError(`stage ${kind} landed but lease release is unconfirmed: ${String(releaseError)}; retry ${kind === "complete" ? "done" : "fail"} to reconcile release`, "stage-release");
|
|
370
|
+
// The terminal event was acknowledged before release began. A stale credential proves this
|
|
371
|
+
// tenure no longer holds the lease, including when the first release succeeded but its reply
|
|
372
|
+
// was lost. Do not republish the terminal payload with that invalidated credential.
|
|
373
|
+
try {
|
|
374
|
+
unlinkSync(statePath(ctx.home, state.ticket));
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
if (err.code !== "ENOENT")
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
ctx.stdout(`${state.ticket}/${state.phase}: ${kind === "complete" ? "done" : "failed"}`);
|
|
382
|
+
}
|
|
383
|
+
function checkedArtifact(path, expectedName) {
|
|
384
|
+
if (basename(path) !== expectedName)
|
|
385
|
+
throw new UsageError(`artifact must be named ${expectedName}`);
|
|
386
|
+
const bytes = readFileSync(path);
|
|
387
|
+
if (bytes.length === 0 || bytes.toString("utf8").trim() === "")
|
|
388
|
+
throw new UsageError(`${path} is empty`);
|
|
389
|
+
return bytes;
|
|
390
|
+
}
|
|
391
|
+
function object(value) {
|
|
392
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
393
|
+
throw new UsageError("evidence must be a JSON object");
|
|
394
|
+
return value;
|
|
395
|
+
}
|
|
396
|
+
function evidenceFor(phase, path, bytes) {
|
|
397
|
+
const evidence = object(JSON.parse(readFileSync(path, "utf8")));
|
|
398
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
399
|
+
if (evidence.artifactSha256 !== digest)
|
|
400
|
+
throw new UsageError("evidence artifactSha256 does not match the artifact bytes");
|
|
401
|
+
for (const [field, allowed] of [
|
|
402
|
+
["scope", "plan"],
|
|
403
|
+
["ladder", "validate"],
|
|
404
|
+
["offPlan", "remediate"],
|
|
405
|
+
["remediateCapReached", "remediate"],
|
|
406
|
+
["conflictRouting", "remediate"],
|
|
407
|
+
["conflictExtraPaths", "remediate"],
|
|
408
|
+
["findingsRouting", "remediate"],
|
|
409
|
+
["findingsSourcePaths", "remediate"],
|
|
410
|
+
]) {
|
|
411
|
+
if (evidence[field] !== undefined && phase !== allowed)
|
|
412
|
+
throw new UsageError(`${field} is only valid for ${allowed}`);
|
|
413
|
+
}
|
|
414
|
+
if (evidence.remediateCapReached !== undefined &&
|
|
415
|
+
typeof evidence.remediateCapReached !== "boolean")
|
|
416
|
+
throw new UsageError("remediateCapReached must be a boolean");
|
|
417
|
+
for (const [field, routing] of [
|
|
418
|
+
["conflictExtraPaths", "conflictRouting"],
|
|
419
|
+
["findingsSourcePaths", "findingsRouting"],
|
|
420
|
+
]) {
|
|
421
|
+
const paths = evidence[field];
|
|
422
|
+
if (paths === undefined)
|
|
423
|
+
continue;
|
|
424
|
+
if (!["validate", "pr", "unmeasured"].includes(String(evidence[routing])) ||
|
|
425
|
+
!Array.isArray(paths) ||
|
|
426
|
+
paths.length > 20 ||
|
|
427
|
+
!paths.every((path) => typeof path === "string" && path.length > 0 && path.length <= 1024))
|
|
428
|
+
throw new UsageError(`${field} needs ${routing} and at most 20 nonempty paths`);
|
|
429
|
+
}
|
|
430
|
+
if (phase === "plan") {
|
|
431
|
+
const scope = object(evidence.scope);
|
|
432
|
+
if (!["declared", "truncated"].includes(String(scope.status)) ||
|
|
433
|
+
!Array.isArray(scope.files) ||
|
|
434
|
+
scope.files.length > 200 ||
|
|
435
|
+
!scope.files.every((file) => typeof file === "string" &&
|
|
436
|
+
file.length > 0 &&
|
|
437
|
+
file.length <= 200 &&
|
|
438
|
+
!file.startsWith("/") &&
|
|
439
|
+
!file.split("/").includes("..")) ||
|
|
440
|
+
!Array.isArray(scope.packages) ||
|
|
441
|
+
scope.packages.length > 32 ||
|
|
442
|
+
!scope.packages.every((value) => typeof value === "string") ||
|
|
443
|
+
!Array.isArray(scope.exclusive) ||
|
|
444
|
+
scope.exclusive.length > 8 ||
|
|
445
|
+
!scope.exclusive.every((value) => typeof value === "string") ||
|
|
446
|
+
Buffer.byteLength(JSON.stringify({
|
|
447
|
+
status: scope.status,
|
|
448
|
+
files: scope.files,
|
|
449
|
+
packages: scope.packages,
|
|
450
|
+
exclusive: scope.exclusive,
|
|
451
|
+
})) >
|
|
452
|
+
16 * 1024)
|
|
453
|
+
throw new UsageError("plan needs a valid evidence.scope with status, files, packages, and exclusive");
|
|
454
|
+
}
|
|
455
|
+
if (phase === "validate") {
|
|
456
|
+
const ladder = object(evidence.ladder);
|
|
457
|
+
const expected = [
|
|
458
|
+
"plan-conformance",
|
|
459
|
+
"type-safety",
|
|
460
|
+
"code-review",
|
|
461
|
+
"security-review",
|
|
462
|
+
"security-audit",
|
|
463
|
+
];
|
|
464
|
+
const steps = ladder.steps;
|
|
465
|
+
if (ladder.version !== 1 ||
|
|
466
|
+
!Array.isArray(steps) ||
|
|
467
|
+
steps.length !== expected.length ||
|
|
468
|
+
!expected.every((step, i) => object(steps[i]).step === step &&
|
|
469
|
+
["PASS", "SKIPPED", "UNAVAILABLE"].includes(String(object(steps[i]).verdict))))
|
|
470
|
+
throw new UsageError("validate needs five non-failing ladder verdicts in the canonical order");
|
|
471
|
+
}
|
|
472
|
+
if (phase === "implement" &&
|
|
473
|
+
(typeof evidence.pushedHeadSha !== "string" ||
|
|
474
|
+
!/^[a-f0-9]{40}$/.test(evidence.pushedHeadSha) ||
|
|
475
|
+
typeof evidence.pullRequestUrl !== "string"))
|
|
476
|
+
throw new UsageError("implement needs pushedHeadSha and pullRequestUrl evidence");
|
|
477
|
+
if (phase === "merge" &&
|
|
478
|
+
(typeof evidence.mergedHeadSha !== "string" ||
|
|
479
|
+
!/^[a-f0-9]{40}$/.test(evidence.mergedHeadSha)))
|
|
480
|
+
throw new UsageError("merge needs mergedHeadSha evidence");
|
|
481
|
+
if (phase === "intake" || phase === "remediate")
|
|
482
|
+
JSON.parse(bytes.toString("utf8"));
|
|
483
|
+
return evidence;
|
|
484
|
+
}
|
|
485
|
+
async function start(ctx, cfg, ticket, phase) {
|
|
486
|
+
if (artifactNames[phase] === undefined)
|
|
487
|
+
throw new UsageError(`stage ${phase} has no declared artifact`);
|
|
488
|
+
try {
|
|
489
|
+
const previous = JSON.parse(readFileSync(statePath(ctx.home, ticket), "utf8"));
|
|
490
|
+
const deadline = Math.max(previous.expiresAtMs, previous.runtime?.expiresAtMs ?? previous.expiresAtMs);
|
|
491
|
+
if (!Number.isSafeInteger(deadline) ||
|
|
492
|
+
ctx.now().getTime() <= deadline + 30_000)
|
|
493
|
+
throw new UsageError(`${ticket} already has a local stage claim`);
|
|
494
|
+
// The saved proof is past both usable deadlines. A fresh cloud claim below is the final
|
|
495
|
+
// authority: if its lease still exists, the cloud refuses and no new stage begins.
|
|
496
|
+
unlinkSync(statePath(ctx.home, ticket));
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
if (err instanceof UsageError)
|
|
500
|
+
throw err;
|
|
501
|
+
if (err.code !== "ENOENT")
|
|
502
|
+
throw new CliError(`cannot reconcile the saved local stage for ${ticket}: ${String(err)}`, "stage-state");
|
|
503
|
+
}
|
|
504
|
+
const { doc } = await loadContract(ctx, cfg, { refresh: true });
|
|
505
|
+
const paths = routes(doc);
|
|
506
|
+
const machine = hostname();
|
|
507
|
+
const incarnation = randomUUID();
|
|
508
|
+
const claimed = (await request(ctx, cfg, paths.claim, {
|
|
509
|
+
body: { ticket, machine, incarnation, ttlMs: 3_600_000 },
|
|
510
|
+
})).body;
|
|
511
|
+
if (!claimed?.claimed ||
|
|
512
|
+
!claimed.assignment ||
|
|
513
|
+
!claimed.runtime ||
|
|
514
|
+
!Number.isSafeInteger(claimed.runtime.expiresAtMs))
|
|
515
|
+
throw new CliError(`claim refused: ${claimed?.refusal ?? "invalid answer"}`, "stage-claim");
|
|
516
|
+
const a = claimed.assignment;
|
|
517
|
+
if (!claimed.credential)
|
|
518
|
+
throw new CliError(`claim granted without a lease credential; it expires at ${new Date(a.grant.expiresAtMs).toISOString()}`, "stage-claim");
|
|
519
|
+
const state = {
|
|
520
|
+
version: 1,
|
|
521
|
+
account: cfg.account,
|
|
522
|
+
baseUrl: cfg.baseUrl,
|
|
523
|
+
ticket: a.ticket,
|
|
524
|
+
phase: a.phase,
|
|
525
|
+
nonce: a.grant.nonce,
|
|
526
|
+
credential: claimed.credential,
|
|
527
|
+
runtime: claimed.runtime,
|
|
528
|
+
machine,
|
|
529
|
+
node: a.lease?.holder ?? `customer-local:${machine}`,
|
|
530
|
+
expiresAtMs: a.grant.expiresAtMs,
|
|
531
|
+
routes: paths,
|
|
532
|
+
};
|
|
533
|
+
if (a.ticket !== ticket || a.phase !== phase) {
|
|
534
|
+
saveState(ctx.home, state);
|
|
535
|
+
await release(ctx, cfg, state);
|
|
536
|
+
throw new CliError(`cloud granted ${a.ticket}/${a.phase}; requested ${ticket}/${phase}; released unrun grant`, "stage-mismatch");
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
// Persist the lease proof before any further network I/O, so a killed CLI leaves a claim the
|
|
540
|
+
// next invocation can renew or release instead of an invisible hour-long ticket lock.
|
|
541
|
+
saveState(ctx.home, state);
|
|
542
|
+
await publishArtifact(ctx, cfg, state, `${phase}-round.json`, JSON.stringify({
|
|
543
|
+
version: 1,
|
|
544
|
+
ticket,
|
|
545
|
+
phase,
|
|
546
|
+
nonce: state.nonce,
|
|
547
|
+
...(a.grant.round === undefined ? {} : { round: a.grant.round }),
|
|
548
|
+
atMs: ctx.now().getTime(),
|
|
549
|
+
substrate: "local",
|
|
550
|
+
node: state.node,
|
|
551
|
+
incarnation,
|
|
552
|
+
}), "application/json");
|
|
553
|
+
for (const ref of a.priorArtifacts ?? []) {
|
|
554
|
+
const parts = ref.split("/");
|
|
555
|
+
const name = parts.at(-1), nonce = Number(parts.at(-2)), priorPhase = parts.at(-3), priorTicket = parts.at(-4);
|
|
556
|
+
if (!name ||
|
|
557
|
+
!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(name) ||
|
|
558
|
+
!priorPhase ||
|
|
559
|
+
!/^[a-z][a-z0-9-]*$/.test(priorPhase) ||
|
|
560
|
+
priorTicket !== ticket ||
|
|
561
|
+
!Number.isSafeInteger(nonce))
|
|
562
|
+
throw new CliError(`invalid prior artifact ref ${ref}`, "stage-prior");
|
|
563
|
+
const prior = await request(ctx, cfg, state.routes.object, {
|
|
564
|
+
method: "GET",
|
|
565
|
+
credential: state.credential,
|
|
566
|
+
query: { ticket, phase: priorPhase, nonce, name },
|
|
567
|
+
});
|
|
568
|
+
if (prior.bytes.length === 0 || prior.bytes.length > 8 * 1024 * 1024)
|
|
569
|
+
throw new CliError(`prior artifact ${ref} has invalid size`, "stage-prior");
|
|
570
|
+
const priorDir = join(configDirFor(ctx.home), "local-stages", "prior", ticket, phase, String(state.nonce));
|
|
571
|
+
const path = join(priorDir, priorPhase, String(nonce), name);
|
|
572
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
573
|
+
writeFileSync(path, prior.bytes, { flag: "wx", mode: 0o600 });
|
|
574
|
+
state.priorDir = priorDir;
|
|
575
|
+
saveState(ctx.home, state);
|
|
576
|
+
}
|
|
577
|
+
await publishEvent(ctx, cfg, state, "started");
|
|
578
|
+
// Under trailing keying, the accepted terminal receipt moves the card. A start event records
|
|
579
|
+
// activity and timing but must not present unfinished work as a completed phase.
|
|
580
|
+
if (doc.ladder.keying === "leading")
|
|
581
|
+
await moveCardToStage(ctx, cfg, doc, state);
|
|
582
|
+
}
|
|
583
|
+
catch (err) {
|
|
584
|
+
if (err instanceof CliError && err.code === "stage-dispossessed") {
|
|
585
|
+
try {
|
|
586
|
+
unlinkSync(statePath(ctx.home, state.ticket));
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
/* stale state only */
|
|
590
|
+
}
|
|
591
|
+
throw err;
|
|
592
|
+
}
|
|
593
|
+
// A start event must have a matching terminal event even when the card move fails.
|
|
594
|
+
// Otherwise the board can show a permanently active seat after this claim is released.
|
|
595
|
+
try {
|
|
596
|
+
await publishEvent(ctx, cfg, state, "failed", `stage_start_failed: ${String(err)}`);
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
/* release and the original error remain authoritative */
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
await release(ctx, cfg, state, "setup_failed", "stage_start_failed");
|
|
603
|
+
}
|
|
604
|
+
catch (releaseError) {
|
|
605
|
+
throw new CliError(`stage start failed (${String(err)}); release also failed (${String(releaseError)}). Run stage renew or fail before lease expiry.`, "stage-release");
|
|
606
|
+
}
|
|
607
|
+
throw err;
|
|
608
|
+
}
|
|
609
|
+
const renewBy = new Date(Math.min(state.expiresAtMs, state.runtime.expiresAtMs)).toISOString();
|
|
610
|
+
ctx.stdout(`${ticket}/${phase}: started; renew before ${renewBy}. Renew with catalyst stage renew ${ticket} ${phase} --assertion <work done>.${state.priorDir ? ` Prior artifacts: ${state.priorDir}.` : ""}`);
|
|
611
|
+
}
|
|
612
|
+
async function renew(ctx, cfg, state, assertion) {
|
|
613
|
+
let result;
|
|
614
|
+
try {
|
|
615
|
+
result = (await request(ctx, cfg, state.routes.renew, {
|
|
616
|
+
credential: state.credential,
|
|
617
|
+
body: {
|
|
618
|
+
ticket: state.ticket,
|
|
619
|
+
phase: state.phase,
|
|
620
|
+
nonce: state.nonce,
|
|
621
|
+
assertion,
|
|
622
|
+
ttlMs: 3_600_000,
|
|
623
|
+
runtime: state.runtime,
|
|
624
|
+
machine: state.machine,
|
|
625
|
+
},
|
|
626
|
+
})).body;
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
if (err instanceof CliError && err.code === "stage-dispossessed") {
|
|
630
|
+
try {
|
|
631
|
+
unlinkSync(statePath(ctx.home, state.ticket));
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* stale state only */
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
if (result?.renewed !== true) {
|
|
640
|
+
try {
|
|
641
|
+
unlinkSync(statePath(ctx.home, state.ticket));
|
|
642
|
+
}
|
|
643
|
+
catch {
|
|
644
|
+
/* stale state only */
|
|
645
|
+
}
|
|
646
|
+
throw new CliError(`renew refused: ${result?.refusal ?? "unknown"}; do not publish`, "stage-dispossessed");
|
|
647
|
+
}
|
|
648
|
+
if (!Number.isSafeInteger(result.grant?.expiresAtMs) ||
|
|
649
|
+
!Number.isSafeInteger(result.runtime?.expiresAtMs))
|
|
650
|
+
throw new CliError("renew succeeded without both work and runtime deadlines; retain the claim and retry before its previous deadline", "stage-renew");
|
|
651
|
+
state.expiresAtMs = result.grant.expiresAtMs;
|
|
652
|
+
state.runtime.expiresAtMs = result.runtime.expiresAtMs;
|
|
653
|
+
saveState(ctx.home, state);
|
|
654
|
+
ctx.stdout(`${state.ticket}/${state.phase}: renewed; renew before ${new Date(Math.min(state.expiresAtMs, state.runtime.expiresAtMs)).toISOString()}`);
|
|
655
|
+
}
|
|
656
|
+
async function terminal(ctx, cfg, state, kind, args) {
|
|
657
|
+
if (state.terminalAttempt?.publicationAcknowledged === true) {
|
|
658
|
+
if (state.terminalAttempt.kind !== kind)
|
|
659
|
+
throw new UsageError("terminal publication already completed with the other outcome");
|
|
660
|
+
await finishTerminalRelease(ctx, cfg, state, kind);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const summary = flagString(args, "summary")?.trim();
|
|
664
|
+
if (kind === "failed" && !summary)
|
|
665
|
+
throw new UsageError("stage fail needs --summary <reason>");
|
|
666
|
+
// Validate local evidence before the first terminal write. A corrected file can then be retried
|
|
667
|
+
// under the same lease, without inventing a failed phase or releasing a live claim.
|
|
668
|
+
let completeBytes;
|
|
669
|
+
let completeEvidence;
|
|
670
|
+
if (kind === "complete") {
|
|
671
|
+
const artifactPath = flagString(args, "artifact") ??
|
|
672
|
+
(ctx.env.CATALYST_ARTIFACT_DIR
|
|
673
|
+
? join(ctx.env.CATALYST_ARTIFACT_DIR, artifactNames[state.phase])
|
|
674
|
+
: undefined);
|
|
675
|
+
if (!artifactPath)
|
|
676
|
+
throw new UsageError("stage done needs --artifact <path> or CATALYST_ARTIFACT_DIR");
|
|
677
|
+
const evidencePath = flagString(args, "evidence");
|
|
678
|
+
if (!evidencePath)
|
|
679
|
+
throw new UsageError("stage done needs --evidence <json> with artifactSha256");
|
|
680
|
+
completeBytes = checkedArtifact(artifactPath, artifactNames[state.phase]);
|
|
681
|
+
completeEvidence = evidenceFor(state.phase, evidencePath, completeBytes);
|
|
682
|
+
}
|
|
683
|
+
const failurePath = kind === "failed" ? flagString(args, "artifact") : undefined;
|
|
684
|
+
const failureDiagnostic = state.phase === "intake" || state.phase === "remediate"
|
|
685
|
+
? JSON.stringify({ failed: true, summary })
|
|
686
|
+
: `# ${state.phase} failed\n\n${summary}\n`;
|
|
687
|
+
const terminalBytes = kind === "complete"
|
|
688
|
+
? completeBytes
|
|
689
|
+
: failurePath
|
|
690
|
+
? checkedArtifact(failurePath, artifactNames[state.phase])
|
|
691
|
+
: Buffer.from(failureDiagnostic);
|
|
692
|
+
const attemptSha256 = createHash("sha256")
|
|
693
|
+
.update(JSON.stringify({ kind, summary, evidence: completeEvidence }))
|
|
694
|
+
.update(terminalBytes)
|
|
695
|
+
.digest("hex");
|
|
696
|
+
if (state.terminalAttempt) {
|
|
697
|
+
if (state.terminalAttempt.kind !== kind ||
|
|
698
|
+
state.terminalAttempt.sha256 !== attemptSha256)
|
|
699
|
+
throw new UsageError("terminal publication already began with different artifact, evidence, or summary; retry the original payload");
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
state.terminalAttempt = { kind, sha256: attemptSha256 };
|
|
703
|
+
saveState(ctx.home, state);
|
|
704
|
+
}
|
|
705
|
+
const priorAmbiguousAttempt = state.terminalAttempt.committedOrAmbiguous === true;
|
|
706
|
+
let authoritativeWriteInFlight = false;
|
|
707
|
+
let artifactKey;
|
|
708
|
+
let failure;
|
|
709
|
+
let terminalCommitted = false;
|
|
710
|
+
try {
|
|
711
|
+
if (kind === "complete") {
|
|
712
|
+
const evidence = completeEvidence;
|
|
713
|
+
artifactKey = await publishArtifact(ctx, cfg, state, artifactNames[state.phase], terminalBytes);
|
|
714
|
+
const receipt = {
|
|
715
|
+
version: 1,
|
|
716
|
+
artifactRef: artifactKey,
|
|
717
|
+
...(evidence.scope ? { scope: evidence.scope } : {}),
|
|
718
|
+
...(evidence.ladder ? { ladder: evidence.ladder } : {}),
|
|
719
|
+
...(evidence.offPlan ? { offPlan: evidence.offPlan } : {}),
|
|
720
|
+
...(typeof evidence.remediateCapReached === "boolean"
|
|
721
|
+
? { remediateCapReached: evidence.remediateCapReached }
|
|
722
|
+
: {}),
|
|
723
|
+
...(evidence.conflictRouting
|
|
724
|
+
? { conflictRouting: evidence.conflictRouting }
|
|
725
|
+
: {}),
|
|
726
|
+
...(evidence.conflictExtraPaths
|
|
727
|
+
? { conflictExtraPaths: evidence.conflictExtraPaths }
|
|
728
|
+
: {}),
|
|
729
|
+
...(evidence.findingsRouting
|
|
730
|
+
? { findingsRouting: evidence.findingsRouting }
|
|
731
|
+
: {}),
|
|
732
|
+
...(evidence.findingsSourcePaths
|
|
733
|
+
? { findingsSourcePaths: evidence.findingsSourcePaths }
|
|
734
|
+
: {}),
|
|
735
|
+
};
|
|
736
|
+
state.terminalAttempt.committedOrAmbiguous = true;
|
|
737
|
+
saveState(ctx.home, state);
|
|
738
|
+
authoritativeWriteInFlight = true;
|
|
739
|
+
await publishArtifact(ctx, cfg, state, "relay-done.json", JSON.stringify(receipt), "application/json");
|
|
740
|
+
terminalCommitted = true;
|
|
741
|
+
state.terminalAttempt.committedOrAmbiguous = true;
|
|
742
|
+
saveState(ctx.home, state);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
state.terminalAttempt.committedOrAmbiguous = true;
|
|
746
|
+
saveState(ctx.home, state);
|
|
747
|
+
authoritativeWriteInFlight = true;
|
|
748
|
+
artifactKey = await publishArtifact(ctx, cfg, state, artifactNames[state.phase], terminalBytes);
|
|
749
|
+
terminalCommitted = true;
|
|
750
|
+
state.terminalAttempt.committedOrAmbiguous = true;
|
|
751
|
+
saveState(ctx.home, state);
|
|
752
|
+
}
|
|
753
|
+
await publishEvent(ctx, cfg, state, kind, summary ?? (kind === "complete" ? `${state.phase} completed` : undefined), artifactKey);
|
|
754
|
+
state.terminalAttempt.publicationAcknowledged = true;
|
|
755
|
+
saveState(ctx.home, state);
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
// A 400/413/422 before the receipt (or failure artifact) commits is a definite rejection. The
|
|
759
|
+
// uploaded main artifact has no accepted receipt yet, so a corrected payload can replace it.
|
|
760
|
+
// A lost response or any rejection after the terminal authority is committed keeps the digest.
|
|
761
|
+
if (!terminalCommitted &&
|
|
762
|
+
!priorAmbiguousAttempt &&
|
|
763
|
+
(!state.terminalAttempt?.committedOrAmbiguous ||
|
|
764
|
+
authoritativeWriteInFlight) &&
|
|
765
|
+
err instanceof CliError &&
|
|
766
|
+
err.code === "stage-http" &&
|
|
767
|
+
[400, 413, 422].includes(err.status ?? 0)) {
|
|
768
|
+
delete state.terminalAttempt;
|
|
769
|
+
saveState(ctx.home, state);
|
|
770
|
+
}
|
|
771
|
+
else if (state.terminalAttempt &&
|
|
772
|
+
!state.terminalAttempt.committedOrAmbiguous) {
|
|
773
|
+
// A transport failure might have committed the terminal write. A later definite rejection
|
|
774
|
+
// cannot prove that earlier write did not land, so keep its digest fenced.
|
|
775
|
+
state.terminalAttempt.committedOrAmbiguous = true;
|
|
776
|
+
saveState(ctx.home, state);
|
|
777
|
+
}
|
|
778
|
+
failure = err;
|
|
779
|
+
}
|
|
780
|
+
if (failure instanceof CliError && failure.code === "stage-dispossessed") {
|
|
781
|
+
try {
|
|
782
|
+
unlinkSync(statePath(ctx.home, state.ticket));
|
|
783
|
+
}
|
|
784
|
+
catch {
|
|
785
|
+
/* stale state only */
|
|
786
|
+
}
|
|
787
|
+
throw failure;
|
|
788
|
+
}
|
|
789
|
+
// A transport error after the receipt write is ambiguous: the cloud may already have advanced
|
|
790
|
+
// the phase. Retain the lease so the operator can retry the same deterministic writes safely.
|
|
791
|
+
if (failure)
|
|
792
|
+
throw new CliError(`stage ${kind} did not finish: ${String(failure)}; lease retained, retry ${kind === "complete" ? "done" : "fail"} or renew before expiry`, "stage-publish");
|
|
793
|
+
await finishTerminalRelease(ctx, cfg, state, kind);
|
|
794
|
+
}
|
|
795
|
+
export async function cmdStage(args, ctx) {
|
|
796
|
+
const [verb, ticket, phase, ...extra] = positionals(args);
|
|
797
|
+
if (!verb ||
|
|
798
|
+
!ticket ||
|
|
799
|
+
!phase ||
|
|
800
|
+
extra.length ||
|
|
801
|
+
!/^[A-Z][A-Z0-9]+-\d+$/.test(ticket))
|
|
802
|
+
throw new UsageError("stage takes start|renew|done|fail <ticket> <phase>");
|
|
803
|
+
if (!["start", "renew", "done", "fail"].includes(verb))
|
|
804
|
+
throw new UsageError(`unknown stage verb ${verb}`);
|
|
805
|
+
const cfg = requireConfig(ctx);
|
|
806
|
+
if (cfg.principal !== "service" || !cfg.key)
|
|
807
|
+
throw new CliError("stage needs a personal API key login; browser sessions cannot publish with a local-phase lease", "stage-credential");
|
|
808
|
+
await withStageLock(ctx.home, ticket, async () => {
|
|
809
|
+
if (verb === "start")
|
|
810
|
+
await start(ctx, cfg, ticket, phase);
|
|
811
|
+
else {
|
|
812
|
+
const state = readState(ctx, cfg, ticket, phase);
|
|
813
|
+
if (verb === "renew") {
|
|
814
|
+
const assertion = flagString(args, "assertion")?.trim();
|
|
815
|
+
if (!assertion)
|
|
816
|
+
throw new UsageError("stage renew needs --assertion <work done>");
|
|
817
|
+
await renew(ctx, cfg, state, assertion);
|
|
818
|
+
}
|
|
819
|
+
else
|
|
820
|
+
await terminal(ctx, cfg, state, verb === "done" ? "complete" : "failed", args);
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
return 0;
|
|
824
|
+
}
|