@coresource/hz 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hz.mjs +4767 -0
- package/package.json +35 -0
package/dist/hz.mjs
ADDED
|
@@ -0,0 +1,4767 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import path7 from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { Command, CommanderError as CommanderError3 } from "commander";
|
|
8
|
+
|
|
9
|
+
// src/cli-support.ts
|
|
10
|
+
import { password } from "@inquirer/prompts";
|
|
11
|
+
var CliError = class extends Error {
|
|
12
|
+
exitCode;
|
|
13
|
+
constructor(message, exitCode = 1) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "CliError";
|
|
16
|
+
this.exitCode = exitCode;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
function createCliContext(dependencies = {}) {
|
|
20
|
+
return {
|
|
21
|
+
env: dependencies.env ?? process.env,
|
|
22
|
+
homeDir: dependencies.homeDir,
|
|
23
|
+
stderr: dependencies.stderr ?? process.stderr,
|
|
24
|
+
stdout: dependencies.stdout ?? process.stdout,
|
|
25
|
+
promptForApiKey: dependencies.promptForApiKey ?? (async (message) => password({ message }))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function writeLine(stream, message = "") {
|
|
29
|
+
stream.write(`${message}
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// src/config.ts
|
|
34
|
+
import { open } from "fs/promises";
|
|
35
|
+
import {
|
|
36
|
+
chmod,
|
|
37
|
+
mkdir,
|
|
38
|
+
readFile,
|
|
39
|
+
rename,
|
|
40
|
+
rm
|
|
41
|
+
} from "fs/promises";
|
|
42
|
+
import os from "os";
|
|
43
|
+
import path from "path";
|
|
44
|
+
var DEFAULT_ENDPOINT = "https://horizon.lvlzero.ai";
|
|
45
|
+
var ConfigError = class extends Error {
|
|
46
|
+
code;
|
|
47
|
+
constructor(message, code) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "ConfigError";
|
|
50
|
+
this.code = code;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
function hasOwnEnv(env, key) {
|
|
54
|
+
return Object.prototype.hasOwnProperty.call(env, key);
|
|
55
|
+
}
|
|
56
|
+
function normalizeEndpoint(value) {
|
|
57
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
58
|
+
return DEFAULT_ENDPOINT;
|
|
59
|
+
}
|
|
60
|
+
const candidate = value.trim();
|
|
61
|
+
try {
|
|
62
|
+
return new URL(candidate).toString().replace(/\/$/, "");
|
|
63
|
+
} catch {
|
|
64
|
+
throw new ConfigError(
|
|
65
|
+
`Invalid endpoint URL: ${candidate}`,
|
|
66
|
+
"invalid_config"
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function resolveHomeDir(options = {}) {
|
|
71
|
+
return options.homeDir ?? options.env?.HOME ?? os.homedir();
|
|
72
|
+
}
|
|
73
|
+
function resolveConfigPath(options = {}) {
|
|
74
|
+
if (options.configPath) {
|
|
75
|
+
return options.configPath;
|
|
76
|
+
}
|
|
77
|
+
return path.join(resolveHomeDir(options), ".factory", "config.json");
|
|
78
|
+
}
|
|
79
|
+
async function readConfig(options = {}) {
|
|
80
|
+
const configPath = resolveConfigPath(options);
|
|
81
|
+
try {
|
|
82
|
+
const raw = await readFile(configPath, "utf8");
|
|
83
|
+
const parsed = JSON.parse(raw);
|
|
84
|
+
if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") {
|
|
85
|
+
throw new ConfigError(
|
|
86
|
+
`Invalid JSON object in ${configPath}`,
|
|
87
|
+
"invalid_config"
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const apiKey = typeof parsed.apiKey === "string" ? parsed.apiKey : void 0;
|
|
91
|
+
return {
|
|
92
|
+
...parsed,
|
|
93
|
+
...apiKey ? { apiKey } : {},
|
|
94
|
+
endpoint: normalizeEndpoint(parsed.endpoint)
|
|
95
|
+
};
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error.code === "ENOENT") {
|
|
98
|
+
return { endpoint: DEFAULT_ENDPOINT };
|
|
99
|
+
}
|
|
100
|
+
if (error instanceof SyntaxError) {
|
|
101
|
+
throw new ConfigError(
|
|
102
|
+
`Invalid JSON in ${configPath}`,
|
|
103
|
+
"invalid_config"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function writeConfig(config, options = {}) {
|
|
110
|
+
const configPath = resolveConfigPath(options);
|
|
111
|
+
const configDir = path.dirname(configPath);
|
|
112
|
+
const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
113
|
+
const payload = JSON.stringify(
|
|
114
|
+
{
|
|
115
|
+
...config,
|
|
116
|
+
endpoint: normalizeEndpoint(config.endpoint)
|
|
117
|
+
},
|
|
118
|
+
null,
|
|
119
|
+
2
|
|
120
|
+
).concat("\n");
|
|
121
|
+
await mkdir(configDir, { recursive: true, mode: 448 });
|
|
122
|
+
const handle = await open(tempPath, "w", 384);
|
|
123
|
+
try {
|
|
124
|
+
await handle.writeFile(payload, "utf8");
|
|
125
|
+
await handle.sync();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
await handle.close();
|
|
128
|
+
await rm(tempPath, { force: true });
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
await handle.close();
|
|
132
|
+
await chmod(tempPath, 384);
|
|
133
|
+
await rename(tempPath, configPath);
|
|
134
|
+
await chmod(configPath, 384);
|
|
135
|
+
return configPath;
|
|
136
|
+
}
|
|
137
|
+
async function clearStoredApiKey(options = {}) {
|
|
138
|
+
const current = await readConfig(options);
|
|
139
|
+
const { apiKey: _apiKey, ...rest } = current;
|
|
140
|
+
return writeConfig(rest, options);
|
|
141
|
+
}
|
|
142
|
+
async function getAuthConfig(options = {}) {
|
|
143
|
+
const env = options.env ?? process.env;
|
|
144
|
+
const storedConfig = await readConfig(options);
|
|
145
|
+
if (hasOwnEnv(env, "HZ_API_KEY")) {
|
|
146
|
+
const envApiKey = (env.HZ_API_KEY ?? "").trim();
|
|
147
|
+
if (envApiKey.length === 0) {
|
|
148
|
+
throw new ConfigError(
|
|
149
|
+
"HZ_API_KEY is set but empty. Remove it or set a valid API key.",
|
|
150
|
+
"invalid_env"
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
...storedConfig,
|
|
155
|
+
apiKey: envApiKey,
|
|
156
|
+
apiKeySource: "env"
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
const storedApiKey = storedConfig.apiKey?.trim() ?? "";
|
|
160
|
+
if (storedApiKey.length === 0) {
|
|
161
|
+
throw new ConfigError(
|
|
162
|
+
"No API key configured. Run `hz auth login`.",
|
|
163
|
+
"auth_required"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...storedConfig,
|
|
168
|
+
apiKey: storedApiKey,
|
|
169
|
+
apiKeySource: "config"
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/api-client.ts
|
|
174
|
+
import { setTimeout as delay } from "timers/promises";
|
|
175
|
+
var ApiError = class extends Error {
|
|
176
|
+
payload;
|
|
177
|
+
retriable;
|
|
178
|
+
status;
|
|
179
|
+
constructor(message, options = {}) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.name = "ApiError";
|
|
182
|
+
this.payload = options.payload;
|
|
183
|
+
this.retriable = options.retriable ?? false;
|
|
184
|
+
this.status = options.status;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
function resolveUrl(endpoint, requestPath) {
|
|
188
|
+
const base = endpoint.endsWith("/") ? endpoint : `${endpoint}/`;
|
|
189
|
+
return new URL(requestPath.replace(/^\//, ""), base).toString();
|
|
190
|
+
}
|
|
191
|
+
function isRetriableStatus(status) {
|
|
192
|
+
return status === 429 || status >= 500;
|
|
193
|
+
}
|
|
194
|
+
function maskHeaders(headers) {
|
|
195
|
+
const masked = {};
|
|
196
|
+
for (const [key, value] of headers.entries()) {
|
|
197
|
+
masked[key] = key.toLowerCase() === "authorization" ? "Bearer ***" : value;
|
|
198
|
+
}
|
|
199
|
+
return masked;
|
|
200
|
+
}
|
|
201
|
+
function sanitizeBody(body) {
|
|
202
|
+
if (body === void 0) {
|
|
203
|
+
return "";
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
return JSON.stringify(body);
|
|
207
|
+
} catch {
|
|
208
|
+
return "[unserializable body]";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function parseResponseBody(response) {
|
|
212
|
+
if (response.status === 204) {
|
|
213
|
+
return void 0;
|
|
214
|
+
}
|
|
215
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
216
|
+
const text = await response.text();
|
|
217
|
+
if (text.length === 0) {
|
|
218
|
+
return void 0;
|
|
219
|
+
}
|
|
220
|
+
if (contentType.includes("application/json")) {
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(text);
|
|
223
|
+
} catch {
|
|
224
|
+
return { raw: text };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return text;
|
|
228
|
+
}
|
|
229
|
+
function toRetryDelayMs(attempt, response) {
|
|
230
|
+
if (response?.status === 429) {
|
|
231
|
+
const retryAfter = response.headers.get("retry-after");
|
|
232
|
+
const retryAfterSeconds = Number.parseInt(retryAfter ?? "", 10);
|
|
233
|
+
if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds >= 0) {
|
|
234
|
+
return retryAfterSeconds * 1e3;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return 200 * 2 ** attempt;
|
|
238
|
+
}
|
|
239
|
+
function formatHttpError(response, payload) {
|
|
240
|
+
if (response.status === 401) {
|
|
241
|
+
return new ApiError(
|
|
242
|
+
"Authentication failed. Run `hz auth login` to configure a valid API key.",
|
|
243
|
+
{ payload, status: 401 }
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
if (response.status === 429) {
|
|
247
|
+
const retryAfter = response.headers.get("retry-after");
|
|
248
|
+
const retrySuffix = retryAfter ? ` Retry after ${retryAfter} seconds.` : "";
|
|
249
|
+
return new ApiError(
|
|
250
|
+
`Rate limited by the Horizon API (429).${retrySuffix}`,
|
|
251
|
+
{ payload, retriable: true, status: 429 }
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (response.status >= 500) {
|
|
255
|
+
return new ApiError(
|
|
256
|
+
`Horizon API returned ${response.status}. Please try again shortly.`,
|
|
257
|
+
{ payload, retriable: true, status: response.status }
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (payload && typeof payload === "object") {
|
|
261
|
+
const message = typeof payload.message === "string" ? payload.message : typeof payload.error === "string" ? payload.error : `Request failed with status ${response.status}.`;
|
|
262
|
+
return new ApiError(message, { payload, status: response.status });
|
|
263
|
+
}
|
|
264
|
+
return new ApiError(`Request failed with status ${response.status}.`, {
|
|
265
|
+
payload,
|
|
266
|
+
status: response.status
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function formatNetworkError(error) {
|
|
270
|
+
if (error instanceof ApiError) {
|
|
271
|
+
return error;
|
|
272
|
+
}
|
|
273
|
+
if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
|
|
274
|
+
return new ApiError(
|
|
275
|
+
"The request timed out before the Horizon API responded."
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const causeCode = error instanceof Error && typeof error.cause?.code === "string" ? error.cause.code : void 0;
|
|
279
|
+
if (causeCode === "ENOTFOUND" || causeCode === "EAI_AGAIN") {
|
|
280
|
+
return new ApiError(
|
|
281
|
+
"DNS lookup failed while contacting the Horizon API."
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (causeCode === "DEPTH_ZERO_SELF_SIGNED_CERT" || causeCode === "ERR_TLS_CERT_ALTNAME_INVALID" || causeCode === "CERT_HAS_EXPIRED") {
|
|
285
|
+
return new ApiError(
|
|
286
|
+
"TLS verification failed while connecting to the Horizon API."
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (error instanceof TypeError && /timeout|aborted/i.test(error.message)) {
|
|
290
|
+
return new ApiError(
|
|
291
|
+
"The request timed out before the Horizon API responded."
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (error instanceof TypeError && /fetch failed/i.test(error.message)) {
|
|
295
|
+
return new ApiError(
|
|
296
|
+
"Network request failed while contacting the Horizon API."
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
if (error instanceof Error) {
|
|
300
|
+
return new ApiError(error.message);
|
|
301
|
+
}
|
|
302
|
+
return new ApiError("Unexpected error while contacting the Horizon API.");
|
|
303
|
+
}
|
|
304
|
+
function createApiClient(options) {
|
|
305
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
306
|
+
const sleep = options.sleep ?? ((ms) => delay(ms).then(() => void 0));
|
|
307
|
+
const retryCount = options.retryCount ?? 2;
|
|
308
|
+
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
309
|
+
return {
|
|
310
|
+
async request(request) {
|
|
311
|
+
const url = resolveUrl(options.config.endpoint, request.path);
|
|
312
|
+
const method = request.method ?? "GET";
|
|
313
|
+
const headers = new Headers(request.headers);
|
|
314
|
+
headers.set("accept", "application/json");
|
|
315
|
+
headers.set("authorization", `Bearer ${options.config.apiKey}`);
|
|
316
|
+
let body;
|
|
317
|
+
if (request.body !== void 0) {
|
|
318
|
+
headers.set("content-type", "application/json");
|
|
319
|
+
body = JSON.stringify(request.body);
|
|
320
|
+
}
|
|
321
|
+
if (options.verbose) {
|
|
322
|
+
options.onVerboseLog?.(
|
|
323
|
+
`[hz] request ${method} ${url} headers=${JSON.stringify(maskHeaders(headers))} body=${sanitizeBody(request.body)}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
for (let attempt = 0; attempt <= retryCount; attempt += 1) {
|
|
327
|
+
const signal = AbortSignal.timeout(request.timeoutMs ?? timeoutMs);
|
|
328
|
+
try {
|
|
329
|
+
const response = await fetchImpl(url, {
|
|
330
|
+
method,
|
|
331
|
+
headers,
|
|
332
|
+
body,
|
|
333
|
+
signal
|
|
334
|
+
});
|
|
335
|
+
if (options.verbose) {
|
|
336
|
+
options.onVerboseLog?.(
|
|
337
|
+
`[hz] response ${response.status} ${method} ${url}`
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (response.ok) {
|
|
341
|
+
return await parseResponseBody(response);
|
|
342
|
+
}
|
|
343
|
+
const payload = await parseResponseBody(response);
|
|
344
|
+
if (isRetriableStatus(response.status) && attempt < retryCount) {
|
|
345
|
+
await sleep(toRetryDelayMs(attempt, response));
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
throw formatHttpError(response, payload);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error instanceof ApiError) {
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
const formatted = formatNetworkError(error);
|
|
354
|
+
throw formatted;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
throw new ApiError("Request failed after retries.");
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/commands/auth.ts
|
|
363
|
+
function trimValue(value) {
|
|
364
|
+
return value?.trim() ?? "";
|
|
365
|
+
}
|
|
366
|
+
function normalizeEndpoint2(value) {
|
|
367
|
+
const candidate = trimValue(value);
|
|
368
|
+
if (candidate.length === 0) {
|
|
369
|
+
throw new CliError("Endpoint URL is required.");
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
return new URL(candidate).toString().replace(/\/$/, "");
|
|
373
|
+
} catch {
|
|
374
|
+
throw new CliError(`Invalid endpoint URL: ${candidate}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
async function validateApiKey(apiKey, endpoint, unauthorizedMessage) {
|
|
378
|
+
const client = createApiClient({
|
|
379
|
+
config: {
|
|
380
|
+
apiKey,
|
|
381
|
+
apiKeySource: "config",
|
|
382
|
+
endpoint
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
try {
|
|
386
|
+
return await client.request({
|
|
387
|
+
method: "POST",
|
|
388
|
+
path: "/internal/api-keys/validate",
|
|
389
|
+
body: { key: apiKey }
|
|
390
|
+
});
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
393
|
+
throw new CliError(unauthorizedMessage);
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function resolveOrganizationName(apiKey, endpoint, tenantId) {
|
|
399
|
+
const base = endpoint.endsWith("/") ? endpoint : `${endpoint}/`;
|
|
400
|
+
try {
|
|
401
|
+
const response = await fetch(new URL("api/session", base), {
|
|
402
|
+
headers: {
|
|
403
|
+
accept: "application/json",
|
|
404
|
+
authorization: `Bearer ${apiKey}`
|
|
405
|
+
},
|
|
406
|
+
signal: AbortSignal.timeout(5e3)
|
|
407
|
+
});
|
|
408
|
+
if (!response.ok) {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
const payload = await response.json();
|
|
412
|
+
if (!Array.isArray(payload.allowed_tenants)) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
const match = payload.allowed_tenants.find(
|
|
416
|
+
(tenant) => trimValue(tenant?.tenant_id) === tenantId
|
|
417
|
+
);
|
|
418
|
+
const displayName = trimValue(match?.display_name);
|
|
419
|
+
return displayName.length > 0 ? displayName : null;
|
|
420
|
+
} catch {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function registerAuthCommands(program, context) {
|
|
425
|
+
const auth = program.command("auth").description("Manage authentication");
|
|
426
|
+
auth.command("login").description("Store an API key in ~/.factory/config.json").option("--endpoint <url>", "API endpoint to use").option("--key <apiKey>", "API key to store locally").action(async (options) => {
|
|
427
|
+
const existing = await readConfig({
|
|
428
|
+
env: context.env,
|
|
429
|
+
homeDir: context.homeDir
|
|
430
|
+
});
|
|
431
|
+
const apiKey = trimValue(options.key) || trimValue(await context.promptForApiKey("Enter your Horizon API key"));
|
|
432
|
+
if (apiKey.length === 0) {
|
|
433
|
+
throw new CliError(
|
|
434
|
+
"API key is required. Pass --key or provide a value interactively."
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const endpoint = trimValue(options.endpoint).length > 0 ? normalizeEndpoint2(options.endpoint ?? "") : existing.endpoint;
|
|
438
|
+
const validation = await validateApiKey(
|
|
439
|
+
apiKey,
|
|
440
|
+
endpoint,
|
|
441
|
+
"The supplied API key is invalid or revoked."
|
|
442
|
+
);
|
|
443
|
+
const configPath = await writeConfig(
|
|
444
|
+
{
|
|
445
|
+
...existing,
|
|
446
|
+
apiKey,
|
|
447
|
+
endpoint
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
env: context.env,
|
|
451
|
+
homeDir: context.homeDir
|
|
452
|
+
}
|
|
453
|
+
);
|
|
454
|
+
writeLine(
|
|
455
|
+
context.stdout,
|
|
456
|
+
`Authenticated tenant ${validation.tenant_id}${trimValue(validation.key_name) ? ` with key "${trimValue(validation.key_name)}"` : ""}.`
|
|
457
|
+
);
|
|
458
|
+
writeLine(context.stdout, `Saved API key to ${configPath}.`);
|
|
459
|
+
writeLine(context.stdout, `Using endpoint ${endpoint}.`);
|
|
460
|
+
});
|
|
461
|
+
auth.command("logout").description("Remove the stored API key").action(async () => {
|
|
462
|
+
const config = await readConfig({
|
|
463
|
+
env: context.env,
|
|
464
|
+
homeDir: context.homeDir
|
|
465
|
+
});
|
|
466
|
+
if (!config.apiKey) {
|
|
467
|
+
writeLine(context.stdout, "You are already logged out.");
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
await clearStoredApiKey({
|
|
471
|
+
env: context.env,
|
|
472
|
+
homeDir: context.homeDir
|
|
473
|
+
});
|
|
474
|
+
writeLine(
|
|
475
|
+
context.stdout,
|
|
476
|
+
`Removed the stored API key from ${resolveConfigPath({
|
|
477
|
+
env: context.env,
|
|
478
|
+
homeDir: context.homeDir
|
|
479
|
+
})}.`
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
auth.command("whoami").description("Show the currently configured authentication context").action(async () => {
|
|
483
|
+
const config = await getAuthConfig({
|
|
484
|
+
env: context.env,
|
|
485
|
+
homeDir: context.homeDir
|
|
486
|
+
});
|
|
487
|
+
const validation = await validateApiKey(
|
|
488
|
+
config.apiKey,
|
|
489
|
+
config.endpoint,
|
|
490
|
+
"Authentication failed: the configured API key is invalid or revoked. Run `hz auth login` to update it."
|
|
491
|
+
);
|
|
492
|
+
const organizationName = await resolveOrganizationName(
|
|
493
|
+
config.apiKey,
|
|
494
|
+
config.endpoint,
|
|
495
|
+
validation.tenant_id
|
|
496
|
+
) ?? validation.tenant_id;
|
|
497
|
+
const keyName = trimValue(validation.key_name) || "(unnamed key)";
|
|
498
|
+
writeLine(context.stdout, `Tenant ID: ${validation.tenant_id}`);
|
|
499
|
+
writeLine(
|
|
500
|
+
context.stdout,
|
|
501
|
+
`Organization: ${organizationName}`
|
|
502
|
+
);
|
|
503
|
+
writeLine(context.stdout, `Key name: ${keyName}`);
|
|
504
|
+
writeLine(context.stdout, `Endpoint: ${config.endpoint}`);
|
|
505
|
+
writeLine(context.stdout, `Credential source: ${config.apiKeySource}`);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// src/commands/mission-support.ts
|
|
510
|
+
import pc from "picocolors";
|
|
511
|
+
function isRecord(value) {
|
|
512
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
513
|
+
}
|
|
514
|
+
function asNonEmptyString(value) {
|
|
515
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
516
|
+
}
|
|
517
|
+
function asFiniteNumber(value) {
|
|
518
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
519
|
+
}
|
|
520
|
+
function asNullableNumber(value) {
|
|
521
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
522
|
+
}
|
|
523
|
+
async function createMissionApiClient(context, command, dependencies = {}) {
|
|
524
|
+
const authConfig = await getAuthConfig({
|
|
525
|
+
env: context.env,
|
|
526
|
+
homeDir: context.homeDir
|
|
527
|
+
});
|
|
528
|
+
const globalOptions = typeof command?.optsWithGlobals === "function" ? command.optsWithGlobals() : {};
|
|
529
|
+
return createApiClient({
|
|
530
|
+
config: authConfig,
|
|
531
|
+
onVerboseLog: (message) => writeLine(context.stderr, message),
|
|
532
|
+
sleep: dependencies.sleep,
|
|
533
|
+
verbose: globalOptions.verbose === true
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
function normalizeMissionSummaries(value) {
|
|
537
|
+
if (!Array.isArray(value)) {
|
|
538
|
+
throw new CliError("Mission list response was not an array.");
|
|
539
|
+
}
|
|
540
|
+
return value.map((entry) => normalizeMissionSummary(entry));
|
|
541
|
+
}
|
|
542
|
+
function normalizeMissionSummary(value) {
|
|
543
|
+
if (!isRecord(value)) {
|
|
544
|
+
throw new CliError("Mission list entry was not an object.");
|
|
545
|
+
}
|
|
546
|
+
const missionId = asNonEmptyString(value.missionId);
|
|
547
|
+
if (!missionId) {
|
|
548
|
+
throw new CliError("Mission list entry is missing a missionId.");
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
completedFeatures: asFiniteNumber(value.completedFeatures),
|
|
552
|
+
createdAt: asNonEmptyString(value.createdAt),
|
|
553
|
+
missionId,
|
|
554
|
+
passedAssertions: asFiniteNumber(value.passedAssertions),
|
|
555
|
+
state: asNonEmptyString(value.state) ?? "unknown",
|
|
556
|
+
totalAssertions: asFiniteNumber(value.totalAssertions),
|
|
557
|
+
totalFeatures: asFiniteNumber(value.totalFeatures)
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
function normalizeMissionState(value) {
|
|
561
|
+
if (!isRecord(value)) {
|
|
562
|
+
throw new CliError("Mission state response was not an object.");
|
|
563
|
+
}
|
|
564
|
+
const missionId = asNonEmptyString(value.missionId);
|
|
565
|
+
if (!missionId) {
|
|
566
|
+
throw new CliError("Mission state response is missing a missionId.");
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
completedFeatures: asFiniteNumber(value.completedFeatures),
|
|
570
|
+
createdAt: asNonEmptyString(value.createdAt),
|
|
571
|
+
currentFeatureId: asNonEmptyString(value.currentFeatureId),
|
|
572
|
+
currentWorkerSessionId: asNonEmptyString(value.currentWorkerSessionId),
|
|
573
|
+
estimatedCostUsd: asNullableNumber(value.estimatedCostUsd),
|
|
574
|
+
inferenceTokensUsed: asNullableNumber(value.inferenceTokensUsed),
|
|
575
|
+
missionId,
|
|
576
|
+
passedAssertions: asFiniteNumber(value.passedAssertions),
|
|
577
|
+
sandboxMinutesUsed: asNullableNumber(value.sandboxMinutesUsed),
|
|
578
|
+
sealedMilestones: asFiniteNumber(value.sealedMilestones),
|
|
579
|
+
state: asNonEmptyString(value.state) ?? "unknown",
|
|
580
|
+
totalAssertions: asFiniteNumber(value.totalAssertions),
|
|
581
|
+
totalFeatures: asFiniteNumber(value.totalFeatures),
|
|
582
|
+
totalMilestones: asFiniteNumber(value.totalMilestones),
|
|
583
|
+
updatedAt: asNonEmptyString(value.updatedAt)
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
function normalizeMissionFeatures(value) {
|
|
587
|
+
if (!Array.isArray(value)) {
|
|
588
|
+
throw new CliError("Mission features response was not an array.");
|
|
589
|
+
}
|
|
590
|
+
return value.map((entry) => {
|
|
591
|
+
if (!isRecord(entry)) {
|
|
592
|
+
throw new CliError("Mission feature entry was not an object.");
|
|
593
|
+
}
|
|
594
|
+
const id = asNonEmptyString(entry.id);
|
|
595
|
+
if (!id) {
|
|
596
|
+
throw new CliError("Mission feature entry is missing an id.");
|
|
597
|
+
}
|
|
598
|
+
return {
|
|
599
|
+
id,
|
|
600
|
+
milestone: asNonEmptyString(entry.milestone),
|
|
601
|
+
status: asNonEmptyString(entry.status) ?? "unknown"
|
|
602
|
+
};
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
function normalizeMissionAssertions(value) {
|
|
606
|
+
if (!Array.isArray(value)) {
|
|
607
|
+
throw new CliError("Mission assertions response was not an array.");
|
|
608
|
+
}
|
|
609
|
+
return value.map((entry) => {
|
|
610
|
+
if (!isRecord(entry)) {
|
|
611
|
+
throw new CliError("Mission assertion entry was not an object.");
|
|
612
|
+
}
|
|
613
|
+
const id = asNonEmptyString(entry.id);
|
|
614
|
+
if (!id) {
|
|
615
|
+
throw new CliError("Mission assertion entry is missing an id.");
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
id,
|
|
619
|
+
milestone: asNonEmptyString(entry.milestone),
|
|
620
|
+
status: asNonEmptyString(entry.status) ?? "unknown"
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function normalizeMissionMilestones(value) {
|
|
625
|
+
if (!Array.isArray(value)) {
|
|
626
|
+
throw new CliError("Mission milestones response was not an array.");
|
|
627
|
+
}
|
|
628
|
+
return value.map((entry) => {
|
|
629
|
+
if (!isRecord(entry)) {
|
|
630
|
+
throw new CliError("Mission milestone entry was not an object.");
|
|
631
|
+
}
|
|
632
|
+
const name = asNonEmptyString(entry.name);
|
|
633
|
+
if (!name) {
|
|
634
|
+
throw new CliError("Mission milestone entry is missing a name.");
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
assertionCount: asFiniteNumber(entry.assertionCount),
|
|
638
|
+
completedFeatureCount: asFiniteNumber(entry.completedFeatureCount),
|
|
639
|
+
featureCount: asFiniteNumber(entry.featureCount),
|
|
640
|
+
name,
|
|
641
|
+
passedAssertionCount: asFiniteNumber(entry.passedAssertionCount),
|
|
642
|
+
state: asNonEmptyString(entry.state) ?? "unknown"
|
|
643
|
+
};
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
function truncateMissionId(missionId, limit = 12) {
|
|
647
|
+
return missionId.length > limit ? `${missionId.slice(0, limit)}\u2026` : missionId;
|
|
648
|
+
}
|
|
649
|
+
function formatMissionState(state) {
|
|
650
|
+
switch (state) {
|
|
651
|
+
case "completed":
|
|
652
|
+
return pc.green(state);
|
|
653
|
+
case "cancelled":
|
|
654
|
+
case "failed":
|
|
655
|
+
case "budget_exceeded":
|
|
656
|
+
return pc.red(state);
|
|
657
|
+
case "paused":
|
|
658
|
+
case "triage_needed":
|
|
659
|
+
return pc.yellow(state);
|
|
660
|
+
case "worker_running":
|
|
661
|
+
case "dispatching":
|
|
662
|
+
return pc.cyan(state);
|
|
663
|
+
case "orchestrator_turn":
|
|
664
|
+
return pc.magenta(state);
|
|
665
|
+
default:
|
|
666
|
+
return state;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function formatDateTime(value) {
|
|
670
|
+
if (!value) {
|
|
671
|
+
return "\u2014";
|
|
672
|
+
}
|
|
673
|
+
const date = new Date(value);
|
|
674
|
+
if (Number.isNaN(date.getTime())) {
|
|
675
|
+
return value;
|
|
676
|
+
}
|
|
677
|
+
const iso = date.toISOString();
|
|
678
|
+
return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
679
|
+
}
|
|
680
|
+
function formatCurrency(value) {
|
|
681
|
+
return value === null ? "\u2014" : `$${value.toFixed(2)}`;
|
|
682
|
+
}
|
|
683
|
+
function formatPercent(part, total) {
|
|
684
|
+
if (total <= 0) {
|
|
685
|
+
return "0.0%";
|
|
686
|
+
}
|
|
687
|
+
return `${(part / total * 100).toFixed(1)}%`;
|
|
688
|
+
}
|
|
689
|
+
function plainCell(value) {
|
|
690
|
+
return {
|
|
691
|
+
display: value,
|
|
692
|
+
width: value.length
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function coloredCell(display, plainValue) {
|
|
696
|
+
return {
|
|
697
|
+
display,
|
|
698
|
+
width: plainValue.length
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
function padCell(cell, width) {
|
|
702
|
+
return `${cell.display}${" ".repeat(Math.max(0, width - cell.width))}`;
|
|
703
|
+
}
|
|
704
|
+
function renderTable(stdout, headers, rows) {
|
|
705
|
+
const widths = headers.map(
|
|
706
|
+
(header, index) => Math.max(
|
|
707
|
+
header.length,
|
|
708
|
+
...rows.map((row) => row[index]?.width ?? 0)
|
|
709
|
+
)
|
|
710
|
+
);
|
|
711
|
+
writeLine(
|
|
712
|
+
stdout,
|
|
713
|
+
headers.map((header, index) => header.padEnd(widths[index])).join(" ")
|
|
714
|
+
);
|
|
715
|
+
writeLine(
|
|
716
|
+
stdout,
|
|
717
|
+
widths.map((width) => "-".repeat(width)).join(" ")
|
|
718
|
+
);
|
|
719
|
+
for (const row of rows) {
|
|
720
|
+
writeLine(
|
|
721
|
+
stdout,
|
|
722
|
+
row.map((cell, index) => padCell(cell, widths[index])).join(" ")
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/commands/mission-list.ts
|
|
728
|
+
function registerMissionListCommand(mission, context, dependencies = {}) {
|
|
729
|
+
mission.command("list").description("List recent missions").action(async (_options, command) => {
|
|
730
|
+
const apiClient = await createMissionApiClient(context, command, dependencies);
|
|
731
|
+
const missions = normalizeMissionSummaries(
|
|
732
|
+
await apiClient.request({
|
|
733
|
+
path: "/missions"
|
|
734
|
+
})
|
|
735
|
+
);
|
|
736
|
+
if (missions.length === 0) {
|
|
737
|
+
writeLine(context.stdout, "No missions found");
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
renderTable(
|
|
741
|
+
context.stdout,
|
|
742
|
+
["Mission ID", "State", "Created", "Features", "Assertions"],
|
|
743
|
+
missions.map((missionSummary) => [
|
|
744
|
+
plainCell(truncateMissionId(missionSummary.missionId)),
|
|
745
|
+
coloredCell(
|
|
746
|
+
formatMissionState(missionSummary.state),
|
|
747
|
+
missionSummary.state
|
|
748
|
+
),
|
|
749
|
+
plainCell(formatDateTime(missionSummary.createdAt)),
|
|
750
|
+
plainCell(
|
|
751
|
+
`${missionSummary.completedFeatures}/${missionSummary.totalFeatures}`
|
|
752
|
+
),
|
|
753
|
+
plainCell(
|
|
754
|
+
`${missionSummary.passedAssertions}/${missionSummary.totalAssertions}`
|
|
755
|
+
)
|
|
756
|
+
])
|
|
757
|
+
);
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/commands/mission-lifecycle.ts
|
|
762
|
+
import { readdir as readdir2, readFile as readFile3, stat } from "fs/promises";
|
|
763
|
+
import path3 from "path";
|
|
764
|
+
import { CommanderError } from "commander";
|
|
765
|
+
import pc4 from "picocolors";
|
|
766
|
+
|
|
767
|
+
// src/monitor.ts
|
|
768
|
+
import { setTimeout as delay2 } from "timers/promises";
|
|
769
|
+
import ora from "ora";
|
|
770
|
+
import pc3 from "picocolors";
|
|
771
|
+
import WebSocket from "ws";
|
|
772
|
+
|
|
773
|
+
// src/state.ts
|
|
774
|
+
import { createHash } from "crypto";
|
|
775
|
+
import { open as open2 } from "fs/promises";
|
|
776
|
+
import {
|
|
777
|
+
chmod as chmod2,
|
|
778
|
+
mkdir as mkdir2,
|
|
779
|
+
readFile as readFile2,
|
|
780
|
+
readdir,
|
|
781
|
+
rename as rename2,
|
|
782
|
+
rm as rm2
|
|
783
|
+
} from "fs/promises";
|
|
784
|
+
import os2 from "os";
|
|
785
|
+
import path2 from "path";
|
|
786
|
+
var StateError = class extends Error {
|
|
787
|
+
code;
|
|
788
|
+
constructor(message, code = "invalid_state") {
|
|
789
|
+
super(message);
|
|
790
|
+
this.name = "StateError";
|
|
791
|
+
this.code = code;
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
var REPO_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
795
|
+
var PHASE_ORDER = {
|
|
796
|
+
idle: 0,
|
|
797
|
+
planning_started: 1,
|
|
798
|
+
clarification_resolved: 2,
|
|
799
|
+
mission_draft_approved: 3,
|
|
800
|
+
manifests_written: 4,
|
|
801
|
+
uploading: 5,
|
|
802
|
+
mission_created: 6
|
|
803
|
+
};
|
|
804
|
+
var UPLOAD_STATUS_ORDER = {
|
|
805
|
+
pending: 0,
|
|
806
|
+
requested: 1,
|
|
807
|
+
uploading: 2,
|
|
808
|
+
uploaded: 3,
|
|
809
|
+
blob_exists: 3
|
|
810
|
+
};
|
|
811
|
+
function resolveHomeDir2(options = {}) {
|
|
812
|
+
return options.homeDir ?? process.env.HOME ?? os2.homedir();
|
|
813
|
+
}
|
|
814
|
+
function resolveLaunchesDir(options = {}) {
|
|
815
|
+
return path2.join(resolveHomeDir2(options), ".factory", "cloud-launches");
|
|
816
|
+
}
|
|
817
|
+
function normalizeTask(task) {
|
|
818
|
+
const normalized = task.trim();
|
|
819
|
+
if (normalized.length === 0) {
|
|
820
|
+
throw new StateError("Task description must not be empty.", "invalid_input");
|
|
821
|
+
}
|
|
822
|
+
return normalized;
|
|
823
|
+
}
|
|
824
|
+
function normalizeRepoPaths(repoPaths, options = {}) {
|
|
825
|
+
if (repoPaths.length === 0) {
|
|
826
|
+
throw new StateError("At least one repo path is required.", "invalid_input");
|
|
827
|
+
}
|
|
828
|
+
const cwd = options.cwd ?? process.cwd();
|
|
829
|
+
const normalized = repoPaths.map((repoPath) => {
|
|
830
|
+
const trimmed = repoPath.trim();
|
|
831
|
+
if (trimmed.length === 0) {
|
|
832
|
+
throw new StateError("Repo paths must not be empty.", "invalid_input");
|
|
833
|
+
}
|
|
834
|
+
return path2.resolve(cwd, trimmed);
|
|
835
|
+
});
|
|
836
|
+
return [...new Set(normalized)].sort(
|
|
837
|
+
(left, right) => left.localeCompare(right)
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
function normalizeLaunchId(launchId) {
|
|
841
|
+
const normalized = launchId.trim();
|
|
842
|
+
if (normalized.length === 0) {
|
|
843
|
+
throw new StateError("Launch ID must not be empty.", "invalid_input");
|
|
844
|
+
}
|
|
845
|
+
return normalized;
|
|
846
|
+
}
|
|
847
|
+
function normalizeRepoId(repoId) {
|
|
848
|
+
const normalized = repoId.trim();
|
|
849
|
+
if (!REPO_ID_PATTERN.test(normalized)) {
|
|
850
|
+
throw new StateError(
|
|
851
|
+
`Invalid repo ID: ${repoId}`,
|
|
852
|
+
"invalid_input"
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
return normalized;
|
|
856
|
+
}
|
|
857
|
+
function normalizeSessionId(sessionId) {
|
|
858
|
+
const normalized = sessionId.trim();
|
|
859
|
+
if (normalized.length === 0) {
|
|
860
|
+
throw new StateError("Session ID must not be empty.", "invalid_input");
|
|
861
|
+
}
|
|
862
|
+
return normalized;
|
|
863
|
+
}
|
|
864
|
+
function normalizeMissionId(missionId) {
|
|
865
|
+
const normalized = missionId.trim();
|
|
866
|
+
if (normalized.length === 0) {
|
|
867
|
+
throw new StateError("Mission ID must not be empty.", "invalid_input");
|
|
868
|
+
}
|
|
869
|
+
return normalized;
|
|
870
|
+
}
|
|
871
|
+
function stableValue(value) {
|
|
872
|
+
if (Array.isArray(value)) {
|
|
873
|
+
return value.map((item) => stableValue(item));
|
|
874
|
+
}
|
|
875
|
+
if (value !== null && typeof value === "object") {
|
|
876
|
+
const result = {};
|
|
877
|
+
for (const key of Object.keys(value).sort(
|
|
878
|
+
(left, right) => left.localeCompare(right)
|
|
879
|
+
)) {
|
|
880
|
+
const child = value[key];
|
|
881
|
+
if (child !== void 0) {
|
|
882
|
+
result[key] = stableValue(child);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return result;
|
|
886
|
+
}
|
|
887
|
+
return value;
|
|
888
|
+
}
|
|
889
|
+
function serializeJson(value) {
|
|
890
|
+
try {
|
|
891
|
+
return JSON.stringify(stableValue(value), null, 2).concat("\n");
|
|
892
|
+
} catch (error) {
|
|
893
|
+
if (error instanceof Error) {
|
|
894
|
+
throw new StateError(
|
|
895
|
+
`State payload must be JSON-serializable: ${error.message}`,
|
|
896
|
+
"invalid_input"
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
throw new StateError(
|
|
900
|
+
"State payload must be JSON-serializable.",
|
|
901
|
+
"invalid_input"
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function isJsonObject(value) {
|
|
906
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
907
|
+
}
|
|
908
|
+
function isUploadRepoStatus(value) {
|
|
909
|
+
return typeof value === "string" && Object.hasOwn(UPLOAD_STATUS_ORDER, value);
|
|
910
|
+
}
|
|
911
|
+
function isTerminalUploadStatus(status) {
|
|
912
|
+
return status === "uploaded" || status === "blob_exists";
|
|
913
|
+
}
|
|
914
|
+
async function ensureDirectory(dirPath) {
|
|
915
|
+
await mkdir2(dirPath, { recursive: true, mode: 448 });
|
|
916
|
+
}
|
|
917
|
+
async function writeJsonAtomically(filePath, value) {
|
|
918
|
+
const payload = serializeJson(value);
|
|
919
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
920
|
+
await ensureDirectory(path2.dirname(filePath));
|
|
921
|
+
const handle = await open2(tempPath, "w", 384);
|
|
922
|
+
try {
|
|
923
|
+
await handle.writeFile(payload, "utf8");
|
|
924
|
+
await handle.sync();
|
|
925
|
+
} catch (error) {
|
|
926
|
+
await handle.close();
|
|
927
|
+
await rm2(tempPath, { force: true });
|
|
928
|
+
throw error;
|
|
929
|
+
}
|
|
930
|
+
await handle.close();
|
|
931
|
+
await chmod2(tempPath, 384);
|
|
932
|
+
await rename2(tempPath, filePath);
|
|
933
|
+
await chmod2(filePath, 384);
|
|
934
|
+
}
|
|
935
|
+
async function readJsonIfExists(filePath) {
|
|
936
|
+
try {
|
|
937
|
+
const raw = await readFile2(filePath, "utf8");
|
|
938
|
+
return JSON.parse(raw);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
const code = error.code;
|
|
941
|
+
if (code === "ENOENT") {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
if (error instanceof SyntaxError) {
|
|
945
|
+
throw new StateError(`Invalid JSON in ${filePath}`, "invalid_state");
|
|
946
|
+
}
|
|
947
|
+
throw error;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function toLaunchRequestRecord(value, filePath) {
|
|
951
|
+
if (value === null) {
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
if (!isJsonObject(value)) {
|
|
955
|
+
throw new StateError(`Expected ${filePath} to contain a JSON object.`, "invalid_state");
|
|
956
|
+
}
|
|
957
|
+
if (typeof value.task !== "string" || !Array.isArray(value.repos) || value.repos.some((repo) => typeof repo !== "string") || typeof value.sessionId !== "string") {
|
|
958
|
+
throw new StateError(`Invalid request state in ${filePath}`, "invalid_state");
|
|
959
|
+
}
|
|
960
|
+
return {
|
|
961
|
+
task: value.task,
|
|
962
|
+
repos: [...value.repos],
|
|
963
|
+
sessionId: value.sessionId
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
function toUploadStateRecord(value, filePath) {
|
|
967
|
+
if (value === null) {
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
if (!isJsonObject(value) || !isJsonObject(value.repos)) {
|
|
971
|
+
throw new StateError(`Invalid upload state in ${filePath}`, "invalid_state");
|
|
972
|
+
}
|
|
973
|
+
const repos = {};
|
|
974
|
+
for (const [repoId, repoState] of Object.entries(value.repos)) {
|
|
975
|
+
if (!isJsonObject(repoState) || !isUploadRepoStatus(repoState.status)) {
|
|
976
|
+
throw new StateError(
|
|
977
|
+
`Invalid upload repo state for ${repoId} in ${filePath}`,
|
|
978
|
+
"invalid_state"
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
repos[repoId] = {
|
|
982
|
+
...repoState,
|
|
983
|
+
status: repoState.status
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
if (value.remoteLaunchId !== void 0 && typeof value.remoteLaunchId !== "string") {
|
|
987
|
+
throw new StateError(`Invalid remoteLaunchId in ${filePath}`, "invalid_state");
|
|
988
|
+
}
|
|
989
|
+
return {
|
|
990
|
+
...typeof value.remoteLaunchId === "string" ? { remoteLaunchId: value.remoteLaunchId } : {},
|
|
991
|
+
repos
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
function toMissionRecord(value, filePath) {
|
|
995
|
+
if (value === null) {
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
if (!isJsonObject(value) || typeof value.missionId !== "string") {
|
|
999
|
+
throw new StateError(`Invalid mission state in ${filePath}`, "invalid_state");
|
|
1000
|
+
}
|
|
1001
|
+
return { missionId: value.missionId };
|
|
1002
|
+
}
|
|
1003
|
+
function resolvePhase(snapshot) {
|
|
1004
|
+
if (snapshot.mission) {
|
|
1005
|
+
return "mission_created";
|
|
1006
|
+
}
|
|
1007
|
+
if (snapshot.uploadState) {
|
|
1008
|
+
return "uploading";
|
|
1009
|
+
}
|
|
1010
|
+
if (Object.keys(snapshot.repoManifests).length > 0) {
|
|
1011
|
+
return "manifests_written";
|
|
1012
|
+
}
|
|
1013
|
+
if (snapshot.missionDraft) {
|
|
1014
|
+
return "mission_draft_approved";
|
|
1015
|
+
}
|
|
1016
|
+
if (snapshot.clarification) {
|
|
1017
|
+
return "clarification_resolved";
|
|
1018
|
+
}
|
|
1019
|
+
if (snapshot.request) {
|
|
1020
|
+
return "planning_started";
|
|
1021
|
+
}
|
|
1022
|
+
return "idle";
|
|
1023
|
+
}
|
|
1024
|
+
function buildResumeState(snapshot) {
|
|
1025
|
+
const manifestRepoIds = Object.keys(snapshot.repoManifests).sort(
|
|
1026
|
+
(left, right) => left.localeCompare(right)
|
|
1027
|
+
);
|
|
1028
|
+
const uploadedRepoIds = manifestRepoIds.filter(
|
|
1029
|
+
(repoId) => isTerminalUploadStatus(snapshot.uploadState?.repos[repoId]?.status ?? "pending")
|
|
1030
|
+
);
|
|
1031
|
+
const pendingUploadRepoIds = manifestRepoIds.filter(
|
|
1032
|
+
(repoId) => !uploadedRepoIds.includes(repoId)
|
|
1033
|
+
);
|
|
1034
|
+
return {
|
|
1035
|
+
skipPlanning: snapshot.phase === "mission_draft_approved" || snapshot.phase === "manifests_written" || snapshot.phase === "uploading" || snapshot.phase === "mission_created",
|
|
1036
|
+
skipMissionCreation: snapshot.mission !== null,
|
|
1037
|
+
missionId: snapshot.mission?.missionId ?? null,
|
|
1038
|
+
manifestRepoIds,
|
|
1039
|
+
pendingUploadRepoIds,
|
|
1040
|
+
uploadedRepoIds
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
function validateSnapshot(snapshot) {
|
|
1044
|
+
if (snapshot.clarification && !snapshot.request) {
|
|
1045
|
+
throw new StateError(
|
|
1046
|
+
"Clarification state exists without request.json",
|
|
1047
|
+
"invalid_state"
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
if (snapshot.missionDraft && !snapshot.request) {
|
|
1051
|
+
throw new StateError(
|
|
1052
|
+
"Mission draft exists without request.json",
|
|
1053
|
+
"invalid_state"
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
if (Object.keys(snapshot.repoManifests).length > 0 && !snapshot.missionDraft) {
|
|
1057
|
+
throw new StateError(
|
|
1058
|
+
"Repo manifests exist without mission-draft.json",
|
|
1059
|
+
"invalid_state"
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
if (snapshot.uploadState && Object.keys(snapshot.repoManifests).length === 0) {
|
|
1063
|
+
throw new StateError(
|
|
1064
|
+
"upload-state.json exists without any repo manifests",
|
|
1065
|
+
"invalid_state"
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
if (snapshot.uploadState) {
|
|
1069
|
+
for (const repoId of Object.keys(snapshot.uploadState.repos)) {
|
|
1070
|
+
if (!Object.hasOwn(snapshot.repoManifests, repoId)) {
|
|
1071
|
+
throw new StateError(
|
|
1072
|
+
`Upload state references unknown repo manifest: ${repoId}`,
|
|
1073
|
+
"invalid_state"
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
if (snapshot.mission && !snapshot.uploadState) {
|
|
1079
|
+
throw new StateError(
|
|
1080
|
+
"mission.json exists without upload-state.json",
|
|
1081
|
+
"invalid_state"
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
function compareJson(left, right) {
|
|
1086
|
+
return serializeJson(left) === serializeJson(right);
|
|
1087
|
+
}
|
|
1088
|
+
function phaseAtLeast(current, expected) {
|
|
1089
|
+
return PHASE_ORDER[current] >= PHASE_ORDER[expected];
|
|
1090
|
+
}
|
|
1091
|
+
function assertNoBackwardTransition(current, target, action) {
|
|
1092
|
+
if (PHASE_ORDER[current] > PHASE_ORDER[target]) {
|
|
1093
|
+
throw new StateError(
|
|
1094
|
+
`Cannot move backward from ${current} while trying to ${action}.`,
|
|
1095
|
+
"invalid_transition"
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
function createDeterministicLaunchId(input, options = {}) {
|
|
1100
|
+
const hash = createHash("sha256").update(
|
|
1101
|
+
serializeJson({
|
|
1102
|
+
task: normalizeTask(input.task),
|
|
1103
|
+
repos: normalizeRepoPaths(input.repoPaths, options)
|
|
1104
|
+
})
|
|
1105
|
+
).digest("hex");
|
|
1106
|
+
return `loc_${hash.slice(0, 24)}`;
|
|
1107
|
+
}
|
|
1108
|
+
function resolveLaunchPaths(launchId, options = {}) {
|
|
1109
|
+
const normalizedLaunchId = normalizeLaunchId(launchId);
|
|
1110
|
+
const launchesDir = resolveLaunchesDir(options);
|
|
1111
|
+
const launchDir = path2.join(launchesDir, normalizedLaunchId);
|
|
1112
|
+
const reposDir = path2.join(launchDir, "repos");
|
|
1113
|
+
return {
|
|
1114
|
+
launchesDir,
|
|
1115
|
+
launchDir,
|
|
1116
|
+
reposDir,
|
|
1117
|
+
requestPath: path2.join(launchDir, "request.json"),
|
|
1118
|
+
clarificationPath: path2.join(launchDir, "clarification.json"),
|
|
1119
|
+
missionDraftPath: path2.join(launchDir, "mission-draft.json"),
|
|
1120
|
+
uploadStatePath: path2.join(launchDir, "upload-state.json"),
|
|
1121
|
+
missionPath: path2.join(launchDir, "mission.json")
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
async function ensureLaunchLayout(paths) {
|
|
1125
|
+
await ensureDirectory(paths.launchesDir);
|
|
1126
|
+
await ensureDirectory(paths.launchDir);
|
|
1127
|
+
await ensureDirectory(paths.reposDir);
|
|
1128
|
+
}
|
|
1129
|
+
async function readRepoManifests(reposDir) {
|
|
1130
|
+
const manifests = {};
|
|
1131
|
+
let entries;
|
|
1132
|
+
try {
|
|
1133
|
+
entries = await readdir(reposDir);
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
if (error.code === "ENOENT") {
|
|
1136
|
+
return manifests;
|
|
1137
|
+
}
|
|
1138
|
+
throw error;
|
|
1139
|
+
}
|
|
1140
|
+
for (const entry of entries.sort((left, right) => left.localeCompare(right))) {
|
|
1141
|
+
if (!entry.endsWith(".manifest.json")) {
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
const repoId = entry.slice(0, -".manifest.json".length);
|
|
1145
|
+
manifests[repoId] = await readJsonIfExists(path2.join(reposDir, entry)) ?? null;
|
|
1146
|
+
}
|
|
1147
|
+
return manifests;
|
|
1148
|
+
}
|
|
1149
|
+
async function loadLaunchSnapshot(launchId, options = {}) {
|
|
1150
|
+
const paths = resolveLaunchPaths(launchId, options);
|
|
1151
|
+
await ensureLaunchLayout(paths);
|
|
1152
|
+
const request = toLaunchRequestRecord(
|
|
1153
|
+
await readJsonIfExists(paths.requestPath),
|
|
1154
|
+
paths.requestPath
|
|
1155
|
+
);
|
|
1156
|
+
const clarification = await readJsonIfExists(paths.clarificationPath);
|
|
1157
|
+
const missionDraft = await readJsonIfExists(paths.missionDraftPath);
|
|
1158
|
+
const repoManifests = await readRepoManifests(paths.reposDir);
|
|
1159
|
+
const uploadState = toUploadStateRecord(
|
|
1160
|
+
await readJsonIfExists(paths.uploadStatePath),
|
|
1161
|
+
paths.uploadStatePath
|
|
1162
|
+
);
|
|
1163
|
+
const mission = toMissionRecord(
|
|
1164
|
+
await readJsonIfExists(paths.missionPath),
|
|
1165
|
+
paths.missionPath
|
|
1166
|
+
);
|
|
1167
|
+
const phase = resolvePhase({
|
|
1168
|
+
launchId,
|
|
1169
|
+
paths,
|
|
1170
|
+
request,
|
|
1171
|
+
clarification,
|
|
1172
|
+
missionDraft,
|
|
1173
|
+
repoManifests,
|
|
1174
|
+
uploadState,
|
|
1175
|
+
mission
|
|
1176
|
+
});
|
|
1177
|
+
const snapshotWithoutResume = {
|
|
1178
|
+
launchId: normalizeLaunchId(launchId),
|
|
1179
|
+
paths,
|
|
1180
|
+
phase,
|
|
1181
|
+
request,
|
|
1182
|
+
clarification,
|
|
1183
|
+
missionDraft,
|
|
1184
|
+
repoManifests,
|
|
1185
|
+
uploadState,
|
|
1186
|
+
mission
|
|
1187
|
+
};
|
|
1188
|
+
validateSnapshot(snapshotWithoutResume);
|
|
1189
|
+
return {
|
|
1190
|
+
...snapshotWithoutResume,
|
|
1191
|
+
resume: buildResumeState(snapshotWithoutResume)
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
async function prepareLaunch(input, options = {}) {
|
|
1195
|
+
const launchId = createDeterministicLaunchId(input, options);
|
|
1196
|
+
return loadLaunchSnapshot(launchId, options);
|
|
1197
|
+
}
|
|
1198
|
+
async function recordPlanningStart(input, options = {}) {
|
|
1199
|
+
const launchId = createDeterministicLaunchId(input, options);
|
|
1200
|
+
const snapshot = await loadLaunchSnapshot(launchId, options);
|
|
1201
|
+
const requestRecord = {
|
|
1202
|
+
task: normalizeTask(input.task),
|
|
1203
|
+
repos: normalizeRepoPaths(input.repoPaths, options),
|
|
1204
|
+
sessionId: normalizeSessionId(input.sessionId)
|
|
1205
|
+
};
|
|
1206
|
+
if (snapshot.request) {
|
|
1207
|
+
if (compareJson(snapshot.request, requestRecord)) {
|
|
1208
|
+
return snapshot;
|
|
1209
|
+
}
|
|
1210
|
+
throw new StateError(
|
|
1211
|
+
"request.json already exists with different content.",
|
|
1212
|
+
"invalid_transition"
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
assertNoBackwardTransition(snapshot.phase, "planning_started", "record planning state");
|
|
1216
|
+
await writeJsonAtomically(snapshot.paths.requestPath, requestRecord);
|
|
1217
|
+
return loadLaunchSnapshot(launchId, options);
|
|
1218
|
+
}
|
|
1219
|
+
async function recordClarification(launchId, clarification, options = {}) {
|
|
1220
|
+
const snapshot = await loadLaunchSnapshot(launchId, options);
|
|
1221
|
+
if (!snapshot.request) {
|
|
1222
|
+
throw new StateError(
|
|
1223
|
+
"Cannot record clarification before request.json exists.",
|
|
1224
|
+
"invalid_transition"
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
if (snapshot.clarification) {
|
|
1228
|
+
if (compareJson(snapshot.clarification, clarification)) {
|
|
1229
|
+
return snapshot;
|
|
1230
|
+
}
|
|
1231
|
+
throw new StateError(
|
|
1232
|
+
"clarification.json already exists with different content.",
|
|
1233
|
+
"invalid_transition"
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
assertNoBackwardTransition(snapshot.phase, "clarification_resolved", "record clarification");
|
|
1237
|
+
await writeJsonAtomically(snapshot.paths.clarificationPath, clarification);
|
|
1238
|
+
return loadLaunchSnapshot(snapshot.launchId, options);
|
|
1239
|
+
}
|
|
1240
|
+
async function recordMissionDraft(launchId, missionDraft, options = {}) {
|
|
1241
|
+
const snapshot = await loadLaunchSnapshot(launchId, options);
|
|
1242
|
+
if (!snapshot.request) {
|
|
1243
|
+
throw new StateError(
|
|
1244
|
+
"Cannot record mission draft before request.json exists.",
|
|
1245
|
+
"invalid_transition"
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
if (snapshot.missionDraft) {
|
|
1249
|
+
if (compareJson(snapshot.missionDraft, missionDraft)) {
|
|
1250
|
+
return snapshot;
|
|
1251
|
+
}
|
|
1252
|
+
throw new StateError(
|
|
1253
|
+
"mission-draft.json already exists with different content.",
|
|
1254
|
+
"invalid_transition"
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
assertNoBackwardTransition(snapshot.phase, "mission_draft_approved", "record mission draft");
|
|
1258
|
+
await writeJsonAtomically(snapshot.paths.missionDraftPath, missionDraft);
|
|
1259
|
+
return loadLaunchSnapshot(snapshot.launchId, options);
|
|
1260
|
+
}
|
|
1261
|
+
async function recordRepoManifest(launchId, repoId, manifest, options = {}) {
|
|
1262
|
+
const normalizedRepoId = normalizeRepoId(repoId);
|
|
1263
|
+
const snapshot = await loadLaunchSnapshot(launchId, options);
|
|
1264
|
+
if (!snapshot.missionDraft) {
|
|
1265
|
+
throw new StateError(
|
|
1266
|
+
"Cannot record repo manifests before mission-draft.json exists.",
|
|
1267
|
+
"invalid_transition"
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
const existingManifest = snapshot.repoManifests[normalizedRepoId];
|
|
1271
|
+
if (existingManifest !== void 0) {
|
|
1272
|
+
if (compareJson(existingManifest, manifest)) {
|
|
1273
|
+
return snapshot;
|
|
1274
|
+
}
|
|
1275
|
+
throw new StateError(
|
|
1276
|
+
`Manifest for ${normalizedRepoId} already exists with different content.`,
|
|
1277
|
+
"invalid_transition"
|
|
1278
|
+
);
|
|
1279
|
+
}
|
|
1280
|
+
if (phaseAtLeast(snapshot.phase, "uploading")) {
|
|
1281
|
+
throw new StateError(
|
|
1282
|
+
"Cannot add repo manifests after upload-state.json exists.",
|
|
1283
|
+
"invalid_transition"
|
|
1284
|
+
);
|
|
1285
|
+
}
|
|
1286
|
+
await writeJsonAtomically(
|
|
1287
|
+
path2.join(snapshot.paths.reposDir, `${normalizedRepoId}.manifest.json`),
|
|
1288
|
+
manifest
|
|
1289
|
+
);
|
|
1290
|
+
return loadLaunchSnapshot(snapshot.launchId, options);
|
|
1291
|
+
}
|
|
1292
|
+
function normalizeUploadUpdate(update) {
|
|
1293
|
+
return Object.fromEntries(
|
|
1294
|
+
Object.entries(update).filter(([, value]) => value !== void 0)
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
async function updateRepoUploadState(launchId, update, options = {}) {
|
|
1298
|
+
const normalizedRepoId = normalizeRepoId(update.repoId);
|
|
1299
|
+
const snapshot = await loadLaunchSnapshot(launchId, options);
|
|
1300
|
+
if (!snapshot.missionDraft) {
|
|
1301
|
+
throw new StateError(
|
|
1302
|
+
"Cannot update upload state before mission-draft.json exists.",
|
|
1303
|
+
"invalid_transition"
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
if (!Object.hasOwn(snapshot.repoManifests, normalizedRepoId)) {
|
|
1307
|
+
throw new StateError(
|
|
1308
|
+
`Cannot update upload state for ${normalizedRepoId} before its manifest exists.`,
|
|
1309
|
+
"invalid_transition"
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
if (snapshot.phase === "mission_created") {
|
|
1313
|
+
throw new StateError(
|
|
1314
|
+
"Cannot update upload-state.json after mission.json exists.",
|
|
1315
|
+
"invalid_transition"
|
|
1316
|
+
);
|
|
1317
|
+
}
|
|
1318
|
+
const currentState = snapshot.uploadState ?? { repos: {} };
|
|
1319
|
+
const currentRepoState = currentState.repos[normalizedRepoId];
|
|
1320
|
+
const nextStatus = update.status;
|
|
1321
|
+
const currentStatus = currentRepoState?.status;
|
|
1322
|
+
if (currentStatus) {
|
|
1323
|
+
const currentRank = UPLOAD_STATUS_ORDER[currentStatus];
|
|
1324
|
+
const nextRank = UPLOAD_STATUS_ORDER[nextStatus];
|
|
1325
|
+
if (nextRank < currentRank) {
|
|
1326
|
+
throw new StateError(
|
|
1327
|
+
`Cannot move upload state for ${normalizedRepoId} backward from ${currentStatus} to ${nextStatus}.`,
|
|
1328
|
+
"invalid_transition"
|
|
1329
|
+
);
|
|
1330
|
+
}
|
|
1331
|
+
if (nextRank === currentRank && currentStatus !== nextStatus) {
|
|
1332
|
+
throw new StateError(
|
|
1333
|
+
`Cannot replace upload state ${currentStatus} with ${nextStatus} for ${normalizedRepoId}.`,
|
|
1334
|
+
"invalid_transition"
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
const remoteLaunchId = update.remoteLaunchId?.trim();
|
|
1339
|
+
if (currentState.remoteLaunchId && remoteLaunchId && currentState.remoteLaunchId !== remoteLaunchId) {
|
|
1340
|
+
throw new StateError(
|
|
1341
|
+
"upload-state.json already records a different remoteLaunchId.",
|
|
1342
|
+
"invalid_transition"
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
const { repoId: _repoId, remoteLaunchId: _ignoredRemoteLaunchId, ...rest } = normalizeUploadUpdate(update);
|
|
1346
|
+
const repoState = {
|
|
1347
|
+
...currentRepoState ?? {},
|
|
1348
|
+
...rest,
|
|
1349
|
+
status: nextStatus
|
|
1350
|
+
};
|
|
1351
|
+
const nextState = {
|
|
1352
|
+
...currentState.remoteLaunchId || remoteLaunchId ? { remoteLaunchId: currentState.remoteLaunchId ?? remoteLaunchId } : {},
|
|
1353
|
+
repos: {
|
|
1354
|
+
...currentState.repos,
|
|
1355
|
+
[normalizedRepoId]: repoState
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
await writeJsonAtomically(snapshot.paths.uploadStatePath, nextState);
|
|
1359
|
+
return loadLaunchSnapshot(snapshot.launchId, options);
|
|
1360
|
+
}
|
|
1361
|
+
async function recordMissionCreated(launchId, missionId, options = {}) {
|
|
1362
|
+
const normalizedMissionId = normalizeMissionId(missionId);
|
|
1363
|
+
const snapshot = await loadLaunchSnapshot(launchId, options);
|
|
1364
|
+
if (!snapshot.uploadState) {
|
|
1365
|
+
throw new StateError(
|
|
1366
|
+
"Cannot record mission.json before upload-state.json exists.",
|
|
1367
|
+
"invalid_transition"
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
const manifestRepoIds = Object.keys(snapshot.repoManifests);
|
|
1371
|
+
const incompleteRepos = manifestRepoIds.filter((repoId) => {
|
|
1372
|
+
const status = snapshot.uploadState?.repos[repoId]?.status;
|
|
1373
|
+
return status === void 0 || !isTerminalUploadStatus(status);
|
|
1374
|
+
});
|
|
1375
|
+
if (incompleteRepos.length > 0) {
|
|
1376
|
+
throw new StateError(
|
|
1377
|
+
`Cannot record mission.json before uploads finish for: ${incompleteRepos.join(", ")}`,
|
|
1378
|
+
"invalid_transition"
|
|
1379
|
+
);
|
|
1380
|
+
}
|
|
1381
|
+
if (snapshot.mission) {
|
|
1382
|
+
if (snapshot.mission.missionId === normalizedMissionId) {
|
|
1383
|
+
return snapshot;
|
|
1384
|
+
}
|
|
1385
|
+
throw new StateError(
|
|
1386
|
+
`mission.json already exists with a different mission ID (${snapshot.mission.missionId}).`,
|
|
1387
|
+
"invalid_transition"
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
await writeJsonAtomically(snapshot.paths.missionPath, {
|
|
1391
|
+
missionId: normalizedMissionId
|
|
1392
|
+
});
|
|
1393
|
+
return loadLaunchSnapshot(snapshot.launchId, options);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// src/mission.ts
|
|
1397
|
+
function asNonEmptyString2(value) {
|
|
1398
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
1399
|
+
}
|
|
1400
|
+
function asNullableNumber2(value) {
|
|
1401
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1402
|
+
}
|
|
1403
|
+
function isRecord2(value) {
|
|
1404
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
1405
|
+
}
|
|
1406
|
+
function toMissionDraft(value) {
|
|
1407
|
+
if (!isRecord2(value)) {
|
|
1408
|
+
throw new CliError(
|
|
1409
|
+
"Cannot create a mission before mission-draft.json has been written."
|
|
1410
|
+
);
|
|
1411
|
+
}
|
|
1412
|
+
return value;
|
|
1413
|
+
}
|
|
1414
|
+
function toRepoRefs(value) {
|
|
1415
|
+
const repoIds = Object.keys(value).sort((left, right) => left.localeCompare(right));
|
|
1416
|
+
if (repoIds.length === 0) {
|
|
1417
|
+
throw new CliError(
|
|
1418
|
+
"Cannot create a mission before repo manifests have been recorded."
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
return repoIds.map((repoId) => {
|
|
1422
|
+
const manifest = value[repoId];
|
|
1423
|
+
if (!isRecord2(manifest)) {
|
|
1424
|
+
throw new CliError(`Saved repo manifest ${repoId} is invalid.`);
|
|
1425
|
+
}
|
|
1426
|
+
return manifest;
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
function normalizeBudgetDetails(value) {
|
|
1430
|
+
const details = isRecord2(value) ? value : {};
|
|
1431
|
+
const warnAtBudgetPercent = Object.hasOwn(details, "warnAtBudgetPercent") ? asNullableNumber2(details.warnAtBudgetPercent) : void 0;
|
|
1432
|
+
return {
|
|
1433
|
+
exceeded: Array.isArray(details.exceeded) ? details.exceeded.filter(
|
|
1434
|
+
(entry) => typeof entry === "string" && entry.trim().length > 0
|
|
1435
|
+
) : [],
|
|
1436
|
+
maxInferenceCost: asNullableNumber2(details.maxInferenceCost),
|
|
1437
|
+
maxInferenceTokens: asNullableNumber2(details.maxInferenceTokens),
|
|
1438
|
+
percent_used: asNullableNumber2(details.percent_used),
|
|
1439
|
+
totalCost: asNullableNumber2(details.totalCost),
|
|
1440
|
+
totalTokens: asNullableNumber2(details.totalTokens),
|
|
1441
|
+
...warnAtBudgetPercent === void 0 ? {} : { warnAtBudgetPercent }
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
async function createMissionFromLaunch(options) {
|
|
1445
|
+
const snapshot = await loadLaunchSnapshot(options.launchId, {
|
|
1446
|
+
cwd: options.cwd,
|
|
1447
|
+
homeDir: options.homeDir
|
|
1448
|
+
});
|
|
1449
|
+
if (snapshot.mission?.missionId) {
|
|
1450
|
+
return {
|
|
1451
|
+
created: false,
|
|
1452
|
+
missionId: snapshot.mission.missionId,
|
|
1453
|
+
state: null
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
const remoteLaunchId = asNonEmptyString2(snapshot.uploadState?.remoteLaunchId);
|
|
1457
|
+
if (!remoteLaunchId) {
|
|
1458
|
+
throw new CliError(
|
|
1459
|
+
"Cannot create a mission before the remote launch has been finalized."
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
if (snapshot.resume.pendingUploadRepoIds.length > 0) {
|
|
1463
|
+
throw new CliError(
|
|
1464
|
+
`Cannot create a mission while uploads are still pending for: ${snapshot.resume.pendingUploadRepoIds.join(", ")}`
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
const missionDraft = toMissionDraft(snapshot.missionDraft);
|
|
1468
|
+
const repoRefs = toRepoRefs(snapshot.repoManifests);
|
|
1469
|
+
const clarificationSummary = snapshot.clarification ?? null;
|
|
1470
|
+
const response = await options.apiClient.request({
|
|
1471
|
+
body: {
|
|
1472
|
+
clarificationSummary,
|
|
1473
|
+
launchId: remoteLaunchId,
|
|
1474
|
+
missionDraft,
|
|
1475
|
+
repoRefs
|
|
1476
|
+
},
|
|
1477
|
+
method: "POST",
|
|
1478
|
+
path: "/missions"
|
|
1479
|
+
});
|
|
1480
|
+
const missionId = asNonEmptyString2(response.missionId);
|
|
1481
|
+
if (!missionId) {
|
|
1482
|
+
throw new CliError("Mission creation did not return a mission ID.");
|
|
1483
|
+
}
|
|
1484
|
+
await recordMissionCreated(options.launchId, missionId, {
|
|
1485
|
+
cwd: options.cwd,
|
|
1486
|
+
homeDir: options.homeDir
|
|
1487
|
+
});
|
|
1488
|
+
return {
|
|
1489
|
+
created: true,
|
|
1490
|
+
missionId,
|
|
1491
|
+
state: asNonEmptyString2(response.state)
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
async function startMission(apiClient, missionId) {
|
|
1495
|
+
const response = await apiClient.request({
|
|
1496
|
+
body: {},
|
|
1497
|
+
method: "POST",
|
|
1498
|
+
path: `/missions/${missionId}/mission/start`
|
|
1499
|
+
});
|
|
1500
|
+
const type = asNonEmptyString2(response.type);
|
|
1501
|
+
switch (type) {
|
|
1502
|
+
case "dispatched": {
|
|
1503
|
+
const featureId = asNonEmptyString2(response.featureId);
|
|
1504
|
+
const workerSessionId = asNonEmptyString2(response.workerSessionId);
|
|
1505
|
+
if (!featureId || !workerSessionId) {
|
|
1506
|
+
throw new CliError(
|
|
1507
|
+
"Mission start returned `dispatched` without featureId or workerSessionId."
|
|
1508
|
+
);
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
featureId,
|
|
1512
|
+
type,
|
|
1513
|
+
workerSessionId
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
case "no_work":
|
|
1517
|
+
case "completed":
|
|
1518
|
+
return { type };
|
|
1519
|
+
case "budget_exceeded":
|
|
1520
|
+
return {
|
|
1521
|
+
details: normalizeBudgetDetails(response.details),
|
|
1522
|
+
state: asNonEmptyString2(response.state),
|
|
1523
|
+
type
|
|
1524
|
+
};
|
|
1525
|
+
default:
|
|
1526
|
+
throw new CliError(
|
|
1527
|
+
`Mission start returned an unexpected response type: ${type ?? "unknown"}.`
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// src/triage.ts
|
|
1533
|
+
import {
|
|
1534
|
+
input as defaultInputPrompt,
|
|
1535
|
+
select as defaultSelectPrompt
|
|
1536
|
+
} from "@inquirer/prompts";
|
|
1537
|
+
import pc2 from "picocolors";
|
|
1538
|
+
var TRIAGE_PROGRESS_PAGE_SIZE = 200;
|
|
1539
|
+
var AUTO_DISMISS_RATIONALE = "Auto-dismissed by `hz mission run --yes`.";
|
|
1540
|
+
function isRecord3(value) {
|
|
1541
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
1542
|
+
}
|
|
1543
|
+
function asNonEmptyString3(value) {
|
|
1544
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
1545
|
+
}
|
|
1546
|
+
function asFiniteNumber2(value) {
|
|
1547
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
1548
|
+
}
|
|
1549
|
+
function asStringArray(value) {
|
|
1550
|
+
return Array.isArray(value) ? value.map((entry) => asNonEmptyString3(entry)).filter((entry) => entry !== null) : [];
|
|
1551
|
+
}
|
|
1552
|
+
function normalizeFeature(value) {
|
|
1553
|
+
if (!isRecord3(value)) {
|
|
1554
|
+
throw new CliError("Mission feature entry was not an object.");
|
|
1555
|
+
}
|
|
1556
|
+
const id = asNonEmptyString3(value.id);
|
|
1557
|
+
if (!id) {
|
|
1558
|
+
throw new CliError("Mission feature entry is missing an id.");
|
|
1559
|
+
}
|
|
1560
|
+
return {
|
|
1561
|
+
attemptCount: asFiniteNumber2(value.attemptCount),
|
|
1562
|
+
description: asNonEmptyString3(value.description) ?? "No description provided.",
|
|
1563
|
+
expectedBehavior: asStringArray(value.expectedBehavior),
|
|
1564
|
+
fulfills: asStringArray(value.fulfills),
|
|
1565
|
+
id,
|
|
1566
|
+
isFixFeature: value.isFixFeature === true,
|
|
1567
|
+
milestone: asNonEmptyString3(value.milestone),
|
|
1568
|
+
preconditions: asStringArray(value.preconditions),
|
|
1569
|
+
skillName: asNonEmptyString3(value.skillName),
|
|
1570
|
+
status: asNonEmptyString3(value.status) ?? "unknown",
|
|
1571
|
+
verificationSteps: asStringArray(value.verificationSteps),
|
|
1572
|
+
workerSessionIds: asStringArray(value.workerSessionIds)
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
function normalizeAssertion(value) {
|
|
1576
|
+
if (!isRecord3(value)) {
|
|
1577
|
+
throw new CliError("Mission assertion entry was not an object.");
|
|
1578
|
+
}
|
|
1579
|
+
const id = asNonEmptyString3(value.id);
|
|
1580
|
+
if (!id) {
|
|
1581
|
+
throw new CliError("Mission assertion entry is missing an id.");
|
|
1582
|
+
}
|
|
1583
|
+
return {
|
|
1584
|
+
description: asNonEmptyString3(value.description) ?? "",
|
|
1585
|
+
id,
|
|
1586
|
+
milestone: asNonEmptyString3(value.milestone),
|
|
1587
|
+
ownerFeatureId: asNonEmptyString3(value.ownerFeatureId),
|
|
1588
|
+
status: asNonEmptyString3(value.status) ?? "unknown",
|
|
1589
|
+
title: asNonEmptyString3(value.title) ?? id
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
function normalizeSuggestedAction(value) {
|
|
1593
|
+
if (!isRecord3(value)) {
|
|
1594
|
+
return null;
|
|
1595
|
+
}
|
|
1596
|
+
const type = asNonEmptyString3(value.type);
|
|
1597
|
+
if (!type) {
|
|
1598
|
+
return null;
|
|
1599
|
+
}
|
|
1600
|
+
return {
|
|
1601
|
+
command: asNonEmptyString3(value.command) ?? void 0,
|
|
1602
|
+
details: asNonEmptyString3(value.details) ?? void 0,
|
|
1603
|
+
exitCode: typeof value.exitCode === "number" ? value.exitCode : void 0,
|
|
1604
|
+
issue: asNonEmptyString3(value.issue) ?? void 0,
|
|
1605
|
+
suggestedFix: asNonEmptyString3(value.suggestedFix) ?? void 0,
|
|
1606
|
+
successState: asNonEmptyString3(value.successState) ?? void 0,
|
|
1607
|
+
type
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
function normalizeProgressEvent(value) {
|
|
1611
|
+
if (!isRecord3(value)) {
|
|
1612
|
+
return null;
|
|
1613
|
+
}
|
|
1614
|
+
return {
|
|
1615
|
+
data: value.data,
|
|
1616
|
+
seq: asFiniteNumber2(value.seq),
|
|
1617
|
+
type: asNonEmptyString3(value.type)
|
|
1618
|
+
};
|
|
1619
|
+
}
|
|
1620
|
+
async function defaultPromptSelect(options) {
|
|
1621
|
+
return defaultSelectPrompt({
|
|
1622
|
+
choices: options.choices.map((choice) => ({
|
|
1623
|
+
description: choice.description,
|
|
1624
|
+
name: choice.name,
|
|
1625
|
+
value: choice.value
|
|
1626
|
+
})),
|
|
1627
|
+
message: options.message
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
async function defaultPromptInput(options) {
|
|
1631
|
+
return defaultInputPrompt({
|
|
1632
|
+
default: options.default,
|
|
1633
|
+
message: options.message
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
function buildIssueId(featureId) {
|
|
1637
|
+
return `triage:${featureId}`;
|
|
1638
|
+
}
|
|
1639
|
+
async function fetchTriageFeatures(apiClient, missionId) {
|
|
1640
|
+
const response = await apiClient.request({
|
|
1641
|
+
path: `/missions/${missionId}/features`
|
|
1642
|
+
});
|
|
1643
|
+
if (!Array.isArray(response)) {
|
|
1644
|
+
throw new CliError("Mission features response was not an array.");
|
|
1645
|
+
}
|
|
1646
|
+
return response.map((feature) => normalizeFeature(feature));
|
|
1647
|
+
}
|
|
1648
|
+
async function fetchAssertions(apiClient, missionId) {
|
|
1649
|
+
const response = await apiClient.request({
|
|
1650
|
+
path: `/missions/${missionId}/assertions`
|
|
1651
|
+
});
|
|
1652
|
+
if (!Array.isArray(response)) {
|
|
1653
|
+
throw new CliError("Mission assertions response was not an array.");
|
|
1654
|
+
}
|
|
1655
|
+
return response.map((assertion) => normalizeAssertion(assertion));
|
|
1656
|
+
}
|
|
1657
|
+
async function fetchLatestTriageEvents(apiClient, missionId) {
|
|
1658
|
+
const eventsByFeature = /* @__PURE__ */ new Map();
|
|
1659
|
+
let since = 0;
|
|
1660
|
+
for (; ; ) {
|
|
1661
|
+
const response = await apiClient.request({
|
|
1662
|
+
path: `/missions/${missionId}/progress?since=${since}&limit=${TRIAGE_PROGRESS_PAGE_SIZE}`
|
|
1663
|
+
});
|
|
1664
|
+
if (!Array.isArray(response) || response.length === 0) {
|
|
1665
|
+
break;
|
|
1666
|
+
}
|
|
1667
|
+
let lastSeq = since;
|
|
1668
|
+
for (const rawEvent of response) {
|
|
1669
|
+
const event = normalizeProgressEvent(rawEvent);
|
|
1670
|
+
if (!event) {
|
|
1671
|
+
continue;
|
|
1672
|
+
}
|
|
1673
|
+
lastSeq = Math.max(lastSeq, event.seq);
|
|
1674
|
+
if (event.type !== "triage.needed" || !isRecord3(event.data)) {
|
|
1675
|
+
continue;
|
|
1676
|
+
}
|
|
1677
|
+
const featureId = asNonEmptyString3(event.data.featureId);
|
|
1678
|
+
if (!featureId) {
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
const suggestedActions = Array.isArray(event.data.suggestedActions) ? event.data.suggestedActions.map((entry) => normalizeSuggestedAction(entry)).filter((entry) => entry !== null) : [];
|
|
1682
|
+
const existing = eventsByFeature.get(featureId);
|
|
1683
|
+
if (!existing || event.seq >= existing.seq) {
|
|
1684
|
+
eventsByFeature.set(featureId, {
|
|
1685
|
+
seq: event.seq,
|
|
1686
|
+
suggestedActions
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (lastSeq <= since) {
|
|
1691
|
+
break;
|
|
1692
|
+
}
|
|
1693
|
+
since = lastSeq;
|
|
1694
|
+
}
|
|
1695
|
+
return new Map(
|
|
1696
|
+
[...eventsByFeature.entries()].map(([featureId, event]) => [
|
|
1697
|
+
featureId,
|
|
1698
|
+
event.suggestedActions
|
|
1699
|
+
])
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
function describeSuggestedAction(action) {
|
|
1703
|
+
switch (action.type) {
|
|
1704
|
+
case "resolve_blocking_issue":
|
|
1705
|
+
return action.suggestedFix ? `${action.issue ?? "Blocking issue"} (${action.suggestedFix})` : action.issue ?? "Blocking issue";
|
|
1706
|
+
case "finish_remaining_work":
|
|
1707
|
+
return action.details ?? "Finish the remaining implementation work.";
|
|
1708
|
+
case "fix_failed_verification":
|
|
1709
|
+
return action.command ? `Fix failed verification: ${action.command}${action.exitCode === void 0 ? "" : ` (exit ${action.exitCode})`}` : "Fix a failed verification command.";
|
|
1710
|
+
case "review_failed_handoff":
|
|
1711
|
+
return action.successState ? `Review a ${action.successState} handoff before redispatching.` : "Review the failed handoff before redispatching.";
|
|
1712
|
+
default:
|
|
1713
|
+
return action.details ?? action.issue ?? action.command ?? action.type;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
function buildFailureDescription(feature, suggestedActions) {
|
|
1717
|
+
const blockingIssue = suggestedActions.find(
|
|
1718
|
+
(action) => action.type === "resolve_blocking_issue" && action.issue
|
|
1719
|
+
);
|
|
1720
|
+
if (blockingIssue?.issue) {
|
|
1721
|
+
return blockingIssue.issue;
|
|
1722
|
+
}
|
|
1723
|
+
const remainingWork = suggestedActions.find(
|
|
1724
|
+
(action) => action.type === "finish_remaining_work" && action.details
|
|
1725
|
+
);
|
|
1726
|
+
if (remainingWork?.details) {
|
|
1727
|
+
return remainingWork.details;
|
|
1728
|
+
}
|
|
1729
|
+
const failedVerification = suggestedActions.find(
|
|
1730
|
+
(action) => action.type === "fix_failed_verification" && action.command
|
|
1731
|
+
);
|
|
1732
|
+
if (failedVerification?.command) {
|
|
1733
|
+
return `Verification failed: ${failedVerification.command}${failedVerification.exitCode === void 0 ? "" : ` (exit ${failedVerification.exitCode})`}.`;
|
|
1734
|
+
}
|
|
1735
|
+
return `Worker handoff for ${feature.id} requires orchestrator triage.`;
|
|
1736
|
+
}
|
|
1737
|
+
function buildTriageItem(feature, allAssertions, suggestedActionsByFeature) {
|
|
1738
|
+
const assertions = allAssertions.filter(
|
|
1739
|
+
(assertion) => assertion.ownerFeatureId === feature.id || feature.fulfills.includes(assertion.id)
|
|
1740
|
+
);
|
|
1741
|
+
const suggestedActions = [...suggestedActionsByFeature.get(feature.id) ?? []];
|
|
1742
|
+
return {
|
|
1743
|
+
assertions,
|
|
1744
|
+
failureDescription: buildFailureDescription(feature, suggestedActions),
|
|
1745
|
+
feature,
|
|
1746
|
+
issueId: buildIssueId(feature.id),
|
|
1747
|
+
suggestedActions
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
function writeItemBlock(stdout, item) {
|
|
1751
|
+
writeLine(stdout);
|
|
1752
|
+
writeLine(stdout, pc2.bold(`Triage required for ${pc2.cyan(item.feature.id)}`));
|
|
1753
|
+
writeLine(stdout, `Failure: ${item.failureDescription}`);
|
|
1754
|
+
writeLine(
|
|
1755
|
+
stdout,
|
|
1756
|
+
`Affected assertions: ${item.assertions.length === 0 ? "none recorded" : item.assertions.map((assertion) => `${assertion.id} (${assertion.status})`).join(", ")}`
|
|
1757
|
+
);
|
|
1758
|
+
if (item.suggestedActions.length > 0) {
|
|
1759
|
+
writeLine(stdout, "Suggested actions:");
|
|
1760
|
+
for (const action of item.suggestedActions) {
|
|
1761
|
+
writeLine(stdout, `- ${describeSuggestedAction(action)}`);
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
writeLine(
|
|
1765
|
+
stdout,
|
|
1766
|
+
"Available actions: dismiss, create-fix, reassign assertions, skip"
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
function buildActionChoices(item) {
|
|
1770
|
+
return [
|
|
1771
|
+
{
|
|
1772
|
+
description: "Record a rationale and continue monitoring.",
|
|
1773
|
+
name: "dismiss",
|
|
1774
|
+
value: "dismiss"
|
|
1775
|
+
},
|
|
1776
|
+
{
|
|
1777
|
+
description: "Create a follow-up fix feature automatically.",
|
|
1778
|
+
name: "create-fix",
|
|
1779
|
+
value: "create-fix"
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
description: item.assertions.length === 0 ? "No affected assertions are available to move." : "Move affected assertions to another feature.",
|
|
1783
|
+
name: "reassign assertions",
|
|
1784
|
+
value: "reassign-assertions"
|
|
1785
|
+
},
|
|
1786
|
+
{
|
|
1787
|
+
description: "Leave this item unresolved for now.",
|
|
1788
|
+
name: "skip",
|
|
1789
|
+
value: "skip"
|
|
1790
|
+
}
|
|
1791
|
+
];
|
|
1792
|
+
}
|
|
1793
|
+
function generateFixFeatureId(sourceFeatureId, features) {
|
|
1794
|
+
const existingIds = new Set(features.map((feature) => feature.id));
|
|
1795
|
+
const baseId = `${sourceFeatureId}-fix`;
|
|
1796
|
+
if (!existingIds.has(baseId)) {
|
|
1797
|
+
return baseId;
|
|
1798
|
+
}
|
|
1799
|
+
for (let suffix = 2; suffix < 1e3; suffix += 1) {
|
|
1800
|
+
const candidate = `${baseId}-${suffix}`;
|
|
1801
|
+
if (!existingIds.has(candidate)) {
|
|
1802
|
+
return candidate;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
throw new CliError(`Could not generate a unique fix feature id for ${sourceFeatureId}.`);
|
|
1806
|
+
}
|
|
1807
|
+
function toFixFeaturePayload(item, features) {
|
|
1808
|
+
const id = generateFixFeatureId(item.feature.id, features);
|
|
1809
|
+
const milestone = item.feature.milestone;
|
|
1810
|
+
if (!milestone) {
|
|
1811
|
+
throw new CliError(
|
|
1812
|
+
`Cannot create a fix feature for ${item.feature.id} because it has no milestone.`
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
return {
|
|
1816
|
+
description: `Resolve triage for ${item.feature.id}: ${item.failureDescription}`,
|
|
1817
|
+
expectedBehavior: item.feature.expectedBehavior.length > 0 ? [...item.feature.expectedBehavior] : [`Resolve triage issues for ${item.feature.id}.`],
|
|
1818
|
+
fulfills: [],
|
|
1819
|
+
id,
|
|
1820
|
+
milestone,
|
|
1821
|
+
preconditions: [],
|
|
1822
|
+
skillName: item.feature.skillName,
|
|
1823
|
+
verificationSteps: item.feature.verificationSteps.length > 0 ? [...item.feature.verificationSteps] : [`Verify that ${item.feature.id} no longer requires triage.`]
|
|
1824
|
+
};
|
|
1825
|
+
}
|
|
1826
|
+
async function dismissItem(apiClient, missionId, item, rationale) {
|
|
1827
|
+
const trimmedRationale = rationale.trim();
|
|
1828
|
+
if (trimmedRationale.length === 0) {
|
|
1829
|
+
throw new CliError("Dismissal rationale is required.");
|
|
1830
|
+
}
|
|
1831
|
+
await apiClient.request({
|
|
1832
|
+
body: {
|
|
1833
|
+
items: [
|
|
1834
|
+
{
|
|
1835
|
+
issueId: item.issueId,
|
|
1836
|
+
rationale: trimmedRationale
|
|
1837
|
+
}
|
|
1838
|
+
]
|
|
1839
|
+
},
|
|
1840
|
+
method: "POST",
|
|
1841
|
+
path: `/missions/${missionId}/triage/dismiss`
|
|
1842
|
+
});
|
|
1843
|
+
}
|
|
1844
|
+
async function createFixFeature(apiClient, missionId, item, features) {
|
|
1845
|
+
const feature = toFixFeaturePayload(item, features);
|
|
1846
|
+
await apiClient.request({
|
|
1847
|
+
body: { feature },
|
|
1848
|
+
method: "POST",
|
|
1849
|
+
path: `/missions/${missionId}/triage/create-fix`
|
|
1850
|
+
});
|
|
1851
|
+
return feature;
|
|
1852
|
+
}
|
|
1853
|
+
async function reassignAssertions(apiClient, missionId, item, features, promptSelect) {
|
|
1854
|
+
if (item.assertions.length === 0) {
|
|
1855
|
+
return 0;
|
|
1856
|
+
}
|
|
1857
|
+
const destinationChoices = features.filter((feature) => feature.id !== item.feature.id).map((feature) => ({
|
|
1858
|
+
description: feature.milestone ? `Milestone: ${feature.milestone} \xB7 Status: ${feature.status}` : `Status: ${feature.status}`,
|
|
1859
|
+
name: feature.id,
|
|
1860
|
+
value: feature.id
|
|
1861
|
+
}));
|
|
1862
|
+
if (destinationChoices.length === 0) {
|
|
1863
|
+
return 0;
|
|
1864
|
+
}
|
|
1865
|
+
const moves = [];
|
|
1866
|
+
for (const assertion of item.assertions) {
|
|
1867
|
+
const targetFeatureId = await promptSelect({
|
|
1868
|
+
choices: destinationChoices,
|
|
1869
|
+
message: `Move ${assertion.id} to which feature?`
|
|
1870
|
+
});
|
|
1871
|
+
const targetFeature = features.find((feature) => feature.id === targetFeatureId);
|
|
1872
|
+
const targetMilestone = targetFeature?.milestone ?? assertion.milestone ?? item.feature.milestone;
|
|
1873
|
+
if (!targetFeature || !targetMilestone) {
|
|
1874
|
+
throw new CliError(
|
|
1875
|
+
`Cannot move ${assertion.id} because the selected feature has no milestone.`
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
moves.push({
|
|
1879
|
+
assertionId: assertion.id,
|
|
1880
|
+
toFeatureId: targetFeature.id,
|
|
1881
|
+
toMilestone: targetMilestone
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
if (moves.length === 0) {
|
|
1885
|
+
return 0;
|
|
1886
|
+
}
|
|
1887
|
+
await apiClient.request({
|
|
1888
|
+
body: { moves },
|
|
1889
|
+
method: "POST",
|
|
1890
|
+
path: `/missions/${missionId}/triage/reassign-assertions`
|
|
1891
|
+
});
|
|
1892
|
+
return moves.length;
|
|
1893
|
+
}
|
|
1894
|
+
async function runTriageInteraction(options) {
|
|
1895
|
+
const promptSelect = options.promptSelect ?? defaultPromptSelect;
|
|
1896
|
+
const promptInput = options.promptInput ?? defaultPromptInput;
|
|
1897
|
+
const [features, assertions, suggestedActionsByFeature] = await Promise.all([
|
|
1898
|
+
fetchTriageFeatures(options.apiClient, options.missionId),
|
|
1899
|
+
fetchAssertions(options.apiClient, options.missionId),
|
|
1900
|
+
fetchLatestTriageEvents(options.apiClient, options.missionId)
|
|
1901
|
+
]);
|
|
1902
|
+
const triageItems = features.filter((feature) => feature.status === "needs_triage").map((feature) => buildTriageItem(feature, assertions, suggestedActionsByFeature));
|
|
1903
|
+
if (triageItems.length === 0) {
|
|
1904
|
+
writeLine(options.stdout, pc2.dim("No outstanding triage items were found."));
|
|
1905
|
+
return {
|
|
1906
|
+
createdFixes: 0,
|
|
1907
|
+
dismissed: 0,
|
|
1908
|
+
handledFeatureIds: [],
|
|
1909
|
+
movedAssertions: 0,
|
|
1910
|
+
skipped: 0,
|
|
1911
|
+
totalItems: 0
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
if (options.autoApprove) {
|
|
1915
|
+
writeLine(
|
|
1916
|
+
options.stdout,
|
|
1917
|
+
pc2.yellow(
|
|
1918
|
+
`Auto-dismissing triage items: ${triageItems.map((item) => item.feature.id).join(", ")}`
|
|
1919
|
+
)
|
|
1920
|
+
);
|
|
1921
|
+
await options.apiClient.request({
|
|
1922
|
+
body: {
|
|
1923
|
+
items: triageItems.map((item) => ({
|
|
1924
|
+
issueId: item.issueId,
|
|
1925
|
+
rationale: AUTO_DISMISS_RATIONALE
|
|
1926
|
+
}))
|
|
1927
|
+
},
|
|
1928
|
+
method: "POST",
|
|
1929
|
+
path: `/missions/${options.missionId}/triage/dismiss`
|
|
1930
|
+
});
|
|
1931
|
+
return {
|
|
1932
|
+
createdFixes: 0,
|
|
1933
|
+
dismissed: triageItems.length,
|
|
1934
|
+
handledFeatureIds: triageItems.map((item) => item.feature.id),
|
|
1935
|
+
movedAssertions: 0,
|
|
1936
|
+
skipped: 0,
|
|
1937
|
+
totalItems: triageItems.length
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
const result = {
|
|
1941
|
+
createdFixes: 0,
|
|
1942
|
+
dismissed: 0,
|
|
1943
|
+
handledFeatureIds: [],
|
|
1944
|
+
movedAssertions: 0,
|
|
1945
|
+
skipped: 0,
|
|
1946
|
+
totalItems: triageItems.length
|
|
1947
|
+
};
|
|
1948
|
+
const mutableFeatures = [...features];
|
|
1949
|
+
for (const item of triageItems) {
|
|
1950
|
+
writeItemBlock(options.stdout, item);
|
|
1951
|
+
const action = await promptSelect({
|
|
1952
|
+
choices: buildActionChoices(item),
|
|
1953
|
+
message: `Choose triage action for ${item.feature.id}`
|
|
1954
|
+
});
|
|
1955
|
+
switch (action) {
|
|
1956
|
+
case "dismiss": {
|
|
1957
|
+
const rationale = await promptInput({
|
|
1958
|
+
message: `Why are you dismissing triage for ${item.feature.id}?`
|
|
1959
|
+
});
|
|
1960
|
+
await dismissItem(options.apiClient, options.missionId, item, rationale);
|
|
1961
|
+
result.dismissed += 1;
|
|
1962
|
+
writeLine(options.stdout, `Dismissed triage for ${pc2.cyan(item.feature.id)}.`);
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
case "create-fix": {
|
|
1966
|
+
const createdFeature = await createFixFeature(
|
|
1967
|
+
options.apiClient,
|
|
1968
|
+
options.missionId,
|
|
1969
|
+
item,
|
|
1970
|
+
mutableFeatures
|
|
1971
|
+
);
|
|
1972
|
+
mutableFeatures.push({
|
|
1973
|
+
attemptCount: 0,
|
|
1974
|
+
description: createdFeature.description,
|
|
1975
|
+
expectedBehavior: [...createdFeature.expectedBehavior],
|
|
1976
|
+
fulfills: [],
|
|
1977
|
+
id: createdFeature.id,
|
|
1978
|
+
isFixFeature: true,
|
|
1979
|
+
milestone: createdFeature.milestone,
|
|
1980
|
+
preconditions: [...createdFeature.preconditions],
|
|
1981
|
+
skillName: createdFeature.skillName,
|
|
1982
|
+
status: "pending",
|
|
1983
|
+
verificationSteps: [...createdFeature.verificationSteps],
|
|
1984
|
+
workerSessionIds: []
|
|
1985
|
+
});
|
|
1986
|
+
result.createdFixes += 1;
|
|
1987
|
+
writeLine(
|
|
1988
|
+
options.stdout,
|
|
1989
|
+
`Created fix feature ${pc2.cyan(createdFeature.id)} for ${pc2.cyan(item.feature.id)}.`
|
|
1990
|
+
);
|
|
1991
|
+
break;
|
|
1992
|
+
}
|
|
1993
|
+
case "reassign-assertions": {
|
|
1994
|
+
const moved = await reassignAssertions(
|
|
1995
|
+
options.apiClient,
|
|
1996
|
+
options.missionId,
|
|
1997
|
+
item,
|
|
1998
|
+
mutableFeatures,
|
|
1999
|
+
promptSelect
|
|
2000
|
+
);
|
|
2001
|
+
if (moved === 0) {
|
|
2002
|
+
result.skipped += 1;
|
|
2003
|
+
writeLine(
|
|
2004
|
+
options.stdout,
|
|
2005
|
+
pc2.yellow(
|
|
2006
|
+
`No assertions were reassigned for ${pc2.cyan(item.feature.id)}.`
|
|
2007
|
+
)
|
|
2008
|
+
);
|
|
2009
|
+
break;
|
|
2010
|
+
}
|
|
2011
|
+
result.movedAssertions += moved;
|
|
2012
|
+
writeLine(
|
|
2013
|
+
options.stdout,
|
|
2014
|
+
`Reassigned ${moved} assertion${moved === 1 ? "" : "s"} for ${pc2.cyan(item.feature.id)}.`
|
|
2015
|
+
);
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
case "skip":
|
|
2019
|
+
default:
|
|
2020
|
+
result.skipped += 1;
|
|
2021
|
+
writeLine(
|
|
2022
|
+
options.stdout,
|
|
2023
|
+
pc2.dim(`Skipped triage for ${item.feature.id}; it will remain unresolved.`)
|
|
2024
|
+
);
|
|
2025
|
+
break;
|
|
2026
|
+
}
|
|
2027
|
+
result.handledFeatureIds.push(item.feature.id);
|
|
2028
|
+
}
|
|
2029
|
+
return result;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// src/monitor.ts
|
|
2033
|
+
var ALL_MISSION_CHANNELS = [
|
|
2034
|
+
"assertions",
|
|
2035
|
+
"features",
|
|
2036
|
+
"handoffs",
|
|
2037
|
+
"milestones",
|
|
2038
|
+
"progress",
|
|
2039
|
+
"state",
|
|
2040
|
+
"triage",
|
|
2041
|
+
"workers"
|
|
2042
|
+
];
|
|
2043
|
+
var INITIAL_RECONNECT_DELAY_MS = 250;
|
|
2044
|
+
var MAX_RECONNECT_DELAY_MS = 4e3;
|
|
2045
|
+
function asNonEmptyString4(value) {
|
|
2046
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
2047
|
+
}
|
|
2048
|
+
function asFiniteNumber3(value) {
|
|
2049
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
2050
|
+
}
|
|
2051
|
+
function isRecord4(value) {
|
|
2052
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
2053
|
+
}
|
|
2054
|
+
function defaultCreateSpinner(text) {
|
|
2055
|
+
const spinner = ora({ text });
|
|
2056
|
+
return {
|
|
2057
|
+
get text() {
|
|
2058
|
+
return spinner.text;
|
|
2059
|
+
},
|
|
2060
|
+
set text(value) {
|
|
2061
|
+
spinner.text = value;
|
|
2062
|
+
},
|
|
2063
|
+
fail(message) {
|
|
2064
|
+
if (message) {
|
|
2065
|
+
spinner.text = message;
|
|
2066
|
+
}
|
|
2067
|
+
spinner.fail();
|
|
2068
|
+
},
|
|
2069
|
+
start(message) {
|
|
2070
|
+
if (message) {
|
|
2071
|
+
spinner.text = message;
|
|
2072
|
+
}
|
|
2073
|
+
spinner.start();
|
|
2074
|
+
},
|
|
2075
|
+
stop() {
|
|
2076
|
+
spinner.stop();
|
|
2077
|
+
},
|
|
2078
|
+
succeed(message) {
|
|
2079
|
+
if (message) {
|
|
2080
|
+
spinner.text = message;
|
|
2081
|
+
}
|
|
2082
|
+
spinner.succeed();
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
function defaultCreateWebSocket(url, options) {
|
|
2087
|
+
return new WebSocket(url, { headers: options.headers });
|
|
2088
|
+
}
|
|
2089
|
+
function defaultRegisterSignalHandler(signal, handler) {
|
|
2090
|
+
process.once(signal, handler);
|
|
2091
|
+
return () => {
|
|
2092
|
+
process.off(signal, handler);
|
|
2093
|
+
};
|
|
2094
|
+
}
|
|
2095
|
+
function createMissionWebSocketUrl(endpoint, missionId) {
|
|
2096
|
+
const url = new URL(`/ws/missions/${missionId}`, endpoint);
|
|
2097
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
2098
|
+
return url.toString();
|
|
2099
|
+
}
|
|
2100
|
+
function formatCurrency2(amount) {
|
|
2101
|
+
return amount === null ? "?" : `$${amount.toFixed(2)}`;
|
|
2102
|
+
}
|
|
2103
|
+
function formatBudgetUsage(details) {
|
|
2104
|
+
const parts = [];
|
|
2105
|
+
if (details.totalCost !== null || details.maxInferenceCost !== null) {
|
|
2106
|
+
parts.push(`${formatCurrency2(details.totalCost)} / ${formatCurrency2(details.maxInferenceCost)}`);
|
|
2107
|
+
}
|
|
2108
|
+
if (details.totalTokens !== null || details.maxInferenceTokens !== null) {
|
|
2109
|
+
parts.push(
|
|
2110
|
+
`${details.totalTokens ?? "?"} tokens / ${details.maxInferenceTokens ?? "?"} tokens`
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
if (details.percent_used !== null) {
|
|
2114
|
+
parts.push(`${details.percent_used}% used`);
|
|
2115
|
+
}
|
|
2116
|
+
return parts.join(" \xB7 ");
|
|
2117
|
+
}
|
|
2118
|
+
function formatSummaryLine(summary) {
|
|
2119
|
+
return `${summary.completedFeatures}/${summary.totalFeatures} features \xB7 ${summary.passedAssertions}/${summary.totalAssertions} assertions`;
|
|
2120
|
+
}
|
|
2121
|
+
function formatSpinnerText(missionId, summary) {
|
|
2122
|
+
return `${pc3.cyan(missionId)} ${pc3.bold(summary.state)} \xB7 ${formatSummaryLine(summary)}`;
|
|
2123
|
+
}
|
|
2124
|
+
function normalizeSummary(missionId, value) {
|
|
2125
|
+
if (!isRecord4(value)) {
|
|
2126
|
+
throw new Error("Progress summary response was not a JSON object.");
|
|
2127
|
+
}
|
|
2128
|
+
return {
|
|
2129
|
+
completedFeatures: asFiniteNumber3(value.completedFeatures),
|
|
2130
|
+
currentFeatureId: asNonEmptyString4(value.currentFeatureId),
|
|
2131
|
+
currentWorkerSessionId: asNonEmptyString4(value.currentWorkerSessionId),
|
|
2132
|
+
dispatchedFeatures: asFiniteNumber3(value.dispatchedFeatures),
|
|
2133
|
+
failedFeatures: asFiniteNumber3(value.failedFeatures),
|
|
2134
|
+
head: asFiniteNumber3(value.head),
|
|
2135
|
+
lastEventAt: asNonEmptyString4(value.lastEventAt),
|
|
2136
|
+
lastEventType: asNonEmptyString4(value.lastEventType),
|
|
2137
|
+
missionId: asNonEmptyString4(value.missionId) ?? missionId,
|
|
2138
|
+
needsTriageFeatures: asFiniteNumber3(value.needsTriageFeatures),
|
|
2139
|
+
openMilestones: asFiniteNumber3(value.openMilestones),
|
|
2140
|
+
passedAssertions: asFiniteNumber3(value.passedAssertions),
|
|
2141
|
+
pendingAssertions: asFiniteNumber3(value.pendingAssertions),
|
|
2142
|
+
pendingFeatures: asFiniteNumber3(value.pendingFeatures),
|
|
2143
|
+
runningFeatures: asFiniteNumber3(value.runningFeatures),
|
|
2144
|
+
sealedMilestones: asFiniteNumber3(value.sealedMilestones),
|
|
2145
|
+
state: asNonEmptyString4(value.state) ?? "unknown",
|
|
2146
|
+
totalAssertions: asFiniteNumber3(value.totalAssertions),
|
|
2147
|
+
totalFeatures: asFiniteNumber3(value.totalFeatures),
|
|
2148
|
+
totalMilestones: asFiniteNumber3(value.totalMilestones)
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
function parseMissionEvent(message) {
|
|
2152
|
+
const text = readWebSocketMessageText(message);
|
|
2153
|
+
if (!text) {
|
|
2154
|
+
return null;
|
|
2155
|
+
}
|
|
2156
|
+
try {
|
|
2157
|
+
const payload = JSON.parse(text);
|
|
2158
|
+
return isRecord4(payload) ? payload : null;
|
|
2159
|
+
} catch {
|
|
2160
|
+
return null;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
function readWebSocketMessageText(message) {
|
|
2164
|
+
if (typeof message === "string") {
|
|
2165
|
+
return message;
|
|
2166
|
+
}
|
|
2167
|
+
if (message instanceof ArrayBuffer) {
|
|
2168
|
+
return new TextDecoder().decode(message);
|
|
2169
|
+
}
|
|
2170
|
+
if (ArrayBuffer.isView(message)) {
|
|
2171
|
+
return new TextDecoder().decode(message);
|
|
2172
|
+
}
|
|
2173
|
+
return "";
|
|
2174
|
+
}
|
|
2175
|
+
function parseProgressEvent(event) {
|
|
2176
|
+
return isRecord4(event) ? event : null;
|
|
2177
|
+
}
|
|
2178
|
+
function buildBackfillDescription(event) {
|
|
2179
|
+
const type = asNonEmptyString4(event.type) ?? "event";
|
|
2180
|
+
const data = isRecord4(event.data) ? event.data : {};
|
|
2181
|
+
const featureId = asNonEmptyString4(data.featureId);
|
|
2182
|
+
const milestone = asNonEmptyString4(data.milestone);
|
|
2183
|
+
const assertionId = asNonEmptyString4(data.assertionId);
|
|
2184
|
+
if (featureId) {
|
|
2185
|
+
return `Backfilled ${type} for ${pc3.cyan(featureId)}.`;
|
|
2186
|
+
}
|
|
2187
|
+
if (assertionId) {
|
|
2188
|
+
return `Backfilled ${type} for ${pc3.cyan(assertionId)}.`;
|
|
2189
|
+
}
|
|
2190
|
+
if (milestone) {
|
|
2191
|
+
return `Backfilled ${type} for milestone ${pc3.cyan(milestone)}.`;
|
|
2192
|
+
}
|
|
2193
|
+
return `Backfilled ${type}.`;
|
|
2194
|
+
}
|
|
2195
|
+
function safeCloseSocket(socket, code = 1e3, reason = "closed") {
|
|
2196
|
+
if (!socket) {
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
try {
|
|
2200
|
+
socket.close(code, reason);
|
|
2201
|
+
} catch {
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
async function waitForSocketOpen(socket) {
|
|
2205
|
+
await new Promise((resolve, reject) => {
|
|
2206
|
+
let settled = false;
|
|
2207
|
+
const settle = (callback) => {
|
|
2208
|
+
if (settled) {
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
settled = true;
|
|
2212
|
+
callback();
|
|
2213
|
+
};
|
|
2214
|
+
socket.on("open", () => settle(resolve));
|
|
2215
|
+
socket.on("error", (error) => {
|
|
2216
|
+
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
2217
|
+
});
|
|
2218
|
+
socket.on("close", () => {
|
|
2219
|
+
settle(() => reject(new Error("WebSocket closed before opening.")));
|
|
2220
|
+
});
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
function toReconnectDelay(attempt) {
|
|
2224
|
+
return Math.min(MAX_RECONNECT_DELAY_MS, INITIAL_RECONNECT_DELAY_MS * 2 ** attempt);
|
|
2225
|
+
}
|
|
2226
|
+
async function fetchSummary(apiClient, missionId) {
|
|
2227
|
+
const response = await apiClient.request({
|
|
2228
|
+
path: `/missions/${missionId}/progress/summary`
|
|
2229
|
+
});
|
|
2230
|
+
return normalizeSummary(missionId, response);
|
|
2231
|
+
}
|
|
2232
|
+
async function monitorMission(options) {
|
|
2233
|
+
const createSpinner = options.createSpinner ?? defaultCreateSpinner;
|
|
2234
|
+
const createWebSocket = options.createWebSocket ?? defaultCreateWebSocket;
|
|
2235
|
+
const registerSignalHandler = options.registerSignalHandler ?? defaultRegisterSignalHandler;
|
|
2236
|
+
const sleep = options.sleep ?? ((ms) => delay2(ms).then(() => void 0));
|
|
2237
|
+
let summary = await fetchSummary(options.apiClient, options.missionId);
|
|
2238
|
+
let lastSeen = summary.head;
|
|
2239
|
+
let pausedForBudget = false;
|
|
2240
|
+
let pausedForTriage = false;
|
|
2241
|
+
let reconnectAttempt = 0;
|
|
2242
|
+
let connectedOnce = false;
|
|
2243
|
+
let interrupted = false;
|
|
2244
|
+
let triageInFlight = false;
|
|
2245
|
+
let activeSocket = null;
|
|
2246
|
+
let settleActiveConnection = null;
|
|
2247
|
+
let startInFlight = false;
|
|
2248
|
+
const spinner = createSpinner(formatSpinnerText(options.missionId, summary));
|
|
2249
|
+
spinner.start();
|
|
2250
|
+
writeLine(
|
|
2251
|
+
options.stdout,
|
|
2252
|
+
`Monitoring mission ${pc3.cyan(options.missionId)} \xB7 ${formatSummaryLine(summary)}`
|
|
2253
|
+
);
|
|
2254
|
+
const updateSpinner = () => {
|
|
2255
|
+
spinner.text = formatSpinnerText(options.missionId, summary);
|
|
2256
|
+
};
|
|
2257
|
+
const refreshSummary = async () => {
|
|
2258
|
+
summary = await fetchSummary(options.apiClient, options.missionId);
|
|
2259
|
+
lastSeen = Math.max(lastSeen, summary.head);
|
|
2260
|
+
updateSpinner();
|
|
2261
|
+
return summary;
|
|
2262
|
+
};
|
|
2263
|
+
const completedResult = async () => {
|
|
2264
|
+
await refreshSummary();
|
|
2265
|
+
const message = `Mission completed \xB7 ${formatSummaryLine(summary)}`;
|
|
2266
|
+
spinner.succeed(message);
|
|
2267
|
+
writeLine(options.stdout, pc3.green(message));
|
|
2268
|
+
return {
|
|
2269
|
+
exitCode: 0,
|
|
2270
|
+
reason: "completed",
|
|
2271
|
+
summary
|
|
2272
|
+
};
|
|
2273
|
+
};
|
|
2274
|
+
const budgetExceededResult = async (details) => {
|
|
2275
|
+
pausedForBudget = true;
|
|
2276
|
+
await refreshSummary();
|
|
2277
|
+
const message = `Budget exceeded \xB7 ${formatBudgetUsage(details)}`;
|
|
2278
|
+
spinner.fail(message);
|
|
2279
|
+
writeLine(options.stdout, pc3.red(message));
|
|
2280
|
+
return {
|
|
2281
|
+
exitCode: 1,
|
|
2282
|
+
reason: "budget_exceeded",
|
|
2283
|
+
summary
|
|
2284
|
+
};
|
|
2285
|
+
};
|
|
2286
|
+
const completeTriage = async () => {
|
|
2287
|
+
if (triageInFlight) {
|
|
2288
|
+
return null;
|
|
2289
|
+
}
|
|
2290
|
+
triageInFlight = true;
|
|
2291
|
+
try {
|
|
2292
|
+
const triageResult = await runTriageInteraction({
|
|
2293
|
+
apiClient: options.apiClient,
|
|
2294
|
+
autoApprove: options.autoApprove,
|
|
2295
|
+
missionId: options.missionId,
|
|
2296
|
+
promptInput: options.promptInput,
|
|
2297
|
+
promptSelect: options.promptSelect,
|
|
2298
|
+
stdout: options.stdout
|
|
2299
|
+
});
|
|
2300
|
+
if (triageResult.totalItems === 0) {
|
|
2301
|
+
writeLine(
|
|
2302
|
+
options.stdout,
|
|
2303
|
+
pc3.dim("No triage items required action. Resuming the orchestration loop.")
|
|
2304
|
+
);
|
|
2305
|
+
} else {
|
|
2306
|
+
writeLine(
|
|
2307
|
+
options.stdout,
|
|
2308
|
+
pc3.green(
|
|
2309
|
+
`Triage complete \xB7 ${triageResult.dismissed} dismissed \xB7 ${triageResult.createdFixes} fix features \xB7 ${triageResult.movedAssertions} assertions moved \xB7 ${triageResult.skipped} skipped`
|
|
2310
|
+
)
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
} finally {
|
|
2314
|
+
triageInFlight = false;
|
|
2315
|
+
pausedForTriage = false;
|
|
2316
|
+
}
|
|
2317
|
+
await refreshSummary();
|
|
2318
|
+
return maybeAutoStart();
|
|
2319
|
+
};
|
|
2320
|
+
const interruptedResult = () => {
|
|
2321
|
+
spinner.fail("Monitoring stopped.");
|
|
2322
|
+
writeLine(
|
|
2323
|
+
options.stdout,
|
|
2324
|
+
pc3.yellow("Interrupted. Local state is saved. Remote mission continues.")
|
|
2325
|
+
);
|
|
2326
|
+
return {
|
|
2327
|
+
exitCode: 130,
|
|
2328
|
+
reason: "interrupted",
|
|
2329
|
+
summary
|
|
2330
|
+
};
|
|
2331
|
+
};
|
|
2332
|
+
const maybeAutoStart = async () => {
|
|
2333
|
+
if (startInFlight || triageInFlight || pausedForBudget || pausedForTriage || summary.state !== "orchestrator_turn") {
|
|
2334
|
+
return null;
|
|
2335
|
+
}
|
|
2336
|
+
startInFlight = true;
|
|
2337
|
+
try {
|
|
2338
|
+
const result = await startMission(options.apiClient, options.missionId);
|
|
2339
|
+
switch (result.type) {
|
|
2340
|
+
case "dispatched":
|
|
2341
|
+
writeLine(
|
|
2342
|
+
options.stdout,
|
|
2343
|
+
`Dispatching ${pc3.cyan(result.featureId)} (${pc3.dim(result.workerSessionId)}).`
|
|
2344
|
+
);
|
|
2345
|
+
return null;
|
|
2346
|
+
case "no_work":
|
|
2347
|
+
writeLine(
|
|
2348
|
+
options.stdout,
|
|
2349
|
+
pc3.dim("No eligible work is available yet. Waiting for updates.")
|
|
2350
|
+
);
|
|
2351
|
+
return null;
|
|
2352
|
+
case "completed":
|
|
2353
|
+
return completedResult();
|
|
2354
|
+
case "budget_exceeded":
|
|
2355
|
+
return budgetExceededResult(result.details);
|
|
2356
|
+
}
|
|
2357
|
+
} finally {
|
|
2358
|
+
startInFlight = false;
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
const unregisterSignalHandler = registerSignalHandler("SIGINT", () => {
|
|
2362
|
+
interrupted = true;
|
|
2363
|
+
safeCloseSocket(activeSocket, 1e3, "SIGINT");
|
|
2364
|
+
settleActiveConnection?.(interruptedResult());
|
|
2365
|
+
});
|
|
2366
|
+
try {
|
|
2367
|
+
for (; ; ) {
|
|
2368
|
+
if (interrupted) {
|
|
2369
|
+
return interruptedResult();
|
|
2370
|
+
}
|
|
2371
|
+
const socket = createWebSocket(
|
|
2372
|
+
createMissionWebSocketUrl(options.endpoint, options.missionId),
|
|
2373
|
+
{
|
|
2374
|
+
headers: {
|
|
2375
|
+
authorization: `Bearer ${options.apiKey}`
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
);
|
|
2379
|
+
activeSocket = socket;
|
|
2380
|
+
try {
|
|
2381
|
+
await waitForSocketOpen(socket);
|
|
2382
|
+
} catch (error) {
|
|
2383
|
+
if (interrupted) {
|
|
2384
|
+
return interruptedResult();
|
|
2385
|
+
}
|
|
2386
|
+
const delayMs2 = toReconnectDelay(reconnectAttempt);
|
|
2387
|
+
reconnectAttempt += 1;
|
|
2388
|
+
writeLine(
|
|
2389
|
+
options.stdout,
|
|
2390
|
+
pc3.yellow(
|
|
2391
|
+
`WebSocket connection failed: ${error instanceof Error ? error.message : String(error)}. Reconnecting in ${delayMs2} ms...`
|
|
2392
|
+
)
|
|
2393
|
+
);
|
|
2394
|
+
await sleep(delayMs2);
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
reconnectAttempt = 0;
|
|
2398
|
+
socket.send(
|
|
2399
|
+
JSON.stringify({
|
|
2400
|
+
channels: [...ALL_MISSION_CHANNELS],
|
|
2401
|
+
type: "subscribe"
|
|
2402
|
+
})
|
|
2403
|
+
);
|
|
2404
|
+
const connectionResult = await new Promise(
|
|
2405
|
+
async (resolve) => {
|
|
2406
|
+
let settled = false;
|
|
2407
|
+
let pending = Promise.resolve();
|
|
2408
|
+
const resolveOnce = (result) => {
|
|
2409
|
+
if (settled) {
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
settled = true;
|
|
2413
|
+
resolve(result);
|
|
2414
|
+
};
|
|
2415
|
+
settleActiveConnection = resolveOnce;
|
|
2416
|
+
const processLiveEvent = async (event) => {
|
|
2417
|
+
const type = asNonEmptyString4(event.type);
|
|
2418
|
+
if (!type) {
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
const data = isRecord4(event.data) ? event.data : {};
|
|
2422
|
+
switch (type) {
|
|
2423
|
+
case "state_changed": {
|
|
2424
|
+
const state = asNonEmptyString4(data.state) ?? "unknown";
|
|
2425
|
+
writeLine(options.stdout, `Mission state \u2192 ${pc3.cyan(state)}`);
|
|
2426
|
+
await refreshSummary();
|
|
2427
|
+
if (state === "completed") {
|
|
2428
|
+
return completedResult();
|
|
2429
|
+
}
|
|
2430
|
+
if (state === "orchestrator_turn") {
|
|
2431
|
+
return maybeAutoStart();
|
|
2432
|
+
}
|
|
2433
|
+
return null;
|
|
2434
|
+
}
|
|
2435
|
+
case "feature_updated": {
|
|
2436
|
+
const featureId = asNonEmptyString4(data.featureId) ?? "feature";
|
|
2437
|
+
const status = asNonEmptyString4(data.status) ?? "updated";
|
|
2438
|
+
if (status === "needs_triage") {
|
|
2439
|
+
pausedForTriage = true;
|
|
2440
|
+
}
|
|
2441
|
+
writeLine(
|
|
2442
|
+
options.stdout,
|
|
2443
|
+
`Feature ${pc3.cyan(featureId)} \u2192 ${pc3.bold(status)}`
|
|
2444
|
+
);
|
|
2445
|
+
await refreshSummary();
|
|
2446
|
+
return null;
|
|
2447
|
+
}
|
|
2448
|
+
case "milestone_updated": {
|
|
2449
|
+
const milestone = asNonEmptyString4(data.milestone) ?? "milestone";
|
|
2450
|
+
const status = asNonEmptyString4(data.status) ?? "updated";
|
|
2451
|
+
writeLine(
|
|
2452
|
+
options.stdout,
|
|
2453
|
+
`Milestone ${pc3.cyan(milestone)} \u2192 ${pc3.bold(status)}`
|
|
2454
|
+
);
|
|
2455
|
+
await refreshSummary();
|
|
2456
|
+
return null;
|
|
2457
|
+
}
|
|
2458
|
+
case "assertion_updated": {
|
|
2459
|
+
const assertionId = asNonEmptyString4(data.assertionId) ?? "assertion";
|
|
2460
|
+
const status = asNonEmptyString4(data.status) ?? "updated";
|
|
2461
|
+
writeLine(
|
|
2462
|
+
options.stdout,
|
|
2463
|
+
`Assertion ${pc3.cyan(assertionId)} \u2192 ${pc3.bold(status)}`
|
|
2464
|
+
);
|
|
2465
|
+
await refreshSummary();
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
case "handoff_received": {
|
|
2469
|
+
const featureId = asNonEmptyString4(data.featureId) ?? "feature";
|
|
2470
|
+
const status = asNonEmptyString4(data.status) ?? "received";
|
|
2471
|
+
writeLine(
|
|
2472
|
+
options.stdout,
|
|
2473
|
+
`Handoff received for ${pc3.cyan(featureId)} (${status}).`
|
|
2474
|
+
);
|
|
2475
|
+
return null;
|
|
2476
|
+
}
|
|
2477
|
+
case "triage_needed": {
|
|
2478
|
+
pausedForTriage = true;
|
|
2479
|
+
const featureId = asNonEmptyString4(data.featureId) ?? "feature";
|
|
2480
|
+
writeLine(
|
|
2481
|
+
options.stdout,
|
|
2482
|
+
pc3.yellow(
|
|
2483
|
+
`Triage is required for ${pc3.cyan(featureId)}. Auto-start is paused.`
|
|
2484
|
+
)
|
|
2485
|
+
);
|
|
2486
|
+
return completeTriage();
|
|
2487
|
+
}
|
|
2488
|
+
case "worker_timeout": {
|
|
2489
|
+
const featureId = asNonEmptyString4(data.featureId) ?? "feature";
|
|
2490
|
+
writeLine(
|
|
2491
|
+
options.stdout,
|
|
2492
|
+
pc3.yellow(`Worker timed out for ${pc3.cyan(featureId)}.`)
|
|
2493
|
+
);
|
|
2494
|
+
await refreshSummary();
|
|
2495
|
+
return null;
|
|
2496
|
+
}
|
|
2497
|
+
case "budget_warning": {
|
|
2498
|
+
const details = normalizeBudgetDetails(data);
|
|
2499
|
+
writeLine(
|
|
2500
|
+
options.stdout,
|
|
2501
|
+
pc3.yellow(`Budget warning \xB7 ${formatBudgetUsage(details)}`)
|
|
2502
|
+
);
|
|
2503
|
+
await refreshSummary();
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
case "budget_exceeded":
|
|
2507
|
+
return budgetExceededResult(normalizeBudgetDetails(data));
|
|
2508
|
+
default:
|
|
2509
|
+
return null;
|
|
2510
|
+
}
|
|
2511
|
+
};
|
|
2512
|
+
socket.on("message", (message) => {
|
|
2513
|
+
pending = pending.then(async () => {
|
|
2514
|
+
const event = parseMissionEvent(message);
|
|
2515
|
+
if (!event) {
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
const result = await processLiveEvent(event);
|
|
2519
|
+
if (!result) {
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
safeCloseSocket(socket, 1e3, result.reason);
|
|
2523
|
+
resolveOnce(result);
|
|
2524
|
+
}).catch((error) => {
|
|
2525
|
+
writeLine(
|
|
2526
|
+
options.stdout,
|
|
2527
|
+
pc3.yellow(
|
|
2528
|
+
`Monitoring update failed: ${error instanceof Error ? error.message : String(error)}. Reconnecting...`
|
|
2529
|
+
)
|
|
2530
|
+
);
|
|
2531
|
+
safeCloseSocket(socket, 1011, "processing_error");
|
|
2532
|
+
resolveOnce("reconnect");
|
|
2533
|
+
});
|
|
2534
|
+
});
|
|
2535
|
+
socket.on("error", (error) => {
|
|
2536
|
+
writeLine(
|
|
2537
|
+
options.stdout,
|
|
2538
|
+
pc3.yellow(
|
|
2539
|
+
`WebSocket error: ${error instanceof Error ? error.message : String(error)}`
|
|
2540
|
+
)
|
|
2541
|
+
);
|
|
2542
|
+
safeCloseSocket(socket, 1011, "socket_error");
|
|
2543
|
+
});
|
|
2544
|
+
socket.on("close", () => {
|
|
2545
|
+
void pending.finally(() => {
|
|
2546
|
+
if (settled) {
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
if (interrupted) {
|
|
2550
|
+
resolveOnce(interruptedResult());
|
|
2551
|
+
return;
|
|
2552
|
+
}
|
|
2553
|
+
resolveOnce("reconnect");
|
|
2554
|
+
});
|
|
2555
|
+
});
|
|
2556
|
+
if (connectedOnce && lastSeen > 0) {
|
|
2557
|
+
const events = await options.apiClient.request({
|
|
2558
|
+
path: `/missions/${options.missionId}/progress?since=${lastSeen}`
|
|
2559
|
+
});
|
|
2560
|
+
for (const rawEvent of events) {
|
|
2561
|
+
const event = parseProgressEvent(rawEvent);
|
|
2562
|
+
if (!event) {
|
|
2563
|
+
continue;
|
|
2564
|
+
}
|
|
2565
|
+
lastSeen = Math.max(lastSeen, asFiniteNumber3(event.seq));
|
|
2566
|
+
writeLine(options.stdout, pc3.dim(buildBackfillDescription(event)));
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
connectedOnce = true;
|
|
2570
|
+
await refreshSummary();
|
|
2571
|
+
const initialStartResult = await maybeAutoStart();
|
|
2572
|
+
if (initialStartResult) {
|
|
2573
|
+
safeCloseSocket(socket, 1e3, initialStartResult.reason);
|
|
2574
|
+
resolveOnce(initialStartResult);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
);
|
|
2578
|
+
settleActiveConnection = null;
|
|
2579
|
+
activeSocket = null;
|
|
2580
|
+
if (connectionResult !== "reconnect") {
|
|
2581
|
+
return connectionResult;
|
|
2582
|
+
}
|
|
2583
|
+
if (interrupted) {
|
|
2584
|
+
return interruptedResult();
|
|
2585
|
+
}
|
|
2586
|
+
const delayMs = toReconnectDelay(reconnectAttempt);
|
|
2587
|
+
reconnectAttempt += 1;
|
|
2588
|
+
writeLine(
|
|
2589
|
+
options.stdout,
|
|
2590
|
+
pc3.yellow(`Reconnecting to mission updates in ${delayMs} ms...`)
|
|
2591
|
+
);
|
|
2592
|
+
await sleep(delayMs);
|
|
2593
|
+
}
|
|
2594
|
+
} finally {
|
|
2595
|
+
const unregister = typeof unregisterSignalHandler === "function" ? unregisterSignalHandler : void 0;
|
|
2596
|
+
unregister?.();
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
// src/commands/mission-lifecycle.ts
|
|
2601
|
+
function asNonEmptyString5(value) {
|
|
2602
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
2603
|
+
}
|
|
2604
|
+
function writeSection(stdout, title) {
|
|
2605
|
+
writeLine(stdout);
|
|
2606
|
+
writeLine(stdout, pc4.bold(title));
|
|
2607
|
+
}
|
|
2608
|
+
function summarizeAssertions(assertions) {
|
|
2609
|
+
return assertions.reduce(
|
|
2610
|
+
(summary, assertion) => ({
|
|
2611
|
+
passed: summary.passed + (assertion.status === "passed" ? 1 : 0),
|
|
2612
|
+
total: summary.total + 1
|
|
2613
|
+
}),
|
|
2614
|
+
{ passed: 0, total: 0 }
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
function renderMissionSnapshot(stdout, snapshot) {
|
|
2618
|
+
writeLine(stdout, `Mission ${snapshot.state.missionId}`);
|
|
2619
|
+
writeLine(stdout, `State: ${snapshot.state.state}`);
|
|
2620
|
+
writeLine(
|
|
2621
|
+
stdout,
|
|
2622
|
+
`Features: ${snapshot.state.completedFeatures}/${snapshot.state.totalFeatures}`
|
|
2623
|
+
);
|
|
2624
|
+
writeLine(
|
|
2625
|
+
stdout,
|
|
2626
|
+
`Assertions: ${snapshot.state.passedAssertions}/${snapshot.state.totalAssertions}`
|
|
2627
|
+
);
|
|
2628
|
+
writeLine(
|
|
2629
|
+
stdout,
|
|
2630
|
+
`Milestones: ${snapshot.state.sealedMilestones}/${snapshot.state.totalMilestones} sealed`
|
|
2631
|
+
);
|
|
2632
|
+
if (snapshot.features.length > 0) {
|
|
2633
|
+
writeSection(stdout, "Features");
|
|
2634
|
+
for (const feature of snapshot.features) {
|
|
2635
|
+
const milestone = feature.milestone ? ` (${feature.milestone})` : "";
|
|
2636
|
+
writeLine(stdout, `- ${feature.id}${milestone} \u2014 ${feature.status}`);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
if (snapshot.milestones.length > 0) {
|
|
2640
|
+
writeSection(stdout, "Milestones");
|
|
2641
|
+
for (const milestone of snapshot.milestones) {
|
|
2642
|
+
writeLine(
|
|
2643
|
+
stdout,
|
|
2644
|
+
`- ${milestone.name} (${milestone.state}) \xB7 ${milestone.completedFeatureCount}/${milestone.featureCount} features \xB7 ${milestone.passedAssertionCount}/${milestone.assertionCount} assertions`
|
|
2645
|
+
);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
if (snapshot.assertions.length > 0) {
|
|
2649
|
+
const assertionSummary = summarizeAssertions(snapshot.assertions);
|
|
2650
|
+
writeSection(stdout, "Assertions");
|
|
2651
|
+
writeLine(
|
|
2652
|
+
stdout,
|
|
2653
|
+
`Overall: ${assertionSummary.passed}/${assertionSummary.total} passed (${formatPercent(assertionSummary.passed, assertionSummary.total)})`
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
async function fetchMissionStateRecord(apiClient, missionId) {
|
|
2658
|
+
return normalizeMissionState(
|
|
2659
|
+
await apiClient.request({
|
|
2660
|
+
path: `/missions/${missionId}/mission/state`
|
|
2661
|
+
})
|
|
2662
|
+
);
|
|
2663
|
+
}
|
|
2664
|
+
async function fetchMissionSnapshot(apiClient, missionId) {
|
|
2665
|
+
const [state, features, milestones, assertions] = await Promise.all([
|
|
2666
|
+
fetchMissionStateRecord(apiClient, missionId),
|
|
2667
|
+
apiClient.request({ path: `/missions/${missionId}/features` }).then((value) => normalizeMissionFeatures(value)),
|
|
2668
|
+
apiClient.request({ path: `/missions/${missionId}/milestones` }).then((value) => normalizeMissionMilestones(value)),
|
|
2669
|
+
apiClient.request({ path: `/missions/${missionId}/assertions` }).then((value) => normalizeMissionAssertions(value))
|
|
2670
|
+
]);
|
|
2671
|
+
return {
|
|
2672
|
+
assertions,
|
|
2673
|
+
features,
|
|
2674
|
+
milestones,
|
|
2675
|
+
state
|
|
2676
|
+
};
|
|
2677
|
+
}
|
|
2678
|
+
async function createMissionClientContext(context, command, dependencies, homeDir) {
|
|
2679
|
+
const authConfig = await getAuthConfig({
|
|
2680
|
+
env: context.env,
|
|
2681
|
+
homeDir
|
|
2682
|
+
});
|
|
2683
|
+
const globalOptions = typeof command.optsWithGlobals === "function" ? command.optsWithGlobals() : {};
|
|
2684
|
+
return {
|
|
2685
|
+
apiClient: createApiClient({
|
|
2686
|
+
config: authConfig,
|
|
2687
|
+
onVerboseLog: (message) => writeLine(context.stderr, message),
|
|
2688
|
+
sleep: dependencies.sleep,
|
|
2689
|
+
verbose: globalOptions.verbose === true
|
|
2690
|
+
}),
|
|
2691
|
+
authConfig,
|
|
2692
|
+
homeDir
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
function resolveLifecycleHomeDir(context) {
|
|
2696
|
+
return resolveHomeDir({
|
|
2697
|
+
env: context.env,
|
|
2698
|
+
homeDir: context.homeDir
|
|
2699
|
+
});
|
|
2700
|
+
}
|
|
2701
|
+
async function findMostRecentMissionId(homeDir) {
|
|
2702
|
+
const launchesDir = path3.join(homeDir, ".factory", "cloud-launches");
|
|
2703
|
+
let entries;
|
|
2704
|
+
try {
|
|
2705
|
+
entries = await readdir2(launchesDir, { withFileTypes: true });
|
|
2706
|
+
} catch (error) {
|
|
2707
|
+
if (error.code === "ENOENT") {
|
|
2708
|
+
return null;
|
|
2709
|
+
}
|
|
2710
|
+
throw error;
|
|
2711
|
+
}
|
|
2712
|
+
const candidates = await Promise.all(
|
|
2713
|
+
entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
|
|
2714
|
+
const missionPath = path3.join(launchesDir, entry.name, "mission.json");
|
|
2715
|
+
try {
|
|
2716
|
+
const [raw, missionStats] = await Promise.all([
|
|
2717
|
+
readFile3(missionPath, "utf8"),
|
|
2718
|
+
stat(missionPath)
|
|
2719
|
+
]);
|
|
2720
|
+
const parsed = JSON.parse(raw);
|
|
2721
|
+
const missionId = asNonEmptyString5(parsed.missionId);
|
|
2722
|
+
if (!missionId) {
|
|
2723
|
+
return null;
|
|
2724
|
+
}
|
|
2725
|
+
return {
|
|
2726
|
+
missionId,
|
|
2727
|
+
updatedAtMs: missionStats.mtimeMs
|
|
2728
|
+
};
|
|
2729
|
+
} catch (error) {
|
|
2730
|
+
if (error instanceof SyntaxError || error.code === "ENOENT") {
|
|
2731
|
+
return null;
|
|
2732
|
+
}
|
|
2733
|
+
throw error;
|
|
2734
|
+
}
|
|
2735
|
+
})
|
|
2736
|
+
);
|
|
2737
|
+
const latest = candidates.filter((candidate) => candidate !== null).sort(
|
|
2738
|
+
(left, right) => right.updatedAtMs - left.updatedAtMs || right.missionId.localeCompare(left.missionId)
|
|
2739
|
+
)[0];
|
|
2740
|
+
return latest?.missionId ?? null;
|
|
2741
|
+
}
|
|
2742
|
+
async function resolveMissionId(missionId, context, command, homeDir) {
|
|
2743
|
+
const explicitMissionId = asNonEmptyString5(missionId);
|
|
2744
|
+
if (explicitMissionId) {
|
|
2745
|
+
return explicitMissionId;
|
|
2746
|
+
}
|
|
2747
|
+
const detectedMissionId = await findMostRecentMissionId(homeDir);
|
|
2748
|
+
if (detectedMissionId) {
|
|
2749
|
+
writeLine(
|
|
2750
|
+
context.stdout,
|
|
2751
|
+
`Using the most recent local mission: ${pc4.cyan(detectedMissionId)}`
|
|
2752
|
+
);
|
|
2753
|
+
return detectedMissionId;
|
|
2754
|
+
}
|
|
2755
|
+
writeLine(
|
|
2756
|
+
context.stderr,
|
|
2757
|
+
"Mission ID is required when no recent local mission state is available."
|
|
2758
|
+
);
|
|
2759
|
+
command.help({ error: true });
|
|
2760
|
+
}
|
|
2761
|
+
async function fetchMissionStateAfterConflict(apiClient, missionId, error) {
|
|
2762
|
+
try {
|
|
2763
|
+
return await fetchMissionStateRecord(apiClient, missionId);
|
|
2764
|
+
} catch {
|
|
2765
|
+
throw error;
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
async function fetchMissionSnapshotAfterConflict(apiClient, missionId, error) {
|
|
2769
|
+
try {
|
|
2770
|
+
return await fetchMissionSnapshot(apiClient, missionId);
|
|
2771
|
+
} catch {
|
|
2772
|
+
throw error;
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
async function runLifecycleMutation(action, missionIdValue, command, context, dependencies) {
|
|
2776
|
+
const homeDir = resolveLifecycleHomeDir(context);
|
|
2777
|
+
const missionId = await resolveMissionId(missionIdValue, context, command, homeDir);
|
|
2778
|
+
const { apiClient } = await createMissionClientContext(
|
|
2779
|
+
context,
|
|
2780
|
+
command,
|
|
2781
|
+
dependencies,
|
|
2782
|
+
homeDir
|
|
2783
|
+
);
|
|
2784
|
+
try {
|
|
2785
|
+
const response = await apiClient.request({
|
|
2786
|
+
method: "POST",
|
|
2787
|
+
path: `/missions/${missionId}/mission/${action}`
|
|
2788
|
+
});
|
|
2789
|
+
const nextState = asNonEmptyString5(response.state) ?? (action === "pause" ? "paused" : "cancelled");
|
|
2790
|
+
if (action === "pause") {
|
|
2791
|
+
writeLine(
|
|
2792
|
+
context.stdout,
|
|
2793
|
+
`Mission ${pc4.cyan(missionId)} paused. Current state: ${pc4.bold(nextState)}.`
|
|
2794
|
+
);
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
writeLine(
|
|
2798
|
+
context.stdout,
|
|
2799
|
+
`Mission ${pc4.cyan(missionId)} cancelled. This action is irreversible. Current state: ${pc4.bold(nextState)}.`
|
|
2800
|
+
);
|
|
2801
|
+
} catch (error) {
|
|
2802
|
+
if (!(error instanceof ApiError) || error.status !== 409) {
|
|
2803
|
+
throw error;
|
|
2804
|
+
}
|
|
2805
|
+
const missionState = await fetchMissionStateAfterConflict(
|
|
2806
|
+
apiClient,
|
|
2807
|
+
missionId,
|
|
2808
|
+
error
|
|
2809
|
+
);
|
|
2810
|
+
const verb = action === "pause" ? "paused" : "cancelled";
|
|
2811
|
+
writeLine(
|
|
2812
|
+
context.stdout,
|
|
2813
|
+
`Mission ${pc4.cyan(missionId)} cannot be ${verb} because it is currently ${pc4.bold(missionState.state)}.`
|
|
2814
|
+
);
|
|
2815
|
+
throw new CommanderError(1, `mission-${action}`, "");
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
function resolveResumeExitCode(state) {
|
|
2819
|
+
switch (state) {
|
|
2820
|
+
case "cancelled":
|
|
2821
|
+
case "completed":
|
|
2822
|
+
return 0;
|
|
2823
|
+
case "failed":
|
|
2824
|
+
case "paused":
|
|
2825
|
+
return 1;
|
|
2826
|
+
default:
|
|
2827
|
+
return null;
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
async function runMissionResume(missionIdValue, command, context, dependencies) {
|
|
2831
|
+
const homeDir = resolveLifecycleHomeDir(context);
|
|
2832
|
+
const missionId = await resolveMissionId(missionIdValue, context, command, homeDir);
|
|
2833
|
+
const { apiClient, authConfig } = await createMissionClientContext(
|
|
2834
|
+
context,
|
|
2835
|
+
command,
|
|
2836
|
+
dependencies,
|
|
2837
|
+
homeDir
|
|
2838
|
+
);
|
|
2839
|
+
let snapshot = await fetchMissionSnapshot(apiClient, missionId);
|
|
2840
|
+
if (snapshot.state.state === "paused") {
|
|
2841
|
+
try {
|
|
2842
|
+
const response = await apiClient.request({
|
|
2843
|
+
method: "POST",
|
|
2844
|
+
path: `/missions/${missionId}/mission/resume`
|
|
2845
|
+
});
|
|
2846
|
+
const nextState = asNonEmptyString5(response.state) ?? "orchestrator_turn";
|
|
2847
|
+
writeLine(
|
|
2848
|
+
context.stdout,
|
|
2849
|
+
`Mission ${pc4.cyan(missionId)} resumed. Current state: ${pc4.bold(nextState)}.`
|
|
2850
|
+
);
|
|
2851
|
+
} catch (error) {
|
|
2852
|
+
if (!(error instanceof ApiError) || error.status !== 409) {
|
|
2853
|
+
throw error;
|
|
2854
|
+
}
|
|
2855
|
+
snapshot = await fetchMissionSnapshotAfterConflict(
|
|
2856
|
+
apiClient,
|
|
2857
|
+
missionId,
|
|
2858
|
+
error
|
|
2859
|
+
);
|
|
2860
|
+
writeLine(
|
|
2861
|
+
context.stdout,
|
|
2862
|
+
`Mission ${pc4.cyan(missionId)} is currently ${pc4.bold(snapshot.state.state)}. Reconnecting to live updates if available.`
|
|
2863
|
+
);
|
|
2864
|
+
}
|
|
2865
|
+
snapshot = await fetchMissionSnapshot(apiClient, missionId);
|
|
2866
|
+
} else if (snapshot.state.state !== "cancelled" && snapshot.state.state !== "completed") {
|
|
2867
|
+
writeLine(
|
|
2868
|
+
context.stdout,
|
|
2869
|
+
`Reconnecting to mission ${pc4.cyan(missionId)} from state ${pc4.bold(snapshot.state.state)}.`
|
|
2870
|
+
);
|
|
2871
|
+
}
|
|
2872
|
+
renderMissionSnapshot(context.stdout, snapshot);
|
|
2873
|
+
const resumeExitCode = resolveResumeExitCode(snapshot.state.state);
|
|
2874
|
+
if (resumeExitCode === 0) {
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
if (resumeExitCode === 1) {
|
|
2878
|
+
throw new CliError(
|
|
2879
|
+
`Mission ${missionId} is currently ${snapshot.state.state}.`,
|
|
2880
|
+
1
|
|
2881
|
+
);
|
|
2882
|
+
}
|
|
2883
|
+
const monitorResult = await monitorMission({
|
|
2884
|
+
apiClient,
|
|
2885
|
+
apiKey: authConfig.apiKey,
|
|
2886
|
+
createSpinner: dependencies.createSpinner,
|
|
2887
|
+
createWebSocket: dependencies.createWebSocket,
|
|
2888
|
+
endpoint: authConfig.endpoint,
|
|
2889
|
+
missionId,
|
|
2890
|
+
promptInput: dependencies.promptInput,
|
|
2891
|
+
promptSelect: dependencies.promptSelect,
|
|
2892
|
+
registerSignalHandler: dependencies.registerSignalHandler,
|
|
2893
|
+
sleep: dependencies.sleep,
|
|
2894
|
+
stdout: context.stdout
|
|
2895
|
+
});
|
|
2896
|
+
if (monitorResult.exitCode !== 0) {
|
|
2897
|
+
throw new CommanderError(monitorResult.exitCode, "mission-monitor", "");
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
function registerMissionLifecycleCommands(mission, context, dependencies = {}) {
|
|
2901
|
+
mission.command("pause").description("Pause a mission").argument("[missionId]").action(async (missionId, _options, command) => {
|
|
2902
|
+
await runLifecycleMutation(
|
|
2903
|
+
"pause",
|
|
2904
|
+
missionId,
|
|
2905
|
+
command,
|
|
2906
|
+
context,
|
|
2907
|
+
dependencies
|
|
2908
|
+
);
|
|
2909
|
+
});
|
|
2910
|
+
mission.command("resume").description("Resume a mission").argument("[missionId]").action(async (missionId, _options, command) => {
|
|
2911
|
+
await runMissionResume(missionId, command, context, dependencies);
|
|
2912
|
+
});
|
|
2913
|
+
mission.command("cancel").description("Cancel a mission").argument("[missionId]").action(async (missionId, _options, command) => {
|
|
2914
|
+
await runLifecycleMutation(
|
|
2915
|
+
"cancel",
|
|
2916
|
+
missionId,
|
|
2917
|
+
command,
|
|
2918
|
+
context,
|
|
2919
|
+
dependencies
|
|
2920
|
+
);
|
|
2921
|
+
});
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
// src/commands/mission-run.ts
|
|
2925
|
+
import { stat as stat3 } from "fs/promises";
|
|
2926
|
+
import path6 from "path";
|
|
2927
|
+
import { CommanderError as CommanderError2 } from "commander";
|
|
2928
|
+
|
|
2929
|
+
// src/planning.ts
|
|
2930
|
+
import {
|
|
2931
|
+
confirm as defaultConfirmPrompt,
|
|
2932
|
+
input as defaultInputPrompt2,
|
|
2933
|
+
select as defaultSelectPrompt2
|
|
2934
|
+
} from "@inquirer/prompts";
|
|
2935
|
+
import ora2 from "ora";
|
|
2936
|
+
import pc5 from "picocolors";
|
|
2937
|
+
var DEFAULT_ANALYSIS_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
2938
|
+
var DEFAULT_POLL_INTERVAL_MS = 5e3;
|
|
2939
|
+
var FREE_TEXT_OPTION_ID = "free_text";
|
|
2940
|
+
function isRecord5(value) {
|
|
2941
|
+
return value !== null && !Array.isArray(value) && typeof value === "object";
|
|
2942
|
+
}
|
|
2943
|
+
function asNonEmptyString6(value) {
|
|
2944
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
2945
|
+
}
|
|
2946
|
+
function asStringArray2(value) {
|
|
2947
|
+
return Array.isArray(value) ? value.map((item) => asNonEmptyString6(item)).filter((item) => item !== null) : [];
|
|
2948
|
+
}
|
|
2949
|
+
function extractQuestions(payload) {
|
|
2950
|
+
if (!payload || !Array.isArray(payload.questions)) {
|
|
2951
|
+
return [];
|
|
2952
|
+
}
|
|
2953
|
+
return payload.questions.filter((question) => isRecord5(question)).map((question) => ({
|
|
2954
|
+
detailPrompt: asNonEmptyString6(question.detailPrompt) ?? void 0,
|
|
2955
|
+
freeTextOptionId: asNonEmptyString6(question.freeTextOptionId) ?? void 0,
|
|
2956
|
+
id: asNonEmptyString6(question.id) ?? "question",
|
|
2957
|
+
inputDefault: asNonEmptyString6(question.inputDefault) ?? void 0,
|
|
2958
|
+
options: Array.isArray(question.options) ? question.options.filter((option) => isRecord5(option)).map((option) => ({
|
|
2959
|
+
description: asNonEmptyString6(option.description) ?? void 0,
|
|
2960
|
+
id: asNonEmptyString6(option.id) ?? "option",
|
|
2961
|
+
label: asNonEmptyString6(option.label) ?? "Option"
|
|
2962
|
+
})) : [],
|
|
2963
|
+
references: asStringArray2(question.references),
|
|
2964
|
+
text: asNonEmptyString6(question.text) ?? "Clarification required."
|
|
2965
|
+
}));
|
|
2966
|
+
}
|
|
2967
|
+
function extractMilestones(payload) {
|
|
2968
|
+
if (!payload || !Array.isArray(payload.milestones)) {
|
|
2969
|
+
return [];
|
|
2970
|
+
}
|
|
2971
|
+
return payload.milestones.filter((milestone) => isRecord5(milestone)).map((milestone) => ({
|
|
2972
|
+
description: asNonEmptyString6(milestone.description) ?? void 0,
|
|
2973
|
+
name: asNonEmptyString6(milestone.name) ?? "milestone",
|
|
2974
|
+
testableOutcomes: asStringArray2(milestone.testableOutcomes)
|
|
2975
|
+
}));
|
|
2976
|
+
}
|
|
2977
|
+
function looksLikeMissionDraft(value) {
|
|
2978
|
+
return isRecord5(value) && typeof value.taskDescription === "string" && Array.isArray(value.milestones) && Array.isArray(value.features) && Array.isArray(value.assertions);
|
|
2979
|
+
}
|
|
2980
|
+
function extractDraft(payload) {
|
|
2981
|
+
if (!payload) {
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
if (looksLikeMissionDraft(payload.draft)) {
|
|
2985
|
+
return payload.draft;
|
|
2986
|
+
}
|
|
2987
|
+
if (looksLikeMissionDraft(payload)) {
|
|
2988
|
+
return payload;
|
|
2989
|
+
}
|
|
2990
|
+
return null;
|
|
2991
|
+
}
|
|
2992
|
+
function countItems(payload, key) {
|
|
2993
|
+
const value = payload[key];
|
|
2994
|
+
return Array.isArray(value) ? value.length : 0;
|
|
2995
|
+
}
|
|
2996
|
+
function formatBudgetSummary(draft) {
|
|
2997
|
+
if (!isRecord5(draft.config) || !isRecord5(draft.config.budget)) {
|
|
2998
|
+
return "unknown";
|
|
2999
|
+
}
|
|
3000
|
+
const budget = draft.config.budget;
|
|
3001
|
+
if (typeof budget.usd === "number" && Number.isFinite(budget.usd)) {
|
|
3002
|
+
return `$${budget.usd.toFixed(2)}`;
|
|
3003
|
+
}
|
|
3004
|
+
try {
|
|
3005
|
+
return JSON.stringify(budget);
|
|
3006
|
+
} catch {
|
|
3007
|
+
return "unknown";
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
function extractFailures(error) {
|
|
3011
|
+
return error instanceof ApiError && isRecord5(error.payload) ? asStringArray2(error.payload.failures) : [];
|
|
3012
|
+
}
|
|
3013
|
+
function extractErrorCode(error) {
|
|
3014
|
+
if (!(error instanceof ApiError) || !isRecord5(error.payload)) {
|
|
3015
|
+
return null;
|
|
3016
|
+
}
|
|
3017
|
+
return asNonEmptyString6(error.payload.error);
|
|
3018
|
+
}
|
|
3019
|
+
function extractRound(payload) {
|
|
3020
|
+
return typeof payload?.round === "number" && Number.isFinite(payload.round) ? payload.round : 1;
|
|
3021
|
+
}
|
|
3022
|
+
function defaultCreateSpinner2(text) {
|
|
3023
|
+
const spinner = ora2({ text });
|
|
3024
|
+
return {
|
|
3025
|
+
get text() {
|
|
3026
|
+
return spinner.text;
|
|
3027
|
+
},
|
|
3028
|
+
set text(value) {
|
|
3029
|
+
spinner.text = value;
|
|
3030
|
+
},
|
|
3031
|
+
fail(message) {
|
|
3032
|
+
if (message) {
|
|
3033
|
+
spinner.text = message;
|
|
3034
|
+
}
|
|
3035
|
+
spinner.fail();
|
|
3036
|
+
},
|
|
3037
|
+
start(message) {
|
|
3038
|
+
if (message) {
|
|
3039
|
+
spinner.text = message;
|
|
3040
|
+
}
|
|
3041
|
+
spinner.start();
|
|
3042
|
+
},
|
|
3043
|
+
stop() {
|
|
3044
|
+
spinner.stop();
|
|
3045
|
+
},
|
|
3046
|
+
succeed(message) {
|
|
3047
|
+
if (message) {
|
|
3048
|
+
spinner.text = message;
|
|
3049
|
+
}
|
|
3050
|
+
spinner.succeed();
|
|
3051
|
+
}
|
|
3052
|
+
};
|
|
3053
|
+
}
|
|
3054
|
+
async function defaultPromptSelect2(options) {
|
|
3055
|
+
return defaultSelectPrompt2({
|
|
3056
|
+
choices: options.choices.map((choice) => ({
|
|
3057
|
+
description: choice.description,
|
|
3058
|
+
name: choice.name,
|
|
3059
|
+
value: choice.value
|
|
3060
|
+
})),
|
|
3061
|
+
message: options.message
|
|
3062
|
+
});
|
|
3063
|
+
}
|
|
3064
|
+
async function defaultPromptInput2(options) {
|
|
3065
|
+
return defaultInputPrompt2({
|
|
3066
|
+
default: options.default,
|
|
3067
|
+
message: options.message
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
3070
|
+
async function defaultPromptConfirm(options) {
|
|
3071
|
+
return defaultConfirmPrompt({
|
|
3072
|
+
default: options.default,
|
|
3073
|
+
message: options.message
|
|
3074
|
+
});
|
|
3075
|
+
}
|
|
3076
|
+
async function getPlanningSession(client, sessionId) {
|
|
3077
|
+
return client.request({
|
|
3078
|
+
path: `/plan/${sessionId}`
|
|
3079
|
+
});
|
|
3080
|
+
}
|
|
3081
|
+
async function postClarification(client, sessionId, body) {
|
|
3082
|
+
return client.request({
|
|
3083
|
+
body,
|
|
3084
|
+
method: "POST",
|
|
3085
|
+
path: `/plan/${sessionId}/clarify`
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
async function postMilestoneConfirmation(client, sessionId, body) {
|
|
3089
|
+
return client.request({
|
|
3090
|
+
body,
|
|
3091
|
+
method: "POST",
|
|
3092
|
+
path: `/plan/${sessionId}/confirm-milestones`
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
async function postDraftApproval(client, sessionId) {
|
|
3096
|
+
return client.request({
|
|
3097
|
+
method: "POST",
|
|
3098
|
+
path: `/plan/${sessionId}/approve`
|
|
3099
|
+
});
|
|
3100
|
+
}
|
|
3101
|
+
function writeSection2(stdout, title) {
|
|
3102
|
+
writeLine(stdout);
|
|
3103
|
+
writeLine(stdout, pc5.bold(title));
|
|
3104
|
+
}
|
|
3105
|
+
function renderQuestion(stdout, question, index) {
|
|
3106
|
+
writeLine(stdout, `${index + 1}. ${question.text}`);
|
|
3107
|
+
if (question.references && question.references.length > 0) {
|
|
3108
|
+
writeLine(stdout, ` ${pc5.dim(`References: ${question.references.join(", ")}`)}`);
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
function renderMilestones(stdout, milestones, reviewRound) {
|
|
3112
|
+
writeSection2(stdout, `Milestone review round ${reviewRound}`);
|
|
3113
|
+
milestones.forEach((milestone, index) => {
|
|
3114
|
+
writeLine(stdout, `${index + 1}. ${pc5.cyan(milestone.name)}`);
|
|
3115
|
+
if (milestone.description) {
|
|
3116
|
+
writeLine(stdout, ` ${milestone.description}`);
|
|
3117
|
+
}
|
|
3118
|
+
const outcomes = milestone.testableOutcomes ?? [];
|
|
3119
|
+
for (const outcome of outcomes) {
|
|
3120
|
+
writeLine(stdout, ` - ${outcome}`);
|
|
3121
|
+
}
|
|
3122
|
+
});
|
|
3123
|
+
}
|
|
3124
|
+
function renderDraftSummary(stdout, payload, draft) {
|
|
3125
|
+
writeSection2(stdout, "Draft summary");
|
|
3126
|
+
writeLine(stdout, `Milestones: ${countItems(payload, "milestones") || countItems(draft, "milestones")}`);
|
|
3127
|
+
writeLine(stdout, `Features: ${countItems(payload, "features") || countItems(draft, "features")}`);
|
|
3128
|
+
writeLine(stdout, `Assertions: ${countItems(payload, "assertions") || countItems(draft, "assertions")}`);
|
|
3129
|
+
writeLine(stdout, `Budget: ${formatBudgetSummary(draft)}`);
|
|
3130
|
+
}
|
|
3131
|
+
function renderFailures(stdout, failures) {
|
|
3132
|
+
writeSection2(stdout, "Unresolved clarification items");
|
|
3133
|
+
failures.forEach((failure) => {
|
|
3134
|
+
writeLine(stdout, `- ${failure}`);
|
|
3135
|
+
});
|
|
3136
|
+
}
|
|
3137
|
+
async function promptForAnswers(options, questions, round) {
|
|
3138
|
+
if (options.autoApprove) {
|
|
3139
|
+
return {
|
|
3140
|
+
answers: [],
|
|
3141
|
+
transcript: []
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
const promptSelect = options.promptSelect ?? defaultPromptSelect2;
|
|
3145
|
+
const promptInput = options.promptInput ?? defaultPromptInput2;
|
|
3146
|
+
const answers = [];
|
|
3147
|
+
const transcript = [];
|
|
3148
|
+
for (const question of questions) {
|
|
3149
|
+
if (question.options && question.options.length > 0) {
|
|
3150
|
+
const choices = question.options.map((option) => ({
|
|
3151
|
+
description: option.description,
|
|
3152
|
+
name: option.label,
|
|
3153
|
+
value: option.id
|
|
3154
|
+
}));
|
|
3155
|
+
const optionId2 = await promptSelect({
|
|
3156
|
+
choices,
|
|
3157
|
+
message: question.text
|
|
3158
|
+
});
|
|
3159
|
+
const selectedOption = question.options.find((option) => option.id === optionId2);
|
|
3160
|
+
answers.push({
|
|
3161
|
+
optionId: optionId2,
|
|
3162
|
+
questionId: question.id
|
|
3163
|
+
});
|
|
3164
|
+
transcript.push({
|
|
3165
|
+
answer: selectedOption?.label ?? optionId2,
|
|
3166
|
+
optionId: optionId2,
|
|
3167
|
+
question: question.text,
|
|
3168
|
+
questionId: question.id,
|
|
3169
|
+
references: question.references ?? [],
|
|
3170
|
+
round
|
|
3171
|
+
});
|
|
3172
|
+
continue;
|
|
3173
|
+
}
|
|
3174
|
+
const detail = await promptInput({
|
|
3175
|
+
default: question.inputDefault,
|
|
3176
|
+
message: question.detailPrompt ?? question.text
|
|
3177
|
+
});
|
|
3178
|
+
const optionId = question.freeTextOptionId ?? FREE_TEXT_OPTION_ID;
|
|
3179
|
+
answers.push({
|
|
3180
|
+
detail,
|
|
3181
|
+
optionId,
|
|
3182
|
+
questionId: question.id
|
|
3183
|
+
});
|
|
3184
|
+
transcript.push({
|
|
3185
|
+
answer: detail,
|
|
3186
|
+
optionId,
|
|
3187
|
+
question: question.text,
|
|
3188
|
+
questionId: question.id,
|
|
3189
|
+
references: question.references ?? [],
|
|
3190
|
+
round
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
return {
|
|
3194
|
+
answers,
|
|
3195
|
+
transcript
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
async function waitForAnalysis(options, sessionId) {
|
|
3199
|
+
const createSpinner = options.createSpinner ?? defaultCreateSpinner2;
|
|
3200
|
+
const sleep = options.sleep ?? (async (ms) => {
|
|
3201
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
3202
|
+
});
|
|
3203
|
+
const now = options.now ?? (() => Date.now());
|
|
3204
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_ANALYSIS_TIMEOUT_MS;
|
|
3205
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
3206
|
+
const deadline = now() + timeoutMs;
|
|
3207
|
+
const spinner = createSpinner("Analyzing repository for planning...");
|
|
3208
|
+
spinner.start();
|
|
3209
|
+
try {
|
|
3210
|
+
let session = await getPlanningSession(options.client, sessionId);
|
|
3211
|
+
while (session.state === "created" || session.state === "analyzing") {
|
|
3212
|
+
if (now() >= deadline) {
|
|
3213
|
+
spinner.fail("Planning analysis timed out.");
|
|
3214
|
+
throw new CliError("Planning analysis timed out after 5 minutes.");
|
|
3215
|
+
}
|
|
3216
|
+
await sleep(pollIntervalMs);
|
|
3217
|
+
session = await getPlanningSession(options.client, sessionId);
|
|
3218
|
+
}
|
|
3219
|
+
spinner.succeed("Analysis complete.");
|
|
3220
|
+
return session;
|
|
3221
|
+
} catch (error) {
|
|
3222
|
+
if (error instanceof CliError) {
|
|
3223
|
+
throw error;
|
|
3224
|
+
}
|
|
3225
|
+
spinner.fail("Planning analysis failed.");
|
|
3226
|
+
throw error;
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
async function resolveClarification(options, sessionId, initialPayload) {
|
|
3230
|
+
let payload = initialPayload;
|
|
3231
|
+
const localTranscript = [];
|
|
3232
|
+
while (payload.state === "clarifying") {
|
|
3233
|
+
let questions = extractQuestions(payload);
|
|
3234
|
+
let round = extractRound(payload);
|
|
3235
|
+
if (questions.length === 0) {
|
|
3236
|
+
payload = await postClarification(options.client, sessionId, {});
|
|
3237
|
+
questions = extractQuestions(payload);
|
|
3238
|
+
round = extractRound(payload);
|
|
3239
|
+
}
|
|
3240
|
+
if (payload.state !== "clarifying") {
|
|
3241
|
+
break;
|
|
3242
|
+
}
|
|
3243
|
+
writeSection2(options.stdout, `Clarification round ${round}`);
|
|
3244
|
+
questions.forEach((question, index) => renderQuestion(options.stdout, question, index));
|
|
3245
|
+
const prompted = await promptForAnswers(options, questions, round);
|
|
3246
|
+
localTranscript.push(...prompted.transcript);
|
|
3247
|
+
try {
|
|
3248
|
+
payload = await postClarification(options.client, sessionId, {
|
|
3249
|
+
answers: prompted.answers
|
|
3250
|
+
});
|
|
3251
|
+
} catch (error) {
|
|
3252
|
+
if (extractErrorCode(error) !== "resolvedness_gate_failed") {
|
|
3253
|
+
throw error;
|
|
3254
|
+
}
|
|
3255
|
+
const failures = extractFailures(error);
|
|
3256
|
+
renderFailures(options.stdout, failures);
|
|
3257
|
+
if (!options.force) {
|
|
3258
|
+
throw new CliError(
|
|
3259
|
+
`Planning clarification is still unresolved: ${failures.join(", ")}`
|
|
3260
|
+
);
|
|
3261
|
+
}
|
|
3262
|
+
writeLine(
|
|
3263
|
+
options.stdout,
|
|
3264
|
+
pc5.yellow("Proceeding because --force is enabled.")
|
|
3265
|
+
);
|
|
3266
|
+
payload = await postMilestoneConfirmation(options.client, sessionId, {});
|
|
3267
|
+
return {
|
|
3268
|
+
payload,
|
|
3269
|
+
persistedClarification: {
|
|
3270
|
+
forced: true,
|
|
3271
|
+
roundsCompleted: round,
|
|
3272
|
+
sessionId,
|
|
3273
|
+
transcript: localTranscript,
|
|
3274
|
+
unresolvedItems: failures
|
|
3275
|
+
}
|
|
3276
|
+
};
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
if (localTranscript.length === 0 && !Array.isArray(payload.clarificationTranscript)) {
|
|
3280
|
+
return {
|
|
3281
|
+
payload,
|
|
3282
|
+
persistedClarification: null
|
|
3283
|
+
};
|
|
3284
|
+
}
|
|
3285
|
+
return {
|
|
3286
|
+
payload,
|
|
3287
|
+
persistedClarification: {
|
|
3288
|
+
forced: false,
|
|
3289
|
+
roundsCompleted: extractRound(payload),
|
|
3290
|
+
sessionId,
|
|
3291
|
+
transcript: Array.isArray(payload.clarificationTranscript) ? payload.clarificationTranscript : localTranscript,
|
|
3292
|
+
unresolvedItems: []
|
|
3293
|
+
}
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3296
|
+
async function resolveMilestones(options, sessionId, initialPayload) {
|
|
3297
|
+
let reviewRound = 1;
|
|
3298
|
+
let payload = initialPayload;
|
|
3299
|
+
if (extractMilestones(payload).length === 0) {
|
|
3300
|
+
payload = await postMilestoneConfirmation(options.client, sessionId, {});
|
|
3301
|
+
}
|
|
3302
|
+
while (true) {
|
|
3303
|
+
const milestones = extractMilestones(payload);
|
|
3304
|
+
renderMilestones(options.stdout, milestones, reviewRound);
|
|
3305
|
+
const confirmed = options.autoApprove ? true : await (options.promptConfirm ?? defaultPromptConfirm)({
|
|
3306
|
+
default: true,
|
|
3307
|
+
message: "Do these milestones look correct?"
|
|
3308
|
+
});
|
|
3309
|
+
if (confirmed) {
|
|
3310
|
+
return postMilestoneConfirmation(options.client, sessionId, {
|
|
3311
|
+
confirmed: true
|
|
3312
|
+
});
|
|
3313
|
+
}
|
|
3314
|
+
const feedback = await (options.promptInput ?? defaultPromptInput2)({
|
|
3315
|
+
message: "What should change about these milestones?"
|
|
3316
|
+
});
|
|
3317
|
+
payload = await postMilestoneConfirmation(options.client, sessionId, {
|
|
3318
|
+
confirmed: false,
|
|
3319
|
+
feedback
|
|
3320
|
+
});
|
|
3321
|
+
reviewRound += 1;
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
async function resolveDraft(options, sessionId, payload) {
|
|
3325
|
+
const directDraft = extractDraft(payload);
|
|
3326
|
+
if (directDraft) {
|
|
3327
|
+
return {
|
|
3328
|
+
draft: directDraft,
|
|
3329
|
+
payload
|
|
3330
|
+
};
|
|
3331
|
+
}
|
|
3332
|
+
const approvedPayload = await postDraftApproval(options.client, sessionId);
|
|
3333
|
+
const approvedDraft = extractDraft(approvedPayload);
|
|
3334
|
+
if (!approvedDraft) {
|
|
3335
|
+
throw new CliError("Planner did not return a mission draft.");
|
|
3336
|
+
}
|
|
3337
|
+
return {
|
|
3338
|
+
draft: approvedDraft,
|
|
3339
|
+
payload: approvedPayload
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
async function runPlanningFlow(options) {
|
|
3343
|
+
if (options.existingMissionDraft) {
|
|
3344
|
+
writeLine(
|
|
3345
|
+
options.stdout,
|
|
3346
|
+
pc5.cyan("Skipping planning because mission-draft.json already exists.")
|
|
3347
|
+
);
|
|
3348
|
+
return {
|
|
3349
|
+
cancelled: false,
|
|
3350
|
+
draft: options.existingMissionDraft,
|
|
3351
|
+
sessionId: options.existingSessionId ?? "",
|
|
3352
|
+
skippedPlanning: true
|
|
3353
|
+
};
|
|
3354
|
+
}
|
|
3355
|
+
const sessionId = asNonEmptyString6(options.existingSessionId) ?? asNonEmptyString6(
|
|
3356
|
+
(await options.client.request({
|
|
3357
|
+
body: {
|
|
3358
|
+
repos: options.repoPaths,
|
|
3359
|
+
task_description: options.taskDescription
|
|
3360
|
+
},
|
|
3361
|
+
method: "POST",
|
|
3362
|
+
path: "/plan/create",
|
|
3363
|
+
timeoutMs: 3e4
|
|
3364
|
+
})).session_id
|
|
3365
|
+
);
|
|
3366
|
+
if (!sessionId) {
|
|
3367
|
+
throw new CliError("Planner did not return a session id.");
|
|
3368
|
+
}
|
|
3369
|
+
if (options.existingSessionId) {
|
|
3370
|
+
writeLine(options.stdout, `Resuming planning session ${sessionId}.`);
|
|
3371
|
+
} else {
|
|
3372
|
+
await options.persistSessionId(sessionId);
|
|
3373
|
+
writeLine(options.stdout, `Created planning session ${sessionId}.`);
|
|
3374
|
+
}
|
|
3375
|
+
let payload = await waitForAnalysis(options, sessionId);
|
|
3376
|
+
if (payload.state === "clarifying") {
|
|
3377
|
+
const clarification = await resolveClarification(options, sessionId, payload);
|
|
3378
|
+
payload = clarification.payload;
|
|
3379
|
+
if (clarification.persistedClarification) {
|
|
3380
|
+
await options.persistClarification(clarification.persistedClarification);
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
if (payload.state === "confirming") {
|
|
3384
|
+
payload = await resolveMilestones(options, sessionId, payload);
|
|
3385
|
+
}
|
|
3386
|
+
if (payload.state !== "complete") {
|
|
3387
|
+
throw new CliError(
|
|
3388
|
+
`Planner returned an unexpected state after milestone confirmation: ${payload.state ?? "unknown"}.`
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
const { draft, payload: summaryPayload } = await resolveDraft(
|
|
3392
|
+
options,
|
|
3393
|
+
sessionId,
|
|
3394
|
+
payload
|
|
3395
|
+
);
|
|
3396
|
+
renderDraftSummary(options.stdout, summaryPayload, draft);
|
|
3397
|
+
const approved = options.autoApprove ? true : await (options.promptConfirm ?? defaultPromptConfirm)({
|
|
3398
|
+
default: true,
|
|
3399
|
+
message: "Approve this draft and continue to upload?"
|
|
3400
|
+
});
|
|
3401
|
+
if (!approved) {
|
|
3402
|
+
writeLine(options.stdout, pc5.yellow("Planning cancelled before upload."));
|
|
3403
|
+
return {
|
|
3404
|
+
cancelled: true,
|
|
3405
|
+
draft,
|
|
3406
|
+
sessionId,
|
|
3407
|
+
skippedPlanning: false
|
|
3408
|
+
};
|
|
3409
|
+
}
|
|
3410
|
+
await options.persistMissionDraft(draft);
|
|
3411
|
+
writeLine(options.stdout, pc5.green("Saved approved mission draft."));
|
|
3412
|
+
return {
|
|
3413
|
+
cancelled: false,
|
|
3414
|
+
draft,
|
|
3415
|
+
sessionId,
|
|
3416
|
+
skippedPlanning: false
|
|
3417
|
+
};
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
// src/upload.ts
|
|
3421
|
+
import { confirm } from "@inquirer/prompts";
|
|
3422
|
+
import { createReadStream as createReadStream2 } from "fs";
|
|
3423
|
+
import path5 from "path";
|
|
3424
|
+
import { Transform as Transform2 } from "stream";
|
|
3425
|
+
import ora3 from "ora";
|
|
3426
|
+
import pc6 from "picocolors";
|
|
3427
|
+
|
|
3428
|
+
// src/snapshot.ts
|
|
3429
|
+
import { execFile } from "child_process";
|
|
3430
|
+
import { createHash as createHash2 } from "crypto";
|
|
3431
|
+
import { createReadStream, createWriteStream } from "fs";
|
|
3432
|
+
import {
|
|
3433
|
+
lstat,
|
|
3434
|
+
mkdir as mkdir3,
|
|
3435
|
+
readlink,
|
|
3436
|
+
readdir as readdir3,
|
|
3437
|
+
rename as rename3,
|
|
3438
|
+
rm as rm3,
|
|
3439
|
+
stat as stat2
|
|
3440
|
+
} from "fs/promises";
|
|
3441
|
+
import path4 from "path";
|
|
3442
|
+
import { createInterface } from "readline";
|
|
3443
|
+
import { Transform } from "stream";
|
|
3444
|
+
import { pipeline } from "stream/promises";
|
|
3445
|
+
import { promisify } from "util";
|
|
3446
|
+
import * as tar from "tar";
|
|
3447
|
+
import { createZstdCompress } from "zlib";
|
|
3448
|
+
var execFileAsync = promisify(execFile);
|
|
3449
|
+
var EPOCH_DATE = /* @__PURE__ */ new Date(0);
|
|
3450
|
+
var SNAPSHOT_SCHEMA_VERSION = 1;
|
|
3451
|
+
var GIT_DIR_NAME = ".git";
|
|
3452
|
+
var GIT_EXCLUDED_RULES = [".git/", "gitignored files"];
|
|
3453
|
+
var NON_GIT_EXCLUDED_RULES = [
|
|
3454
|
+
".git/",
|
|
3455
|
+
"node_modules/",
|
|
3456
|
+
".DS_Store",
|
|
3457
|
+
"__pycache__/",
|
|
3458
|
+
".env"
|
|
3459
|
+
];
|
|
3460
|
+
var REPO_ID_PATTERN2 = /^[A-Za-z0-9._-]+$/;
|
|
3461
|
+
var SECRET_PATTERNS = [
|
|
3462
|
+
{
|
|
3463
|
+
kind: "stripe_live_key",
|
|
3464
|
+
message: "Found a Stripe live secret key.",
|
|
3465
|
+
regex: /sk_live_[A-Za-z0-9]+/g
|
|
3466
|
+
},
|
|
3467
|
+
{
|
|
3468
|
+
kind: "aws_access_key",
|
|
3469
|
+
message: "Found an AWS access key ID.",
|
|
3470
|
+
regex: /AKIA[0-9A-Z]{16}/g
|
|
3471
|
+
},
|
|
3472
|
+
{
|
|
3473
|
+
kind: "github_personal_access_token",
|
|
3474
|
+
message: "Found a GitHub personal access token.",
|
|
3475
|
+
regex: /ghp_[A-Za-z0-9]+/g
|
|
3476
|
+
},
|
|
3477
|
+
{
|
|
3478
|
+
kind: "private_key",
|
|
3479
|
+
message: "Found a private key header.",
|
|
3480
|
+
regex: /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/g
|
|
3481
|
+
}
|
|
3482
|
+
];
|
|
3483
|
+
var SnapshotError = class extends Error {
|
|
3484
|
+
code;
|
|
3485
|
+
constructor(message, code = "snapshot_failed") {
|
|
3486
|
+
super(message);
|
|
3487
|
+
this.name = "SnapshotError";
|
|
3488
|
+
this.code = code;
|
|
3489
|
+
}
|
|
3490
|
+
};
|
|
3491
|
+
var SecretDetectionError = class extends SnapshotError {
|
|
3492
|
+
failures;
|
|
3493
|
+
constructor(failures) {
|
|
3494
|
+
const summary = failures.map(
|
|
3495
|
+
(failure) => `${failure.localPath}: ${failure.findings.map((finding) => finding.filePath).join(", ")}`
|
|
3496
|
+
).join("; ");
|
|
3497
|
+
super(
|
|
3498
|
+
`Secret scan detected findings in ${failures.length} repo(s): ${summary}`,
|
|
3499
|
+
"secrets_detected"
|
|
3500
|
+
);
|
|
3501
|
+
this.name = "SecretDetectionError";
|
|
3502
|
+
this.failures = failures;
|
|
3503
|
+
}
|
|
3504
|
+
};
|
|
3505
|
+
function compareStrings(left, right) {
|
|
3506
|
+
if (left === right) {
|
|
3507
|
+
return 0;
|
|
3508
|
+
}
|
|
3509
|
+
return left < right ? -1 : 1;
|
|
3510
|
+
}
|
|
3511
|
+
function toPosixPath(filePath) {
|
|
3512
|
+
return filePath.split(path4.sep).join(path4.posix.sep);
|
|
3513
|
+
}
|
|
3514
|
+
function splitNullTerminatedBuffer(buffer) {
|
|
3515
|
+
return buffer.toString("utf8").split("\0").filter((entry) => entry.length > 0);
|
|
3516
|
+
}
|
|
3517
|
+
function sanitizeIdentifier(value) {
|
|
3518
|
+
const sanitized = value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+/, "").replace(/-+$/, "");
|
|
3519
|
+
return sanitized.length > 0 ? sanitized : "repo";
|
|
3520
|
+
}
|
|
3521
|
+
function createPathHash(value, length = 8) {
|
|
3522
|
+
return createHash2("sha256").update(value).digest("hex").slice(0, length);
|
|
3523
|
+
}
|
|
3524
|
+
function normalizeArchiveMode(mode) {
|
|
3525
|
+
return mode & 73 ? 493 : 420;
|
|
3526
|
+
}
|
|
3527
|
+
function buildTreeShaInput(entries) {
|
|
3528
|
+
return JSON.stringify(
|
|
3529
|
+
entries.map((entry) => ({
|
|
3530
|
+
linkTarget: entry.linkTarget ?? null,
|
|
3531
|
+
mode: entry.mode,
|
|
3532
|
+
path: entry.relativePath,
|
|
3533
|
+
sha256: entry.sha256 ?? null,
|
|
3534
|
+
size: entry.size,
|
|
3535
|
+
type: entry.type
|
|
3536
|
+
}))
|
|
3537
|
+
);
|
|
3538
|
+
}
|
|
3539
|
+
async function ensureDirectory2(dirPath) {
|
|
3540
|
+
await mkdir3(dirPath, { recursive: true });
|
|
3541
|
+
}
|
|
3542
|
+
async function validateDirectoryPath(repoPath, options) {
|
|
3543
|
+
const cwd = options.cwd ?? process.cwd();
|
|
3544
|
+
const absolutePath = path4.resolve(cwd, repoPath);
|
|
3545
|
+
let directoryStats;
|
|
3546
|
+
try {
|
|
3547
|
+
directoryStats = await stat2(absolutePath);
|
|
3548
|
+
} catch (error) {
|
|
3549
|
+
const code = error.code;
|
|
3550
|
+
if (code === "ENOENT") {
|
|
3551
|
+
throw new SnapshotError(
|
|
3552
|
+
`Repo path does not exist: ${repoPath}`,
|
|
3553
|
+
"invalid_repo"
|
|
3554
|
+
);
|
|
3555
|
+
}
|
|
3556
|
+
throw error;
|
|
3557
|
+
}
|
|
3558
|
+
if (!directoryStats.isDirectory()) {
|
|
3559
|
+
throw new SnapshotError(
|
|
3560
|
+
`Repo path is not a directory: ${repoPath}`,
|
|
3561
|
+
"invalid_repo"
|
|
3562
|
+
);
|
|
3563
|
+
}
|
|
3564
|
+
return absolutePath;
|
|
3565
|
+
}
|
|
3566
|
+
async function resolveRepositoryInputs(repoPaths, options) {
|
|
3567
|
+
if (repoPaths.length === 0) {
|
|
3568
|
+
throw new SnapshotError("At least one repo path is required.", "invalid_repo");
|
|
3569
|
+
}
|
|
3570
|
+
const absolutePaths = await Promise.all(
|
|
3571
|
+
repoPaths.map((repoPath) => validateDirectoryPath(repoPath, options))
|
|
3572
|
+
);
|
|
3573
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3574
|
+
const baseNames = absolutePaths.map(
|
|
3575
|
+
(absolutePath) => sanitizeIdentifier(path4.basename(absolutePath))
|
|
3576
|
+
);
|
|
3577
|
+
for (const baseName of baseNames) {
|
|
3578
|
+
counts.set(baseName, (counts.get(baseName) ?? 0) + 1);
|
|
3579
|
+
}
|
|
3580
|
+
return absolutePaths.map((absolutePath, index) => {
|
|
3581
|
+
const baseName = baseNames[index];
|
|
3582
|
+
const pathHash = createPathHash(absolutePath);
|
|
3583
|
+
const repoId = `${baseName}-${pathHash}`;
|
|
3584
|
+
const mountAs = (counts.get(baseName) ?? 0) > 1 ? `${baseName}-${pathHash}` : baseName;
|
|
3585
|
+
if (!REPO_ID_PATTERN2.test(repoId)) {
|
|
3586
|
+
throw new SnapshotError(
|
|
3587
|
+
`Generated repo ID is invalid for ${absolutePath}`,
|
|
3588
|
+
"invalid_repo"
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
return {
|
|
3592
|
+
repoId,
|
|
3593
|
+
localPath: absolutePath,
|
|
3594
|
+
mountAs
|
|
3595
|
+
};
|
|
3596
|
+
});
|
|
3597
|
+
}
|
|
3598
|
+
async function readSha256(filePath) {
|
|
3599
|
+
const hash = createHash2("sha256");
|
|
3600
|
+
const stream = createReadStream(filePath);
|
|
3601
|
+
for await (const chunk of stream) {
|
|
3602
|
+
hash.update(chunk);
|
|
3603
|
+
}
|
|
3604
|
+
return hash.digest("hex");
|
|
3605
|
+
}
|
|
3606
|
+
function matchSecretPatterns(line, relativePath, lineNumber) {
|
|
3607
|
+
const findings = [];
|
|
3608
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
3609
|
+
pattern.regex.lastIndex = 0;
|
|
3610
|
+
if (pattern.regex.test(line)) {
|
|
3611
|
+
findings.push({
|
|
3612
|
+
kind: pattern.kind,
|
|
3613
|
+
filePath: relativePath,
|
|
3614
|
+
line: lineNumber,
|
|
3615
|
+
message: pattern.message
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
return findings;
|
|
3620
|
+
}
|
|
3621
|
+
async function scanRegularFileForSecrets(filePath, relativePath) {
|
|
3622
|
+
const findings = [];
|
|
3623
|
+
const basename = path4.posix.basename(relativePath);
|
|
3624
|
+
if (basename.startsWith(".env")) {
|
|
3625
|
+
findings.push({
|
|
3626
|
+
kind: "env_file",
|
|
3627
|
+
filePath: relativePath,
|
|
3628
|
+
line: null,
|
|
3629
|
+
message: "Found a .env-style file."
|
|
3630
|
+
});
|
|
3631
|
+
}
|
|
3632
|
+
const input = createReadStream(filePath, { encoding: "utf8" });
|
|
3633
|
+
const lines = createInterface({
|
|
3634
|
+
input,
|
|
3635
|
+
crlfDelay: Number.POSITIVE_INFINITY
|
|
3636
|
+
});
|
|
3637
|
+
let lineNumber = 0;
|
|
3638
|
+
for await (const line of lines) {
|
|
3639
|
+
lineNumber += 1;
|
|
3640
|
+
findings.push(...matchSecretPatterns(line, relativePath, lineNumber));
|
|
3641
|
+
}
|
|
3642
|
+
return findings;
|
|
3643
|
+
}
|
|
3644
|
+
async function classifyRepositoryEntry(repoPath, relativePath) {
|
|
3645
|
+
const absolutePath = path4.join(repoPath, relativePath);
|
|
3646
|
+
const entryStats = await lstat(absolutePath);
|
|
3647
|
+
if (entryStats.isDirectory()) {
|
|
3648
|
+
return null;
|
|
3649
|
+
}
|
|
3650
|
+
if (entryStats.isSymbolicLink()) {
|
|
3651
|
+
return {
|
|
3652
|
+
absolutePath,
|
|
3653
|
+
linkTarget: await readlink(absolutePath),
|
|
3654
|
+
mode: 493,
|
|
3655
|
+
relativePath,
|
|
3656
|
+
size: 0,
|
|
3657
|
+
type: "symlink"
|
|
3658
|
+
};
|
|
3659
|
+
}
|
|
3660
|
+
if (!entryStats.isFile()) {
|
|
3661
|
+
return null;
|
|
3662
|
+
}
|
|
3663
|
+
return {
|
|
3664
|
+
absolutePath,
|
|
3665
|
+
mode: normalizeArchiveMode(entryStats.mode),
|
|
3666
|
+
relativePath,
|
|
3667
|
+
sha256: await readSha256(absolutePath),
|
|
3668
|
+
size: entryStats.size,
|
|
3669
|
+
type: "file"
|
|
3670
|
+
};
|
|
3671
|
+
}
|
|
3672
|
+
function shouldIgnoreNonGitEntry(relativePath, isDirectory) {
|
|
3673
|
+
const normalizedPath = toPosixPath(relativePath);
|
|
3674
|
+
const basename = path4.posix.basename(normalizedPath);
|
|
3675
|
+
const segments = normalizedPath.split(path4.posix.sep);
|
|
3676
|
+
if (segments.includes(GIT_DIR_NAME)) {
|
|
3677
|
+
return true;
|
|
3678
|
+
}
|
|
3679
|
+
if (isDirectory) {
|
|
3680
|
+
return basename === "node_modules" || basename === "__pycache__";
|
|
3681
|
+
}
|
|
3682
|
+
return basename === ".DS_Store" || basename === ".env";
|
|
3683
|
+
}
|
|
3684
|
+
async function walkNonGitEntries(repoPath, currentRelativePath = "") {
|
|
3685
|
+
const currentAbsolutePath = path4.join(repoPath, currentRelativePath);
|
|
3686
|
+
const dirEntries = await readdir3(currentAbsolutePath, { withFileTypes: true });
|
|
3687
|
+
const results = [];
|
|
3688
|
+
for (const dirEntry of dirEntries.sort(
|
|
3689
|
+
(left, right) => compareStrings(left.name, right.name)
|
|
3690
|
+
)) {
|
|
3691
|
+
const relativePath = currentRelativePath ? path4.posix.join(currentRelativePath, dirEntry.name) : dirEntry.name;
|
|
3692
|
+
if (shouldIgnoreNonGitEntry(relativePath, dirEntry.isDirectory())) {
|
|
3693
|
+
continue;
|
|
3694
|
+
}
|
|
3695
|
+
if (dirEntry.isDirectory()) {
|
|
3696
|
+
results.push(...await walkNonGitEntries(repoPath, relativePath));
|
|
3697
|
+
continue;
|
|
3698
|
+
}
|
|
3699
|
+
results.push(relativePath);
|
|
3700
|
+
}
|
|
3701
|
+
return results;
|
|
3702
|
+
}
|
|
3703
|
+
async function pathExists(filePath) {
|
|
3704
|
+
try {
|
|
3705
|
+
await lstat(filePath);
|
|
3706
|
+
return true;
|
|
3707
|
+
} catch (error) {
|
|
3708
|
+
if (error.code === "ENOENT") {
|
|
3709
|
+
return false;
|
|
3710
|
+
}
|
|
3711
|
+
throw error;
|
|
3712
|
+
}
|
|
3713
|
+
}
|
|
3714
|
+
async function isGitBackedRepository(repoPath) {
|
|
3715
|
+
return pathExists(path4.join(repoPath, GIT_DIR_NAME));
|
|
3716
|
+
}
|
|
3717
|
+
async function runGitCommand(repoPath, args, options = {}) {
|
|
3718
|
+
const encoding = options.encoding ?? "utf8";
|
|
3719
|
+
try {
|
|
3720
|
+
const result = await execFileAsync("git", [...args], {
|
|
3721
|
+
cwd: repoPath,
|
|
3722
|
+
encoding,
|
|
3723
|
+
maxBuffer: 32 * 1024 * 1024
|
|
3724
|
+
});
|
|
3725
|
+
return result.stdout;
|
|
3726
|
+
} catch (error) {
|
|
3727
|
+
if (options.allowFailure) {
|
|
3728
|
+
return null;
|
|
3729
|
+
}
|
|
3730
|
+
const stderr = error instanceof Error && "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim() : "";
|
|
3731
|
+
const reason = stderr.length > 0 ? `: ${stderr}` : "";
|
|
3732
|
+
throw new SnapshotError(
|
|
3733
|
+
`Git command failed in ${repoPath}${reason}`,
|
|
3734
|
+
"git_failed"
|
|
3735
|
+
);
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
function parseGitStatus(output) {
|
|
3739
|
+
const records = splitNullTerminatedBuffer(output);
|
|
3740
|
+
const parsed = [];
|
|
3741
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
3742
|
+
const record = records[index];
|
|
3743
|
+
const code = record.slice(0, 2);
|
|
3744
|
+
const currentPath = record.slice(3);
|
|
3745
|
+
if (code.includes("R") || code.includes("C")) {
|
|
3746
|
+
const nextPath = records[index + 1] ?? currentPath;
|
|
3747
|
+
parsed.push({ code, path: nextPath });
|
|
3748
|
+
index += 1;
|
|
3749
|
+
continue;
|
|
3750
|
+
}
|
|
3751
|
+
parsed.push({ code, path: currentPath });
|
|
3752
|
+
}
|
|
3753
|
+
return parsed;
|
|
3754
|
+
}
|
|
3755
|
+
async function discoverGitEntries(repoPath) {
|
|
3756
|
+
const trackedOutput = await runGitCommand(repoPath, ["ls-files", "-z"], {
|
|
3757
|
+
encoding: "buffer"
|
|
3758
|
+
});
|
|
3759
|
+
const statusOutput = await runGitCommand(
|
|
3760
|
+
repoPath,
|
|
3761
|
+
["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=no"],
|
|
3762
|
+
{ encoding: "buffer" }
|
|
3763
|
+
);
|
|
3764
|
+
const branchOutput = await runGitCommand(
|
|
3765
|
+
repoPath,
|
|
3766
|
+
["branch", "--show-current"],
|
|
3767
|
+
{ allowFailure: true, encoding: "utf8" }
|
|
3768
|
+
);
|
|
3769
|
+
const headOutput = await runGitCommand(
|
|
3770
|
+
repoPath,
|
|
3771
|
+
["rev-parse", "HEAD"],
|
|
3772
|
+
{ allowFailure: true, encoding: "utf8" }
|
|
3773
|
+
);
|
|
3774
|
+
const trackedEntries = splitNullTerminatedBuffer(trackedOutput);
|
|
3775
|
+
const statusRecords = parseGitStatus(statusOutput);
|
|
3776
|
+
const untrackedEntries = statusRecords.filter((record) => record.code === "??").map((record) => record.path);
|
|
3777
|
+
const candidateEntries = [.../* @__PURE__ */ new Set([...trackedEntries, ...untrackedEntries])].map((entry) => toPosixPath(entry)).filter((entry) => !entry.startsWith(".git/") && entry !== ".git");
|
|
3778
|
+
const relativePaths = [];
|
|
3779
|
+
for (const entry of candidateEntries.sort(compareStrings)) {
|
|
3780
|
+
if (await pathExists(path4.join(repoPath, entry))) {
|
|
3781
|
+
relativePaths.push(entry);
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
const branch = typeof branchOutput === "string" && branchOutput.trim().length > 0 ? branchOutput.trim() : null;
|
|
3785
|
+
const headCommit = typeof headOutput === "string" && headOutput.trim().length > 0 ? headOutput.trim() : null;
|
|
3786
|
+
return {
|
|
3787
|
+
branch,
|
|
3788
|
+
dirty: statusRecords.length > 0,
|
|
3789
|
+
excludedRules: GIT_EXCLUDED_RULES,
|
|
3790
|
+
headCommit,
|
|
3791
|
+
relativePaths
|
|
3792
|
+
};
|
|
3793
|
+
}
|
|
3794
|
+
async function discoverRepositoryEntries(input) {
|
|
3795
|
+
if (await isGitBackedRepository(input.localPath)) {
|
|
3796
|
+
const gitInfo = await discoverGitEntries(input.localPath);
|
|
3797
|
+
return {
|
|
3798
|
+
...gitInfo,
|
|
3799
|
+
isGitBacked: true,
|
|
3800
|
+
warnings: []
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
return {
|
|
3804
|
+
branch: null,
|
|
3805
|
+
dirty: true,
|
|
3806
|
+
excludedRules: NON_GIT_EXCLUDED_RULES,
|
|
3807
|
+
headCommit: null,
|
|
3808
|
+
isGitBacked: false,
|
|
3809
|
+
relativePaths: (await walkNonGitEntries(input.localPath)).sort(compareStrings),
|
|
3810
|
+
warnings: [
|
|
3811
|
+
`Repo ${input.localPath} is not git-backed; using filesystem walk with default ignores.`
|
|
3812
|
+
]
|
|
3813
|
+
};
|
|
3814
|
+
}
|
|
3815
|
+
async function analyzeRepository(input) {
|
|
3816
|
+
const discovered = await discoverRepositoryEntries(input);
|
|
3817
|
+
const entries = [];
|
|
3818
|
+
const secretFindings = [];
|
|
3819
|
+
for (const relativePath of discovered.relativePaths) {
|
|
3820
|
+
const entry = await classifyRepositoryEntry(input.localPath, relativePath);
|
|
3821
|
+
if (!entry) {
|
|
3822
|
+
continue;
|
|
3823
|
+
}
|
|
3824
|
+
if (entry.type === "file") {
|
|
3825
|
+
secretFindings.push(
|
|
3826
|
+
...await scanRegularFileForSecrets(entry.absolutePath, relativePath)
|
|
3827
|
+
);
|
|
3828
|
+
}
|
|
3829
|
+
entries.push(entry);
|
|
3830
|
+
}
|
|
3831
|
+
entries.sort((left, right) => compareStrings(left.relativePath, right.relativePath));
|
|
3832
|
+
secretFindings.sort((left, right) => {
|
|
3833
|
+
const pathCompare = compareStrings(left.filePath, right.filePath);
|
|
3834
|
+
if (pathCompare !== 0) {
|
|
3835
|
+
return pathCompare;
|
|
3836
|
+
}
|
|
3837
|
+
const leftLine = left.line ?? -1;
|
|
3838
|
+
const rightLine = right.line ?? -1;
|
|
3839
|
+
if (leftLine !== rightLine) {
|
|
3840
|
+
return leftLine - rightLine;
|
|
3841
|
+
}
|
|
3842
|
+
return compareStrings(left.kind, right.kind);
|
|
3843
|
+
});
|
|
3844
|
+
const treeSha256 = createHash2("sha256").update(buildTreeShaInput(entries)).digest("hex");
|
|
3845
|
+
return {
|
|
3846
|
+
branch: discovered.branch,
|
|
3847
|
+
dirty: discovered.dirty,
|
|
3848
|
+
entries,
|
|
3849
|
+
excludedRules: discovered.excludedRules,
|
|
3850
|
+
headCommit: discovered.headCommit,
|
|
3851
|
+
input,
|
|
3852
|
+
isGitBacked: discovered.isGitBacked,
|
|
3853
|
+
secretFindings,
|
|
3854
|
+
treeSha256,
|
|
3855
|
+
warnings: discovered.warnings
|
|
3856
|
+
};
|
|
3857
|
+
}
|
|
3858
|
+
async function writeDeterministicArchive(analysis, outputDir) {
|
|
3859
|
+
await ensureDirectory2(outputDir);
|
|
3860
|
+
const finalArchivePath = path4.join(outputDir, `${analysis.input.repoId}.tar.zst`);
|
|
3861
|
+
const tempArchivePath = `${finalArchivePath}.${process.pid}.${Date.now()}.tmp`;
|
|
3862
|
+
const archiveHash = createHash2("sha256");
|
|
3863
|
+
let archiveBytes = 0;
|
|
3864
|
+
const meter = new Transform({
|
|
3865
|
+
transform(chunk, _encoding, callback) {
|
|
3866
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
3867
|
+
archiveHash.update(buffer);
|
|
3868
|
+
archiveBytes += buffer.length;
|
|
3869
|
+
callback(null, buffer);
|
|
3870
|
+
}
|
|
3871
|
+
});
|
|
3872
|
+
const tarStream = tar.c(
|
|
3873
|
+
{
|
|
3874
|
+
cwd: analysis.input.localPath,
|
|
3875
|
+
mtime: EPOCH_DATE,
|
|
3876
|
+
noMtime: false,
|
|
3877
|
+
onWriteEntry(entry) {
|
|
3878
|
+
entry.mtime = EPOCH_DATE;
|
|
3879
|
+
if (entry.stat) {
|
|
3880
|
+
entry.stat.atime = EPOCH_DATE;
|
|
3881
|
+
entry.stat.ctime = EPOCH_DATE;
|
|
3882
|
+
entry.stat.mode = normalizeArchiveMode(entry.stat.mode);
|
|
3883
|
+
entry.stat.mtime = EPOCH_DATE;
|
|
3884
|
+
entry.stat.uid = 0;
|
|
3885
|
+
entry.stat.gid = 0;
|
|
3886
|
+
}
|
|
3887
|
+
entry.myuid = 0;
|
|
3888
|
+
entry.myuser = "";
|
|
3889
|
+
entry.portable = false;
|
|
3890
|
+
},
|
|
3891
|
+
portable: true,
|
|
3892
|
+
preservePaths: false
|
|
3893
|
+
},
|
|
3894
|
+
analysis.entries.map((entry) => entry.relativePath)
|
|
3895
|
+
);
|
|
3896
|
+
try {
|
|
3897
|
+
await pipeline(
|
|
3898
|
+
tarStream,
|
|
3899
|
+
createZstdCompress(),
|
|
3900
|
+
meter,
|
|
3901
|
+
createWriteStream(tempArchivePath, { mode: 384 })
|
|
3902
|
+
);
|
|
3903
|
+
await rename3(tempArchivePath, finalArchivePath);
|
|
3904
|
+
} catch (error) {
|
|
3905
|
+
await rm3(tempArchivePath, { force: true });
|
|
3906
|
+
if (error instanceof SnapshotError) {
|
|
3907
|
+
throw error;
|
|
3908
|
+
}
|
|
3909
|
+
throw new SnapshotError(
|
|
3910
|
+
`Failed to create repo snapshot for ${analysis.input.localPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3911
|
+
"snapshot_failed"
|
|
3912
|
+
);
|
|
3913
|
+
}
|
|
3914
|
+
return {
|
|
3915
|
+
archiveBytes,
|
|
3916
|
+
archivePath: finalArchivePath,
|
|
3917
|
+
archiveSha256: archiveHash.digest("hex")
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
async function snapshotRepositories(options) {
|
|
3921
|
+
const resolvedInputs = await resolveRepositoryInputs(options.repoPaths, options);
|
|
3922
|
+
const analyses = await Promise.all(
|
|
3923
|
+
resolvedInputs.map((input) => analyzeRepository(input))
|
|
3924
|
+
);
|
|
3925
|
+
const secretFailures = analyses.filter(
|
|
3926
|
+
(analysis) => analysis.secretFindings.length > 0 && options.allowSecrets !== true
|
|
3927
|
+
).map((analysis) => ({
|
|
3928
|
+
findings: analysis.secretFindings,
|
|
3929
|
+
localPath: analysis.input.localPath,
|
|
3930
|
+
repoId: analysis.input.repoId
|
|
3931
|
+
}));
|
|
3932
|
+
if (secretFailures.length > 0) {
|
|
3933
|
+
throw new SecretDetectionError(secretFailures);
|
|
3934
|
+
}
|
|
3935
|
+
const snapshots = [];
|
|
3936
|
+
for (const analysis of analyses) {
|
|
3937
|
+
const archive = await writeDeterministicArchive(analysis, options.outputDir);
|
|
3938
|
+
const warnings = [...analysis.warnings];
|
|
3939
|
+
if (analysis.secretFindings.length > 0 && options.allowSecrets === true) {
|
|
3940
|
+
warnings.push(
|
|
3941
|
+
`Secrets detected in ${analysis.input.localPath}; proceeding because --allow-secrets is enabled.`
|
|
3942
|
+
);
|
|
3943
|
+
}
|
|
3944
|
+
snapshots.push({
|
|
3945
|
+
archivePath: archive.archivePath,
|
|
3946
|
+
manifest: {
|
|
3947
|
+
archiveBytes: archive.archiveBytes,
|
|
3948
|
+
archiveSha256: archive.archiveSha256,
|
|
3949
|
+
blobKey: `blobs/repos/${archive.archiveSha256}.tar.zst`,
|
|
3950
|
+
branch: analysis.branch,
|
|
3951
|
+
dirty: analysis.dirty,
|
|
3952
|
+
excludedRules: analysis.excludedRules,
|
|
3953
|
+
headCommit: analysis.headCommit,
|
|
3954
|
+
includedFileCount: analysis.entries.length,
|
|
3955
|
+
localPath: analysis.input.localPath,
|
|
3956
|
+
mountAs: analysis.input.mountAs,
|
|
3957
|
+
repoId: analysis.input.repoId,
|
|
3958
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
3959
|
+
treeSha256: analysis.treeSha256
|
|
3960
|
+
},
|
|
3961
|
+
secretFindings: analysis.secretFindings,
|
|
3962
|
+
warnings
|
|
3963
|
+
});
|
|
3964
|
+
}
|
|
3965
|
+
return snapshots;
|
|
3966
|
+
}
|
|
3967
|
+
|
|
3968
|
+
// src/upload.ts
|
|
3969
|
+
function defaultCreateSpinner3(text) {
|
|
3970
|
+
const spinner = ora3({ text });
|
|
3971
|
+
spinner.start();
|
|
3972
|
+
return spinner;
|
|
3973
|
+
}
|
|
3974
|
+
async function defaultPromptConfirm2(options) {
|
|
3975
|
+
return confirm({
|
|
3976
|
+
default: options.default,
|
|
3977
|
+
message: options.message
|
|
3978
|
+
});
|
|
3979
|
+
}
|
|
3980
|
+
function formatBytes(bytes) {
|
|
3981
|
+
if (!Number.isFinite(bytes) || bytes < 1024) {
|
|
3982
|
+
return `${bytes} B`;
|
|
3983
|
+
}
|
|
3984
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
3985
|
+
let value = bytes / 1024;
|
|
3986
|
+
let unitIndex = 0;
|
|
3987
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
3988
|
+
value /= 1024;
|
|
3989
|
+
unitIndex += 1;
|
|
3990
|
+
}
|
|
3991
|
+
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
|
3992
|
+
}
|
|
3993
|
+
function formatDuration(milliseconds) {
|
|
3994
|
+
if (milliseconds < 1e3) {
|
|
3995
|
+
return `${Math.max(1, Math.round(milliseconds))} ms`;
|
|
3996
|
+
}
|
|
3997
|
+
return `${(milliseconds / 1e3).toFixed(1)} s`;
|
|
3998
|
+
}
|
|
3999
|
+
function formatRate(bytesTransferred, elapsedMs) {
|
|
4000
|
+
const safeElapsedMs = Math.max(elapsedMs, 1);
|
|
4001
|
+
const bytesPerSecond = bytesTransferred * 1e3 / safeElapsedMs;
|
|
4002
|
+
return `${formatBytes(Math.max(1, Math.round(bytesPerSecond)))}/s`;
|
|
4003
|
+
}
|
|
4004
|
+
function formatFinding(finding) {
|
|
4005
|
+
const location = finding.line === null ? finding.filePath : `${finding.filePath}:${finding.line}`;
|
|
4006
|
+
return `${location} \u2014 ${finding.message}`;
|
|
4007
|
+
}
|
|
4008
|
+
function normalizeLaunchId2(value) {
|
|
4009
|
+
return typeof value === "string" ? value.trim() : "";
|
|
4010
|
+
}
|
|
4011
|
+
function normalizeExistingBlobs(value) {
|
|
4012
|
+
if (!Array.isArray(value)) {
|
|
4013
|
+
return /* @__PURE__ */ new Set();
|
|
4014
|
+
}
|
|
4015
|
+
return new Set(
|
|
4016
|
+
value.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter((entry) => entry.length > 0)
|
|
4017
|
+
);
|
|
4018
|
+
}
|
|
4019
|
+
function isTerminalUploadStatus2(status) {
|
|
4020
|
+
return status === "uploaded" || status === "blob_exists";
|
|
4021
|
+
}
|
|
4022
|
+
function renderSafetyGate(stdout, repos, existingBlobs) {
|
|
4023
|
+
writeLine(stdout, pc6.bold("Upload safety check"));
|
|
4024
|
+
writeLine(stdout, "Repos queued for upload:");
|
|
4025
|
+
for (const repo of repos) {
|
|
4026
|
+
const cleanliness = repo.manifest.dirty ? pc6.yellow("dirty") : pc6.green("clean");
|
|
4027
|
+
const secretSummary = repo.secretFindings.length > 0 ? pc6.red(`${repo.secretFindings.length} finding(s)`) : pc6.green("no findings");
|
|
4028
|
+
const dedupSummary = existingBlobs.has(repo.manifest.archiveSha256) ? pc6.cyan("blob already exists remotely") : "new upload";
|
|
4029
|
+
writeLine(
|
|
4030
|
+
stdout,
|
|
4031
|
+
`- ${pc6.cyan(repo.manifest.repoId)} \u2014 ${repo.manifest.localPath}`
|
|
4032
|
+
);
|
|
4033
|
+
writeLine(
|
|
4034
|
+
stdout,
|
|
4035
|
+
` Status: ${cleanliness}; archive ${formatBytes(repo.manifest.archiveBytes)}; ${dedupSummary}; secret scan ${secretSummary}`
|
|
4036
|
+
);
|
|
4037
|
+
if (repo.warnings.length > 0) {
|
|
4038
|
+
for (const warning of repo.warnings) {
|
|
4039
|
+
writeLine(stdout, ` Warning: ${warning}`);
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
if (repo.secretFindings.length > 0) {
|
|
4043
|
+
for (const finding of repo.secretFindings) {
|
|
4044
|
+
writeLine(stdout, ` Secret: ${formatFinding(finding)}`);
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
writeLine(stdout, "Secret scan results are shown above.");
|
|
4049
|
+
}
|
|
4050
|
+
function assertArtifactsMatchPersistedState(repos, persistedManifests) {
|
|
4051
|
+
const persistedRepoIds = Object.keys(persistedManifests).sort(
|
|
4052
|
+
(left, right) => left.localeCompare(right)
|
|
4053
|
+
);
|
|
4054
|
+
const currentRepoIds = repos.map((repo) => repo.manifest.repoId).sort((left, right) => left.localeCompare(right));
|
|
4055
|
+
if (JSON.stringify(currentRepoIds) !== JSON.stringify(persistedRepoIds)) {
|
|
4056
|
+
throw new CliError(
|
|
4057
|
+
"Current repo set does not match the saved upload manifests. Start a new launch for updated repos."
|
|
4058
|
+
);
|
|
4059
|
+
}
|
|
4060
|
+
}
|
|
4061
|
+
async function createRemoteLaunch(options, repos, taskDescription) {
|
|
4062
|
+
const response = await options.apiClient.request({
|
|
4063
|
+
body: {
|
|
4064
|
+
...taskDescription ? {
|
|
4065
|
+
requestSummary: {
|
|
4066
|
+
repoCount: repos.length,
|
|
4067
|
+
task: taskDescription
|
|
4068
|
+
}
|
|
4069
|
+
} : {},
|
|
4070
|
+
repos: repos.map((repo) => ({
|
|
4071
|
+
archiveBytes: repo.manifest.archiveBytes,
|
|
4072
|
+
archiveSha256: repo.manifest.archiveSha256,
|
|
4073
|
+
repoId: repo.manifest.repoId
|
|
4074
|
+
}))
|
|
4075
|
+
},
|
|
4076
|
+
method: "POST",
|
|
4077
|
+
path: "/launches"
|
|
4078
|
+
});
|
|
4079
|
+
const remoteLaunchId = normalizeLaunchId2(response.launchId);
|
|
4080
|
+
if (remoteLaunchId.length === 0) {
|
|
4081
|
+
throw new CliError("Launch creation did not return a launch ID.");
|
|
4082
|
+
}
|
|
4083
|
+
return {
|
|
4084
|
+
existingBlobs: normalizeExistingBlobs(response.existingBlobs),
|
|
4085
|
+
remoteLaunchId
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
async function uploadArchive(options, repo, uploadUrl) {
|
|
4089
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
4090
|
+
const createSpinner = options.createSpinner ?? defaultCreateSpinner3;
|
|
4091
|
+
const spinner = createSpinner(
|
|
4092
|
+
`Uploading ${repo.manifest.repoId} 0 B / ${formatBytes(repo.manifest.archiveBytes)} (0 B/s)`
|
|
4093
|
+
);
|
|
4094
|
+
spinner.start(spinner.text);
|
|
4095
|
+
const startedAt = Date.now();
|
|
4096
|
+
let bytesTransferred = 0;
|
|
4097
|
+
const meter = new Transform2({
|
|
4098
|
+
transform(chunk, _encoding, callback) {
|
|
4099
|
+
bytesTransferred += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
|
|
4100
|
+
const elapsedMs2 = Date.now() - startedAt;
|
|
4101
|
+
spinner.text = `Uploading ${repo.manifest.repoId} ${formatBytes(bytesTransferred)} / ${formatBytes(repo.manifest.archiveBytes)} (${formatRate(bytesTransferred, elapsedMs2)})`;
|
|
4102
|
+
callback(null, chunk);
|
|
4103
|
+
}
|
|
4104
|
+
});
|
|
4105
|
+
let response;
|
|
4106
|
+
try {
|
|
4107
|
+
const body = createReadStream2(repo.archivePath).pipe(meter);
|
|
4108
|
+
const requestInit = {
|
|
4109
|
+
body,
|
|
4110
|
+
duplex: "half",
|
|
4111
|
+
headers: {
|
|
4112
|
+
"content-length": String(repo.manifest.archiveBytes)
|
|
4113
|
+
},
|
|
4114
|
+
method: "PUT"
|
|
4115
|
+
};
|
|
4116
|
+
response = await fetchImpl(uploadUrl, requestInit);
|
|
4117
|
+
} catch (error) {
|
|
4118
|
+
spinner.fail(`Upload failed for ${repo.manifest.repoId}`);
|
|
4119
|
+
throw new CliError(
|
|
4120
|
+
`Upload failed for ${repo.manifest.repoId}: ${error instanceof Error ? error.message : String(error)}`
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
if (!response.ok) {
|
|
4124
|
+
const detail = await response.text().catch(() => "");
|
|
4125
|
+
spinner.fail(`Upload failed for ${repo.manifest.repoId}`);
|
|
4126
|
+
throw new CliError(
|
|
4127
|
+
`Upload failed for ${repo.manifest.repoId}: HTTP ${response.status}${detail ? ` ${detail}` : ""}`
|
|
4128
|
+
);
|
|
4129
|
+
}
|
|
4130
|
+
const elapsedMs = Date.now() - startedAt;
|
|
4131
|
+
spinner.succeed(
|
|
4132
|
+
`Uploaded ${repo.manifest.repoId} ${formatBytes(bytesTransferred)} in ${formatDuration(elapsedMs)} (${formatRate(bytesTransferred, elapsedMs)})`
|
|
4133
|
+
);
|
|
4134
|
+
}
|
|
4135
|
+
async function processRepoUpload(options, repo, remoteLaunchId) {
|
|
4136
|
+
const snapshot = await loadLaunchSnapshot(options.launchId, {
|
|
4137
|
+
cwd: options.cwd,
|
|
4138
|
+
homeDir: options.homeDir
|
|
4139
|
+
});
|
|
4140
|
+
const existingStatus = snapshot.uploadState?.repos[repo.manifest.repoId]?.status;
|
|
4141
|
+
if (existingStatus !== "uploading") {
|
|
4142
|
+
await updateRepoUploadState(
|
|
4143
|
+
options.launchId,
|
|
4144
|
+
{
|
|
4145
|
+
remoteLaunchId,
|
|
4146
|
+
repoId: repo.manifest.repoId,
|
|
4147
|
+
status: "requested"
|
|
4148
|
+
},
|
|
4149
|
+
{
|
|
4150
|
+
cwd: options.cwd,
|
|
4151
|
+
homeDir: options.homeDir
|
|
4152
|
+
}
|
|
4153
|
+
);
|
|
4154
|
+
}
|
|
4155
|
+
const response = await options.apiClient.request({
|
|
4156
|
+
body: repo.manifest,
|
|
4157
|
+
method: "POST",
|
|
4158
|
+
path: `/launches/${remoteLaunchId}/repos/${repo.manifest.repoId}`
|
|
4159
|
+
});
|
|
4160
|
+
if (response.status === "blob_exists") {
|
|
4161
|
+
await updateRepoUploadState(
|
|
4162
|
+
options.launchId,
|
|
4163
|
+
{
|
|
4164
|
+
remoteLaunchId,
|
|
4165
|
+
repoId: repo.manifest.repoId,
|
|
4166
|
+
status: "blob_exists"
|
|
4167
|
+
},
|
|
4168
|
+
{
|
|
4169
|
+
cwd: options.cwd,
|
|
4170
|
+
homeDir: options.homeDir
|
|
4171
|
+
}
|
|
4172
|
+
);
|
|
4173
|
+
writeLine(
|
|
4174
|
+
options.stdout,
|
|
4175
|
+
pc6.cyan(`Skipping upload for ${repo.manifest.repoId}; blob already exists remotely.`)
|
|
4176
|
+
);
|
|
4177
|
+
return;
|
|
4178
|
+
}
|
|
4179
|
+
const uploadUrl = typeof response.uploadUrl === "string" ? response.uploadUrl : null;
|
|
4180
|
+
if (response.status !== "upload_required" || !uploadUrl) {
|
|
4181
|
+
throw new CliError(
|
|
4182
|
+
`Launch repo ${repo.manifest.repoId} did not return a usable upload URL.`
|
|
4183
|
+
);
|
|
4184
|
+
}
|
|
4185
|
+
await updateRepoUploadState(
|
|
4186
|
+
options.launchId,
|
|
4187
|
+
{
|
|
4188
|
+
remoteLaunchId,
|
|
4189
|
+
repoId: repo.manifest.repoId,
|
|
4190
|
+
status: "uploading"
|
|
4191
|
+
},
|
|
4192
|
+
{
|
|
4193
|
+
cwd: options.cwd,
|
|
4194
|
+
homeDir: options.homeDir
|
|
4195
|
+
}
|
|
4196
|
+
);
|
|
4197
|
+
await uploadArchive(options, repo, uploadUrl);
|
|
4198
|
+
await updateRepoUploadState(
|
|
4199
|
+
options.launchId,
|
|
4200
|
+
{
|
|
4201
|
+
remoteLaunchId,
|
|
4202
|
+
repoId: repo.manifest.repoId,
|
|
4203
|
+
status: "uploaded"
|
|
4204
|
+
},
|
|
4205
|
+
{
|
|
4206
|
+
cwd: options.cwd,
|
|
4207
|
+
homeDir: options.homeDir
|
|
4208
|
+
}
|
|
4209
|
+
);
|
|
4210
|
+
}
|
|
4211
|
+
async function runUploadPipeline(options) {
|
|
4212
|
+
const promptConfirm = options.promptConfirm ?? defaultPromptConfirm2;
|
|
4213
|
+
const launchSnapshot = await loadLaunchSnapshot(options.launchId, {
|
|
4214
|
+
cwd: options.cwd,
|
|
4215
|
+
homeDir: options.homeDir
|
|
4216
|
+
});
|
|
4217
|
+
if (!launchSnapshot.missionDraft) {
|
|
4218
|
+
throw new CliError(
|
|
4219
|
+
"Cannot start uploads before a mission draft has been approved."
|
|
4220
|
+
);
|
|
4221
|
+
}
|
|
4222
|
+
let repos;
|
|
4223
|
+
try {
|
|
4224
|
+
repos = await snapshotRepositories({
|
|
4225
|
+
allowSecrets: options.allowSecrets === true,
|
|
4226
|
+
cwd: options.cwd,
|
|
4227
|
+
outputDir: path5.join(launchSnapshot.paths.launchDir, "archives"),
|
|
4228
|
+
repoPaths: options.repoPaths
|
|
4229
|
+
});
|
|
4230
|
+
} catch (error) {
|
|
4231
|
+
if (!(error instanceof SecretDetectionError) || options.allowSecrets === true) {
|
|
4232
|
+
throw error;
|
|
4233
|
+
}
|
|
4234
|
+
repos = await snapshotRepositories({
|
|
4235
|
+
allowSecrets: true,
|
|
4236
|
+
cwd: options.cwd,
|
|
4237
|
+
outputDir: path5.join(launchSnapshot.paths.launchDir, "archives"),
|
|
4238
|
+
repoPaths: options.repoPaths
|
|
4239
|
+
});
|
|
4240
|
+
repos = repos.map((repo) => ({
|
|
4241
|
+
...repo,
|
|
4242
|
+
warnings: repo.warnings.filter(
|
|
4243
|
+
(warning) => !warning.includes("--allow-secrets is enabled")
|
|
4244
|
+
)
|
|
4245
|
+
}));
|
|
4246
|
+
}
|
|
4247
|
+
if (Object.keys(launchSnapshot.repoManifests).length > 0) {
|
|
4248
|
+
assertArtifactsMatchPersistedState(
|
|
4249
|
+
repos,
|
|
4250
|
+
launchSnapshot.repoManifests
|
|
4251
|
+
);
|
|
4252
|
+
}
|
|
4253
|
+
let existingBlobs = /* @__PURE__ */ new Set();
|
|
4254
|
+
let remoteLaunchId = launchSnapshot.uploadState?.remoteLaunchId ?? null;
|
|
4255
|
+
if (!remoteLaunchId) {
|
|
4256
|
+
renderSafetyGate(options.stdout, repos, existingBlobs);
|
|
4257
|
+
const hasSecretFindings = repos.some((repo) => repo.secretFindings.length > 0);
|
|
4258
|
+
if (hasSecretFindings && options.allowSecrets !== true) {
|
|
4259
|
+
throw new CliError(
|
|
4260
|
+
"Secret scan findings blocked the upload. Re-run with `--allow-secrets` to proceed."
|
|
4261
|
+
);
|
|
4262
|
+
}
|
|
4263
|
+
if (options.autoApprove !== true && !await promptConfirm({
|
|
4264
|
+
default: false,
|
|
4265
|
+
message: "Proceed with launch upload?"
|
|
4266
|
+
})) {
|
|
4267
|
+
writeLine(
|
|
4268
|
+
options.stdout,
|
|
4269
|
+
pc6.yellow("Upload cancelled before creating a remote launch.")
|
|
4270
|
+
);
|
|
4271
|
+
return {
|
|
4272
|
+
finalized: false,
|
|
4273
|
+
remoteLaunchId: null
|
|
4274
|
+
};
|
|
4275
|
+
}
|
|
4276
|
+
const createdLaunch = await createRemoteLaunch(
|
|
4277
|
+
options,
|
|
4278
|
+
repos,
|
|
4279
|
+
launchSnapshot.request?.task ?? null
|
|
4280
|
+
);
|
|
4281
|
+
existingBlobs = createdLaunch.existingBlobs;
|
|
4282
|
+
remoteLaunchId = createdLaunch.remoteLaunchId;
|
|
4283
|
+
if (existingBlobs.size > 0) {
|
|
4284
|
+
writeLine(
|
|
4285
|
+
options.stdout,
|
|
4286
|
+
pc6.cyan(
|
|
4287
|
+
`Remote dedup will reuse ${existingBlobs.size} existing blob${existingBlobs.size === 1 ? "" : "s"}.`
|
|
4288
|
+
)
|
|
4289
|
+
);
|
|
4290
|
+
}
|
|
4291
|
+
for (const repo of repos) {
|
|
4292
|
+
await recordRepoManifest(
|
|
4293
|
+
options.launchId,
|
|
4294
|
+
repo.manifest.repoId,
|
|
4295
|
+
repo.manifest,
|
|
4296
|
+
{
|
|
4297
|
+
cwd: options.cwd,
|
|
4298
|
+
homeDir: options.homeDir
|
|
4299
|
+
}
|
|
4300
|
+
);
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
const uploadStates = new Map(
|
|
4304
|
+
Object.entries(launchSnapshot.uploadState?.repos ?? {}).map(
|
|
4305
|
+
([repoId, state]) => [repoId, state?.status]
|
|
4306
|
+
)
|
|
4307
|
+
);
|
|
4308
|
+
const failures = [];
|
|
4309
|
+
for (const repo of repos) {
|
|
4310
|
+
if (isTerminalUploadStatus2(uploadStates.get(repo.manifest.repoId))) {
|
|
4311
|
+
writeLine(
|
|
4312
|
+
options.stdout,
|
|
4313
|
+
pc6.cyan(`Skipping ${repo.manifest.repoId}; already completed in upload-state.json.`)
|
|
4314
|
+
);
|
|
4315
|
+
continue;
|
|
4316
|
+
}
|
|
4317
|
+
try {
|
|
4318
|
+
await processRepoUpload(options, repo, remoteLaunchId);
|
|
4319
|
+
const refreshedSnapshot = await loadLaunchSnapshot(options.launchId, {
|
|
4320
|
+
cwd: options.cwd,
|
|
4321
|
+
homeDir: options.homeDir
|
|
4322
|
+
});
|
|
4323
|
+
uploadStates.set(
|
|
4324
|
+
repo.manifest.repoId,
|
|
4325
|
+
refreshedSnapshot.uploadState?.repos[repo.manifest.repoId]?.status
|
|
4326
|
+
);
|
|
4327
|
+
} catch (error) {
|
|
4328
|
+
failures.push(repo.manifest.repoId);
|
|
4329
|
+
writeLine(
|
|
4330
|
+
options.stdout,
|
|
4331
|
+
pc6.red(
|
|
4332
|
+
error instanceof Error ? error.message : `Upload failed for ${repo.manifest.repoId}.`
|
|
4333
|
+
)
|
|
4334
|
+
);
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
if (failures.length > 0) {
|
|
4338
|
+
throw new CliError(
|
|
4339
|
+
`Upload failed for ${failures.join(", ")}. Re-run the command to resume the remaining repos.`
|
|
4340
|
+
);
|
|
4341
|
+
}
|
|
4342
|
+
const finalizeResponse = await options.apiClient.request({
|
|
4343
|
+
method: "POST",
|
|
4344
|
+
path: `/launches/${remoteLaunchId}/finalize`
|
|
4345
|
+
});
|
|
4346
|
+
if (finalizeResponse.status !== "complete") {
|
|
4347
|
+
throw new CliError(
|
|
4348
|
+
`Remote launch ${remoteLaunchId} did not finalize successfully.`
|
|
4349
|
+
);
|
|
4350
|
+
}
|
|
4351
|
+
writeLine(
|
|
4352
|
+
options.stdout,
|
|
4353
|
+
pc6.green(`Upload complete. Remote launch ${remoteLaunchId} is finalized.`)
|
|
4354
|
+
);
|
|
4355
|
+
return {
|
|
4356
|
+
finalized: true,
|
|
4357
|
+
remoteLaunchId
|
|
4358
|
+
};
|
|
4359
|
+
}
|
|
4360
|
+
|
|
4361
|
+
// src/commands/mission-run.ts
|
|
4362
|
+
function collectValues(value, previous = []) {
|
|
4363
|
+
return [...previous, value];
|
|
4364
|
+
}
|
|
4365
|
+
async function resolveRepoPaths(repoValues, cwd = process.cwd()) {
|
|
4366
|
+
if (repoValues.length === 0) {
|
|
4367
|
+
throw new CliError("At least one `--repo` path is required.");
|
|
4368
|
+
}
|
|
4369
|
+
const resolved = [...new Set(repoValues.map((repoPath) => path6.resolve(cwd, repoPath)))];
|
|
4370
|
+
for (const repoPath of resolved) {
|
|
4371
|
+
let repoStats;
|
|
4372
|
+
try {
|
|
4373
|
+
repoStats = await stat3(repoPath);
|
|
4374
|
+
} catch (error) {
|
|
4375
|
+
if (error.code === "ENOENT") {
|
|
4376
|
+
throw new CliError(`Repo path does not exist: ${repoPath}`);
|
|
4377
|
+
}
|
|
4378
|
+
throw error;
|
|
4379
|
+
}
|
|
4380
|
+
if (!repoStats.isDirectory()) {
|
|
4381
|
+
throw new CliError(`Repo path is not a directory: ${repoPath}`);
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
return resolved.sort((left, right) => left.localeCompare(right));
|
|
4385
|
+
}
|
|
4386
|
+
function resolveTaskDescription(taskDescription) {
|
|
4387
|
+
const trimmed = taskDescription.trim();
|
|
4388
|
+
if (trimmed.length === 0) {
|
|
4389
|
+
throw new CliError("Task description must not be empty.");
|
|
4390
|
+
}
|
|
4391
|
+
return trimmed;
|
|
4392
|
+
}
|
|
4393
|
+
function registerMissionRunCommand(mission, context, dependencies = {}) {
|
|
4394
|
+
mission.command("run").description("Create and run a mission").argument("<taskDescription>").option(
|
|
4395
|
+
"--repo <path>",
|
|
4396
|
+
"Repository path to include in the planning request",
|
|
4397
|
+
collectValues,
|
|
4398
|
+
[]
|
|
4399
|
+
).option("--yes", "Automatically approve all planning prompts").option("--force", "Continue if clarification remains unresolved").option(
|
|
4400
|
+
"--allow-secrets",
|
|
4401
|
+
"Allow uploads to continue when the secret scan reports findings"
|
|
4402
|
+
).action(
|
|
4403
|
+
async (taskDescription, options, command) => {
|
|
4404
|
+
const task = resolveTaskDescription(taskDescription);
|
|
4405
|
+
const repoPaths = await resolveRepoPaths(options.repo ?? []);
|
|
4406
|
+
const homeDir = resolveHomeDir({
|
|
4407
|
+
env: context.env,
|
|
4408
|
+
homeDir: context.homeDir
|
|
4409
|
+
});
|
|
4410
|
+
const authConfig = await getAuthConfig({
|
|
4411
|
+
env: context.env,
|
|
4412
|
+
homeDir
|
|
4413
|
+
});
|
|
4414
|
+
const launchSnapshot = await prepareLaunch(
|
|
4415
|
+
{
|
|
4416
|
+
repoPaths,
|
|
4417
|
+
task
|
|
4418
|
+
},
|
|
4419
|
+
{
|
|
4420
|
+
cwd: process.cwd(),
|
|
4421
|
+
homeDir
|
|
4422
|
+
}
|
|
4423
|
+
);
|
|
4424
|
+
const globalOptions = command.optsWithGlobals();
|
|
4425
|
+
const client = createApiClient({
|
|
4426
|
+
config: authConfig,
|
|
4427
|
+
onVerboseLog: (message) => writeLine(context.stderr, message),
|
|
4428
|
+
sleep: dependencies.sleep,
|
|
4429
|
+
verbose: globalOptions.verbose === true
|
|
4430
|
+
});
|
|
4431
|
+
const result = await runPlanningFlow({
|
|
4432
|
+
autoApprove: options.yes === true,
|
|
4433
|
+
client,
|
|
4434
|
+
createSpinner: dependencies.createSpinner,
|
|
4435
|
+
existingMissionDraft: launchSnapshot.missionDraft,
|
|
4436
|
+
existingSessionId: launchSnapshot.request?.sessionId,
|
|
4437
|
+
force: options.force === true,
|
|
4438
|
+
now: dependencies.now,
|
|
4439
|
+
persistClarification: async (clarification) => {
|
|
4440
|
+
await recordClarification(launchSnapshot.launchId, clarification, {
|
|
4441
|
+
cwd: process.cwd(),
|
|
4442
|
+
homeDir
|
|
4443
|
+
});
|
|
4444
|
+
},
|
|
4445
|
+
persistMissionDraft: async (draft) => {
|
|
4446
|
+
await recordMissionDraft(launchSnapshot.launchId, draft, {
|
|
4447
|
+
cwd: process.cwd(),
|
|
4448
|
+
homeDir
|
|
4449
|
+
});
|
|
4450
|
+
},
|
|
4451
|
+
persistSessionId: async (sessionId) => {
|
|
4452
|
+
await recordPlanningStart(
|
|
4453
|
+
{
|
|
4454
|
+
repoPaths,
|
|
4455
|
+
sessionId,
|
|
4456
|
+
task
|
|
4457
|
+
},
|
|
4458
|
+
{
|
|
4459
|
+
cwd: process.cwd(),
|
|
4460
|
+
homeDir
|
|
4461
|
+
}
|
|
4462
|
+
);
|
|
4463
|
+
},
|
|
4464
|
+
promptConfirm: dependencies.promptConfirm,
|
|
4465
|
+
promptInput: dependencies.promptInput,
|
|
4466
|
+
promptSelect: dependencies.promptSelect,
|
|
4467
|
+
repoPaths,
|
|
4468
|
+
sleep: dependencies.sleep,
|
|
4469
|
+
stdout: context.stdout,
|
|
4470
|
+
taskDescription: task
|
|
4471
|
+
});
|
|
4472
|
+
if (!result.cancelled && !result.skippedPlanning) {
|
|
4473
|
+
writeLine(
|
|
4474
|
+
context.stdout,
|
|
4475
|
+
`Planning is complete. Draft saved to ${launchSnapshot.paths.missionDraftPath}.`
|
|
4476
|
+
);
|
|
4477
|
+
}
|
|
4478
|
+
if (result.cancelled) {
|
|
4479
|
+
return;
|
|
4480
|
+
}
|
|
4481
|
+
const uploadResult = await runUploadPipeline({
|
|
4482
|
+
allowSecrets: options.allowSecrets === true,
|
|
4483
|
+
apiClient: client,
|
|
4484
|
+
autoApprove: options.yes === true,
|
|
4485
|
+
createSpinner: dependencies.createSpinner,
|
|
4486
|
+
cwd: process.cwd(),
|
|
4487
|
+
homeDir,
|
|
4488
|
+
launchId: launchSnapshot.launchId,
|
|
4489
|
+
promptConfirm: dependencies.promptConfirm,
|
|
4490
|
+
repoPaths,
|
|
4491
|
+
stdout: context.stdout
|
|
4492
|
+
});
|
|
4493
|
+
if (!uploadResult.finalized) {
|
|
4494
|
+
return;
|
|
4495
|
+
}
|
|
4496
|
+
const mission2 = await createMissionFromLaunch({
|
|
4497
|
+
apiClient: client,
|
|
4498
|
+
cwd: process.cwd(),
|
|
4499
|
+
homeDir,
|
|
4500
|
+
launchId: launchSnapshot.launchId
|
|
4501
|
+
});
|
|
4502
|
+
writeLine(
|
|
4503
|
+
context.stdout,
|
|
4504
|
+
mission2.created ? `Created mission ${mission2.missionId}.` : `Resuming mission ${mission2.missionId}.`
|
|
4505
|
+
);
|
|
4506
|
+
const monitorResult = await monitorMission({
|
|
4507
|
+
apiClient: client,
|
|
4508
|
+
apiKey: authConfig.apiKey,
|
|
4509
|
+
autoApprove: options.yes === true,
|
|
4510
|
+
createSpinner: dependencies.createSpinner,
|
|
4511
|
+
createWebSocket: dependencies.createWebSocket,
|
|
4512
|
+
endpoint: authConfig.endpoint,
|
|
4513
|
+
missionId: mission2.missionId,
|
|
4514
|
+
promptInput: dependencies.promptInput,
|
|
4515
|
+
promptSelect: dependencies.promptSelect,
|
|
4516
|
+
registerSignalHandler: dependencies.registerSignalHandler,
|
|
4517
|
+
sleep: dependencies.sleep,
|
|
4518
|
+
stdout: context.stdout
|
|
4519
|
+
});
|
|
4520
|
+
if (monitorResult.exitCode !== 0) {
|
|
4521
|
+
throw new CommanderError2(monitorResult.exitCode, "mission-monitor", "");
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
);
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
// src/commands/mission-status.ts
|
|
4528
|
+
import pc7 from "picocolors";
|
|
4529
|
+
function writeSection3(stdout, title) {
|
|
4530
|
+
writeLine(stdout);
|
|
4531
|
+
writeLine(stdout, pc7.bold(title));
|
|
4532
|
+
}
|
|
4533
|
+
function normalizeMilestoneOrder(milestones, features, assertions) {
|
|
4534
|
+
const known = new Map(milestones.map((milestone) => [milestone.name, milestone]));
|
|
4535
|
+
for (const feature of features) {
|
|
4536
|
+
if (!feature.milestone || known.has(feature.milestone)) {
|
|
4537
|
+
continue;
|
|
4538
|
+
}
|
|
4539
|
+
known.set(feature.milestone, {
|
|
4540
|
+
assertionCount: 0,
|
|
4541
|
+
completedFeatureCount: 0,
|
|
4542
|
+
featureCount: 0,
|
|
4543
|
+
name: feature.milestone,
|
|
4544
|
+
passedAssertionCount: 0,
|
|
4545
|
+
state: "unknown"
|
|
4546
|
+
});
|
|
4547
|
+
}
|
|
4548
|
+
for (const assertion of assertions) {
|
|
4549
|
+
if (!assertion.milestone || known.has(assertion.milestone)) {
|
|
4550
|
+
continue;
|
|
4551
|
+
}
|
|
4552
|
+
known.set(assertion.milestone, {
|
|
4553
|
+
assertionCount: 0,
|
|
4554
|
+
completedFeatureCount: 0,
|
|
4555
|
+
featureCount: 0,
|
|
4556
|
+
name: assertion.milestone,
|
|
4557
|
+
passedAssertionCount: 0,
|
|
4558
|
+
state: "unknown"
|
|
4559
|
+
});
|
|
4560
|
+
}
|
|
4561
|
+
return [...known.values()];
|
|
4562
|
+
}
|
|
4563
|
+
function summarizeAssertions2(assertions) {
|
|
4564
|
+
return assertions.reduce(
|
|
4565
|
+
(summary, assertion) => ({
|
|
4566
|
+
passed: summary.passed + (assertion.status === "passed" ? 1 : 0),
|
|
4567
|
+
total: summary.total + 1
|
|
4568
|
+
}),
|
|
4569
|
+
{ passed: 0, total: 0 }
|
|
4570
|
+
);
|
|
4571
|
+
}
|
|
4572
|
+
function summarizeAssertionsByMilestone(assertions) {
|
|
4573
|
+
const summary = /* @__PURE__ */ new Map();
|
|
4574
|
+
for (const assertion of assertions) {
|
|
4575
|
+
const milestone = assertion.milestone ?? "unassigned";
|
|
4576
|
+
const current = summary.get(milestone) ?? { passed: 0, total: 0 };
|
|
4577
|
+
current.total += 1;
|
|
4578
|
+
if (assertion.status === "passed") {
|
|
4579
|
+
current.passed += 1;
|
|
4580
|
+
}
|
|
4581
|
+
summary.set(milestone, current);
|
|
4582
|
+
}
|
|
4583
|
+
return summary;
|
|
4584
|
+
}
|
|
4585
|
+
function renderMissionOverview(stdout, missionState) {
|
|
4586
|
+
writeLine(stdout, `Mission ${missionState.missionId}`);
|
|
4587
|
+
writeLine(stdout, `State: ${missionState.state}`);
|
|
4588
|
+
writeLine(stdout, `Created: ${formatDateTime(missionState.createdAt)}`);
|
|
4589
|
+
writeLine(stdout, `Updated: ${formatDateTime(missionState.updatedAt)}`);
|
|
4590
|
+
writeLine(
|
|
4591
|
+
stdout,
|
|
4592
|
+
`Features: ${missionState.completedFeatures}/${missionState.totalFeatures}`
|
|
4593
|
+
);
|
|
4594
|
+
writeLine(
|
|
4595
|
+
stdout,
|
|
4596
|
+
`Assertions: ${missionState.passedAssertions}/${missionState.totalAssertions}`
|
|
4597
|
+
);
|
|
4598
|
+
writeLine(
|
|
4599
|
+
stdout,
|
|
4600
|
+
`Milestones: ${missionState.sealedMilestones}/${missionState.totalMilestones} sealed`
|
|
4601
|
+
);
|
|
4602
|
+
}
|
|
4603
|
+
function renderActiveWorker(stdout, missionState) {
|
|
4604
|
+
writeSection3(stdout, "Active worker");
|
|
4605
|
+
writeLine(stdout, `Feature: ${missionState.currentFeatureId ?? "none"}`);
|
|
4606
|
+
writeLine(
|
|
4607
|
+
stdout,
|
|
4608
|
+
`Worker session: ${missionState.currentWorkerSessionId ?? "none"}`
|
|
4609
|
+
);
|
|
4610
|
+
}
|
|
4611
|
+
function renderBudget(stdout, missionState) {
|
|
4612
|
+
if (missionState.estimatedCostUsd === null && missionState.inferenceTokensUsed === null && missionState.sandboxMinutesUsed === null) {
|
|
4613
|
+
return;
|
|
4614
|
+
}
|
|
4615
|
+
writeSection3(stdout, "Budget / usage");
|
|
4616
|
+
writeLine(stdout, `Estimated cost: ${formatCurrency(missionState.estimatedCostUsd)}`);
|
|
4617
|
+
writeLine(
|
|
4618
|
+
stdout,
|
|
4619
|
+
`Inference tokens: ${missionState.inferenceTokensUsed ?? "\u2014"}`
|
|
4620
|
+
);
|
|
4621
|
+
writeLine(
|
|
4622
|
+
stdout,
|
|
4623
|
+
`Sandbox minutes: ${missionState.sandboxMinutesUsed ?? "\u2014"}`
|
|
4624
|
+
);
|
|
4625
|
+
}
|
|
4626
|
+
function renderMilestones2(stdout, milestones, features) {
|
|
4627
|
+
writeSection3(stdout, "Milestones");
|
|
4628
|
+
for (const milestone of milestones) {
|
|
4629
|
+
writeLine(
|
|
4630
|
+
stdout,
|
|
4631
|
+
`- ${pc7.cyan(milestone.name)} (${milestone.state}) \xB7 ${milestone.completedFeatureCount}/${milestone.featureCount} features \xB7 ${milestone.passedAssertionCount}/${milestone.assertionCount} assertions`
|
|
4632
|
+
);
|
|
4633
|
+
const milestoneFeatures = features.filter(
|
|
4634
|
+
(feature) => feature.milestone === milestone.name
|
|
4635
|
+
);
|
|
4636
|
+
for (const feature of milestoneFeatures) {
|
|
4637
|
+
writeLine(stdout, ` \u2022 ${feature.id} \u2014 ${feature.status}`);
|
|
4638
|
+
}
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
function renderAssertionSummary(stdout, milestones, assertions) {
|
|
4642
|
+
writeSection3(stdout, "Assertion pass rates");
|
|
4643
|
+
const overall = summarizeAssertions2(assertions);
|
|
4644
|
+
writeLine(
|
|
4645
|
+
stdout,
|
|
4646
|
+
`Overall: ${overall.passed}/${overall.total} passed (${formatPercent(overall.passed, overall.total)})`
|
|
4647
|
+
);
|
|
4648
|
+
const byMilestone = summarizeAssertionsByMilestone(assertions);
|
|
4649
|
+
for (const milestone of milestones) {
|
|
4650
|
+
const summary = byMilestone.get(milestone.name) ?? {
|
|
4651
|
+
passed: milestone.passedAssertionCount,
|
|
4652
|
+
total: milestone.assertionCount
|
|
4653
|
+
};
|
|
4654
|
+
writeLine(
|
|
4655
|
+
stdout,
|
|
4656
|
+
`- ${milestone.name}: ${summary.passed}/${summary.total} passed (${formatPercent(summary.passed, summary.total)})`
|
|
4657
|
+
);
|
|
4658
|
+
}
|
|
4659
|
+
}
|
|
4660
|
+
function registerMissionStatusCommand(mission, context, dependencies = {}) {
|
|
4661
|
+
mission.command("status").description("Show the status of a mission").argument("<missionId>").action(async (missionId, command) => {
|
|
4662
|
+
const apiClient = await createMissionApiClient(context, command, dependencies);
|
|
4663
|
+
const missionState = normalizeMissionState(
|
|
4664
|
+
await apiClient.request({
|
|
4665
|
+
path: `/missions/${missionId}/mission/state`
|
|
4666
|
+
})
|
|
4667
|
+
);
|
|
4668
|
+
const [features, milestones, assertions] = await Promise.all([
|
|
4669
|
+
apiClient.request({ path: `/missions/${missionId}/features` }).then((value) => normalizeMissionFeatures(value)),
|
|
4670
|
+
apiClient.request({ path: `/missions/${missionId}/milestones` }).then((value) => normalizeMissionMilestones(value)),
|
|
4671
|
+
apiClient.request({ path: `/missions/${missionId}/assertions` }).then((value) => normalizeMissionAssertions(value))
|
|
4672
|
+
]);
|
|
4673
|
+
const milestoneList = normalizeMilestoneOrder(
|
|
4674
|
+
milestones,
|
|
4675
|
+
features,
|
|
4676
|
+
assertions
|
|
4677
|
+
);
|
|
4678
|
+
renderMissionOverview(context.stdout, missionState);
|
|
4679
|
+
renderActiveWorker(context.stdout, missionState);
|
|
4680
|
+
renderBudget(context.stdout, missionState);
|
|
4681
|
+
renderMilestones2(context.stdout, milestoneList, features);
|
|
4682
|
+
renderAssertionSummary(context.stdout, milestoneList, assertions);
|
|
4683
|
+
});
|
|
4684
|
+
}
|
|
4685
|
+
|
|
4686
|
+
// src/commands/mission.ts
|
|
4687
|
+
function registerMissionCommands(program, context, dependencies = {}) {
|
|
4688
|
+
const mission = program.command("mission").description("Manage missions");
|
|
4689
|
+
registerMissionRunCommand(mission, context, dependencies);
|
|
4690
|
+
registerMissionStatusCommand(mission, context, dependencies);
|
|
4691
|
+
registerMissionListCommand(mission, context, dependencies);
|
|
4692
|
+
registerMissionLifecycleCommands(mission, context, dependencies);
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
// src/cli.ts
|
|
4696
|
+
function readPackageVersion() {
|
|
4697
|
+
const packagePath = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
4698
|
+
const packageJson = JSON.parse(
|
|
4699
|
+
readFileSync(packagePath, "utf8")
|
|
4700
|
+
);
|
|
4701
|
+
return packageJson.version ?? "0.0.0";
|
|
4702
|
+
}
|
|
4703
|
+
function createProgram(dependencies = {}) {
|
|
4704
|
+
const context = createCliContext(dependencies);
|
|
4705
|
+
const program = new Command();
|
|
4706
|
+
program.name("hz").description("Horizon CLI").version(readPackageVersion()).showHelpAfterError("(run `hz --help` for usage)").showSuggestionAfterError(true);
|
|
4707
|
+
program.configureOutput({
|
|
4708
|
+
writeErr: (value) => context.stderr.write(value),
|
|
4709
|
+
writeOut: (value) => context.stdout.write(value)
|
|
4710
|
+
});
|
|
4711
|
+
program.option("--verbose", "show request and response details");
|
|
4712
|
+
registerAuthCommands(program, context);
|
|
4713
|
+
registerMissionCommands(program, context, dependencies);
|
|
4714
|
+
return program;
|
|
4715
|
+
}
|
|
4716
|
+
function isEntrypoint() {
|
|
4717
|
+
const entryPath = process.argv[1];
|
|
4718
|
+
if (!entryPath) {
|
|
4719
|
+
return false;
|
|
4720
|
+
}
|
|
4721
|
+
return path7.resolve(entryPath) === fileURLToPath(import.meta.url);
|
|
4722
|
+
}
|
|
4723
|
+
function writeError(stream, message) {
|
|
4724
|
+
writeLine(stream, `Error: ${message}`);
|
|
4725
|
+
}
|
|
4726
|
+
function handleRunError(error, stderr) {
|
|
4727
|
+
if (error instanceof CommanderError3) {
|
|
4728
|
+
return error.exitCode;
|
|
4729
|
+
}
|
|
4730
|
+
if (error instanceof CliError) {
|
|
4731
|
+
writeError(stderr, error.message);
|
|
4732
|
+
return error.exitCode;
|
|
4733
|
+
}
|
|
4734
|
+
if (error instanceof ConfigError) {
|
|
4735
|
+
writeError(stderr, error.message);
|
|
4736
|
+
return 1;
|
|
4737
|
+
}
|
|
4738
|
+
if (error instanceof Error) {
|
|
4739
|
+
writeError(stderr, error.message);
|
|
4740
|
+
return 1;
|
|
4741
|
+
}
|
|
4742
|
+
writeError(stderr, "Unexpected CLI error.");
|
|
4743
|
+
return 1;
|
|
4744
|
+
}
|
|
4745
|
+
async function runCli(argv, dependencies = {}) {
|
|
4746
|
+
const program = createProgram(dependencies);
|
|
4747
|
+
program.exitOverride();
|
|
4748
|
+
try {
|
|
4749
|
+
await program.parseAsync(argv, { from: "user" });
|
|
4750
|
+
return 0;
|
|
4751
|
+
} catch (error) {
|
|
4752
|
+
return handleRunError(error, dependencies.stderr ?? process.stderr);
|
|
4753
|
+
}
|
|
4754
|
+
}
|
|
4755
|
+
async function main() {
|
|
4756
|
+
const exitCode = await runCli(process.argv.slice(2));
|
|
4757
|
+
if (exitCode !== 0) {
|
|
4758
|
+
process.exitCode = exitCode;
|
|
4759
|
+
}
|
|
4760
|
+
}
|
|
4761
|
+
if (isEntrypoint()) {
|
|
4762
|
+
await main();
|
|
4763
|
+
}
|
|
4764
|
+
export {
|
|
4765
|
+
runCli
|
|
4766
|
+
};
|
|
4767
|
+
//# sourceMappingURL=hz.mjs.map
|