@mytegroupinc/myte-core 0.0.26 → 0.0.27
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 +3 -5
- package/cli.js +97 -36
- package/package.json +2 -1
- package/scripts/mission-live-disposable-harness.js +226 -0
- package/scripts/mission-live-full-harness.js +824 -0
package/README.md
CHANGED
|
@@ -40,7 +40,7 @@ Coding agents should treat `MYTE_API_KEY` as a project-scoped Project Assistant
|
|
|
40
40
|
- Use `suggestions create` for both existing-mission edits and new-mission proposals. The only valid `change_type` values are `update` and `create`.
|
|
41
41
|
- Use `suggestions revise` only against an existing `suggestion_id`; do not send `change_type` on revisions because the backend keeps the thread's original type.
|
|
42
42
|
- Use `suggestions review` for owner/elevated mission-review decisions: `approve`, `request_changes`, or `reject`.
|
|
43
|
-
- Use `mission archive`
|
|
43
|
+
- Use `mission archive` for project-key lifecycle archival. Do not send `Archived` through `mission status`; restore archived missions from the web archived-board view.
|
|
44
44
|
- Use `feedback move` only when a direct audited state move is intended and include a concrete `--reason`.
|
|
45
45
|
- Use `update-team` when an agent needs to leave a project-level implementation note or verification comment.
|
|
46
46
|
|
|
@@ -72,9 +72,8 @@ Feedback comment support:
|
|
|
72
72
|
| Suggest new mission | `myte suggestions create` with `change_type: create` | Requires `change_set.title` and `change_set.description`; creates a review thread only; the mission card is created only after approval. |
|
|
73
73
|
| Revise a suggestion | `myte suggestions revise` with `suggestion_id` | Adds another revision to the same thread. Do not include `change_type`. |
|
|
74
74
|
| Review a suggestion | `myte suggestions review` with `review_action: approve|request_changes|reject` | Project Owner or elevated mission-review delegate required. Approval mutates/creates the mission once; reject archives the thread only; request_changes keeps it actionable. |
|
|
75
|
-
| Update mission status | `myte mission status --mission-ids "M001" --status todo|in_progress|done` | Project-key surface updates canonical mission status for active cards. |
|
|
76
|
-
| Archive mission | `myte mission archive --mission-ids "M001[,M002]" --reason "..."` | Project Owner or elevated delegate required. Sets `Archived`, removes cards from normal bootstrap/board state, and
|
|
77
|
-
| Restore mission | `myte mission restore --mission-ids "M001[,M002]" --status todo|in_progress|done --reason "..."` | Project Owner or elevated delegate required. Restores archived cards to an active status and writes mission activity. |
|
|
75
|
+
| Update mission status | `myte mission status --mission-ids "M001" --status todo|in_progress|done` | Project-key surface updates canonical mission status for active cards and refreshes local bootstrap + mission-ops by default. |
|
|
76
|
+
| Archive mission | `myte mission archive --mission-ids "M001[,M002]" --reason "..."` | Project Owner or elevated delegate required. Sets `Archived`, removes cards from normal bootstrap/board state, writes mission activity, and refreshes local bootstrap + mission-ops by default. |
|
|
78
77
|
|
|
79
78
|
Create payloads:
|
|
80
79
|
|
|
@@ -178,7 +177,6 @@ Mutation routes:
|
|
|
178
177
|
- `POST /api/project-assistant/run-qaqc`
|
|
179
178
|
- `POST /api/project-assistant/mission-status-update`
|
|
180
179
|
- `POST /api/project-assistant/mission-archive`
|
|
181
|
-
- `POST /api/project-assistant/mission-restore`
|
|
182
180
|
- `POST /api/project-assistant/project-comment`
|
|
183
181
|
- `POST /api/project-assistant/update-owner`
|
|
184
182
|
- `POST /api/project-assistant/client-update-drafts`
|
package/cli.js
CHANGED
|
@@ -30,6 +30,7 @@ const REMOVED_COMMAND_MESSAGES = {
|
|
|
30
30
|
"add-prd": "The `add-prd` alias has been removed. Use `myte create-prd <file.md> [more.md ...]`.",
|
|
31
31
|
prd: "The `prd` alias has been removed. Use `myte create-prd <file.md> [more.md ...]`.",
|
|
32
32
|
"qaqc-status-update": "The `qaqc-status-update` command has been removed. Use `myte mission status --mission-ids \"M001,M002\" --status todo|in_progress|done`.",
|
|
33
|
+
"mission-restore": "The project-key `myte mission restore` command has been removed. Restore archived missions from the Myte web archived missions board.",
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
function findEnvPath(startDir) {
|
|
@@ -185,9 +186,8 @@ function printHelp() {
|
|
|
185
186
|
" myte config [--json]",
|
|
186
187
|
" myte bootstrap [--output-dir ./MyteCommandCenter] [--json]",
|
|
187
188
|
" myte run-qaqc --mission-ids \"M001[,M002...]\" [--wait] [--sync] [--force] [--json]",
|
|
188
|
-
" myte mission status --mission-ids \"M001[,M002...]\" --status todo|in_progress|done [--json]",
|
|
189
|
-
" myte mission archive --mission-ids \"M001[,M002...]\" [--reason \"...\"] [--json]",
|
|
190
|
-
" myte mission restore --mission-ids \"M001[,M002...]\" [--status todo|in_progress|done] [--reason \"...\"] [--json]",
|
|
189
|
+
" myte mission status --mission-ids \"M001[,M002...]\" --status todo|in_progress|done [--no-sync] [--json]",
|
|
190
|
+
" myte mission archive --mission-ids \"M001[,M002...]\" [--reason \"...\"] [--no-sync] [--json]",
|
|
191
191
|
" myte sync-qaqc [--output-dir ./MyteCommandCenter] [--json]",
|
|
192
192
|
" myte suggestions sync [--output-dir ./MyteCommandCenter] [--json]",
|
|
193
193
|
" myte suggestions create [--file ./payload.yml] [--no-sync] [--json]",
|
|
@@ -266,13 +266,16 @@ function printHelp() {
|
|
|
266
266
|
" - Accepts mission business ids and normalizes status aliases like todo, in_progress, and done",
|
|
267
267
|
" - Canonical mission statuses are exactly: Todo, In Progress, Done",
|
|
268
268
|
" - This only changes mission state. It does not queue QAQC and it does not sync MyteCommandCenter/data/qaqc.yml",
|
|
269
|
-
" -
|
|
269
|
+
" - Status updates refresh bootstrap + mission-ops locally by default after success unless --no-sync is set",
|
|
270
|
+
" - Use mission archive for lifecycle archival; do not send Archived through mission status",
|
|
270
271
|
"",
|
|
271
|
-
"mission archive
|
|
272
|
-
" - Archive calls /api/project-assistant/mission-archive
|
|
273
|
-
" -
|
|
274
|
-
" - Archive hides cards from normal bootstrap/board state
|
|
275
|
-
" -
|
|
272
|
+
"mission archive contract:",
|
|
273
|
+
" - Archive calls /api/project-assistant/mission-archive",
|
|
274
|
+
" - Project Owner or elevated owner_delegate authority is required on the authenticated project key",
|
|
275
|
+
" - Archive hides cards from normal bootstrap/board state and keeps the mission record immutable",
|
|
276
|
+
" - Archive refreshes bootstrap + mission-ops locally by default after success unless --no-sync is set",
|
|
277
|
+
" - Restore is intentionally web-only from the archived missions board",
|
|
278
|
+
" - The project-key surface does not hard-delete mission cards",
|
|
276
279
|
"",
|
|
277
280
|
"create-prd contract:",
|
|
278
281
|
" - Required: valid MYTE_API_KEY, PRD markdown body, title",
|
|
@@ -372,13 +375,13 @@ function printHelp() {
|
|
|
372
375
|
" --review-note <text> Review note stored in feedback refinement history",
|
|
373
376
|
" --with-prd-text Include extracted PRD text so local PRD files are materialized during feedback-sync (default: on)",
|
|
374
377
|
" --no-with-prd-text Skip PRD text download and write only feedback metadata/comment turns",
|
|
375
|
-
" --mission-ids <ids> Comma-separated mission business ids for run-qaqc or mission
|
|
376
|
-
" --reason <text> Optional reason for governed mission archive
|
|
378
|
+
" --mission-ids <ids> Comma-separated mission business ids for run-qaqc, mission status, or mission archive (quote multi-id values on PowerShell)",
|
|
379
|
+
" --reason <text> Optional reason for governed mission archive or feedback changes",
|
|
377
380
|
" --actor-scope <id> Actor workspace key inside mission-ops.yml (defaults to machine-cwd slug)",
|
|
378
381
|
" --wait Poll batch status until terminal completion for run-qaqc",
|
|
379
382
|
" --sync After run-qaqc completes, refresh local QAQC file",
|
|
380
383
|
" --force Allow run-qaqc to bypass stale-state protection when supported",
|
|
381
|
-
" --no-sync Skip automatic
|
|
384
|
+
" --no-sync Skip automatic post-mutation sync for suggestions or mission status/archive",
|
|
382
385
|
" --print-context Print JSON payload and exit (no query call)",
|
|
383
386
|
" --no-fetch Don't git fetch origin main/master before diff",
|
|
384
387
|
"",
|
|
@@ -394,7 +397,6 @@ function printHelp() {
|
|
|
394
397
|
" myte run-qaqc --mission-ids \"M001,M002\" --wait --sync",
|
|
395
398
|
" myte mission status --mission-ids \"M001,M002\" --status done",
|
|
396
399
|
" myte mission archive --mission-ids \"M001\" --reason \"Duplicate disposable test mission\"",
|
|
397
|
-
" myte mission restore --mission-ids \"M001\" --status todo",
|
|
398
400
|
" myte bootstrap --output-dir ./MyteCommandCenter",
|
|
399
401
|
" myte sync-qaqc",
|
|
400
402
|
" myte update-team \"Backend deploy completed; QAQC rerun queued.\"",
|
|
@@ -2412,6 +2414,18 @@ async function runMissionStatus(args) {
|
|
|
2412
2414
|
process.exit(1);
|
|
2413
2415
|
}
|
|
2414
2416
|
|
|
2417
|
+
const shouldSync = shouldSyncMissionStateAfterMutation(args);
|
|
2418
|
+
let syncSummary = null;
|
|
2419
|
+
if (shouldSync) {
|
|
2420
|
+
try {
|
|
2421
|
+
syncSummary = await resyncBootstrapAfterMissionMutation({ args, apiBase, key, timeoutMs });
|
|
2422
|
+
} catch (err) {
|
|
2423
|
+
console.error("Mission status update succeeded, but bootstrap sync failed:", err?.message || err);
|
|
2424
|
+
console.error("Run `myte bootstrap` to refresh local Command Center state.");
|
|
2425
|
+
process.exit(1);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2415
2429
|
const output = {
|
|
2416
2430
|
project_id: data.project_id || null,
|
|
2417
2431
|
requested_count: data.requested_count || missionIds.length,
|
|
@@ -2421,6 +2435,7 @@ async function runMissionStatus(args) {
|
|
|
2421
2435
|
rejected_count: data.rejected_count || 0,
|
|
2422
2436
|
new_status: data.new_status || newStatus,
|
|
2423
2437
|
missions: Array.isArray(data.missions) ? data.missions : [],
|
|
2438
|
+
sync: syncSummary,
|
|
2424
2439
|
};
|
|
2425
2440
|
|
|
2426
2441
|
if (args.json) {
|
|
@@ -2443,11 +2458,14 @@ async function runMissionStatus(args) {
|
|
|
2443
2458
|
if (mission?.new_status) detailParts.push(`target=${mission.new_status}`);
|
|
2444
2459
|
console.log(`- ${missionId}: ${status}${detailParts.length ? ` (${detailParts.join(", ")})` : ""}`);
|
|
2445
2460
|
}
|
|
2461
|
+
if (syncSummary?.output_root) {
|
|
2462
|
+
console.log(`Synced Command Center: ${syncSummary.output_root}`);
|
|
2463
|
+
}
|
|
2446
2464
|
}
|
|
2447
2465
|
|
|
2448
|
-
async function runMissionArchiveCommand(args
|
|
2466
|
+
async function runMissionArchiveCommand(args) {
|
|
2449
2467
|
const key = getProjectApiKey();
|
|
2450
|
-
const commandLabel =
|
|
2468
|
+
const commandLabel = "mission archive";
|
|
2451
2469
|
if (!key) {
|
|
2452
2470
|
console.error("Missing MYTE_API_KEY (project key) in environment/.env");
|
|
2453
2471
|
process.exit(1);
|
|
@@ -2463,21 +2481,12 @@ async function runMissionArchiveCommand(args, { restore = false } = {}) {
|
|
|
2463
2481
|
const payload = {
|
|
2464
2482
|
mission_ids: missionIds,
|
|
2465
2483
|
};
|
|
2466
|
-
if (restore) {
|
|
2467
|
-
const restoreStatus = normalizeMissionStatusInput(firstNonEmptyString(args.status, args["restore-status"], args.restoreStatus, args.restore_status) || "todo");
|
|
2468
|
-
if (!restoreStatus) {
|
|
2469
|
-
console.error("Missing or invalid --status for `myte mission restore`. Use todo, in_progress, or done.");
|
|
2470
|
-
printHelp();
|
|
2471
|
-
process.exit(1);
|
|
2472
|
-
}
|
|
2473
|
-
payload.restore_status = restoreStatus;
|
|
2474
|
-
}
|
|
2475
2484
|
const reason = firstNonEmptyString(args.reason);
|
|
2476
2485
|
if (reason) payload.reason = reason;
|
|
2477
2486
|
const clientSessionId = firstNonEmptyString(args["client-session-id"], args.clientSessionId, args.client_session_id);
|
|
2478
2487
|
if (clientSessionId) payload.client_session_id = clientSessionId;
|
|
2479
2488
|
|
|
2480
|
-
const operation =
|
|
2489
|
+
const operation = "mission-archive";
|
|
2481
2490
|
const idempotencyKey = resolveProjectMutationIdempotencyKey({
|
|
2482
2491
|
args,
|
|
2483
2492
|
operation,
|
|
@@ -2491,7 +2500,7 @@ async function runMissionArchiveCommand(args, { restore = false } = {}) {
|
|
|
2491
2500
|
|
|
2492
2501
|
const timeoutMs = resolveTimeoutMs(args);
|
|
2493
2502
|
const apiBase = resolveApiBase(args);
|
|
2494
|
-
const endpoint =
|
|
2503
|
+
const endpoint = "/project-assistant/mission-archive";
|
|
2495
2504
|
|
|
2496
2505
|
let data;
|
|
2497
2506
|
try {
|
|
@@ -2508,11 +2517,23 @@ async function runMissionArchiveCommand(args, { restore = false } = {}) {
|
|
|
2508
2517
|
if (err?.name === "AbortError") {
|
|
2509
2518
|
console.error(`Request timed out after ${timeoutMs}ms`);
|
|
2510
2519
|
} else {
|
|
2511
|
-
console.error(
|
|
2520
|
+
console.error("Mission archive failed:", err?.message || err);
|
|
2512
2521
|
}
|
|
2513
2522
|
process.exit(1);
|
|
2514
2523
|
}
|
|
2515
2524
|
|
|
2525
|
+
const shouldSync = shouldSyncMissionStateAfterMutation(args);
|
|
2526
|
+
let syncSummary = null;
|
|
2527
|
+
if (shouldSync) {
|
|
2528
|
+
try {
|
|
2529
|
+
syncSummary = await resyncBootstrapAfterMissionMutation({ args, apiBase, key, timeoutMs });
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
console.error("Mission archive succeeded, but bootstrap sync failed:", err?.message || err);
|
|
2532
|
+
console.error("Run `myte bootstrap` to refresh local Command Center state.");
|
|
2533
|
+
process.exit(1);
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2516
2537
|
const output = {
|
|
2517
2538
|
project_id: data.project_id || null,
|
|
2518
2539
|
requested_count: data.requested_count || missionIds.length,
|
|
@@ -2520,9 +2541,10 @@ async function runMissionArchiveCommand(args, { restore = false } = {}) {
|
|
|
2520
2541
|
updated_count: data.updated_count || 0,
|
|
2521
2542
|
unchanged_count: data.unchanged_count || 0,
|
|
2522
2543
|
rejected_count: data.rejected_count || 0,
|
|
2523
|
-
archive_state: data.archive_state ||
|
|
2524
|
-
new_status: data.new_status ||
|
|
2544
|
+
archive_state: data.archive_state || "archived",
|
|
2545
|
+
new_status: data.new_status || "Archived",
|
|
2525
2546
|
missions: Array.isArray(data.missions) ? data.missions : [],
|
|
2547
|
+
sync: syncSummary,
|
|
2526
2548
|
};
|
|
2527
2549
|
|
|
2528
2550
|
if (args.json) {
|
|
@@ -2546,6 +2568,9 @@ async function runMissionArchiveCommand(args, { restore = false } = {}) {
|
|
|
2546
2568
|
if (mission?.new_status) detailParts.push(`target=${mission.new_status}`);
|
|
2547
2569
|
console.log(`- ${missionId}: ${status}${detailParts.length ? ` (${detailParts.join(", ")})` : ""}`);
|
|
2548
2570
|
}
|
|
2571
|
+
if (syncSummary?.output_root) {
|
|
2572
|
+
console.log(`Synced Command Center: ${syncSummary.output_root}`);
|
|
2573
|
+
}
|
|
2549
2574
|
}
|
|
2550
2575
|
|
|
2551
2576
|
function formatTargetContacts(contacts) {
|
|
@@ -3653,6 +3678,11 @@ function shouldSyncSuggestionsAfterMutation(args) {
|
|
|
3653
3678
|
return !rawArgs.includes("--no-sync");
|
|
3654
3679
|
}
|
|
3655
3680
|
|
|
3681
|
+
function shouldSyncMissionStateAfterMutation(args) {
|
|
3682
|
+
const rawArgs = Array.isArray(args?.__raw) ? args.__raw : [];
|
|
3683
|
+
return !rawArgs.includes("--no-sync");
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3656
3686
|
function resolveBooleanFlag(args, dashedName, defaultValue) {
|
|
3657
3687
|
const rawArgs = Array.isArray(args?.__raw) ? args.__raw : [];
|
|
3658
3688
|
const enabledToken = `--${dashedName}`;
|
|
@@ -5640,6 +5670,42 @@ async function resyncMissionOpsAfterMutation({ apiBase, key, timeoutMs, actorSco
|
|
|
5640
5670
|
};
|
|
5641
5671
|
}
|
|
5642
5672
|
|
|
5673
|
+
async function resyncBootstrapAfterMissionMutation({ args, apiBase, key, timeoutMs }) {
|
|
5674
|
+
const outputDir = args["output-dir"] || args.outputDir || args.output_dir;
|
|
5675
|
+
const existingPaths = resolveMissionOpsPaths(args, { requireExisting: false });
|
|
5676
|
+
const existingPayload = existingPaths && fs.existsSync(existingPaths.missionOpsPath)
|
|
5677
|
+
? (readYamlFile(existingPaths.missionOpsPath) || {})
|
|
5678
|
+
: {};
|
|
5679
|
+
const actorScope = resolveActorScope(args, existingPayload);
|
|
5680
|
+
const snapshot = await fetchBootstrapSnapshot({ apiBase, key, timeoutMs });
|
|
5681
|
+
const resolved = resolvePortableWorkspace(snapshot.repo_names || []);
|
|
5682
|
+
const wrapperRoot = !outputDir && existingPaths?.wrapperRoot ? existingPaths.wrapperRoot : resolved.root;
|
|
5683
|
+
const writeResult = writeBootstrapSnapshot({ snapshot, wrapperRoot, outputDir });
|
|
5684
|
+
const missionOpsSnapshot = await fetchSuggestionsSyncSnapshot({ apiBase, key, timeoutMs, actorScope });
|
|
5685
|
+
const missionOpsWrite = writeMissionOpsSnapshot({ snapshot: missionOpsSnapshot, wrapperRoot, outputDir });
|
|
5686
|
+
const missionOpsCounts = missionOpsWrite?.manifest?.sync?.counts || {};
|
|
5687
|
+
const outputRoot = outputDir ? path.resolve(process.cwd(), String(outputDir)) : path.join(wrapperRoot, "MyteCommandCenter");
|
|
5688
|
+
return {
|
|
5689
|
+
wrapper_root: wrapperRoot,
|
|
5690
|
+
output_root: outputRoot,
|
|
5691
|
+
data_root: writeResult.dataRoot,
|
|
5692
|
+
snapshot_hash: snapshot.snapshot_hash || null,
|
|
5693
|
+
counts: {
|
|
5694
|
+
phases: Array.isArray(snapshot.phases) ? snapshot.phases.length : 0,
|
|
5695
|
+
epics: Array.isArray(snapshot.epics) ? snapshot.epics.length : 0,
|
|
5696
|
+
stories: Array.isArray(snapshot.stories) ? snapshot.stories.length : 0,
|
|
5697
|
+
missions: Array.isArray(snapshot.missions) ? snapshot.missions.length : 0,
|
|
5698
|
+
},
|
|
5699
|
+
mission_ops: {
|
|
5700
|
+
actor_scope: missionOpsWrite?.manifest?.sync?.actor_scope || actorScope,
|
|
5701
|
+
total_threads: Number(missionOpsCounts.total_threads || 0),
|
|
5702
|
+
actionable_threads: Number(missionOpsCounts.actionable_threads || 0),
|
|
5703
|
+
snapshot_hash: missionOpsWrite?.manifest?.snapshot_hash || null,
|
|
5704
|
+
data_root: missionOpsWrite?.dataRoot || writeResult.dataRoot,
|
|
5705
|
+
},
|
|
5706
|
+
};
|
|
5707
|
+
}
|
|
5708
|
+
|
|
5643
5709
|
async function runSuggestionsCreate(args) {
|
|
5644
5710
|
let context;
|
|
5645
5711
|
try {
|
|
@@ -6207,7 +6273,7 @@ async function main() {
|
|
|
6207
6273
|
process.exit(1);
|
|
6208
6274
|
}
|
|
6209
6275
|
if (command === "mission") {
|
|
6210
|
-
console.error("Unknown mission command. Use `myte mission status
|
|
6276
|
+
console.error("Unknown mission command. Use `myte mission status` or `myte mission archive`.");
|
|
6211
6277
|
process.exit(1);
|
|
6212
6278
|
}
|
|
6213
6279
|
const args = parseArgs(rest);
|
|
@@ -6242,12 +6308,7 @@ async function main() {
|
|
|
6242
6308
|
}
|
|
6243
6309
|
|
|
6244
6310
|
if (command === "mission-archive") {
|
|
6245
|
-
await runMissionArchiveCommand(args
|
|
6246
|
-
return;
|
|
6247
|
-
}
|
|
6248
|
-
|
|
6249
|
-
if (command === "mission-restore") {
|
|
6250
|
-
await runMissionArchiveCommand(args, { restore: true });
|
|
6311
|
+
await runMissionArchiveCommand(args);
|
|
6251
6312
|
return;
|
|
6252
6313
|
}
|
|
6253
6314
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mytegroupinc/myte-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"description": "Myte CLI core implementation.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "cli.js",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"README.md",
|
|
9
9
|
"cli.js",
|
|
10
10
|
"lib",
|
|
11
|
+
"scripts",
|
|
11
12
|
"package.json"
|
|
12
13
|
],
|
|
13
14
|
"scripts": {
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
|
|
11
|
+
function parseArgs(argv) {
|
|
12
|
+
const args = { _: [] };
|
|
13
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
14
|
+
const token = argv[index];
|
|
15
|
+
if (!token.startsWith("--")) {
|
|
16
|
+
args._.push(token);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const key = token.slice(2);
|
|
20
|
+
const next = argv[index + 1];
|
|
21
|
+
if (!next || next.startsWith("--")) {
|
|
22
|
+
args[key] = true;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
args[key] = next;
|
|
26
|
+
index += 1;
|
|
27
|
+
}
|
|
28
|
+
return args;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function requireConfirm(args) {
|
|
32
|
+
if (!args["confirm-live"]) {
|
|
33
|
+
throw new Error("Refusing to run live mission mutations. Re-run with --confirm-live after backend/frontend deploy.");
|
|
34
|
+
}
|
|
35
|
+
if (!process.env.MYTE_API_KEY && !process.env.MYTE_PROJECT_API_KEY) {
|
|
36
|
+
throw new Error("Missing MYTE_API_KEY or MYTE_PROJECT_API_KEY.");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function runCli(cliArgs, cwd, envPatch = {}) {
|
|
41
|
+
const result = spawnSync(process.execPath, [CLI_PATH, ...cliArgs], {
|
|
42
|
+
cwd,
|
|
43
|
+
env: { ...process.env, ...envPatch },
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
if (result.status !== 0) {
|
|
48
|
+
throw new Error([
|
|
49
|
+
`Command failed: myte ${cliArgs.join(" ")}`,
|
|
50
|
+
result.stderr || result.stdout || "(no output)",
|
|
51
|
+
].join("\n"));
|
|
52
|
+
}
|
|
53
|
+
const stdout = String(result.stdout || "").trim();
|
|
54
|
+
if (!stdout) return {};
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(stdout);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(`Expected JSON from myte ${cliArgs.join(" ")}:\n${stdout}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function writeJson(workspace, name, payload) {
|
|
63
|
+
const filePath = path.join(workspace, name);
|
|
64
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
65
|
+
return filePath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function collectMissionFileText(workspace) {
|
|
69
|
+
const missionsDir = path.join(workspace, "MyteCommandCenter", "data", "missions");
|
|
70
|
+
if (!fs.existsSync(missionsDir)) return "";
|
|
71
|
+
return fs.readdirSync(missionsDir)
|
|
72
|
+
.filter((name) => name.endsWith(".yml") || name.endsWith(".yaml") || name.endsWith(".json"))
|
|
73
|
+
.map((name) => fs.readFileSync(path.join(missionsDir, name), "utf8"))
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertMissionPresence(workspace, missionId, expectedPresent, label) {
|
|
78
|
+
const text = collectMissionFileText(workspace);
|
|
79
|
+
const present = text.includes(`mission_id: ${missionId}`) || text.includes(`"mission_id": "${missionId}"`);
|
|
80
|
+
if (present !== expectedPresent) {
|
|
81
|
+
throw new Error(`${label}: expected mission ${missionId} ${expectedPresent ? "in" : "absent from"} bootstrap mission state.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pickSuggestionId(createOutput) {
|
|
86
|
+
for (const item of createOutput.items || []) {
|
|
87
|
+
const suggestionId = item?.suggestion?.suggestion_id || item?.suggestion_id;
|
|
88
|
+
if (suggestionId) return String(suggestionId);
|
|
89
|
+
}
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pickAppliedMissionId(reviewOutput) {
|
|
94
|
+
for (const item of reviewOutput.items || []) {
|
|
95
|
+
const missionId = item?.applied_mission?.mission_id || item?.mission_id || item?.suggestion?.mission_id;
|
|
96
|
+
if (missionId) return String(missionId);
|
|
97
|
+
}
|
|
98
|
+
return "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function main() {
|
|
102
|
+
const args = parseArgs(process.argv.slice(2));
|
|
103
|
+
requireConfirm(args);
|
|
104
|
+
|
|
105
|
+
const workspace = path.resolve(
|
|
106
|
+
args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-live-mission-harness-")),
|
|
107
|
+
);
|
|
108
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
109
|
+
|
|
110
|
+
const actorScope = String(args["actor-scope"] || `live-disposable-harness-${Date.now()}`);
|
|
111
|
+
const baseArgs = [];
|
|
112
|
+
if (args["base-url"]) {
|
|
113
|
+
baseArgs.push("--base-url", String(args["base-url"]));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const title = String(args.title || `Disposable mission harness ${new Date().toISOString()}`);
|
|
117
|
+
const description = String(args.description || "Disposable mission used to verify Myte mission create, approve, archive, and cleanup flows.");
|
|
118
|
+
const reason = String(args.reason || "Disposable live mission harness verification");
|
|
119
|
+
|
|
120
|
+
console.log(`Workspace: ${workspace}`);
|
|
121
|
+
console.log(`Actor scope: ${actorScope}`);
|
|
122
|
+
|
|
123
|
+
runCli(["bootstrap", "--json", "--actor-scope", actorScope, ...baseArgs], workspace);
|
|
124
|
+
|
|
125
|
+
const createFile = writeJson(workspace, "mission-create.json", {
|
|
126
|
+
items: [
|
|
127
|
+
{
|
|
128
|
+
change_type: "create",
|
|
129
|
+
change_description: "Create disposable mission for live harness verification",
|
|
130
|
+
change_set: {
|
|
131
|
+
title,
|
|
132
|
+
description,
|
|
133
|
+
acceptance_criteria: [
|
|
134
|
+
"Mission can be approved from the suggestion review loop.",
|
|
135
|
+
"Mission can be archived without hard delete.",
|
|
136
|
+
"Archived missions disappear from normal bootstrap state.",
|
|
137
|
+
],
|
|
138
|
+
labels: ["harness", "disposable"],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
const createOutput = runCli([
|
|
144
|
+
"suggestions",
|
|
145
|
+
"create",
|
|
146
|
+
"--file",
|
|
147
|
+
createFile,
|
|
148
|
+
"--actor-scope",
|
|
149
|
+
actorScope,
|
|
150
|
+
"--no-sync",
|
|
151
|
+
"--json",
|
|
152
|
+
...baseArgs,
|
|
153
|
+
], workspace);
|
|
154
|
+
const suggestionId = pickSuggestionId(createOutput);
|
|
155
|
+
if (!suggestionId) {
|
|
156
|
+
throw new Error(`Suggestion create did not return a suggestion id: ${JSON.stringify(createOutput, null, 2)}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const reviewFile = writeJson(workspace, "mission-review.json", {
|
|
160
|
+
items: [
|
|
161
|
+
{
|
|
162
|
+
suggestion_id: suggestionId,
|
|
163
|
+
action: "approve",
|
|
164
|
+
review_action: "approve",
|
|
165
|
+
final_change_set: {
|
|
166
|
+
title,
|
|
167
|
+
description,
|
|
168
|
+
acceptance_criteria: [
|
|
169
|
+
"Mission can be approved from the suggestion review loop.",
|
|
170
|
+
"Mission can be archived without hard delete.",
|
|
171
|
+
"Archived missions disappear from normal bootstrap state.",
|
|
172
|
+
],
|
|
173
|
+
labels: ["harness", "disposable"],
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
const reviewOutput = runCli([
|
|
179
|
+
"suggestions",
|
|
180
|
+
"review",
|
|
181
|
+
"--file",
|
|
182
|
+
reviewFile,
|
|
183
|
+
"--actor-scope",
|
|
184
|
+
actorScope,
|
|
185
|
+
"--no-sync",
|
|
186
|
+
"--json",
|
|
187
|
+
...baseArgs,
|
|
188
|
+
], workspace);
|
|
189
|
+
const missionId = pickAppliedMissionId(reviewOutput);
|
|
190
|
+
if (!missionId) {
|
|
191
|
+
throw new Error(`Suggestion approval did not return an applied mission id: ${JSON.stringify(reviewOutput, null, 2)}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const archiveOutput = runCli([
|
|
195
|
+
"mission",
|
|
196
|
+
"archive",
|
|
197
|
+
"--mission-ids",
|
|
198
|
+
missionId,
|
|
199
|
+
"--reason",
|
|
200
|
+
reason,
|
|
201
|
+
"--json",
|
|
202
|
+
...baseArgs,
|
|
203
|
+
], workspace);
|
|
204
|
+
if (!Number(archiveOutput.updated_count || 0) && !Number(archiveOutput.unchanged_count || 0)) {
|
|
205
|
+
throw new Error(`Archive did not update or confirm mission state: ${JSON.stringify(archiveOutput, null, 2)}`);
|
|
206
|
+
}
|
|
207
|
+
runCli(["bootstrap", "--json", "--actor-scope", actorScope, ...baseArgs], workspace);
|
|
208
|
+
assertMissionPresence(workspace, missionId, false, "Archived bootstrap check");
|
|
209
|
+
|
|
210
|
+
const finalState = "archived";
|
|
211
|
+
|
|
212
|
+
console.log(JSON.stringify({
|
|
213
|
+
status: "success",
|
|
214
|
+
workspace,
|
|
215
|
+
suggestion_id: suggestionId,
|
|
216
|
+
mission_id: missionId,
|
|
217
|
+
final_state: finalState,
|
|
218
|
+
}, null, 2));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
main();
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.error(err?.message || err);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
@@ -0,0 +1,824 @@
|
|
|
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 cliInvocation(args, cliMode, packageVersion) {
|
|
104
|
+
if (cliMode === "global") return { command: "myte", args };
|
|
105
|
+
if (cliMode === "npx") return { command: "npx", args: ["--yes", `myte@${packageVersion}`, ...args] };
|
|
106
|
+
return { command: process.execPath, args: [CLI_PATH, ...args] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function commandText(invocation) {
|
|
110
|
+
return [invocation.command, ...invocation.args].join(" ");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function runCli(cliArgs, options = {}) {
|
|
114
|
+
const {
|
|
115
|
+
workspace,
|
|
116
|
+
baseUrl,
|
|
117
|
+
actorScope,
|
|
118
|
+
expectFailure = false,
|
|
119
|
+
parseJson = true,
|
|
120
|
+
cliMode = "local",
|
|
121
|
+
packageVersion = "latest",
|
|
122
|
+
timeoutMs = 120000,
|
|
123
|
+
} = options;
|
|
124
|
+
const finalArgs = [...cliArgs];
|
|
125
|
+
if (actorScope) finalArgs.push("--actor-scope", actorScope);
|
|
126
|
+
if (baseUrl) finalArgs.push("--base-url", baseUrl);
|
|
127
|
+
const invocation = cliInvocation(finalArgs, cliMode, packageVersion);
|
|
128
|
+
const result = spawnSync(invocation.command, invocation.args, {
|
|
129
|
+
cwd: workspace || process.cwd(),
|
|
130
|
+
env: process.env,
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
133
|
+
timeout: timeoutMs,
|
|
134
|
+
});
|
|
135
|
+
const stdout = String(result.stdout || "").trim();
|
|
136
|
+
const stderr = String(result.stderr || "").trim();
|
|
137
|
+
const ok = result.status === 0;
|
|
138
|
+
if (expectFailure) {
|
|
139
|
+
if (ok) {
|
|
140
|
+
throw new Error(`Expected command to fail but it succeeded: ${commandText(invocation)}`);
|
|
141
|
+
}
|
|
142
|
+
return { ok: false, status: result.status, stdout, stderr };
|
|
143
|
+
}
|
|
144
|
+
if (!ok) {
|
|
145
|
+
throw new Error([`Command failed: ${commandText(invocation)}`, stderr || stdout || "(no output)"].join("\n"));
|
|
146
|
+
}
|
|
147
|
+
if (!parseJson) return { ok: true, stdout, stderr };
|
|
148
|
+
try {
|
|
149
|
+
return stdout ? JSON.parse(stdout) : {};
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new Error(`Expected JSON from ${commandText(invocation)}:\n${stdout}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function apiJson({ method = "GET", baseUrl, route, key, body, query, idempotencyKey }) {
|
|
156
|
+
const url = new URL(`${baseUrl}${route}`);
|
|
157
|
+
for (const [queryKey, queryValue] of Object.entries(query || {})) {
|
|
158
|
+
if (queryValue !== undefined && queryValue !== null && String(queryValue).trim() !== "") {
|
|
159
|
+
url.searchParams.set(queryKey, String(queryValue));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const headers = { Authorization: `Bearer ${key}` };
|
|
163
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
164
|
+
if (idempotencyKey) headers["X-Idempotency-Key"] = String(idempotencyKey);
|
|
165
|
+
const response = await fetch(url.toString(), {
|
|
166
|
+
method,
|
|
167
|
+
headers,
|
|
168
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
169
|
+
});
|
|
170
|
+
let payload = {};
|
|
171
|
+
try {
|
|
172
|
+
payload = await response.json();
|
|
173
|
+
} catch (_) {
|
|
174
|
+
payload = {};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
ok: response.ok && payload.status === "success",
|
|
178
|
+
statusCode: response.status,
|
|
179
|
+
message: payload.message || "",
|
|
180
|
+
data: payload.data || {},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function missionPublicId(mission) {
|
|
185
|
+
return String(mission?.mission_id || mission?.public_id || mission?.id || "").trim();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function missionTitle(mission) {
|
|
189
|
+
return String(mission?.title || "").trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function activeMissions(bootstrap) {
|
|
193
|
+
return (Array.isArray(bootstrap?.missions) ? bootstrap.missions : [])
|
|
194
|
+
.filter((mission) => missionPublicId(mission))
|
|
195
|
+
.filter((mission) => String(mission.status || "").toLowerCase() !== "archived")
|
|
196
|
+
.filter((mission) => mission.is_archived !== true);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function chooseStableMission(bootstrap) {
|
|
200
|
+
const missions = activeMissions(bootstrap);
|
|
201
|
+
return missions.find((mission) => !/^Disposable mission harness/i.test(missionTitle(mission))) || missions[0] || null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasRunnableQaqcCases(mission) {
|
|
205
|
+
if (String(mission?.status || "").toLowerCase() === "done") return false;
|
|
206
|
+
const testCases = mission?.test_cases || {};
|
|
207
|
+
const success = Array.isArray(testCases.success) ? testCases.success : [];
|
|
208
|
+
const failure = Array.isArray(testCases.failure) ? testCases.failure : [];
|
|
209
|
+
return success.length > 0 || failure.length > 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function chooseQaqcMission(bootstrap) {
|
|
213
|
+
const missions = activeMissions(bootstrap).filter(hasRunnableQaqcCases);
|
|
214
|
+
return missions.find((mission) => !/^Disposable mission harness/i.test(missionTitle(mission))) || missions[0] || null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pickSuggestionId(output) {
|
|
218
|
+
for (const item of output.items || []) {
|
|
219
|
+
const suggestionId = item?.suggestion?.suggestion_id || item?.suggestion_id;
|
|
220
|
+
if (suggestionId) return String(suggestionId);
|
|
221
|
+
}
|
|
222
|
+
return "";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function pickAppliedMissionId(output) {
|
|
226
|
+
for (const item of output.items || []) {
|
|
227
|
+
const missionId = item?.applied_mission?.mission_id || item?.mission_id || item?.suggestion?.mission_id;
|
|
228
|
+
if (missionId) return String(missionId);
|
|
229
|
+
}
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function threadRefs(snapshot) {
|
|
234
|
+
const refs = [];
|
|
235
|
+
for (const source of [snapshot?.queue, snapshot?.threads, snapshot?.suggestions]) {
|
|
236
|
+
if (!Array.isArray(source)) continue;
|
|
237
|
+
for (const item of source) {
|
|
238
|
+
refs.push({
|
|
239
|
+
suggestion_id: String(item?.suggestion_id || item?.id || "").trim(),
|
|
240
|
+
mission_id: String(item?.mission_id || "").trim(),
|
|
241
|
+
change_type: String(item?.change_type || "").trim(),
|
|
242
|
+
status: String(item?.status || item?.thread_status || "").trim(),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return refs.filter((item) => item.suggestion_id || item.mission_id);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function queueRefs(snapshot) {
|
|
250
|
+
return threadRefs({ queue: snapshot?.queue || [] });
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasMissionInBootstrap(bootstrap, missionId) {
|
|
254
|
+
const target = String(missionId || "").toUpperCase();
|
|
255
|
+
return activeMissions(bootstrap).some((mission) => missionPublicId(mission).toUpperCase() === target);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function assertCondition(condition, message) {
|
|
259
|
+
if (!condition) throw new Error(message);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function safeResult(details = {}) {
|
|
263
|
+
return JSON.parse(JSON.stringify(details, (_key, value) => {
|
|
264
|
+
if (typeof value !== "string") return value;
|
|
265
|
+
if (value.includes("@")) return "[redacted]";
|
|
266
|
+
return value.length > 180 ? `${value.slice(0, 180)}...` : value;
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
class Harness {
|
|
271
|
+
constructor(args) {
|
|
272
|
+
loadEnvFromNearest(process.cwd());
|
|
273
|
+
this.args = args;
|
|
274
|
+
this.confirmLive = args["confirm-live"] === true;
|
|
275
|
+
this.keepGoing = args["keep-going"] === true;
|
|
276
|
+
this.baseUrl = normalizeBaseUrl(args["base-url"] || process.env.MYTE_API_BASE_URL || DEFAULT_BASE_URL);
|
|
277
|
+
this.key = getProjectKey();
|
|
278
|
+
this.actorBase = String(args["actor-scope"] || `mission-live-full-${nowId()}`);
|
|
279
|
+
this.cliMode = String(args.cli || "local").toLowerCase();
|
|
280
|
+
this.packageVersion = String(args["package-version"] || "0.0.27");
|
|
281
|
+
this.batchSize = Math.max(1, Math.min(10, Number(args["batch-size"] || 10)));
|
|
282
|
+
this.includeQaqcRun = args["include-qaqc-run"] === true;
|
|
283
|
+
this.workspace = path.resolve(
|
|
284
|
+
args.workspace || fs.mkdtempSync(path.join(os.tmpdir(), "myte-mission-full-harness-")),
|
|
285
|
+
);
|
|
286
|
+
this.payloadDir = path.join(this.workspace, "payloads");
|
|
287
|
+
this.reportPath = path.resolve(args["report-file"] || path.join(this.workspace, "mission-live-full-report.json"));
|
|
288
|
+
this.scenarios = stableScenarioList(args.scenarios, this.confirmLive);
|
|
289
|
+
this.createdMissions = new Set();
|
|
290
|
+
this.openSuggestions = new Set();
|
|
291
|
+
this.archiveRouteAvailable = null;
|
|
292
|
+
this.report = {
|
|
293
|
+
status: "running",
|
|
294
|
+
generated_at: new Date().toISOString(),
|
|
295
|
+
base_url: this.baseUrl,
|
|
296
|
+
cli_mode: this.cliMode,
|
|
297
|
+
package_version: this.packageVersion,
|
|
298
|
+
workspace: this.workspace,
|
|
299
|
+
scenarios: [],
|
|
300
|
+
created_mission_ids: [],
|
|
301
|
+
open_suggestion_ids: [],
|
|
302
|
+
notes: [],
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
requireKey() {
|
|
307
|
+
if (!this.key) throw new Error("Missing MYTE_API_KEY or MYTE_PROJECT_API_KEY.");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
requireLive(name) {
|
|
311
|
+
if (!this.confirmLive) {
|
|
312
|
+
throw new Error(`${name} requires --confirm-live because it mutates live project data.`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
cli(args, options = {}) {
|
|
317
|
+
return runCli(args, {
|
|
318
|
+
workspace: this.workspace,
|
|
319
|
+
baseUrl: this.baseUrl,
|
|
320
|
+
cliMode: this.cliMode,
|
|
321
|
+
packageVersion: this.packageVersion,
|
|
322
|
+
...options,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
cliWithActor(args, suffix, options = {}) {
|
|
327
|
+
return this.cli(args, {
|
|
328
|
+
actorScope: `${this.actorBase}-${suffix}`,
|
|
329
|
+
...options,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async api(route, options = {}) {
|
|
334
|
+
return apiJson({
|
|
335
|
+
baseUrl: this.baseUrl,
|
|
336
|
+
key: this.key,
|
|
337
|
+
route,
|
|
338
|
+
...options,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async bootstrap() {
|
|
343
|
+
const response = await this.api("/project-assistant/bootstrap");
|
|
344
|
+
if (!response.ok) throw new Error(`Bootstrap API failed (${response.statusCode}): ${response.message}`);
|
|
345
|
+
return response.data;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async suggestions(actorScope) {
|
|
349
|
+
const response = await this.api("/project-assistant/suggestions", {
|
|
350
|
+
query: actorScope ? { actor_scope: actorScope } : {},
|
|
351
|
+
});
|
|
352
|
+
if (!response.ok) throw new Error(`Suggestions API failed (${response.statusCode}): ${response.message}`);
|
|
353
|
+
return response.data;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async probeArchiveRoute() {
|
|
357
|
+
const response = await this.api("/project-assistant/mission-archive", {
|
|
358
|
+
method: "POST",
|
|
359
|
+
idempotencyKey: `mission-live-full-route-probe-${nowId()}`,
|
|
360
|
+
body: {
|
|
361
|
+
mission_ids: ["__NOT_A_REAL_MISSION__"],
|
|
362
|
+
reason: "mission live full harness route probe",
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
this.archiveRouteAvailable = response.statusCode === 200;
|
|
366
|
+
if (!this.archiveRouteAvailable) {
|
|
367
|
+
throw new Error(`Archive route is not live (${response.statusCode}): ${response.message || "no message"}`);
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
status_code: response.statusCode,
|
|
371
|
+
rejected_count: response.data?.rejected_count,
|
|
372
|
+
updated_count: response.data?.updated_count,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async scenarioPreflight() {
|
|
377
|
+
this.requireKey();
|
|
378
|
+
const config = this.cli(["config", "--json"]);
|
|
379
|
+
const bootstrap = this.cli(["bootstrap", "--json"]);
|
|
380
|
+
const sync = this.cliWithActor(["suggestions", "sync", "--json"], "preflight");
|
|
381
|
+
return {
|
|
382
|
+
project_id: config.project_id || bootstrap.project_id || null,
|
|
383
|
+
repo_count: Array.isArray(config.repo_names) ? config.repo_names.length : 0,
|
|
384
|
+
active_mission_count: bootstrap.counts?.missions || 0,
|
|
385
|
+
suggestion_threads: sync.thread_count || 0,
|
|
386
|
+
actionable_threads: sync.actionable_count || 0,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async scenarioContracts() {
|
|
391
|
+
const invalidCreate = writeJson(this.payloadDir, "invalid-create-change-type.json", {
|
|
392
|
+
items: [{ change_type: "add-mission", change_description: "invalid", change_set: { title: "Invalid" } }],
|
|
393
|
+
});
|
|
394
|
+
const invalidRevise = writeJson(this.payloadDir, "invalid-revise-change-type.json", {
|
|
395
|
+
items: [{ suggestion_id: "000000000000000000000000", change_type: "create", change_set: { title: "Invalid" } }],
|
|
396
|
+
});
|
|
397
|
+
const badMissionIds = Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
398
|
+
const tooManyQaqcIds = Array.from({ length: 11 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
399
|
+
|
|
400
|
+
const checks = [];
|
|
401
|
+
checks.push(this.cli(["suggestions", "create", "--file", invalidCreate, "--json"], { expectFailure: true, parseJson: false }));
|
|
402
|
+
checks.push(this.cli(["suggestions", "revise", "--file", invalidRevise, "--json"], { expectFailure: true, parseJson: false }));
|
|
403
|
+
checks.push(this.cli(["mission", "status", "--mission-ids", "M001", "--status", "archived", "--json"], { expectFailure: true, parseJson: false }));
|
|
404
|
+
checks.push(this.cli(["mission", "restore", "--mission-ids", "M001", "--status", "todo", "--json"], { expectFailure: true, parseJson: false }));
|
|
405
|
+
checks.push(this.cli(["mission", "delete", "--mission-ids", "M001", "--json"], { expectFailure: true, parseJson: false }));
|
|
406
|
+
checks.push(this.cli(["mission", "archive", "--mission-ids", "M001", "--reason", "dry run", "--dry-run", "--json"]));
|
|
407
|
+
checks.push(this.cli(["mission", "status", "--mission-ids", badMissionIds, "--status", "todo", "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }));
|
|
408
|
+
checks.push(this.cli(["run-qaqc", "--mission-ids", tooManyQaqcIds, "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }));
|
|
409
|
+
return { checks: checks.length };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async scenarioSuggestionUpdateLoop() {
|
|
413
|
+
this.requireLive("suggestion_update_loop");
|
|
414
|
+
const actor = `${this.actorBase}-update-loop`;
|
|
415
|
+
const bootstrap = await this.bootstrap();
|
|
416
|
+
const mission = chooseStableMission(bootstrap);
|
|
417
|
+
assertCondition(mission, "No active mission available for update suggestion loop.");
|
|
418
|
+
const missionId = missionPublicId(mission);
|
|
419
|
+
|
|
420
|
+
const createOne = writeJson(this.payloadDir, "update-loop-create-1.json", {
|
|
421
|
+
items: [{
|
|
422
|
+
change_type: "update",
|
|
423
|
+
mission_id: missionId,
|
|
424
|
+
change_description: "Harness proposed non-applied update",
|
|
425
|
+
change_set: { description: "Harness proposed description. This should be rejected during cleanup." },
|
|
426
|
+
}],
|
|
427
|
+
});
|
|
428
|
+
const first = this.cli(["suggestions", "create", "--file", createOne, "--no-sync", "--json"], { actorScope: actor });
|
|
429
|
+
const suggestionId = pickSuggestionId(first);
|
|
430
|
+
assertCondition(suggestionId, "Update suggestion create did not return suggestion_id.");
|
|
431
|
+
this.openSuggestions.add(suggestionId);
|
|
432
|
+
|
|
433
|
+
const createTwo = writeJson(this.payloadDir, "update-loop-create-2.json", {
|
|
434
|
+
items: [{
|
|
435
|
+
change_type: "update",
|
|
436
|
+
mission_id: missionId,
|
|
437
|
+
change_description: "Harness append to existing pending update thread",
|
|
438
|
+
change_set: { acceptance_criteria: ["Harness proposed acceptance criterion. This should be rejected."] },
|
|
439
|
+
}],
|
|
440
|
+
});
|
|
441
|
+
const second = this.cli(["suggestions", "create", "--file", createTwo, "--no-sync", "--json"], { actorScope: actor });
|
|
442
|
+
const appendedId = pickSuggestionId(second);
|
|
443
|
+
assertCondition(appendedId === suggestionId, `Expected append to same suggestion ${suggestionId}, got ${appendedId || "(none)"}.`);
|
|
444
|
+
|
|
445
|
+
const revise = writeJson(this.payloadDir, "update-loop-revise.json", {
|
|
446
|
+
items: [{
|
|
447
|
+
suggestion_id: suggestionId,
|
|
448
|
+
change_description: "Harness revision after append",
|
|
449
|
+
change_set: { technical_requirements: ["Harness proposed requirement. This should be rejected."] },
|
|
450
|
+
}],
|
|
451
|
+
});
|
|
452
|
+
const revised = this.cli(["suggestions", "revise", "--file", revise, "--no-sync", "--json"], { actorScope: actor });
|
|
453
|
+
assertCondition((revised.revised_count || 0) >= 1, "Update suggestion revise did not revise the thread.");
|
|
454
|
+
|
|
455
|
+
const requestChanges = writeJson(this.payloadDir, "update-loop-request-changes.json", {
|
|
456
|
+
items: [{ suggestion_id: suggestionId, action: "request_changes", review_action: "request_changes", review_note: "Harness request changes check." }],
|
|
457
|
+
});
|
|
458
|
+
const requested = this.cli(["suggestions", "review", "--file", requestChanges, "--no-sync", "--json"], { actorScope: actor });
|
|
459
|
+
assertCondition((requested.processed_count || 0) >= 1, "Update suggestion request_changes did not process.");
|
|
460
|
+
|
|
461
|
+
const reviseAfterRequest = writeJson(this.payloadDir, "update-loop-revise-after-request.json", {
|
|
462
|
+
items: [{
|
|
463
|
+
suggestion_id: suggestionId,
|
|
464
|
+
change_description: "Harness revision after request_changes",
|
|
465
|
+
change_set: { labels: ["harness-proposed-update"] },
|
|
466
|
+
}],
|
|
467
|
+
});
|
|
468
|
+
const revisedAgain = this.cli(["suggestions", "revise", "--file", reviseAfterRequest, "--no-sync", "--json"], { actorScope: actor });
|
|
469
|
+
assertCondition((revisedAgain.revised_count || 0) >= 1, "Update suggestion revise after request_changes did not process.");
|
|
470
|
+
|
|
471
|
+
const reject = writeJson(this.payloadDir, "update-loop-reject.json", {
|
|
472
|
+
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: "Harness cleanup reject." }],
|
|
473
|
+
});
|
|
474
|
+
const rejected = this.cli(["suggestions", "review", "--file", reject, "--json"], { actorScope: actor });
|
|
475
|
+
assertCondition((rejected.processed_count || 0) >= 1, "Update suggestion reject cleanup did not process.");
|
|
476
|
+
this.openSuggestions.delete(suggestionId);
|
|
477
|
+
|
|
478
|
+
return { mission_id: missionId, suggestion_id: suggestionId, append_verified: true };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async scenarioCreateRejectLoop() {
|
|
482
|
+
this.requireLive("create_reject_loop");
|
|
483
|
+
const actor = `${this.actorBase}-create-reject`;
|
|
484
|
+
const title = `Harness rejected mission ${nowId()}`;
|
|
485
|
+
const before = await this.bootstrap();
|
|
486
|
+
|
|
487
|
+
const createFile = writeJson(this.payloadDir, "create-reject-create.json", {
|
|
488
|
+
items: [{
|
|
489
|
+
change_type: "create",
|
|
490
|
+
change_description: "Harness new mission proposal that will be rejected",
|
|
491
|
+
change_set: {
|
|
492
|
+
title,
|
|
493
|
+
description: "Harness proposal that should never become an active mission.",
|
|
494
|
+
acceptance_criteria: ["The rejected proposal does not create a mission card."],
|
|
495
|
+
labels: ["harness", "reject-path"],
|
|
496
|
+
},
|
|
497
|
+
}],
|
|
498
|
+
});
|
|
499
|
+
const created = this.cli(["suggestions", "create", "--file", createFile, "--no-sync", "--json"], { actorScope: actor });
|
|
500
|
+
const suggestionId = pickSuggestionId(created);
|
|
501
|
+
assertCondition(suggestionId, "Create suggestion did not return suggestion_id.");
|
|
502
|
+
this.openSuggestions.add(suggestionId);
|
|
503
|
+
|
|
504
|
+
const afterCreate = await this.bootstrap();
|
|
505
|
+
assertCondition(
|
|
506
|
+
activeMissions(afterCreate).length === activeMissions(before).length,
|
|
507
|
+
"New mission proposal created an active mission before approval.",
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
const reviseFile = writeJson(this.payloadDir, "create-reject-revise.json", {
|
|
511
|
+
items: [{
|
|
512
|
+
suggestion_id: suggestionId,
|
|
513
|
+
change_description: "Harness revision for rejected create thread",
|
|
514
|
+
change_set: { description: "Harness revised proposal. This should still be rejected." },
|
|
515
|
+
}],
|
|
516
|
+
});
|
|
517
|
+
const revised = this.cli(["suggestions", "revise", "--file", reviseFile, "--no-sync", "--json"], { actorScope: actor });
|
|
518
|
+
assertCondition((revised.revised_count || 0) >= 1, "Create suggestion revise did not process.");
|
|
519
|
+
|
|
520
|
+
const requestChanges = writeJson(this.payloadDir, "create-reject-request-changes.json", {
|
|
521
|
+
items: [{ suggestion_id: suggestionId, action: "request_changes", review_action: "request_changes", review_note: "Harness request changes check." }],
|
|
522
|
+
});
|
|
523
|
+
const requested = this.cli(["suggestions", "review", "--file", requestChanges, "--no-sync", "--json"], { actorScope: actor });
|
|
524
|
+
assertCondition((requested.processed_count || 0) >= 1, "Create suggestion request_changes did not process.");
|
|
525
|
+
|
|
526
|
+
const reject = writeJson(this.payloadDir, "create-reject-reject.json", {
|
|
527
|
+
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: "Harness cleanup reject." }],
|
|
528
|
+
});
|
|
529
|
+
const rejected = this.cli(["suggestions", "review", "--file", reject, "--json"], { actorScope: actor });
|
|
530
|
+
assertCondition((rejected.processed_count || 0) >= 1, "Create suggestion reject cleanup did not process.");
|
|
531
|
+
this.openSuggestions.delete(suggestionId);
|
|
532
|
+
|
|
533
|
+
const afterReject = await this.bootstrap();
|
|
534
|
+
assertCondition(
|
|
535
|
+
activeMissions(afterReject).every((mission) => missionTitle(mission) !== title),
|
|
536
|
+
"Rejected create suggestion appeared as an active mission.",
|
|
537
|
+
);
|
|
538
|
+
return { suggestion_id: suggestionId, active_mission_created: false };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async archiveMissions(missionIds, reason) {
|
|
542
|
+
if (!missionIds.length) return null;
|
|
543
|
+
return this.cli(["mission", "archive", "--mission-ids", missionIds.join(","), "--reason", reason, "--json"], {
|
|
544
|
+
timeoutMs: 180000,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async rejectSuggestion(suggestionId, actor, note) {
|
|
549
|
+
const rejectFile = writeJson(this.payloadDir, `reject-${suggestionId}.json`, {
|
|
550
|
+
items: [{ suggestion_id: suggestionId, action: "reject", review_action: "reject", review_note: note || "Harness cleanup reject." }],
|
|
551
|
+
});
|
|
552
|
+
return this.cli(["suggestions", "review", "--file", rejectFile, "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async createApprovedMission(actor, index = 1) {
|
|
556
|
+
const title = `Harness approved mission ${nowId()} ${index}`;
|
|
557
|
+
const description = "Disposable mission created by the reusable live mission harness.";
|
|
558
|
+
const createFile = writeJson(this.payloadDir, `approved-create-${index}.json`, {
|
|
559
|
+
items: [{
|
|
560
|
+
change_type: "create",
|
|
561
|
+
change_description: "Harness approved disposable mission creation",
|
|
562
|
+
change_set: {
|
|
563
|
+
title,
|
|
564
|
+
description,
|
|
565
|
+
acceptance_criteria: [
|
|
566
|
+
"Mission can be created through suggestion approval.",
|
|
567
|
+
"Mission can be mutated through project-key mission commands.",
|
|
568
|
+
"Mission can be archived without hard delete.",
|
|
569
|
+
],
|
|
570
|
+
labels: ["harness", "approved-disposable"],
|
|
571
|
+
},
|
|
572
|
+
}],
|
|
573
|
+
});
|
|
574
|
+
const created = this.cli(["suggestions", "create", "--file", createFile, "--no-sync", "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
575
|
+
const suggestionId = pickSuggestionId(created);
|
|
576
|
+
assertCondition(suggestionId, "Approved mission create did not return suggestion_id.");
|
|
577
|
+
this.openSuggestions.add(suggestionId);
|
|
578
|
+
const reviewFile = writeJson(this.payloadDir, `approved-review-${index}.json`, {
|
|
579
|
+
items: [{
|
|
580
|
+
suggestion_id: suggestionId,
|
|
581
|
+
action: "approve",
|
|
582
|
+
review_action: "approve",
|
|
583
|
+
final_change_set: {
|
|
584
|
+
title,
|
|
585
|
+
description,
|
|
586
|
+
acceptance_criteria: [
|
|
587
|
+
"Mission can be created through suggestion approval.",
|
|
588
|
+
"Mission can be mutated through project-key mission commands.",
|
|
589
|
+
"Mission can be archived without hard delete.",
|
|
590
|
+
],
|
|
591
|
+
labels: ["harness", "approved-disposable"],
|
|
592
|
+
},
|
|
593
|
+
}],
|
|
594
|
+
});
|
|
595
|
+
const reviewed = this.cli(["suggestions", "review", "--file", reviewFile, "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
596
|
+
const missionId = pickAppliedMissionId(reviewed);
|
|
597
|
+
assertCondition(missionId, "Approved mission review did not return applied mission id.");
|
|
598
|
+
this.createdMissions.add(missionId);
|
|
599
|
+
this.openSuggestions.delete(suggestionId);
|
|
600
|
+
return { mission_id: missionId, suggestion_id: suggestionId, title };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async scenarioApprovedLifecycle() {
|
|
604
|
+
this.requireLive("approved_lifecycle");
|
|
605
|
+
if (this.archiveRouteAvailable !== true) await this.probeArchiveRoute();
|
|
606
|
+
const actor = `${this.actorBase}-approved-lifecycle`;
|
|
607
|
+
const created = await this.createApprovedMission(actor, 1);
|
|
608
|
+
let pendingSuggestionId = "";
|
|
609
|
+
try {
|
|
610
|
+
const activeAfterCreate = await this.bootstrap();
|
|
611
|
+
assertCondition(hasMissionInBootstrap(activeAfterCreate, created.mission_id), "Approved mission is missing from active bootstrap state.");
|
|
612
|
+
|
|
613
|
+
for (const status of ["in_progress", "done", "todo"]) {
|
|
614
|
+
const result = this.cli(["mission", "status", "--mission-ids", created.mission_id, "--status", status, "--json"], { timeoutMs: 180000 });
|
|
615
|
+
assertCondition((result.matched_count || 0) >= 1, `Mission status ${status} did not match the disposable mission.`);
|
|
616
|
+
}
|
|
617
|
+
const noop = this.cli(["mission", "status", "--mission-ids", created.mission_id, "--status", "todo", "--json"], { timeoutMs: 180000 });
|
|
618
|
+
assertCondition((noop.unchanged_count || 0) >= 1 || (noop.matched_count || 0) >= 1, "Idempotent mission status no-op did not report a stable match.");
|
|
619
|
+
|
|
620
|
+
const pendingFile = writeJson(this.payloadDir, "approved-lifecycle-pending-update.json", {
|
|
621
|
+
items: [{
|
|
622
|
+
change_type: "update",
|
|
623
|
+
mission_id: created.mission_id,
|
|
624
|
+
change_description: "Harness pending suggestion across archive",
|
|
625
|
+
change_set: { description: "Harness pending suggestion should be hidden from actionable queue while archived." },
|
|
626
|
+
}],
|
|
627
|
+
});
|
|
628
|
+
const pending = this.cli(["suggestions", "create", "--file", pendingFile, "--no-sync", "--json"], { actorScope: actor, timeoutMs: 180000 });
|
|
629
|
+
pendingSuggestionId = pickSuggestionId(pending);
|
|
630
|
+
assertCondition(pendingSuggestionId, "Pending update suggestion for approved mission was not created.");
|
|
631
|
+
this.openSuggestions.add(pendingSuggestionId);
|
|
632
|
+
|
|
633
|
+
const archived = await this.archiveMissions([created.mission_id], "Harness archive active mission with pending suggestion");
|
|
634
|
+
assertCondition((archived.updated_count || 0) + (archived.unchanged_count || 0) >= 1, "Archive did not update or confirm the disposable mission.");
|
|
635
|
+
|
|
636
|
+
const afterArchive = await this.bootstrap();
|
|
637
|
+
assertCondition(!hasMissionInBootstrap(afterArchive, created.mission_id), "Archived mission is still present in active bootstrap state.");
|
|
638
|
+
const archivedSuggestions = await this.suggestions(actor);
|
|
639
|
+
const archivedQueueRefs = queueRefs(archivedSuggestions).filter((item) => item.mission_id.toUpperCase() === created.mission_id.toUpperCase());
|
|
640
|
+
assertCondition(archivedQueueRefs.length === 0, "Archived mission still appears in actionable suggestion queue.");
|
|
641
|
+
const archivedThreadRefs = threadRefs(archivedSuggestions).filter((item) => item.suggestion_id === pendingSuggestionId);
|
|
642
|
+
assertCondition(archivedThreadRefs.length >= 1, "Archived mission pending suggestion was not preserved in thread history.");
|
|
643
|
+
|
|
644
|
+
await this.rejectSuggestion(pendingSuggestionId, actor, "Harness cleanup reject after archived-thread preservation check.");
|
|
645
|
+
this.openSuggestions.delete(pendingSuggestionId);
|
|
646
|
+
return {
|
|
647
|
+
mission_id: created.mission_id,
|
|
648
|
+
create_suggestion_id: created.suggestion_id,
|
|
649
|
+
pending_suggestion_id: pendingSuggestionId,
|
|
650
|
+
archive_hides_active: true,
|
|
651
|
+
archive_preserves_thread_history: true,
|
|
652
|
+
};
|
|
653
|
+
} catch (error) {
|
|
654
|
+
if (pendingSuggestionId) {
|
|
655
|
+
try {
|
|
656
|
+
await this.rejectSuggestion(pendingSuggestionId, actor, "Harness failure cleanup reject.");
|
|
657
|
+
this.openSuggestions.delete(pendingSuggestionId);
|
|
658
|
+
} catch (_) {}
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
await this.archiveMissions([created.mission_id], "Harness failure cleanup archive.");
|
|
662
|
+
} catch (_) {}
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async scenarioBatchLimits() {
|
|
668
|
+
const statusIds = Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
669
|
+
const qaqcIds = Array.from({ length: 11 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`).join(",");
|
|
670
|
+
const archiveProbe = await this.api("/project-assistant/mission-archive", {
|
|
671
|
+
method: "POST",
|
|
672
|
+
idempotencyKey: `mission-live-full-archive-limit-${nowId()}`,
|
|
673
|
+
body: {
|
|
674
|
+
mission_ids: Array.from({ length: 101 }, (_, index) => `ZZ${String(index).padStart(3, "0")}`),
|
|
675
|
+
reason: "Harness limit check",
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
const checks = [
|
|
679
|
+
this.cli(["mission", "status", "--mission-ids", statusIds, "--status", "todo", "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }),
|
|
680
|
+
this.cli(["run-qaqc", "--mission-ids", qaqcIds, "--json"], { expectFailure: true, parseJson: false, timeoutMs: 180000 }),
|
|
681
|
+
];
|
|
682
|
+
assertCondition(
|
|
683
|
+
archiveProbe.statusCode === 400 || archiveProbe.statusCode === 404,
|
|
684
|
+
`Expected archive limit check to return 400 when route is live or 404 when route is absent, got ${archiveProbe.statusCode}.`,
|
|
685
|
+
);
|
|
686
|
+
return {
|
|
687
|
+
checks: checks.length + 1,
|
|
688
|
+
mission_status_limit: 100,
|
|
689
|
+
run_qaqc_limit: 10,
|
|
690
|
+
archive_limit: archiveProbe.statusCode === 400 ? 100 : "route_not_live",
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async scenarioBatchMutation() {
|
|
695
|
+
this.requireLive("batch_mutation");
|
|
696
|
+
if (this.archiveRouteAvailable !== true) await this.probeArchiveRoute();
|
|
697
|
+
const actor = `${this.actorBase}-batch`;
|
|
698
|
+
const created = [];
|
|
699
|
+
try {
|
|
700
|
+
for (let index = 1; index <= this.batchSize; index += 1) {
|
|
701
|
+
created.push(await this.createApprovedMission(actor, index));
|
|
702
|
+
}
|
|
703
|
+
const missionIds = created.map((item) => item.mission_id);
|
|
704
|
+
for (const status of ["in_progress", "done", "todo"]) {
|
|
705
|
+
const result = this.cli(["mission", "status", "--mission-ids", missionIds.join(","), "--status", status, "--json"], { timeoutMs: 240000 });
|
|
706
|
+
assertCondition((result.matched_count || 0) === missionIds.length, `Batch status ${status} matched ${result.matched_count || 0}/${missionIds.length}.`);
|
|
707
|
+
}
|
|
708
|
+
const archived = await this.archiveMissions(missionIds, "Harness batch cleanup archive");
|
|
709
|
+
assertCondition((archived.updated_count || 0) + (archived.unchanged_count || 0) === missionIds.length, "Batch archive did not cover every disposable mission.");
|
|
710
|
+
return { mission_count: missionIds.length, mission_ids: missionIds };
|
|
711
|
+
} catch (error) {
|
|
712
|
+
try {
|
|
713
|
+
await this.archiveMissions(created.map((item) => item.mission_id), "Harness batch failure cleanup archive");
|
|
714
|
+
} catch (_) {}
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async scenarioQaqc() {
|
|
720
|
+
const sync = this.cli(["sync-qaqc", "--json"], { timeoutMs: 180000 });
|
|
721
|
+
const result = {
|
|
722
|
+
sync_mission_count: sync.counts?.missions || sync.missions_count || 0,
|
|
723
|
+
run_executed: false,
|
|
724
|
+
};
|
|
725
|
+
if (!this.includeQaqcRun) {
|
|
726
|
+
result.skipped_run_reason = "Pass --include-qaqc-run to queue a live QAQC batch.";
|
|
727
|
+
return result;
|
|
728
|
+
}
|
|
729
|
+
this.requireLive("qaqc run");
|
|
730
|
+
const bootstrap = await this.bootstrap();
|
|
731
|
+
const mission = chooseQaqcMission(bootstrap);
|
|
732
|
+
assertCondition(mission, "No active non-Done mission with test cases is available for QAQC run.");
|
|
733
|
+
const missionId = missionPublicId(mission);
|
|
734
|
+
const run = this.cli(["run-qaqc", "--mission-ids", missionId, "--wait", "--sync", "--json"], { timeoutMs: 600000 });
|
|
735
|
+
result.run_executed = true;
|
|
736
|
+
result.mission_id = missionId;
|
|
737
|
+
result.batch_id = run.batch_id || null;
|
|
738
|
+
result.status = run.status || null;
|
|
739
|
+
result.accepted_count = run.accepted_count || 0;
|
|
740
|
+
assertCondition(result.accepted_count > 0, `QAQC run did not accept ${missionId}; status=${result.status || "unknown"}.`);
|
|
741
|
+
return result;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
scenarioMap() {
|
|
745
|
+
return {
|
|
746
|
+
preflight: () => this.scenarioPreflight(),
|
|
747
|
+
contracts: () => this.scenarioContracts(),
|
|
748
|
+
suggestion_update_loop: () => this.scenarioSuggestionUpdateLoop(),
|
|
749
|
+
create_reject_loop: () => this.scenarioCreateRejectLoop(),
|
|
750
|
+
archive_route_probe: () => this.probeArchiveRoute(),
|
|
751
|
+
approved_lifecycle: () => this.scenarioApprovedLifecycle(),
|
|
752
|
+
batch_limits: () => this.scenarioBatchLimits(),
|
|
753
|
+
batch_mutation: () => this.scenarioBatchMutation(),
|
|
754
|
+
qaqc: () => this.scenarioQaqc(),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async runScenario(name) {
|
|
759
|
+
const startedAt = new Date().toISOString();
|
|
760
|
+
const entry = { name, status: "running", started_at: startedAt };
|
|
761
|
+
this.report.scenarios.push(entry);
|
|
762
|
+
const map = this.scenarioMap();
|
|
763
|
+
if (!map[name]) throw new Error(`Unknown scenario: ${name}`);
|
|
764
|
+
try {
|
|
765
|
+
const details = await map[name]();
|
|
766
|
+
entry.status = "passed";
|
|
767
|
+
entry.completed_at = new Date().toISOString();
|
|
768
|
+
entry.details = safeResult(details || {});
|
|
769
|
+
console.log(`[pass] ${name}`);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
entry.status = "failed";
|
|
772
|
+
entry.completed_at = new Date().toISOString();
|
|
773
|
+
entry.error = String(error?.message || error);
|
|
774
|
+
console.error(`[fail] ${name}: ${entry.error}`);
|
|
775
|
+
if (!this.keepGoing) throw error;
|
|
776
|
+
} finally {
|
|
777
|
+
this.writeReport();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
writeReport() {
|
|
782
|
+
this.report.created_mission_ids = Array.from(this.createdMissions).sort();
|
|
783
|
+
this.report.open_suggestion_ids = Array.from(this.openSuggestions).sort();
|
|
784
|
+
if (!this.report.completed_at) {
|
|
785
|
+
this.report.status = this.report.scenarios.some((item) => item.status === "failed") ? "failed" : "running";
|
|
786
|
+
}
|
|
787
|
+
ensureDir(path.dirname(this.reportPath));
|
|
788
|
+
fs.writeFileSync(this.reportPath, JSON.stringify(this.report, null, 2), "utf8");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
async run() {
|
|
792
|
+
this.requireKey();
|
|
793
|
+
ensureDir(this.workspace);
|
|
794
|
+
ensureDir(this.payloadDir);
|
|
795
|
+
console.log(`Workspace: ${this.workspace}`);
|
|
796
|
+
console.log(`Report: ${this.reportPath}`);
|
|
797
|
+
console.log(`Scenarios: ${this.scenarios.join(", ")}`);
|
|
798
|
+
if (!this.confirmLive) {
|
|
799
|
+
console.log("Live mutations disabled. Pass --confirm-live to run mutating scenarios.");
|
|
800
|
+
}
|
|
801
|
+
for (const scenario of this.scenarios) {
|
|
802
|
+
await this.runScenario(scenario);
|
|
803
|
+
}
|
|
804
|
+
const failed = this.report.scenarios.filter((item) => item.status === "failed");
|
|
805
|
+
this.report.status = failed.length ? "failed" : "passed";
|
|
806
|
+
this.report.completed_at = new Date().toISOString();
|
|
807
|
+
this.writeReport();
|
|
808
|
+
console.log(JSON.stringify({
|
|
809
|
+
status: this.report.status,
|
|
810
|
+
report_file: this.reportPath,
|
|
811
|
+
passed: this.report.scenarios.filter((item) => item.status === "passed").length,
|
|
812
|
+
failed: failed.length,
|
|
813
|
+
created_mission_ids: this.report.created_mission_ids,
|
|
814
|
+
open_suggestion_ids: this.report.open_suggestion_ids,
|
|
815
|
+
}, null, 2));
|
|
816
|
+
if (failed.length && this.args["no-fail-exit"] !== true) process.exitCode = 1;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const args = parseArgs(process.argv.slice(2));
|
|
821
|
+
new Harness(args).run().catch((error) => {
|
|
822
|
+
console.error(error?.message || error);
|
|
823
|
+
process.exit(1);
|
|
824
|
+
});
|