@mytegroupinc/myte-core 0.0.28 → 0.0.29
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/README.md +210 -196
- package/cli.js +6631 -6384
- package/lib/ai-gateway.js +115 -115
- package/package.json +28 -28
- package/scripts/feedback-live-full-harness.js +366 -0
- package/scripts/mission-live-disposable-harness.js +226 -226
- package/scripts/mission-live-full-harness.js +832 -832
|
@@ -1,832 +1,832 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
|
|
4
|
-
const fs = require("node:fs");
|
|
5
|
-
const os = require("node:os");
|
|
6
|
-
const path = require("node:path");
|
|
7
|
-
const { spawnSync } = require("node:child_process");
|
|
8
|
-
|
|
9
|
-
const CLI_PATH = path.resolve(__dirname, "..", "cli.js");
|
|
10
|
-
const DEFAULT_BASE_URL = "https://api.myte.dev/api";
|
|
11
|
-
|
|
12
|
-
function parseArgs(argv) {
|
|
13
|
-
const args = { _: [] };
|
|
14
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
-
const token = argv[index];
|
|
16
|
-
if (!token.startsWith("--")) {
|
|
17
|
-
args._.push(token);
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
const key = token.slice(2);
|
|
21
|
-
const next = argv[index + 1];
|
|
22
|
-
if (!next || next.startsWith("--")) {
|
|
23
|
-
args[key] = true;
|
|
24
|
-
continue;
|
|
25
|
-
}
|
|
26
|
-
args[key] = next;
|
|
27
|
-
index += 1;
|
|
28
|
-
}
|
|
29
|
-
return args;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function loadEnvFromNearest(startDir) {
|
|
33
|
-
let cur = startDir;
|
|
34
|
-
for (let index = 0; index < 8; index += 1) {
|
|
35
|
-
const filePath = path.join(cur, ".env");
|
|
36
|
-
if (fs.existsSync(filePath)) {
|
|
37
|
-
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
|
38
|
-
const trimmed = String(line || "").trim();
|
|
39
|
-
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
40
|
-
const splitAt = trimmed.indexOf("=");
|
|
41
|
-
if (splitAt <= 0) continue;
|
|
42
|
-
const key = trimmed.slice(0, splitAt).trim();
|
|
43
|
-
let value = trimmed.slice(splitAt + 1).trim();
|
|
44
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
45
|
-
value = value.slice(1, -1);
|
|
46
|
-
}
|
|
47
|
-
if (key && process.env[key] === undefined) process.env[key] = value;
|
|
48
|
-
}
|
|
49
|
-
return filePath;
|
|
50
|
-
}
|
|
51
|
-
const parent = path.dirname(cur);
|
|
52
|
-
if (parent === cur) break;
|
|
53
|
-
cur = parent;
|
|
54
|
-
}
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function nowId() {
|
|
59
|
-
return new Date().toISOString().replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function ensureDir(dirPath) {
|
|
63
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function writeJson(dirPath, name, payload) {
|
|
67
|
-
ensureDir(dirPath);
|
|
68
|
-
const filePath = path.join(dirPath, name);
|
|
69
|
-
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
70
|
-
return filePath;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function normalizeBaseUrl(value) {
|
|
74
|
-
const raw = String(value || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
75
|
-
return raw.endsWith("/api") ? raw : `${raw}/api`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function getProjectKey() {
|
|
79
|
-
return process.env.MYTE_API_KEY || process.env.MYTE_PROJECT_API_KEY || "";
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function stableScenarioList(raw, confirmLive) {
|
|
83
|
-
if (raw) {
|
|
84
|
-
return String(raw)
|
|
85
|
-
.split(",")
|
|
86
|
-
.map((item) => item.trim())
|
|
87
|
-
.filter(Boolean);
|
|
88
|
-
}
|
|
89
|
-
if (!confirmLive) return ["preflight", "contracts"];
|
|
90
|
-
return [
|
|
91
|
-
"preflight",
|
|
92
|
-
"contracts",
|
|
93
|
-
"suggestion_update_loop",
|
|
94
|
-
"create_reject_loop",
|
|
95
|
-
"archive_route_probe",
|
|
96
|
-
"approved_lifecycle",
|
|
97
|
-
"batch_limits",
|
|
98
|
-
"batch_mutation",
|
|
99
|
-
"qaqc",
|
|
100
|
-
];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function commandForPlatform(command) {
|
|
104
|
-
if (process.platform !== "win32") return command;
|
|
105
|
-
if (command.endsWith(".cmd") || command.endsWith(".exe")) return command;
|
|
106
|
-
return `${command}.cmd`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function cliInvocation(args, cliMode, packageVersion) {
|
|
110
|
-
if (cliMode === "global") return { command: commandForPlatform("myte"), args };
|
|
111
|
-
if (cliMode === "npx") return { command: commandForPlatform("npx"), args: ["--yes", `myte@${packageVersion}`, ...args] };
|
|
112
|
-
return { command: process.execPath, args: [CLI_PATH, ...args] };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function commandText(invocation) {
|
|
116
|
-
return [invocation.command, ...invocation.args].join(" ");
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function runCli(cliArgs, options = {}) {
|
|
120
|
-
const {
|
|
121
|
-
workspace,
|
|
122
|
-
baseUrl,
|
|
123
|
-
actorScope,
|
|
124
|
-
expectFailure = false,
|
|
125
|
-
parseJson = true,
|
|
126
|
-
cliMode = "local",
|
|
127
|
-
packageVersion = "latest",
|
|
128
|
-
timeoutMs = 120000,
|
|
129
|
-
} = options;
|
|
130
|
-
const finalArgs = [...cliArgs];
|
|
131
|
-
if (actorScope) finalArgs.push("--actor-scope", actorScope);
|
|
132
|
-
if (baseUrl) finalArgs.push("--base-url", baseUrl);
|
|
133
|
-
const invocation = cliInvocation(finalArgs, cliMode, packageVersion);
|
|
134
|
-
const result = spawnSync(invocation.command, invocation.args, {
|
|
135
|
-
cwd: workspace || process.cwd(),
|
|
136
|
-
env: process.env,
|
|
137
|
-
encoding: "utf8",
|
|
138
|
-
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(invocation.command),
|
|
139
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
140
|
-
timeout: timeoutMs,
|
|
141
|
-
});
|
|
142
|
-
const stdout = String(result.stdout || "").trim();
|
|
143
|
-
const stderr = String(result.stderr || "").trim();
|
|
144
|
-
const ok = result.status === 0;
|
|
145
|
-
const processError = result.error ? String(result.error.message || result.error) : "";
|
|
146
|
-
if (expectFailure) {
|
|
147
|
-
if (ok) {
|
|
148
|
-
throw new Error(`Expected command to fail but it succeeded: ${commandText(invocation)}`);
|
|
149
|
-
}
|
|
150
|
-
return { ok: false, status: result.status, stdout, stderr, error: processError };
|
|
151
|
-
}
|
|
152
|
-
if (!ok) {
|
|
153
|
-
throw new Error([`Command failed: ${commandText(invocation)}`, processError || stderr || stdout || "(no output)"].join("\n"));
|
|
154
|
-
}
|
|
155
|
-
if (!parseJson) return { ok: true, stdout, stderr };
|
|
156
|
-
try {
|
|
157
|
-
return stdout ? JSON.parse(stdout) : {};
|
|
158
|
-
} catch (error) {
|
|
159
|
-
throw new Error(`Expected JSON from ${commandText(invocation)}:\n${stdout}`);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function apiJson({ method = "GET", baseUrl, route, key, body, query, idempotencyKey }) {
|
|
164
|
-
const url = new URL(`${baseUrl}${route}`);
|
|
165
|
-
for (const [queryKey, queryValue] of Object.entries(query || {})) {
|
|
166
|
-
if (queryValue !== undefined && queryValue !== null && String(queryValue).trim() !== "") {
|
|
167
|
-
url.searchParams.set(queryKey, String(queryValue));
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
const headers = { Authorization: `Bearer ${key}` };
|
|
171
|
-
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
172
|
-
if (idempotencyKey) headers["X-Idempotency-Key"] = String(idempotencyKey);
|
|
173
|
-
const response = await fetch(url.toString(), {
|
|
174
|
-
method,
|
|
175
|
-
headers,
|
|
176
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
177
|
-
});
|
|
178
|
-
let payload = {};
|
|
179
|
-
try {
|
|
180
|
-
payload = await response.json();
|
|
181
|
-
} catch (_) {
|
|
182
|
-
payload = {};
|
|
183
|
-
}
|
|
184
|
-
return {
|
|
185
|
-
ok: response.ok && payload.status === "success",
|
|
186
|
-
statusCode: response.status,
|
|
187
|
-
message: payload.message || "",
|
|
188
|
-
data: payload.data || {},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function missionPublicId(mission) {
|
|
193
|
-
return String(mission?.mission_id || mission?.public_id || mission?.id || "").trim();
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function missionTitle(mission) {
|
|
197
|
-
return String(mission?.title || "").trim();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function activeMissions(bootstrap) {
|
|
201
|
-
return (Array.isArray(bootstrap?.missions) ? bootstrap.missions : [])
|
|
202
|
-
.filter((mission) => missionPublicId(mission))
|
|
203
|
-
.filter((mission) => String(mission.status || "").toLowerCase() !== "archived")
|
|
204
|
-
.filter((mission) => mission.is_archived !== true);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function chooseStableMission(bootstrap) {
|
|
208
|
-
const missions = activeMissions(bootstrap);
|
|
209
|
-
return missions.find((mission) => !/^Disposable mission harness/i.test(missionTitle(mission))) || missions[0] || null;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function hasRunnableQaqcCases(mission) {
|
|
213
|
-
if (String(mission?.status || "").toLowerCase() === "done") return false;
|
|
214
|
-
const testCases = mission?.test_cases || {};
|
|
215
|
-
const success = Array.isArray(testCases.success) ? testCases.success : [];
|
|
216
|
-
const failure = Array.isArray(testCases.failure) ? testCases.failure : [];
|
|
217
|
-
return success.length > 0 || failure.length > 0;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function chooseQaqcMission(bootstrap) {
|
|
221
|
-
const missions = activeMissions(bootstrap).filter(hasRunnableQaqcCases);
|
|
222
|
-
return missions.find((mission) => !/^Disposable mission harness/i.test(missionTitle(mission))) || missions[0] || null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function pickSuggestionId(output) {
|
|
226
|
-
for (const item of output.items || []) {
|
|
227
|
-
const suggestionId = item?.suggestion?.suggestion_id || item?.suggestion_id;
|
|
228
|
-
if (suggestionId) return String(suggestionId);
|
|
229
|
-
}
|
|
230
|
-
return "";
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function pickAppliedMissionId(output) {
|
|
234
|
-
for (const item of output.items || []) {
|
|
235
|
-
const missionId = item?.applied_mission?.mission_id || item?.mission_id || item?.suggestion?.mission_id;
|
|
236
|
-
if (missionId) return String(missionId);
|
|
237
|
-
}
|
|
238
|
-
return "";
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function threadRefs(snapshot) {
|
|
242
|
-
const refs = [];
|
|
243
|
-
for (const source of [snapshot?.queue, snapshot?.threads, snapshot?.suggestions]) {
|
|
244
|
-
if (!Array.isArray(source)) continue;
|
|
245
|
-
for (const item of source) {
|
|
246
|
-
refs.push({
|
|
247
|
-
suggestion_id: String(item?.suggestion_id || item?.id || "").trim(),
|
|
248
|
-
mission_id: String(item?.mission_id || "").trim(),
|
|
249
|
-
change_type: String(item?.change_type || "").trim(),
|
|
250
|
-
status: String(item?.status || item?.thread_status || "").trim(),
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
return refs.filter((item) => item.suggestion_id || item.mission_id);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function queueRefs(snapshot) {
|
|
258
|
-
return threadRefs({ queue: snapshot?.queue || [] });
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function hasMissionInBootstrap(bootstrap, missionId) {
|
|
262
|
-
const target = String(missionId || "").toUpperCase();
|
|
263
|
-
return activeMissions(bootstrap).some((mission) => missionPublicId(mission).toUpperCase() === target);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function assertCondition(condition, message) {
|
|
267
|
-
if (!condition) throw new Error(message);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function safeResult(details = {}) {
|
|
271
|
-
return JSON.parse(JSON.stringify(details, (_key, value) => {
|
|
272
|
-
if (typeof value !== "string") return value;
|
|
273
|
-
if (value.includes("@")) return "[redacted]";
|
|
274
|
-
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
|
|
275
|
-
}));
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
class Harness {
|
|
279
|
-
constructor(args) {
|
|
280
|
-
loadEnvFromNearest(process.cwd());
|
|
281
|
-
this.args = args;
|
|
282
|
-
this.confirmLive = args["confirm-live"] === true;
|
|
283
|
-
this.keepGoing = args["keep-going"] === true;
|
|
284
|
-
this.baseUrl = normalizeBaseUrl(args["base-url"] || process.env.MYTE_API_BASE_URL || DEFAULT_BASE_URL);
|
|
285
|
-
this.key = getProjectKey();
|
|
286
|
-
this.actorBase = String(args["actor-scope"] || `mission-live-full-${nowId()}`);
|
|
287
|
-
this.cliMode = String(args.cli || "local").toLowerCase();
|
|
288
|
-
this.packageVersion = String(args["package-version"] || "0.0.28");
|
|
289
|
-
this.batchSize = Math.max(1, Math.min(10, Number(args["batch-size"] || 10)));
|
|
290
|
-
this.includeQaqcRun = args["include-qaqc-run"] === true;
|
|
291
|
-
this.workspace = path.resolve(
|
|
292
|
-
args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-mission-full-harness-")),
|
|
293
|
-
);
|
|
294
|
-
this.payloadDir = path.join(this.workspace, "payloads");
|
|
295
|
-
this.reportPath = path.resolve(args["report-file"] || path.join(this.workspace, "mission-live-full-report.json"));
|
|
296
|
-
this.scenarios = stableScenarioList(args.scenarios, this.confirmLive);
|
|
297
|
-
this.createdMissions = new Set();
|
|
298
|
-
this.openSuggestions = new Set();
|
|
299
|
-
this.archiveRouteAvailable = null;
|
|
300
|
-
this.report = {
|
|
301
|
-
status: "running",
|
|
302
|
-
generated_at: new Date().toISOString(),
|
|
303
|
-
base_url: this.baseUrl,
|
|
304
|
-
cli_mode: this.cliMode,
|
|
305
|
-
package_version: this.packageVersion,
|
|
306
|
-
workspace: this.workspace,
|
|
307
|
-
scenarios: [],
|
|
308
|
-
created_mission_ids: [],
|
|
309
|
-
open_suggestion_ids: [],
|
|
310
|
-
notes: [],
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
requireKey() {
|
|
315
|
-
if (!this.key) throw new Error("Missing MYTE_API_KEY or MYTE_PROJECT_API_KEY.");
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
requireLive(name) {
|
|
319
|
-
if (!this.confirmLive) {
|
|
320
|
-
throw new Error(`${name} requires --confirm-live because it mutates live project data.`);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
cli(args, options = {}) {
|
|
325
|
-
return runCli(args, {
|
|
326
|
-
workspace: this.workspace,
|
|
327
|
-
baseUrl: this.baseUrl,
|
|
328
|
-
cliMode: this.cliMode,
|
|
329
|
-
packageVersion: this.packageVersion,
|
|
330
|
-
...options,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
cliWithActor(args, suffix, options = {}) {
|
|
335
|
-
return this.cli(args, {
|
|
336
|
-
actorScope: `${this.actorBase}-${suffix}`,
|
|
337
|
-
...options,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
async api(route, options = {}) {
|
|
342
|
-
return apiJson({
|
|
343
|
-
baseUrl: this.baseUrl,
|
|
344
|
-
key: this.key,
|
|
345
|
-
route,
|
|
346
|
-
...options,
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
async bootstrap() {
|
|
351
|
-
const response = await this.api("/project-assistant/bootstrap");
|
|
352
|
-
if (!response.ok) throw new Error(`Bootstrap API failed (${response.statusCode}): ${response.message}`);
|
|
353
|
-
return response.data;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
async suggestions(actorScope) {
|
|
357
|
-
const response = await this.api("/project-assistant/suggestions", {
|
|
358
|
-
query: actorScope ? { actor_scope: actorScope } : {},
|
|
359
|
-
});
|
|
360
|
-
if (!response.ok) throw new Error(`Suggestions API failed (${response.statusCode}): ${response.message}`);
|
|
361
|
-
return response.data;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
async probeArchiveRoute() {
|
|
365
|
-
const response = await this.api("/project-assistant/mission-archive", {
|
|
366
|
-
method: "POST",
|
|
367
|
-
idempotencyKey: `mission-live-full-route-probe-${nowId()}`,
|
|
368
|
-
body: {
|
|
369
|
-
mission_ids: ["__NOT_A_REAL_MISSION__"],
|
|
370
|
-
reason: "mission live full harness route probe",
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
this.archiveRouteAvailable = response.statusCode === 200;
|
|
374
|
-
if (!this.archiveRouteAvailable) {
|
|
375
|
-
throw new Error(`Archive route is not live (${response.statusCode}): ${response.message || "no message"}`);
|
|
376
|
-
}
|
|
377
|
-
return {
|
|
378
|
-
status_code: response.statusCode,
|
|
379
|
-
rejected_count: response.data?.rejected_count,
|
|
380
|
-
updated_count: response.data?.updated_count,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
async scenarioPreflight() {
|
|
385
|
-
this.requireKey();
|
|
386
|
-
const config = this.cli(["config", "--json"]);
|
|
387
|
-
const bootstrap = this.cli(["bootstrap", "--json"]);
|
|
388
|
-
const sync = this.cliWithActor(["suggestions", "sync", "--json"], "preflight");
|
|
389
|
-
return {
|
|
390
|
-
project_id: config.project_id || bootstrap.project_id || null,
|
|
391
|
-
repo_count: Array.isArray(config.repo_names) ? config.repo_names.length : 0,
|
|
392
|
-
active_mission_count: bootstrap.counts?.missions || 0,
|
|
393
|
-
suggestion_threads: sync.thread_count || 0,
|
|
394
|
-
actionable_threads: sync.actionable_count || 0,
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async scenarioContracts() {
|
|
399
|
-
const invalidCreate = writeJson(this.payloadDir, "invalid-create-change-type.json", {
|
|
400
|
-
items: [{ change_type: "add-mission", change_description: "invalid", change_set: { title: "Invalid" } }],
|
|
401
|
-
});
|
|
402
|
-
const invalidRevise = writeJson(this.payloadDir, "invalid-revise-change-type.json", {
|
|
403
|
-
items: [{ suggestion_id: "000000000000000000000000", change_type: "create", change_set: { title: "Invalid" } }],
|
|
404
|
-
});
|
|
405
|
-
const badMissionIds = Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
406
|
-
const tooManyQaqcIds = Array.from({ length: 11 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
407
|
-
|
|
408
|
-
const checks = [];
|
|
409
|
-
checks.push(this.cli(["suggestions", "create", "--file", invalidCreate, "--json"], { expectFailure: true, parseJson: false }));
|
|
410
|
-
checks.push(this.cli(["suggestions", "revise", "--file", invalidRevise, "--json"], { expectFailure: true, parseJson: false }));
|
|
411
|
-
checks.push(this.cli(["mission", "status", "--mission-ids", "M001", "--status", "archived", "--json"], { expectFailure: true, parseJson: false }));
|
|
412
|
-
checks.push(this.cli(["mission", "restore", "--mission-ids", "M001", "--status", "todo", "--json"], { expectFailure: true, parseJson: false }));
|
|
413
|
-
checks.push(this.cli(["mission", "delete", "--mission-ids", "M001", "--json"], { expectFailure: true, parseJson: false }));
|
|
414
|
-
checks.push(this.cli(["mission", "archive", "--mission-ids", "M001", "--reason", "dry run", "--dry-run", "--json"]));
|
|
415
|
-
checks.push(this.cli(["mission", "status", "--mission-ids", badMissionIds, "--status", "todo", "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }));
|
|
416
|
-
checks.push(this.cli(["run-qaqc", "--mission-ids", tooManyQaqcIds, "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }));
|
|
417
|
-
return { checks: checks.length };
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
async scenarioSuggestionUpdateLoop() {
|
|
421
|
-
this.requireLive("suggestion_update_loop");
|
|
422
|
-
const actor = `${this.actorBase}-update-loop`;
|
|
423
|
-
const bootstrap = await this.bootstrap();
|
|
424
|
-
const mission = chooseStableMission(bootstrap);
|
|
425
|
-
assertCondition(mission, "No active mission available for update suggestion loop.");
|
|
426
|
-
const missionId = missionPublicId(mission);
|
|
427
|
-
|
|
428
|
-
const createOne = writeJson(this.payloadDir, "update-loop-create-1.json", {
|
|
429
|
-
items: [{
|
|
430
|
-
change_type: "update",
|
|
431
|
-
mission_id: missionId,
|
|
432
|
-
change_description: "Harness proposed non-applied update",
|
|
433
|
-
change_set: { description: "Harness proposed description. This should be rejected during cleanup." },
|
|
434
|
-
}],
|
|
435
|
-
});
|
|
436
|
-
const first = this.cli(["suggestions", "create", "--file", createOne, "--no-sync", "--json"], { actorScope: actor });
|
|
437
|
-
const suggestionId = pickSuggestionId(first);
|
|
438
|
-
assertCondition(suggestionId, "Update suggestion create did not return suggestion_id.");
|
|
439
|
-
this.openSuggestions.add(suggestionId);
|
|
440
|
-
|
|
441
|
-
const createTwo = writeJson(this.payloadDir, "update-loop-create-2.json", {
|
|
442
|
-
items: [{
|
|
443
|
-
change_type: "update",
|
|
444
|
-
mission_id: missionId,
|
|
445
|
-
change_description: "Harness append to existing pending update thread",
|
|
446
|
-
change_set: { acceptance_criteria: ["Harness proposed acceptance criterion. This should be rejected."] },
|
|
447
|
-
}],
|
|
448
|
-
});
|
|
449
|
-
const second = this.cli(["suggestions", "create", "--file", createTwo, "--no-sync", "--json"], { actorScope: actor });
|
|
450
|
-
const appendedId = pickSuggestionId(second);
|
|
451
|
-
assertCondition(appendedId === suggestionId, `Expected append to same suggestion ${suggestionId}, got ${appendedId || "(none)"}.`);
|
|
452
|
-
|
|
453
|
-
const revise = writeJson(this.payloadDir, "update-loop-revise.json", {
|
|
454
|
-
items: [{
|
|
455
|
-
suggestion_id: suggestionId,
|
|
456
|
-
change_description: "Harness revision after append",
|
|
457
|
-
change_set: { technical_requirements: ["Harness proposed requirement. This should be rejected."] },
|
|
458
|
-
}],
|
|
459
|
-
});
|
|
460
|
-
const revised = this.cli(["suggestions", "revise", "--file", revise, "--no-sync", "--json"], { actorScope: actor });
|
|
461
|
-
assertCondition((revised.revised_count || 0) >= 1, "Update suggestion revise did not revise the thread.");
|
|
462
|
-
|
|
463
|
-
const requestChanges = writeJson(this.payloadDir, "update-loop-request-changes.json", {
|
|
464
|
-
items: [{ suggestion_id: suggestionId, action: "request_changes", review_action: "request_changes", review_note: "Harness request changes check." }],
|
|
465
|
-
});
|
|
466
|
-
const requested = this.cli(["suggestions", "review", "--file", requestChanges, "--no-sync", "--json"], { actorScope: actor });
|
|
467
|
-
assertCondition((requested.processed_count || 0) >= 1, "Update suggestion request_changes did not process.");
|
|
468
|
-
|
|
469
|
-
const reviseAfterRequest = writeJson(this.payloadDir, "update-loop-revise-after-request.json", {
|
|
470
|
-
items: [{
|
|
471
|
-
suggestion_id: suggestionId,
|
|
472
|
-
change_description: "Harness revision after request_changes",
|
|
473
|
-
change_set: { labels: ["harness-proposed-update"] },
|
|
474
|
-
}],
|
|
475
|
-
});
|
|
476
|
-
const revisedAgain = this.cli(["suggestions", "revise", "--file", reviseAfterRequest, "--no-sync", "--json"], { actorScope: actor });
|
|
477
|
-
assertCondition((revisedAgain.revised_count || 0) >= 1, "Update suggestion revise after request_changes did not process.");
|
|
478
|
-
|
|
479
|
-
const reject = writeJson(this.payloadDir, "update-loop-reject.json", {
|
|
480
|
-
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: "Harness cleanup reject." }],
|
|
481
|
-
});
|
|
482
|
-
const rejected = this.cli(["suggestions", "review", "--file", reject, "--json"], { actorScope: actor });
|
|
483
|
-
assertCondition((rejected.processed_count || 0) >= 1, "Update suggestion reject cleanup did not process.");
|
|
484
|
-
this.openSuggestions.delete(suggestionId);
|
|
485
|
-
|
|
486
|
-
return { mission_id: missionId, suggestion_id: suggestionId, append_verified: true };
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
async scenarioCreateRejectLoop() {
|
|
490
|
-
this.requireLive("create_reject_loop");
|
|
491
|
-
const actor = `${this.actorBase}-create-reject`;
|
|
492
|
-
const title = `Harness rejected mission ${nowId()}`;
|
|
493
|
-
const before = await this.bootstrap();
|
|
494
|
-
|
|
495
|
-
const createFile = writeJson(this.payloadDir, "create-reject-create.json", {
|
|
496
|
-
items: [{
|
|
497
|
-
change_type: "create",
|
|
498
|
-
change_description: "Harness new mission proposal that will be rejected",
|
|
499
|
-
change_set: {
|
|
500
|
-
title,
|
|
501
|
-
description: "Harness proposal that should never become an active mission.",
|
|
502
|
-
acceptance_criteria: ["The rejected proposal does not create a mission card."],
|
|
503
|
-
labels: ["harness", "reject-path"],
|
|
504
|
-
},
|
|
505
|
-
}],
|
|
506
|
-
});
|
|
507
|
-
const created = this.cli(["suggestions", "create", "--file", createFile, "--no-sync", "--json"], { actorScope: actor });
|
|
508
|
-
const suggestionId = pickSuggestionId(created);
|
|
509
|
-
assertCondition(suggestionId, "Create suggestion did not return suggestion_id.");
|
|
510
|
-
this.openSuggestions.add(suggestionId);
|
|
511
|
-
|
|
512
|
-
const afterCreate = await this.bootstrap();
|
|
513
|
-
assertCondition(
|
|
514
|
-
activeMissions(afterCreate).length === activeMissions(before).length,
|
|
515
|
-
"New mission proposal created an active mission before approval.",
|
|
516
|
-
);
|
|
517
|
-
|
|
518
|
-
const reviseFile = writeJson(this.payloadDir, "create-reject-revise.json", {
|
|
519
|
-
items: [{
|
|
520
|
-
suggestion_id: suggestionId,
|
|
521
|
-
change_description: "Harness revision for rejected create thread",
|
|
522
|
-
change_set: { description: "Harness revised proposal. This should still be rejected." },
|
|
523
|
-
}],
|
|
524
|
-
});
|
|
525
|
-
const revised = this.cli(["suggestions", "revise", "--file", reviseFile, "--no-sync", "--json"], { actorScope: actor });
|
|
526
|
-
assertCondition((revised.revised_count || 0) >= 1, "Create suggestion revise did not process.");
|
|
527
|
-
|
|
528
|
-
const requestChanges = writeJson(this.payloadDir, "create-reject-request-changes.json", {
|
|
529
|
-
items: [{ suggestion_id: suggestionId, action: "request_changes", review_action: "request_changes", review_note: "Harness request changes check." }],
|
|
530
|
-
});
|
|
531
|
-
const requested = this.cli(["suggestions", "review", "--file", requestChanges, "--no-sync", "--json"], { actorScope: actor });
|
|
532
|
-
assertCondition((requested.processed_count || 0) >= 1, "Create suggestion request_changes did not process.");
|
|
533
|
-
|
|
534
|
-
const reject = writeJson(this.payloadDir, "create-reject-reject.json", {
|
|
535
|
-
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: "Harness cleanup reject." }],
|
|
536
|
-
});
|
|
537
|
-
const rejected = this.cli(["suggestions", "review", "--file", reject, "--json"], { actorScope: actor });
|
|
538
|
-
assertCondition((rejected.processed_count || 0) >= 1, "Create suggestion reject cleanup did not process.");
|
|
539
|
-
this.openSuggestions.delete(suggestionId);
|
|
540
|
-
|
|
541
|
-
const afterReject = await this.bootstrap();
|
|
542
|
-
assertCondition(
|
|
543
|
-
activeMissions(afterReject).every((mission) => missionTitle(mission) !== title),
|
|
544
|
-
"Rejected create suggestion appeared as an active mission.",
|
|
545
|
-
);
|
|
546
|
-
return { suggestion_id: suggestionId, active_mission_created: false };
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
async archiveMissions(missionIds, reason) {
|
|
550
|
-
if (!missionIds.length) return null;
|
|
551
|
-
return this.cli(["mission", "archive", "--mission-ids", missionIds.join(","), "--reason", reason, "--json"], {
|
|
552
|
-
timeoutMs: 180000,
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
async rejectSuggestion(suggestionId, actor, note) {
|
|
557
|
-
const rejectFile = writeJson(this.payloadDir, `reject-${suggestionId}.json`, {
|
|
558
|
-
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: note || "Harness cleanup reject." }],
|
|
559
|
-
});
|
|
560
|
-
return this.cli(["suggestions", "review", "--file", rejectFile, "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
async createApprovedMission(actor, index = 1) {
|
|
564
|
-
const title = `Harness approved mission ${nowId()} ${index}`;
|
|
565
|
-
const description = "Disposable mission created by the reusable live mission harness.";
|
|
566
|
-
const createFile = writeJson(this.payloadDir, `approved-create-${index}.json`, {
|
|
567
|
-
items: [{
|
|
568
|
-
change_type: "create",
|
|
569
|
-
change_description: "Harness approved disposable mission creation",
|
|
570
|
-
change_set: {
|
|
571
|
-
title,
|
|
572
|
-
description,
|
|
573
|
-
acceptance_criteria: [
|
|
574
|
-
"Mission can be created through suggestion approval.",
|
|
575
|
-
"Mission can be mutated through project-key mission commands.",
|
|
576
|
-
"Mission can be archived without hard delete.",
|
|
577
|
-
],
|
|
578
|
-
labels: ["harness", "approved-disposable"],
|
|
579
|
-
},
|
|
580
|
-
}],
|
|
581
|
-
});
|
|
582
|
-
const created = this.cli(["suggestions", "create", "--file", createFile, "--no-sync", "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
583
|
-
const suggestionId = pickSuggestionId(created);
|
|
584
|
-
assertCondition(suggestionId, "Approved mission create did not return suggestion_id.");
|
|
585
|
-
this.openSuggestions.add(suggestionId);
|
|
586
|
-
const reviewFile = writeJson(this.payloadDir, `approved-review-${index}.json`, {
|
|
587
|
-
items: [{
|
|
588
|
-
suggestion_id: suggestionId,
|
|
589
|
-
action: "approve",
|
|
590
|
-
review_action: "approve",
|
|
591
|
-
final_change_set: {
|
|
592
|
-
title,
|
|
593
|
-
description,
|
|
594
|
-
acceptance_criteria: [
|
|
595
|
-
"Mission can be created through suggestion approval.",
|
|
596
|
-
"Mission can be mutated through project-key mission commands.",
|
|
597
|
-
"Mission can be archived without hard delete.",
|
|
598
|
-
],
|
|
599
|
-
labels: ["harness", "approved-disposable"],
|
|
600
|
-
},
|
|
601
|
-
}],
|
|
602
|
-
});
|
|
603
|
-
const reviewed = this.cli(["suggestions", "review", "--file", reviewFile, "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
604
|
-
const missionId = pickAppliedMissionId(reviewed);
|
|
605
|
-
assertCondition(missionId, "Approved mission review did not return applied mission id.");
|
|
606
|
-
this.createdMissions.add(missionId);
|
|
607
|
-
this.openSuggestions.delete(suggestionId);
|
|
608
|
-
return { mission_id: missionId, suggestion_id: suggestionId, title };
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
async scenarioApprovedLifecycle() {
|
|
612
|
-
this.requireLive("approved_lifecycle");
|
|
613
|
-
if (this.archiveRouteAvailable !== true) await this.probeArchiveRoute();
|
|
614
|
-
const actor = `${this.actorBase}-approved-lifecycle`;
|
|
615
|
-
const created = await this.createApprovedMission(actor, 1);
|
|
616
|
-
let pendingSuggestionId = "";
|
|
617
|
-
try {
|
|
618
|
-
const activeAfterCreate = await this.bootstrap();
|
|
619
|
-
assertCondition(hasMissionInBootstrap(activeAfterCreate, created.mission_id), "Approved mission is missing from active bootstrap state.");
|
|
620
|
-
|
|
621
|
-
for (const status of ["in_progress", "done", "todo"]) {
|
|
622
|
-
const result = this.cli(["mission", "status", "--mission-ids", created.mission_id, "--status", status, "--json"], { timeoutMs: 180000 });
|
|
623
|
-
assertCondition((result.matched_count || 0) >= 1, `Mission status ${status} did not match the disposable mission.`);
|
|
624
|
-
}
|
|
625
|
-
const noop = this.cli(["mission", "status", "--mission-ids", created.mission_id, "--status", "todo", "--json"], { timeoutMs: 180000 });
|
|
626
|
-
assertCondition((noop.unchanged_count || 0) >= 1 || (noop.matched_count || 0) >= 1, "Idempotent mission status no-op did not report a stable match.");
|
|
627
|
-
|
|
628
|
-
const pendingFile = writeJson(this.payloadDir, "approved-lifecycle-pending-update.json", {
|
|
629
|
-
items: [{
|
|
630
|
-
change_type: "update",
|
|
631
|
-
mission_id: created.mission_id,
|
|
632
|
-
change_description: "Harness pending suggestion across archive",
|
|
633
|
-
change_set: { description: "Harness pending suggestion should be hidden from actionable queue while archived." },
|
|
634
|
-
}],
|
|
635
|
-
});
|
|
636
|
-
const pending = this.cli(["suggestions", "create", "--file", pendingFile, "--no-sync", "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
637
|
-
pendingSuggestionId = pickSuggestionId(pending);
|
|
638
|
-
assertCondition(pendingSuggestionId, "Pending update suggestion for approved mission was not created.");
|
|
639
|
-
this.openSuggestions.add(pendingSuggestionId);
|
|
640
|
-
|
|
641
|
-
const archived = await this.archiveMissions([created.mission_id], "Harness archive active mission with pending suggestion");
|
|
642
|
-
assertCondition((archived.updated_count || 0) + (archived.unchanged_count || 0) >= 1, "Archive did not update or confirm the disposable mission.");
|
|
643
|
-
|
|
644
|
-
const afterArchive = await this.bootstrap();
|
|
645
|
-
assertCondition(!hasMissionInBootstrap(afterArchive, created.mission_id), "Archived mission is still present in active bootstrap state.");
|
|
646
|
-
const archivedSuggestions = await this.suggestions(actor);
|
|
647
|
-
const archivedQueueRefs = queueRefs(archivedSuggestions).filter((item) => item.mission_id.toUpperCase() === created.mission_id.toUpperCase());
|
|
648
|
-
assertCondition(archivedQueueRefs.length === 0, "Archived mission still appears in actionable suggestion queue.");
|
|
649
|
-
const archivedThreadRefs = threadRefs(archivedSuggestions).filter((item) => item.suggestion_id === pendingSuggestionId);
|
|
650
|
-
assertCondition(archivedThreadRefs.length >= 1, "Archived mission pending suggestion was not preserved in thread history.");
|
|
651
|
-
|
|
652
|
-
await this.rejectSuggestion(pendingSuggestionId, actor, "Harness cleanup reject after archived-thread preservation check.");
|
|
653
|
-
this.openSuggestions.delete(pendingSuggestionId);
|
|
654
|
-
return {
|
|
655
|
-
mission_id: created.mission_id,
|
|
656
|
-
create_suggestion_id: created.suggestion_id,
|
|
657
|
-
pending_suggestion_id: pendingSuggestionId,
|
|
658
|
-
archive_hides_active: true,
|
|
659
|
-
archive_preserves_thread_history: true,
|
|
660
|
-
};
|
|
661
|
-
} catch (error) {
|
|
662
|
-
if (pendingSuggestionId) {
|
|
663
|
-
try {
|
|
664
|
-
await this.rejectSuggestion(pendingSuggestionId, actor, "Harness failure cleanup reject.");
|
|
665
|
-
this.openSuggestions.delete(pendingSuggestionId);
|
|
666
|
-
} catch (_) {}
|
|
667
|
-
}
|
|
668
|
-
try {
|
|
669
|
-
await this.archiveMissions([created.mission_id], "Harness failure cleanup archive.");
|
|
670
|
-
} catch (_) {}
|
|
671
|
-
throw error;
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
async scenarioBatchLimits() {
|
|
676
|
-
const statusIds = Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
677
|
-
const qaqcIds = Array.from({ length: 11 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
678
|
-
const archiveProbe = await this.api("/project-assistant/mission-archive", {
|
|
679
|
-
method: "POST",
|
|
680
|
-
idempotencyKey: `mission-live-full-archive-limit-${nowId()}`,
|
|
681
|
-
body: {
|
|
682
|
-
mission_ids: Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`),
|
|
683
|
-
reason: "Harness limit check",
|
|
684
|
-
},
|
|
685
|
-
});
|
|
686
|
-
const checks = [
|
|
687
|
-
this.cli(["mission", "status", "--mission-ids", statusIds, "--status", "todo", "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }),
|
|
688
|
-
this.cli(["run-qaqc", "--mission-ids", qaqcIds, "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }),
|
|
689
|
-
];
|
|
690
|
-
assertCondition(
|
|
691
|
-
archiveProbe.statusCode === 400 || archiveProbe.statusCode === 404,
|
|
692
|
-
`Expected archive limit check to return 400 when route is live or 404 when route is absent, got ${archiveProbe.statusCode}.`,
|
|
693
|
-
);
|
|
694
|
-
return {
|
|
695
|
-
checks: checks.length + 1,
|
|
696
|
-
mission_status_limit: 100,
|
|
697
|
-
run_qaqc_limit: 10,
|
|
698
|
-
archive_limit: archiveProbe.statusCode === 400 ? 100 : "route_not_live",
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
async scenarioBatchMutation() {
|
|
703
|
-
this.requireLive("batch_mutation");
|
|
704
|
-
if (this.archiveRouteAvailable !== true) await this.probeArchiveRoute();
|
|
705
|
-
const actor = `${this.actorBase}-batch`;
|
|
706
|
-
const created = [];
|
|
707
|
-
try {
|
|
708
|
-
for (let index = 1; index <= this.batchSize; index += 1) {
|
|
709
|
-
created.push(await this.createApprovedMission(actor, index));
|
|
710
|
-
}
|
|
711
|
-
const missionIds = created.map((item) => item.mission_id);
|
|
712
|
-
for (const status of ["in_progress", "done", "todo"]) {
|
|
713
|
-
const result = this.cli(["mission", "status", "--mission-ids", missionIds.join(","), "--status", status, "--json"], { timeoutMs: 240000 });
|
|
714
|
-
assertCondition((result.matched_count || 0) === missionIds.length, `Batch status ${status} matched ${result.matched_count || 0}/${missionIds.length}.`);
|
|
715
|
-
}
|
|
716
|
-
const archived = await this.archiveMissions(missionIds, "Harness batch cleanup archive");
|
|
717
|
-
assertCondition((archived.updated_count || 0) + (archived.unchanged_count || 0) === missionIds.length, "Batch archive did not cover every disposable mission.");
|
|
718
|
-
return { mission_count: missionIds.length, mission_ids: missionIds };
|
|
719
|
-
} catch (error) {
|
|
720
|
-
try {
|
|
721
|
-
await this.archiveMissions(created.map((item) => item.mission_id), "Harness batch failure cleanup archive");
|
|
722
|
-
} catch (_) {}
|
|
723
|
-
throw error;
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async scenarioQaqc() {
|
|
728
|
-
const sync = this.cli(["sync-qaqc", "--json"], { timeoutMs: 180000 });
|
|
729
|
-
const result = {
|
|
730
|
-
sync_mission_count: sync.counts?.missions || sync.missions_count || 0,
|
|
731
|
-
run_executed: false,
|
|
732
|
-
};
|
|
733
|
-
if (!this.includeQaqcRun) {
|
|
734
|
-
result.skipped_run_reason = "Pass --include-qaqc-run to queue a live QAQC batch.";
|
|
735
|
-
return result;
|
|
736
|
-
}
|
|
737
|
-
this.requireLive("qaqc run");
|
|
738
|
-
const bootstrap = await this.bootstrap();
|
|
739
|
-
const mission = chooseQaqcMission(bootstrap);
|
|
740
|
-
assertCondition(mission, "No active non-Done mission with test cases is available for QAQC run.");
|
|
741
|
-
const missionId = missionPublicId(mission);
|
|
742
|
-
const run = this.cli(["run-qaqc", "--mission-ids", missionId, "--wait", "--sync", "--json"], { timeoutMs: 600000 });
|
|
743
|
-
result.run_executed = true;
|
|
744
|
-
result.mission_id = missionId;
|
|
745
|
-
result.batch_id = run.batch_id || null;
|
|
746
|
-
result.status = run.status || null;
|
|
747
|
-
result.accepted_count = run.accepted_count || 0;
|
|
748
|
-
assertCondition(result.accepted_count > 0, `QAQC run did not accept ${missionId}; status=${result.status || "unknown"}.`);
|
|
749
|
-
return result;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
scenarioMap() {
|
|
753
|
-
return {
|
|
754
|
-
preflight: () => this.scenarioPreflight(),
|
|
755
|
-
contracts: () => this.scenarioContracts(),
|
|
756
|
-
suggestion_update_loop: () => this.scenarioSuggestionUpdateLoop(),
|
|
757
|
-
create_reject_loop: () => this.scenarioCreateRejectLoop(),
|
|
758
|
-
archive_route_probe: () => this.probeArchiveRoute(),
|
|
759
|
-
approved_lifecycle: () => this.scenarioApprovedLifecycle(),
|
|
760
|
-
batch_limits: () => this.scenarioBatchLimits(),
|
|
761
|
-
batch_mutation: () => this.scenarioBatchMutation(),
|
|
762
|
-
qaqc: () => this.scenarioQaqc(),
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
async runScenario(name) {
|
|
767
|
-
const startedAt = new Date().toISOString();
|
|
768
|
-
const entry = { name, status: "running", started_at: startedAt };
|
|
769
|
-
this.report.scenarios.push(entry);
|
|
770
|
-
const map = this.scenarioMap();
|
|
771
|
-
if (!map[name]) throw new Error(`Unknown scenario: ${name}`);
|
|
772
|
-
try {
|
|
773
|
-
const details = await map[name]();
|
|
774
|
-
entry.status = "passed";
|
|
775
|
-
entry.completed_at = new Date().toISOString();
|
|
776
|
-
entry.details = safeResult(details || {});
|
|
777
|
-
console.log(`[pass] ${name}`);
|
|
778
|
-
} catch (error) {
|
|
779
|
-
entry.status = "failed";
|
|
780
|
-
entry.completed_at = new Date().toISOString();
|
|
781
|
-
entry.error = String(error?.message || error);
|
|
782
|
-
console.error(`[fail] ${name}: ${entry.error}`);
|
|
783
|
-
if (!this.keepGoing) throw error;
|
|
784
|
-
} finally {
|
|
785
|
-
this.writeReport();
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
writeReport() {
|
|
790
|
-
this.report.created_mission_ids = Array.from(this.createdMissions).sort();
|
|
791
|
-
this.report.open_suggestion_ids = Array.from(this.openSuggestions).sort();
|
|
792
|
-
if (!this.report.completed_at) {
|
|
793
|
-
this.report.status = this.report.scenarios.some((item) => item.status === "failed") ? "failed" : "running";
|
|
794
|
-
}
|
|
795
|
-
ensureDir(path.dirname(this.reportPath));
|
|
796
|
-
fs.writeFileSync(this.reportPath, JSON.stringify(this.report, null, 2), "utf8");
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
async run() {
|
|
800
|
-
this.requireKey();
|
|
801
|
-
ensureDir(this.workspace);
|
|
802
|
-
ensureDir(this.payloadDir);
|
|
803
|
-
console.log(`Workspace: ${this.workspace}`);
|
|
804
|
-
console.log(`Report: ${this.reportPath}`);
|
|
805
|
-
console.log(`Scenarios: ${this.scenarios.join(", ")}`);
|
|
806
|
-
if (!this.confirmLive) {
|
|
807
|
-
console.log("Live mutations disabled. Pass --confirm-live to run mutating scenarios.");
|
|
808
|
-
}
|
|
809
|
-
for (const scenario of this.scenarios) {
|
|
810
|
-
await this.runScenario(scenario);
|
|
811
|
-
}
|
|
812
|
-
const failed = this.report.scenarios.filter((item) => item.status === "failed");
|
|
813
|
-
this.report.status = failed.length ? "failed" : "passed";
|
|
814
|
-
this.report.completed_at = new Date().toISOString();
|
|
815
|
-
this.writeReport();
|
|
816
|
-
console.log(JSON.stringify({
|
|
817
|
-
status: this.report.status,
|
|
818
|
-
report_file: this.reportPath,
|
|
819
|
-
passed: this.report.scenarios.filter((item) => item.status === "passed").length,
|
|
820
|
-
failed: failed.length,
|
|
821
|
-
created_mission_ids: this.report.created_mission_ids,
|
|
822
|
-
open_suggestion_ids: this.report.open_suggestion_ids,
|
|
823
|
-
}, null, 2));
|
|
824
|
-
if (failed.length && this.args["no-fail-exit"] !== true) process.exitCode = 1;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
const args = parseArgs(process.argv.slice(2));
|
|
829
|
-
new Harness(args).run().catch((error) => {
|
|
830
|
-
console.error(error?.message || error);
|
|
831
|
-
process.exit(1);
|
|
832
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const { spawnSync } = require("node:child_process");
|
|
8
|
+
|
|
9
|
+
const CLI_PATH = path.resolve(__dirname, "..", "cli.js");
|
|
10
|
+
const DEFAULT_BASE_URL = "https://api.myte.dev/api";
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = { _: [] };
|
|
14
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
+
const token = argv[index];
|
|
16
|
+
if (!token.startsWith("--")) {
|
|
17
|
+
args._.push(token);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const key = token.slice(2);
|
|
21
|
+
const next = argv[index + 1];
|
|
22
|
+
if (!next || next.startsWith("--")) {
|
|
23
|
+
args[key] = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
args[key] = next;
|
|
27
|
+
index += 1;
|
|
28
|
+
}
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadEnvFromNearest(startDir) {
|
|
33
|
+
let cur = startDir;
|
|
34
|
+
for (let index = 0; index < 8; index += 1) {
|
|
35
|
+
const filePath = path.join(cur, ".env");
|
|
36
|
+
if (fs.existsSync(filePath)) {
|
|
37
|
+
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
|
38
|
+
const trimmed = String(line || "").trim();
|
|
39
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
40
|
+
const splitAt = trimmed.indexOf("=");
|
|
41
|
+
if (splitAt <= 0) continue;
|
|
42
|
+
const key = trimmed.slice(0, splitAt).trim();
|
|
43
|
+
let value = trimmed.slice(splitAt + 1).trim();
|
|
44
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
45
|
+
value = value.slice(1, -1);
|
|
46
|
+
}
|
|
47
|
+
if (key && process.env[key] === undefined) process.env[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return filePath;
|
|
50
|
+
}
|
|
51
|
+
const parent = path.dirname(cur);
|
|
52
|
+
if (parent === cur) break;
|
|
53
|
+
cur = parent;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nowId() {
|
|
59
|
+
return new Date().toISOString().replace(/[^0-9A-Za-z]+/g, "-").replace(/^-|-$/g, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureDir(dirPath) {
|
|
63
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function writeJson(dirPath, name, payload) {
|
|
67
|
+
ensureDir(dirPath);
|
|
68
|
+
const filePath = path.join(dirPath, name);
|
|
69
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
70
|
+
return filePath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeBaseUrl(value) {
|
|
74
|
+
const raw = String(value || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
75
|
+
return raw.endsWith("/api") ? raw : `${raw}/api`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getProjectKey() {
|
|
79
|
+
return process.env.MYTE_API_KEY || process.env.MYTE_PROJECT_API_KEY || "";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stableScenarioList(raw, confirmLive) {
|
|
83
|
+
if (raw) {
|
|
84
|
+
return String(raw)
|
|
85
|
+
.split(",")
|
|
86
|
+
.map((item) => item.trim())
|
|
87
|
+
.filter(Boolean);
|
|
88
|
+
}
|
|
89
|
+
if (!confirmLive) return ["preflight", "contracts"];
|
|
90
|
+
return [
|
|
91
|
+
"preflight",
|
|
92
|
+
"contracts",
|
|
93
|
+
"suggestion_update_loop",
|
|
94
|
+
"create_reject_loop",
|
|
95
|
+
"archive_route_probe",
|
|
96
|
+
"approved_lifecycle",
|
|
97
|
+
"batch_limits",
|
|
98
|
+
"batch_mutation",
|
|
99
|
+
"qaqc",
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function commandForPlatform(command) {
|
|
104
|
+
if (process.platform !== "win32") return command;
|
|
105
|
+
if (command.endsWith(".cmd") || command.endsWith(".exe")) return command;
|
|
106
|
+
return `${command}.cmd`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function cliInvocation(args, cliMode, packageVersion) {
|
|
110
|
+
if (cliMode === "global") return { command: commandForPlatform("myte"), args };
|
|
111
|
+
if (cliMode === "npx") return { command: commandForPlatform("npx"), args: ["--yes", `myte@${packageVersion}`, ...args] };
|
|
112
|
+
return { command: process.execPath, args: [CLI_PATH, ...args] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function commandText(invocation) {
|
|
116
|
+
return [invocation.command, ...invocation.args].join(" ");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function runCli(cliArgs, options = {}) {
|
|
120
|
+
const {
|
|
121
|
+
workspace,
|
|
122
|
+
baseUrl,
|
|
123
|
+
actorScope,
|
|
124
|
+
expectFailure = false,
|
|
125
|
+
parseJson = true,
|
|
126
|
+
cliMode = "local",
|
|
127
|
+
packageVersion = "latest",
|
|
128
|
+
timeoutMs = 120000,
|
|
129
|
+
} = options;
|
|
130
|
+
const finalArgs = [...cliArgs];
|
|
131
|
+
if (actorScope) finalArgs.push("--actor-scope", actorScope);
|
|
132
|
+
if (baseUrl) finalArgs.push("--base-url", baseUrl);
|
|
133
|
+
const invocation = cliInvocation(finalArgs, cliMode, packageVersion);
|
|
134
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
135
|
+
cwd: workspace || process.cwd(),
|
|
136
|
+
env: process.env,
|
|
137
|
+
encoding: "utf8",
|
|
138
|
+
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(invocation.command),
|
|
139
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
140
|
+
timeout: timeoutMs,
|
|
141
|
+
});
|
|
142
|
+
const stdout = String(result.stdout || "").trim();
|
|
143
|
+
const stderr = String(result.stderr || "").trim();
|
|
144
|
+
const ok = result.status === 0;
|
|
145
|
+
const processError = result.error ? String(result.error.message || result.error) : "";
|
|
146
|
+
if (expectFailure) {
|
|
147
|
+
if (ok) {
|
|
148
|
+
throw new Error(`Expected command to fail but it succeeded: ${commandText(invocation)}`);
|
|
149
|
+
}
|
|
150
|
+
return { ok: false, status: result.status, stdout, stderr, error: processError };
|
|
151
|
+
}
|
|
152
|
+
if (!ok) {
|
|
153
|
+
throw new Error([`Command failed: ${commandText(invocation)}`, processError || stderr || stdout || "(no output)"].join("\n"));
|
|
154
|
+
}
|
|
155
|
+
if (!parseJson) return { ok: true, stdout, stderr };
|
|
156
|
+
try {
|
|
157
|
+
return stdout ? JSON.parse(stdout) : {};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new Error(`Expected JSON from ${commandText(invocation)}:\n${stdout}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function apiJson({ method = "GET", baseUrl, route, key, body, query, idempotencyKey }) {
|
|
164
|
+
const url = new URL(`${baseUrl}${route}`);
|
|
165
|
+
for (const [queryKey, queryValue] of Object.entries(query || {})) {
|
|
166
|
+
if (queryValue !== undefined && queryValue !== null && String(queryValue).trim() !== "") {
|
|
167
|
+
url.searchParams.set(queryKey, String(queryValue));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const headers = { Authorization: `Bearer ${key}` };
|
|
171
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
172
|
+
if (idempotencyKey) headers["X-Idempotency-Key"] = String(idempotencyKey);
|
|
173
|
+
const response = await fetch(url.toString(), {
|
|
174
|
+
method,
|
|
175
|
+
headers,
|
|
176
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
177
|
+
});
|
|
178
|
+
let payload = {};
|
|
179
|
+
try {
|
|
180
|
+
payload = await response.json();
|
|
181
|
+
} catch (_) {
|
|
182
|
+
payload = {};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
ok: response.ok && payload.status === "success",
|
|
186
|
+
statusCode: response.status,
|
|
187
|
+
message: payload.message || "",
|
|
188
|
+
data: payload.data || {},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function missionPublicId(mission) {
|
|
193
|
+
return String(mission?.mission_id || mission?.public_id || mission?.id || "").trim();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function missionTitle(mission) {
|
|
197
|
+
return String(mission?.title || "").trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function activeMissions(bootstrap) {
|
|
201
|
+
return (Array.isArray(bootstrap?.missions) ? bootstrap.missions : [])
|
|
202
|
+
.filter((mission) => missionPublicId(mission))
|
|
203
|
+
.filter((mission) => String(mission.status || "").toLowerCase() !== "archived")
|
|
204
|
+
.filter((mission) => mission.is_archived !== true);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function chooseStableMission(bootstrap) {
|
|
208
|
+
const missions = activeMissions(bootstrap);
|
|
209
|
+
return missions.find((mission) => !/^Disposable mission harness/i.test(missionTitle(mission))) || missions[0] || null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function hasRunnableQaqcCases(mission) {
|
|
213
|
+
if (String(mission?.status || "").toLowerCase() === "done") return false;
|
|
214
|
+
const testCases = mission?.test_cases || {};
|
|
215
|
+
const success = Array.isArray(testCases.success) ? testCases.success : [];
|
|
216
|
+
const failure = Array.isArray(testCases.failure) ? testCases.failure : [];
|
|
217
|
+
return success.length > 0 || failure.length > 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function chooseQaqcMission(bootstrap) {
|
|
221
|
+
const missions = activeMissions(bootstrap).filter(hasRunnableQaqcCases);
|
|
222
|
+
return missions.find((mission) => !/^Disposable mission harness/i.test(missionTitle(mission))) || missions[0] || null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function pickSuggestionId(output) {
|
|
226
|
+
for (const item of output.items || []) {
|
|
227
|
+
const suggestionId = item?.suggestion?.suggestion_id || item?.suggestion_id;
|
|
228
|
+
if (suggestionId) return String(suggestionId);
|
|
229
|
+
}
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function pickAppliedMissionId(output) {
|
|
234
|
+
for (const item of output.items || []) {
|
|
235
|
+
const missionId = item?.applied_mission?.mission_id || item?.mission_id || item?.suggestion?.mission_id;
|
|
236
|
+
if (missionId) return String(missionId);
|
|
237
|
+
}
|
|
238
|
+
return "";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function threadRefs(snapshot) {
|
|
242
|
+
const refs = [];
|
|
243
|
+
for (const source of [snapshot?.queue, snapshot?.threads, snapshot?.suggestions]) {
|
|
244
|
+
if (!Array.isArray(source)) continue;
|
|
245
|
+
for (const item of source) {
|
|
246
|
+
refs.push({
|
|
247
|
+
suggestion_id: String(item?.suggestion_id || item?.id || "").trim(),
|
|
248
|
+
mission_id: String(item?.mission_id || "").trim(),
|
|
249
|
+
change_type: String(item?.change_type || "").trim(),
|
|
250
|
+
status: String(item?.status || item?.thread_status || "").trim(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return refs.filter((item) => item.suggestion_id || item.mission_id);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function queueRefs(snapshot) {
|
|
258
|
+
return threadRefs({ queue: snapshot?.queue || [] });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hasMissionInBootstrap(bootstrap, missionId) {
|
|
262
|
+
const target = String(missionId || "").toUpperCase();
|
|
263
|
+
return activeMissions(bootstrap).some((mission) => missionPublicId(mission).toUpperCase() === target);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function assertCondition(condition, message) {
|
|
267
|
+
if (!condition) throw new Error(message);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function safeResult(details = {}) {
|
|
271
|
+
return JSON.parse(JSON.stringify(details, (_key, value) => {
|
|
272
|
+
if (typeof value !== "string") return value;
|
|
273
|
+
if (value.includes("@")) return "[redacted]";
|
|
274
|
+
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
|
|
275
|
+
}));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
class Harness {
|
|
279
|
+
constructor(args) {
|
|
280
|
+
loadEnvFromNearest(process.cwd());
|
|
281
|
+
this.args = args;
|
|
282
|
+
this.confirmLive = args["confirm-live"] === true;
|
|
283
|
+
this.keepGoing = args["keep-going"] === true;
|
|
284
|
+
this.baseUrl = normalizeBaseUrl(args["base-url"] || process.env.MYTE_API_BASE_URL || DEFAULT_BASE_URL);
|
|
285
|
+
this.key = getProjectKey();
|
|
286
|
+
this.actorBase = String(args["actor-scope"] || `mission-live-full-${nowId()}`);
|
|
287
|
+
this.cliMode = String(args.cli || "local").toLowerCase();
|
|
288
|
+
this.packageVersion = String(args["package-version"] || "0.0.28");
|
|
289
|
+
this.batchSize = Math.max(1, Math.min(10, Number(args["batch-size"] || 10)));
|
|
290
|
+
this.includeQaqcRun = args["include-qaqc-run"] === true;
|
|
291
|
+
this.workspace = path.resolve(
|
|
292
|
+
args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-mission-full-harness-")),
|
|
293
|
+
);
|
|
294
|
+
this.payloadDir = path.join(this.workspace, "payloads");
|
|
295
|
+
this.reportPath = path.resolve(args["report-file"] || path.join(this.workspace, "mission-live-full-report.json"));
|
|
296
|
+
this.scenarios = stableScenarioList(args.scenarios, this.confirmLive);
|
|
297
|
+
this.createdMissions = new Set();
|
|
298
|
+
this.openSuggestions = new Set();
|
|
299
|
+
this.archiveRouteAvailable = null;
|
|
300
|
+
this.report = {
|
|
301
|
+
status: "running",
|
|
302
|
+
generated_at: new Date().toISOString(),
|
|
303
|
+
base_url: this.baseUrl,
|
|
304
|
+
cli_mode: this.cliMode,
|
|
305
|
+
package_version: this.packageVersion,
|
|
306
|
+
workspace: this.workspace,
|
|
307
|
+
scenarios: [],
|
|
308
|
+
created_mission_ids: [],
|
|
309
|
+
open_suggestion_ids: [],
|
|
310
|
+
notes: [],
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
requireKey() {
|
|
315
|
+
if (!this.key) throw new Error("Missing MYTE_API_KEY or MYTE_PROJECT_API_KEY.");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
requireLive(name) {
|
|
319
|
+
if (!this.confirmLive) {
|
|
320
|
+
throw new Error(`${name} requires --confirm-live because it mutates live project data.`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
cli(args, options = {}) {
|
|
325
|
+
return runCli(args, {
|
|
326
|
+
workspace: this.workspace,
|
|
327
|
+
baseUrl: this.baseUrl,
|
|
328
|
+
cliMode: this.cliMode,
|
|
329
|
+
packageVersion: this.packageVersion,
|
|
330
|
+
...options,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
cliWithActor(args, suffix, options = {}) {
|
|
335
|
+
return this.cli(args, {
|
|
336
|
+
actorScope: `${this.actorBase}-${suffix}`,
|
|
337
|
+
...options,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async api(route, options = {}) {
|
|
342
|
+
return apiJson({
|
|
343
|
+
baseUrl: this.baseUrl,
|
|
344
|
+
key: this.key,
|
|
345
|
+
route,
|
|
346
|
+
...options,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async bootstrap() {
|
|
351
|
+
const response = await this.api("/project-assistant/bootstrap");
|
|
352
|
+
if (!response.ok) throw new Error(`Bootstrap API failed (${response.statusCode}): ${response.message}`);
|
|
353
|
+
return response.data;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async suggestions(actorScope) {
|
|
357
|
+
const response = await this.api("/project-assistant/suggestions", {
|
|
358
|
+
query: actorScope ? { actor_scope: actorScope } : {},
|
|
359
|
+
});
|
|
360
|
+
if (!response.ok) throw new Error(`Suggestions API failed (${response.statusCode}): ${response.message}`);
|
|
361
|
+
return response.data;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async probeArchiveRoute() {
|
|
365
|
+
const response = await this.api("/project-assistant/mission-archive", {
|
|
366
|
+
method: "POST",
|
|
367
|
+
idempotencyKey: `mission-live-full-route-probe-${nowId()}`,
|
|
368
|
+
body: {
|
|
369
|
+
mission_ids: ["__NOT_A_REAL_MISSION__"],
|
|
370
|
+
reason: "mission live full harness route probe",
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
this.archiveRouteAvailable = response.statusCode === 200;
|
|
374
|
+
if (!this.archiveRouteAvailable) {
|
|
375
|
+
throw new Error(`Archive route is not live (${response.statusCode}): ${response.message || "no message"}`);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
status_code: response.statusCode,
|
|
379
|
+
rejected_count: response.data?.rejected_count,
|
|
380
|
+
updated_count: response.data?.updated_count,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async scenarioPreflight() {
|
|
385
|
+
this.requireKey();
|
|
386
|
+
const config = this.cli(["config", "--json"]);
|
|
387
|
+
const bootstrap = this.cli(["bootstrap", "--json"]);
|
|
388
|
+
const sync = this.cliWithActor(["suggestions", "sync", "--json"], "preflight");
|
|
389
|
+
return {
|
|
390
|
+
project_id: config.project_id || bootstrap.project_id || null,
|
|
391
|
+
repo_count: Array.isArray(config.repo_names) ? config.repo_names.length : 0,
|
|
392
|
+
active_mission_count: bootstrap.counts?.missions || 0,
|
|
393
|
+
suggestion_threads: sync.thread_count || 0,
|
|
394
|
+
actionable_threads: sync.actionable_count || 0,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async scenarioContracts() {
|
|
399
|
+
const invalidCreate = writeJson(this.payloadDir, "invalid-create-change-type.json", {
|
|
400
|
+
items: [{ change_type: "add-mission", change_description: "invalid", change_set: { title: "Invalid" } }],
|
|
401
|
+
});
|
|
402
|
+
const invalidRevise = writeJson(this.payloadDir, "invalid-revise-change-type.json", {
|
|
403
|
+
items: [{ suggestion_id: "000000000000000000000000", change_type: "create", change_set: { title: "Invalid" } }],
|
|
404
|
+
});
|
|
405
|
+
const badMissionIds = Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
406
|
+
const tooManyQaqcIds = Array.from({ length: 11 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
407
|
+
|
|
408
|
+
const checks = [];
|
|
409
|
+
checks.push(this.cli(["suggestions", "create", "--file", invalidCreate, "--json"], { expectFailure: true, parseJson: false }));
|
|
410
|
+
checks.push(this.cli(["suggestions", "revise", "--file", invalidRevise, "--json"], { expectFailure: true, parseJson: false }));
|
|
411
|
+
checks.push(this.cli(["mission", "status", "--mission-ids", "M001", "--status", "archived", "--json"], { expectFailure: true, parseJson: false }));
|
|
412
|
+
checks.push(this.cli(["mission", "restore", "--mission-ids", "M001", "--status", "todo", "--json"], { expectFailure: true, parseJson: false }));
|
|
413
|
+
checks.push(this.cli(["mission", "delete", "--mission-ids", "M001", "--json"], { expectFailure: true, parseJson: false }));
|
|
414
|
+
checks.push(this.cli(["mission", "archive", "--mission-ids", "M001", "--reason", "dry run", "--dry-run", "--json"]));
|
|
415
|
+
checks.push(this.cli(["mission", "status", "--mission-ids", badMissionIds, "--status", "todo", "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }));
|
|
416
|
+
checks.push(this.cli(["run-qaqc", "--mission-ids", tooManyQaqcIds, "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }));
|
|
417
|
+
return { checks: checks.length };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async scenarioSuggestionUpdateLoop() {
|
|
421
|
+
this.requireLive("suggestion_update_loop");
|
|
422
|
+
const actor = `${this.actorBase}-update-loop`;
|
|
423
|
+
const bootstrap = await this.bootstrap();
|
|
424
|
+
const mission = chooseStableMission(bootstrap);
|
|
425
|
+
assertCondition(mission, "No active mission available for update suggestion loop.");
|
|
426
|
+
const missionId = missionPublicId(mission);
|
|
427
|
+
|
|
428
|
+
const createOne = writeJson(this.payloadDir, "update-loop-create-1.json", {
|
|
429
|
+
items: [{
|
|
430
|
+
change_type: "update",
|
|
431
|
+
mission_id: missionId,
|
|
432
|
+
change_description: "Harness proposed non-applied update",
|
|
433
|
+
change_set: { description: "Harness proposed description. This should be rejected during cleanup." },
|
|
434
|
+
}],
|
|
435
|
+
});
|
|
436
|
+
const first = this.cli(["suggestions", "create", "--file", createOne, "--no-sync", "--json"], { actorScope: actor });
|
|
437
|
+
const suggestionId = pickSuggestionId(first);
|
|
438
|
+
assertCondition(suggestionId, "Update suggestion create did not return suggestion_id.");
|
|
439
|
+
this.openSuggestions.add(suggestionId);
|
|
440
|
+
|
|
441
|
+
const createTwo = writeJson(this.payloadDir, "update-loop-create-2.json", {
|
|
442
|
+
items: [{
|
|
443
|
+
change_type: "update",
|
|
444
|
+
mission_id: missionId,
|
|
445
|
+
change_description: "Harness append to existing pending update thread",
|
|
446
|
+
change_set: { acceptance_criteria: ["Harness proposed acceptance criterion. This should be rejected."] },
|
|
447
|
+
}],
|
|
448
|
+
});
|
|
449
|
+
const second = this.cli(["suggestions", "create", "--file", createTwo, "--no-sync", "--json"], { actorScope: actor });
|
|
450
|
+
const appendedId = pickSuggestionId(second);
|
|
451
|
+
assertCondition(appendedId === suggestionId, `Expected append to same suggestion ${suggestionId}, got ${appendedId || "(none)"}.`);
|
|
452
|
+
|
|
453
|
+
const revise = writeJson(this.payloadDir, "update-loop-revise.json", {
|
|
454
|
+
items: [{
|
|
455
|
+
suggestion_id: suggestionId,
|
|
456
|
+
change_description: "Harness revision after append",
|
|
457
|
+
change_set: { technical_requirements: ["Harness proposed requirement. This should be rejected."] },
|
|
458
|
+
}],
|
|
459
|
+
});
|
|
460
|
+
const revised = this.cli(["suggestions", "revise", "--file", revise, "--no-sync", "--json"], { actorScope: actor });
|
|
461
|
+
assertCondition((revised.revised_count || 0) >= 1, "Update suggestion revise did not revise the thread.");
|
|
462
|
+
|
|
463
|
+
const requestChanges = writeJson(this.payloadDir, "update-loop-request-changes.json", {
|
|
464
|
+
items: [{ suggestion_id: suggestionId, action: "request_changes", review_action: "request_changes", review_note: "Harness request changes check." }],
|
|
465
|
+
});
|
|
466
|
+
const requested = this.cli(["suggestions", "review", "--file", requestChanges, "--no-sync", "--json"], { actorScope: actor });
|
|
467
|
+
assertCondition((requested.processed_count || 0) >= 1, "Update suggestion request_changes did not process.");
|
|
468
|
+
|
|
469
|
+
const reviseAfterRequest = writeJson(this.payloadDir, "update-loop-revise-after-request.json", {
|
|
470
|
+
items: [{
|
|
471
|
+
suggestion_id: suggestionId,
|
|
472
|
+
change_description: "Harness revision after request_changes",
|
|
473
|
+
change_set: { labels: ["harness-proposed-update"] },
|
|
474
|
+
}],
|
|
475
|
+
});
|
|
476
|
+
const revisedAgain = this.cli(["suggestions", "revise", "--file", reviseAfterRequest, "--no-sync", "--json"], { actorScope: actor });
|
|
477
|
+
assertCondition((revisedAgain.revised_count || 0) >= 1, "Update suggestion revise after request_changes did not process.");
|
|
478
|
+
|
|
479
|
+
const reject = writeJson(this.payloadDir, "update-loop-reject.json", {
|
|
480
|
+
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: "Harness cleanup reject." }],
|
|
481
|
+
});
|
|
482
|
+
const rejected = this.cli(["suggestions", "review", "--file", reject, "--json"], { actorScope: actor });
|
|
483
|
+
assertCondition((rejected.processed_count || 0) >= 1, "Update suggestion reject cleanup did not process.");
|
|
484
|
+
this.openSuggestions.delete(suggestionId);
|
|
485
|
+
|
|
486
|
+
return { mission_id: missionId, suggestion_id: suggestionId, append_verified: true };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async scenarioCreateRejectLoop() {
|
|
490
|
+
this.requireLive("create_reject_loop");
|
|
491
|
+
const actor = `${this.actorBase}-create-reject`;
|
|
492
|
+
const title = `Harness rejected mission ${nowId()}`;
|
|
493
|
+
const before = await this.bootstrap();
|
|
494
|
+
|
|
495
|
+
const createFile = writeJson(this.payloadDir, "create-reject-create.json", {
|
|
496
|
+
items: [{
|
|
497
|
+
change_type: "create",
|
|
498
|
+
change_description: "Harness new mission proposal that will be rejected",
|
|
499
|
+
change_set: {
|
|
500
|
+
title,
|
|
501
|
+
description: "Harness proposal that should never become an active mission.",
|
|
502
|
+
acceptance_criteria: ["The rejected proposal does not create a mission card."],
|
|
503
|
+
labels: ["harness", "reject-path"],
|
|
504
|
+
},
|
|
505
|
+
}],
|
|
506
|
+
});
|
|
507
|
+
const created = this.cli(["suggestions", "create", "--file", createFile, "--no-sync", "--json"], { actorScope: actor });
|
|
508
|
+
const suggestionId = pickSuggestionId(created);
|
|
509
|
+
assertCondition(suggestionId, "Create suggestion did not return suggestion_id.");
|
|
510
|
+
this.openSuggestions.add(suggestionId);
|
|
511
|
+
|
|
512
|
+
const afterCreate = await this.bootstrap();
|
|
513
|
+
assertCondition(
|
|
514
|
+
activeMissions(afterCreate).length === activeMissions(before).length,
|
|
515
|
+
"New mission proposal created an active mission before approval.",
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
const reviseFile = writeJson(this.payloadDir, "create-reject-revise.json", {
|
|
519
|
+
items: [{
|
|
520
|
+
suggestion_id: suggestionId,
|
|
521
|
+
change_description: "Harness revision for rejected create thread",
|
|
522
|
+
change_set: { description: "Harness revised proposal. This should still be rejected." },
|
|
523
|
+
}],
|
|
524
|
+
});
|
|
525
|
+
const revised = this.cli(["suggestions", "revise", "--file", reviseFile, "--no-sync", "--json"], { actorScope: actor });
|
|
526
|
+
assertCondition((revised.revised_count || 0) >= 1, "Create suggestion revise did not process.");
|
|
527
|
+
|
|
528
|
+
const requestChanges = writeJson(this.payloadDir, "create-reject-request-changes.json", {
|
|
529
|
+
items: [{ suggestion_id: suggestionId, action: "request_changes", review_action: "request_changes", review_note: "Harness request changes check." }],
|
|
530
|
+
});
|
|
531
|
+
const requested = this.cli(["suggestions", "review", "--file", requestChanges, "--no-sync", "--json"], { actorScope: actor });
|
|
532
|
+
assertCondition((requested.processed_count || 0) >= 1, "Create suggestion request_changes did not process.");
|
|
533
|
+
|
|
534
|
+
const reject = writeJson(this.payloadDir, "create-reject-reject.json", {
|
|
535
|
+
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: "Harness cleanup reject." }],
|
|
536
|
+
});
|
|
537
|
+
const rejected = this.cli(["suggestions", "review", "--file", reject, "--json"], { actorScope: actor });
|
|
538
|
+
assertCondition((rejected.processed_count || 0) >= 1, "Create suggestion reject cleanup did not process.");
|
|
539
|
+
this.openSuggestions.delete(suggestionId);
|
|
540
|
+
|
|
541
|
+
const afterReject = await this.bootstrap();
|
|
542
|
+
assertCondition(
|
|
543
|
+
activeMissions(afterReject).every((mission) => missionTitle(mission) !== title),
|
|
544
|
+
"Rejected create suggestion appeared as an active mission.",
|
|
545
|
+
);
|
|
546
|
+
return { suggestion_id: suggestionId, active_mission_created: false };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async archiveMissions(missionIds, reason) {
|
|
550
|
+
if (!missionIds.length) return null;
|
|
551
|
+
return this.cli(["mission", "archive", "--mission-ids", missionIds.join(","), "--reason", reason, "--json"], {
|
|
552
|
+
timeoutMs: 180000,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async rejectSuggestion(suggestionId, actor, note) {
|
|
557
|
+
const rejectFile = writeJson(this.payloadDir, `reject-${suggestionId}.json`, {
|
|
558
|
+
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: note || "Harness cleanup reject." }],
|
|
559
|
+
});
|
|
560
|
+
return this.cli(["suggestions", "review", "--file", rejectFile, "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async createApprovedMission(actor, index = 1) {
|
|
564
|
+
const title = `Harness approved mission ${nowId()} ${index}`;
|
|
565
|
+
const description = "Disposable mission created by the reusable live mission harness.";
|
|
566
|
+
const createFile = writeJson(this.payloadDir, `approved-create-${index}.json`, {
|
|
567
|
+
items: [{
|
|
568
|
+
change_type: "create",
|
|
569
|
+
change_description: "Harness approved disposable mission creation",
|
|
570
|
+
change_set: {
|
|
571
|
+
title,
|
|
572
|
+
description,
|
|
573
|
+
acceptance_criteria: [
|
|
574
|
+
"Mission can be created through suggestion approval.",
|
|
575
|
+
"Mission can be mutated through project-key mission commands.",
|
|
576
|
+
"Mission can be archived without hard delete.",
|
|
577
|
+
],
|
|
578
|
+
labels: ["harness", "approved-disposable"],
|
|
579
|
+
},
|
|
580
|
+
}],
|
|
581
|
+
});
|
|
582
|
+
const created = this.cli(["suggestions", "create", "--file", createFile, "--no-sync", "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
583
|
+
const suggestionId = pickSuggestionId(created);
|
|
584
|
+
assertCondition(suggestionId, "Approved mission create did not return suggestion_id.");
|
|
585
|
+
this.openSuggestions.add(suggestionId);
|
|
586
|
+
const reviewFile = writeJson(this.payloadDir, `approved-review-${index}.json`, {
|
|
587
|
+
items: [{
|
|
588
|
+
suggestion_id: suggestionId,
|
|
589
|
+
action: "approve",
|
|
590
|
+
review_action: "approve",
|
|
591
|
+
final_change_set: {
|
|
592
|
+
title,
|
|
593
|
+
description,
|
|
594
|
+
acceptance_criteria: [
|
|
595
|
+
"Mission can be created through suggestion approval.",
|
|
596
|
+
"Mission can be mutated through project-key mission commands.",
|
|
597
|
+
"Mission can be archived without hard delete.",
|
|
598
|
+
],
|
|
599
|
+
labels: ["harness", "approved-disposable"],
|
|
600
|
+
},
|
|
601
|
+
}],
|
|
602
|
+
});
|
|
603
|
+
const reviewed = this.cli(["suggestions", "review", "--file", reviewFile, "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
604
|
+
const missionId = pickAppliedMissionId(reviewed);
|
|
605
|
+
assertCondition(missionId, "Approved mission review did not return applied mission id.");
|
|
606
|
+
this.createdMissions.add(missionId);
|
|
607
|
+
this.openSuggestions.delete(suggestionId);
|
|
608
|
+
return { mission_id: missionId, suggestion_id: suggestionId, title };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async scenarioApprovedLifecycle() {
|
|
612
|
+
this.requireLive("approved_lifecycle");
|
|
613
|
+
if (this.archiveRouteAvailable !== true) await this.probeArchiveRoute();
|
|
614
|
+
const actor = `${this.actorBase}-approved-lifecycle`;
|
|
615
|
+
const created = await this.createApprovedMission(actor, 1);
|
|
616
|
+
let pendingSuggestionId = "";
|
|
617
|
+
try {
|
|
618
|
+
const activeAfterCreate = await this.bootstrap();
|
|
619
|
+
assertCondition(hasMissionInBootstrap(activeAfterCreate, created.mission_id), "Approved mission is missing from active bootstrap state.");
|
|
620
|
+
|
|
621
|
+
for (const status of ["in_progress", "done", "todo"]) {
|
|
622
|
+
const result = this.cli(["mission", "status", "--mission-ids", created.mission_id, "--status", status, "--json"], { timeoutMs: 180000 });
|
|
623
|
+
assertCondition((result.matched_count || 0) >= 1, `Mission status ${status} did not match the disposable mission.`);
|
|
624
|
+
}
|
|
625
|
+
const noop = this.cli(["mission", "status", "--mission-ids", created.mission_id, "--status", "todo", "--json"], { timeoutMs: 180000 });
|
|
626
|
+
assertCondition((noop.unchanged_count || 0) >= 1 || (noop.matched_count || 0) >= 1, "Idempotent mission status no-op did not report a stable match.");
|
|
627
|
+
|
|
628
|
+
const pendingFile = writeJson(this.payloadDir, "approved-lifecycle-pending-update.json", {
|
|
629
|
+
items: [{
|
|
630
|
+
change_type: "update",
|
|
631
|
+
mission_id: created.mission_id,
|
|
632
|
+
change_description: "Harness pending suggestion across archive",
|
|
633
|
+
change_set: { description: "Harness pending suggestion should be hidden from actionable queue while archived." },
|
|
634
|
+
}],
|
|
635
|
+
});
|
|
636
|
+
const pending = this.cli(["suggestions", "create", "--file", pendingFile, "--no-sync", "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
637
|
+
pendingSuggestionId = pickSuggestionId(pending);
|
|
638
|
+
assertCondition(pendingSuggestionId, "Pending update suggestion for approved mission was not created.");
|
|
639
|
+
this.openSuggestions.add(pendingSuggestionId);
|
|
640
|
+
|
|
641
|
+
const archived = await this.archiveMissions([created.mission_id], "Harness archive active mission with pending suggestion");
|
|
642
|
+
assertCondition((archived.updated_count || 0) + (archived.unchanged_count || 0) >= 1, "Archive did not update or confirm the disposable mission.");
|
|
643
|
+
|
|
644
|
+
const afterArchive = await this.bootstrap();
|
|
645
|
+
assertCondition(!hasMissionInBootstrap(afterArchive, created.mission_id), "Archived mission is still present in active bootstrap state.");
|
|
646
|
+
const archivedSuggestions = await this.suggestions(actor);
|
|
647
|
+
const archivedQueueRefs = queueRefs(archivedSuggestions).filter((item) => item.mission_id.toUpperCase() === created.mission_id.toUpperCase());
|
|
648
|
+
assertCondition(archivedQueueRefs.length === 0, "Archived mission still appears in actionable suggestion queue.");
|
|
649
|
+
const archivedThreadRefs = threadRefs(archivedSuggestions).filter((item) => item.suggestion_id === pendingSuggestionId);
|
|
650
|
+
assertCondition(archivedThreadRefs.length >= 1, "Archived mission pending suggestion was not preserved in thread history.");
|
|
651
|
+
|
|
652
|
+
await this.rejectSuggestion(pendingSuggestionId, actor, "Harness cleanup reject after archived-thread preservation check.");
|
|
653
|
+
this.openSuggestions.delete(pendingSuggestionId);
|
|
654
|
+
return {
|
|
655
|
+
mission_id: created.mission_id,
|
|
656
|
+
create_suggestion_id: created.suggestion_id,
|
|
657
|
+
pending_suggestion_id: pendingSuggestionId,
|
|
658
|
+
archive_hides_active: true,
|
|
659
|
+
archive_preserves_thread_history: true,
|
|
660
|
+
};
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (pendingSuggestionId) {
|
|
663
|
+
try {
|
|
664
|
+
await this.rejectSuggestion(pendingSuggestionId, actor, "Harness failure cleanup reject.");
|
|
665
|
+
this.openSuggestions.delete(pendingSuggestionId);
|
|
666
|
+
} catch (_) {}
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
await this.archiveMissions([created.mission_id], "Harness failure cleanup archive.");
|
|
670
|
+
} catch (_) {}
|
|
671
|
+
throw error;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async scenarioBatchLimits() {
|
|
676
|
+
const statusIds = Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
677
|
+
const qaqcIds = Array.from({ length: 11 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
678
|
+
const archiveProbe = await this.api("/project-assistant/mission-archive", {
|
|
679
|
+
method: "POST",
|
|
680
|
+
idempotencyKey: `mission-live-full-archive-limit-${nowId()}`,
|
|
681
|
+
body: {
|
|
682
|
+
mission_ids: Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`),
|
|
683
|
+
reason: "Harness limit check",
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
const checks = [
|
|
687
|
+
this.cli(["mission", "status", "--mission-ids", statusIds, "--status", "todo", "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }),
|
|
688
|
+
this.cli(["run-qaqc", "--mission-ids", qaqcIds, "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }),
|
|
689
|
+
];
|
|
690
|
+
assertCondition(
|
|
691
|
+
archiveProbe.statusCode === 400 || archiveProbe.statusCode === 404,
|
|
692
|
+
`Expected archive limit check to return 400 when route is live or 404 when route is absent, got ${archiveProbe.statusCode}.`,
|
|
693
|
+
);
|
|
694
|
+
return {
|
|
695
|
+
checks: checks.length + 1,
|
|
696
|
+
mission_status_limit: 100,
|
|
697
|
+
run_qaqc_limit: 10,
|
|
698
|
+
archive_limit: archiveProbe.statusCode === 400 ? 100 : "route_not_live",
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
async scenarioBatchMutation() {
|
|
703
|
+
this.requireLive("batch_mutation");
|
|
704
|
+
if (this.archiveRouteAvailable !== true) await this.probeArchiveRoute();
|
|
705
|
+
const actor = `${this.actorBase}-batch`;
|
|
706
|
+
const created = [];
|
|
707
|
+
try {
|
|
708
|
+
for (let index = 1; index <= this.batchSize; index += 1) {
|
|
709
|
+
created.push(await this.createApprovedMission(actor, index));
|
|
710
|
+
}
|
|
711
|
+
const missionIds = created.map((item) => item.mission_id);
|
|
712
|
+
for (const status of ["in_progress", "done", "todo"]) {
|
|
713
|
+
const result = this.cli(["mission", "status", "--mission-ids", missionIds.join(","), "--status", status, "--json"], { timeoutMs: 240000 });
|
|
714
|
+
assertCondition((result.matched_count || 0) === missionIds.length, `Batch status ${status} matched ${result.matched_count || 0}/${missionIds.length}.`);
|
|
715
|
+
}
|
|
716
|
+
const archived = await this.archiveMissions(missionIds, "Harness batch cleanup archive");
|
|
717
|
+
assertCondition((archived.updated_count || 0) + (archived.unchanged_count || 0) === missionIds.length, "Batch archive did not cover every disposable mission.");
|
|
718
|
+
return { mission_count: missionIds.length, mission_ids: missionIds };
|
|
719
|
+
} catch (error) {
|
|
720
|
+
try {
|
|
721
|
+
await this.archiveMissions(created.map((item) => item.mission_id), "Harness batch failure cleanup archive");
|
|
722
|
+
} catch (_) {}
|
|
723
|
+
throw error;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async scenarioQaqc() {
|
|
728
|
+
const sync = this.cli(["sync-qaqc", "--json"], { timeoutMs: 180000 });
|
|
729
|
+
const result = {
|
|
730
|
+
sync_mission_count: sync.counts?.missions || sync.missions_count || 0,
|
|
731
|
+
run_executed: false,
|
|
732
|
+
};
|
|
733
|
+
if (!this.includeQaqcRun) {
|
|
734
|
+
result.skipped_run_reason = "Pass --include-qaqc-run to queue a live QAQC batch.";
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
this.requireLive("qaqc run");
|
|
738
|
+
const bootstrap = await this.bootstrap();
|
|
739
|
+
const mission = chooseQaqcMission(bootstrap);
|
|
740
|
+
assertCondition(mission, "No active non-Done mission with test cases is available for QAQC run.");
|
|
741
|
+
const missionId = missionPublicId(mission);
|
|
742
|
+
const run = this.cli(["run-qaqc", "--mission-ids", missionId, "--wait", "--sync", "--json"], { timeoutMs: 600000 });
|
|
743
|
+
result.run_executed = true;
|
|
744
|
+
result.mission_id = missionId;
|
|
745
|
+
result.batch_id = run.batch_id || null;
|
|
746
|
+
result.status = run.status || null;
|
|
747
|
+
result.accepted_count = run.accepted_count || 0;
|
|
748
|
+
assertCondition(result.accepted_count > 0, `QAQC run did not accept ${missionId}; status=${result.status || "unknown"}.`);
|
|
749
|
+
return result;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
scenarioMap() {
|
|
753
|
+
return {
|
|
754
|
+
preflight: () => this.scenarioPreflight(),
|
|
755
|
+
contracts: () => this.scenarioContracts(),
|
|
756
|
+
suggestion_update_loop: () => this.scenarioSuggestionUpdateLoop(),
|
|
757
|
+
create_reject_loop: () => this.scenarioCreateRejectLoop(),
|
|
758
|
+
archive_route_probe: () => this.probeArchiveRoute(),
|
|
759
|
+
approved_lifecycle: () => this.scenarioApprovedLifecycle(),
|
|
760
|
+
batch_limits: () => this.scenarioBatchLimits(),
|
|
761
|
+
batch_mutation: () => this.scenarioBatchMutation(),
|
|
762
|
+
qaqc: () => this.scenarioQaqc(),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async runScenario(name) {
|
|
767
|
+
const startedAt = new Date().toISOString();
|
|
768
|
+
const entry = { name, status: "running", started_at: startedAt };
|
|
769
|
+
this.report.scenarios.push(entry);
|
|
770
|
+
const map = this.scenarioMap();
|
|
771
|
+
if (!map[name]) throw new Error(`Unknown scenario: ${name}`);
|
|
772
|
+
try {
|
|
773
|
+
const details = await map[name]();
|
|
774
|
+
entry.status = "passed";
|
|
775
|
+
entry.completed_at = new Date().toISOString();
|
|
776
|
+
entry.details = safeResult(details || {});
|
|
777
|
+
console.log(`[pass] ${name}`);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
entry.status = "failed";
|
|
780
|
+
entry.completed_at = new Date().toISOString();
|
|
781
|
+
entry.error = String(error?.message || error);
|
|
782
|
+
console.error(`[fail] ${name}: ${entry.error}`);
|
|
783
|
+
if (!this.keepGoing) throw error;
|
|
784
|
+
} finally {
|
|
785
|
+
this.writeReport();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
writeReport() {
|
|
790
|
+
this.report.created_mission_ids = Array.from(this.createdMissions).sort();
|
|
791
|
+
this.report.open_suggestion_ids = Array.from(this.openSuggestions).sort();
|
|
792
|
+
if (!this.report.completed_at) {
|
|
793
|
+
this.report.status = this.report.scenarios.some((item) => item.status === "failed") ? "failed" : "running";
|
|
794
|
+
}
|
|
795
|
+
ensureDir(path.dirname(this.reportPath));
|
|
796
|
+
fs.writeFileSync(this.reportPath, JSON.stringify(this.report, null, 2), "utf8");
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async run() {
|
|
800
|
+
this.requireKey();
|
|
801
|
+
ensureDir(this.workspace);
|
|
802
|
+
ensureDir(this.payloadDir);
|
|
803
|
+
console.log(`Workspace: ${this.workspace}`);
|
|
804
|
+
console.log(`Report: ${this.reportPath}`);
|
|
805
|
+
console.log(`Scenarios: ${this.scenarios.join(", ")}`);
|
|
806
|
+
if (!this.confirmLive) {
|
|
807
|
+
console.log("Live mutations disabled. Pass --confirm-live to run mutating scenarios.");
|
|
808
|
+
}
|
|
809
|
+
for (const scenario of this.scenarios) {
|
|
810
|
+
await this.runScenario(scenario);
|
|
811
|
+
}
|
|
812
|
+
const failed = this.report.scenarios.filter((item) => item.status === "failed");
|
|
813
|
+
this.report.status = failed.length ? "failed" : "passed";
|
|
814
|
+
this.report.completed_at = new Date().toISOString();
|
|
815
|
+
this.writeReport();
|
|
816
|
+
console.log(JSON.stringify({
|
|
817
|
+
status: this.report.status,
|
|
818
|
+
report_file: this.reportPath,
|
|
819
|
+
passed: this.report.scenarios.filter((item) => item.status === "passed").length,
|
|
820
|
+
failed: failed.length,
|
|
821
|
+
created_mission_ids: this.report.created_mission_ids,
|
|
822
|
+
open_suggestion_ids: this.report.open_suggestion_ids,
|
|
823
|
+
}, null, 2));
|
|
824
|
+
if (failed.length && this.args["no-fail-exit"] !== true) process.exitCode = 1;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const args = parseArgs(process.argv.slice(2));
|
|
829
|
+
new Harness(args).run().catch((error) => {
|
|
830
|
+
console.error(error?.message || error);
|
|
831
|
+
process.exit(1);
|
|
832
|
+
});
|