@bridge_gpt/mcp-server 0.2.62 → 0.2.66
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 +66 -15
- package/build/agent-launchers/claude-executor-adapter.js +145 -20
- package/build/agent-launchers/executor-adapter.js +81 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +565 -32
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +351 -0
- package/build/conductor/epic-implementer-cli.js +154 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +45 -12
- package/build/conductor/recovery-cli.js +859 -27
- package/build/conductor/recovery-operations.js +574 -5
- package/build/conductor/repair-contract.js +69 -3
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +52 -13
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +3 -2
- package/build/doctor.js +147 -21
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1537 -125
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +41 -33
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +58 -19
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +291 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +12 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +91 -4
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +61 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +1550 -55
- package/build/executor/job-types.js +9 -6
- package/build/executor/preflight.js +189 -1
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +366 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +168 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +13 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +70 -34
- package/build/executor/worker-finalization.js +168 -26
- package/build/executor/worker-guard-hook.js +75 -14
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +594 -1152
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +231 -41
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/merge-pull-request.js +1 -1
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +146 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +208 -125
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +5 -4
- package/build/worktree-core.js +6 -5
- package/docs/CONDUCTOR.md +116 -2
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +2 -1
- package/pipelines/implement-ticket.json +1 -1
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `conductor` operator recovery and repair verbs —
|
|
2
|
+
* `conductor` operator recovery and repair verbs — thirteen CLI-only commands.
|
|
3
3
|
*
|
|
4
4
|
* The four BAPI-872 recovery verbs: `stop-run`, `abandon-run`, `unpark`,
|
|
5
5
|
* `adopt-current-head-and-unpark`. The six run-addressed repair verbs: BAPI-1154's
|
|
6
6
|
* `scope-reparse`, `dispatch-remediation`, `merge-child`, and BAPI-1176's
|
|
7
|
-
* `scope-catch-up`, `patch-policy`, `resume-run`.
|
|
7
|
+
* `scope-catch-up`, `patch-policy`, `resume-run`. BAPI-1300's parked-child
|
|
8
|
+
* continuation pair: `continue-child` and the read-only `continuation-receipt`.
|
|
9
|
+
* BAPI-1326's allowance-preserving CI resume: `resume-ci`.
|
|
8
10
|
*
|
|
9
11
|
* CLI-only by design — this is an explicit ticket non-goal, not an oversight:
|
|
10
|
-
* none of these
|
|
12
|
+
* none of these thirteen command names is ever registered as an MCP tool, tool
|
|
11
13
|
* description, or generated tool-schema input (`index-static.test.ts` guards
|
|
12
14
|
* this). They exist so an operator (or `plane down`, via
|
|
13
15
|
* `recovery-operations.ts`) can recover a stuck run or ticket WITHOUT
|
|
@@ -23,7 +25,8 @@
|
|
|
23
25
|
*/
|
|
24
26
|
import { readFile } from "node:fs/promises";
|
|
25
27
|
import { ConductorValidationError, toConductorErrorEnvelope } from "./errors.js";
|
|
26
|
-
import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES } from "./repair-contract.js";
|
|
28
|
+
import { DRIVER_CONTEXT_MAX_BYTES, POLICY_PATCH_MAX_BYTES, RESUME_RUN_UNCONFIRMED_MESSAGE, } from "./repair-contract.js";
|
|
29
|
+
import { decodeGuidedBinding, GuidedBindingError, } from "./guided-repair.js";
|
|
27
30
|
// ---------------------------------------------------------------------------
|
|
28
31
|
// Flag parsing
|
|
29
32
|
// ---------------------------------------------------------------------------
|
|
@@ -43,6 +46,12 @@ const REJECTED_VERSION_FLAGS = new Set([
|
|
|
43
46
|
"--expected_row_version",
|
|
44
47
|
"--expected-generation",
|
|
45
48
|
"--expected_generation",
|
|
49
|
+
// BAPI-1300: the continuation's park-episode binding is read once and stored
|
|
50
|
+
// by the client itself; an operator never supplies it.
|
|
51
|
+
"--park-generation",
|
|
52
|
+
"--park_generation",
|
|
53
|
+
"--source-park-generation",
|
|
54
|
+
"--source_park_generation",
|
|
46
55
|
]);
|
|
47
56
|
/**
|
|
48
57
|
* Tokenize `--flag value` / `--flag=value` / boolean flags for the recovery
|
|
@@ -266,16 +275,52 @@ const ADOPT_CURRENT_HEAD_USAGE = [
|
|
|
266
275
|
"off its anchored commit: adopt the CURRENT PR head and unpark in one step.",
|
|
267
276
|
"Retries a bounded number of times on its own if the ticket changes",
|
|
268
277
|
"concurrently — never accepts a version counter.",
|
|
278
|
+
"",
|
|
279
|
+
"For a ticket parked as unexplained_review_head_drift this is \"Adopt current",
|
|
280
|
+
"head and resume CI\": the server reads the provider's current head, accepts it,",
|
|
281
|
+
"keeps every repair allowance already spent (no repair attempt is granted), and",
|
|
282
|
+
"resumes the ticket at CI. No worker is started and nothing is marked passed —",
|
|
283
|
+
"CI and review must pass again on the accepted head. A repeat of the same",
|
|
284
|
+
"request reports the recorded result instead of adopting twice.",
|
|
269
285
|
].join("\n");
|
|
270
|
-
|
|
286
|
+
/** BAPI-1310 (E1): the fixed allowance sentence the adoption result carries. */
|
|
287
|
+
const REVIEW_HEAD_ALLOWANCE_LINE = "Allowance effect: repair allowances preserved; acceptance does not grant another repair.";
|
|
288
|
+
/** BAPI-1310 (E1): the warning shown when run control still holds the run. */
|
|
289
|
+
export const REVIEW_HEAD_HELD_WARNING = "Head accepted; run remains held by run control";
|
|
290
|
+
/** Render the E1 adoption result (committed or replayed) to stdout. */
|
|
291
|
+
function renderReviewHeadRecovery(ticketKey, epicRunId, recovery) {
|
|
292
|
+
const outcome = recovery.result === "replayed"
|
|
293
|
+
? "replayed — this operation already committed; nothing changed now"
|
|
294
|
+
: "committed";
|
|
295
|
+
console.log(`Ticket ${ticketKey} (run ${epicRunId}): Adopt current head and resume CI — ${outcome}.`);
|
|
296
|
+
console.log(` Previous expected head: ${recovery.previous_expected_head_sha}`);
|
|
297
|
+
console.log(` Committed accepted head: ${recovery.accepted_head_sha}`);
|
|
298
|
+
console.log(` ${REVIEW_HEAD_ALLOWANCE_LINE}`);
|
|
299
|
+
console.log(` Resumed at: ${recovery.resumed_gate}`);
|
|
300
|
+
console.log(` Current gate: ${recovery.current_gate ?? "unavailable"}`);
|
|
301
|
+
console.log(" CI and review must pass again on the accepted head; no worker was started and nothing was marked passed.");
|
|
302
|
+
if (recovery.held_by_run_control) {
|
|
303
|
+
console.log(` WARNING: ${REVIEW_HEAD_HELD_WARNING}.`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export function renderTicketRecoveryResult(result, json) {
|
|
271
307
|
if (json) {
|
|
272
308
|
console.log(JSON.stringify(result));
|
|
273
309
|
return result.ok ? 0 : 1;
|
|
274
310
|
}
|
|
275
311
|
if (result.ok) {
|
|
312
|
+
if (result.reviewHeadRecovery) {
|
|
313
|
+
renderReviewHeadRecovery(result.ticketKey, result.epicRunId, result.reviewHeadRecovery);
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
276
316
|
console.log(`Ticket ${result.ticketKey} (run ${result.epicRunId}): unparked — status: ${result.status}.`);
|
|
277
317
|
return 0;
|
|
278
318
|
}
|
|
319
|
+
if (result.kind === "refused") {
|
|
320
|
+
console.error(`Ticket ${result.ticketKey} (run ${result.epicRunId}): refused (${result.errorCode}) — ` +
|
|
321
|
+
`${result.message}. The ticket remains parked; nothing was changed.`);
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
279
324
|
if (result.kind === "ticket-not-found") {
|
|
280
325
|
console.error(`Ticket ${result.ticketKey}: not found in run ${result.epicRunId}.`);
|
|
281
326
|
return 1;
|
|
@@ -335,13 +380,26 @@ export async function runAdoptCurrentHeadAndUnparkCommand(argv) {
|
|
|
335
380
|
// ---------------------------------------------------------------------------
|
|
336
381
|
// BAPI-1154 — repair verbs: scope-reparse, dispatch-remediation, merge-child
|
|
337
382
|
// ---------------------------------------------------------------------------
|
|
338
|
-
|
|
383
|
+
// BAPI-1333 — `--binding` is the ONE guided-repair input: the opaque token
|
|
384
|
+
// ExplainRun rendered, carrying the subject the command is pinned to. It is not
|
|
385
|
+
// a version counter the operator composes, so REJECTED_VERSION_FLAGS is unchanged.
|
|
386
|
+
const SCOPE_REPARSE_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id", "--binding"]);
|
|
339
387
|
const DISPATCH_REMEDIATION_VALUE_FLAGS = new Set([
|
|
340
388
|
"--epic-run-id",
|
|
341
389
|
"--ticket-key",
|
|
342
390
|
"--context-file",
|
|
391
|
+
"--binding",
|
|
343
392
|
]);
|
|
344
393
|
const MERGE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--pr"]);
|
|
394
|
+
/**
|
|
395
|
+
* BAPI-1333 — attach `binding` ONLY when `--binding` was given, so an unguided
|
|
396
|
+
* parse result is exactly what it was before.
|
|
397
|
+
*/
|
|
398
|
+
function withBinding(parsed, values) {
|
|
399
|
+
if (!values.has("--binding"))
|
|
400
|
+
return parsed;
|
|
401
|
+
return { ...parsed, binding: requireFlag(values, "--binding") };
|
|
402
|
+
}
|
|
345
403
|
function requireFlag(values, flag) {
|
|
346
404
|
const value = values.get(flag);
|
|
347
405
|
if (value === undefined || value.trim().length === 0) {
|
|
@@ -354,25 +412,25 @@ export function parseScopeReparseArgs(argv) {
|
|
|
354
412
|
if (bools.has("--help")) {
|
|
355
413
|
return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
|
|
356
414
|
}
|
|
357
|
-
return {
|
|
415
|
+
return withBinding({
|
|
358
416
|
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
359
417
|
scopeId: requireFlag(values, "--scope-id"),
|
|
360
418
|
json: bools.has("--json"),
|
|
361
419
|
help: false,
|
|
362
|
-
};
|
|
420
|
+
}, values);
|
|
363
421
|
}
|
|
364
422
|
export function parseDispatchRemediationArgs(argv) {
|
|
365
423
|
const { values, bools } = tokenizeRecoveryFlags(argv, DISPATCH_REMEDIATION_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
366
424
|
if (bools.has("--help")) {
|
|
367
425
|
return { epicRunId: "", ticketKey: "", contextFile: "", json: bools.has("--json"), help: true };
|
|
368
426
|
}
|
|
369
|
-
return {
|
|
427
|
+
return withBinding({
|
|
370
428
|
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
371
429
|
ticketKey: requireFlag(values, "--ticket-key"),
|
|
372
430
|
contextFile: requireFlag(values, "--context-file"),
|
|
373
431
|
json: bools.has("--json"),
|
|
374
432
|
help: false,
|
|
375
|
-
};
|
|
433
|
+
}, values);
|
|
376
434
|
}
|
|
377
435
|
export function parseMergeChildArgs(argv) {
|
|
378
436
|
const { values, bools } = tokenizeRecoveryFlags(argv, MERGE_CHILD_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
@@ -414,6 +472,124 @@ export function decodeDriverContextFile(bytes) {
|
|
|
414
472
|
}
|
|
415
473
|
return text;
|
|
416
474
|
}
|
|
475
|
+
/** One repair result, however it ended, as the renderer needs it. */
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// BAPI-1333 — guided (binding-pinned) repairs
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
/** Exit codes shared with `resume-ci`: 0 applied/no-op, 2 refused/stale, 3 unconfirmed. */
|
|
480
|
+
export const GUIDED_REPAIR_EXIT_OK = 0;
|
|
481
|
+
export const GUIDED_REPAIR_EXIT_FAILED = 1;
|
|
482
|
+
export const GUIDED_REPAIR_EXIT_REFUSED = 2;
|
|
483
|
+
export const GUIDED_REPAIR_EXIT_UNCONFIRMED = 3;
|
|
484
|
+
export const GUIDED_REPAIR_UNCONFIRMED_MESSAGE = "the outcome could not be confirmed: the server may or may not have applied this " +
|
|
485
|
+
"guided repair. Re-run the SAME command to re-send the same pinned request, or " +
|
|
486
|
+
"inspect the run read-only";
|
|
487
|
+
const GUIDED_STALE_GUIDANCE = "Nothing was written. The run changed since this command was rendered (possibly " +
|
|
488
|
+
"by this command's own earlier delivery). Re-read the run explanation before " +
|
|
489
|
+
"choosing another action.";
|
|
490
|
+
/**
|
|
491
|
+
* Decode `--binding` and prove it names THIS command, run, and target. Runs
|
|
492
|
+
* before credentials are resolved, so a mismatched token reaches no one.
|
|
493
|
+
*/
|
|
494
|
+
function checkGuidedBinding(token, verb, expected) {
|
|
495
|
+
let binding;
|
|
496
|
+
try {
|
|
497
|
+
binding = decodeGuidedBinding(token);
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
throw new ConductorValidationError(err instanceof GuidedBindingError ? err.message : "--binding could not be decoded; nothing was sent.");
|
|
501
|
+
}
|
|
502
|
+
if (binding.verb !== verb ||
|
|
503
|
+
binding.epicRunId !== expected.epicRunId ||
|
|
504
|
+
(expected.scopeId !== undefined && binding.scopeId !== expected.scopeId) ||
|
|
505
|
+
(expected.ticketKey !== undefined && binding.ticketKey !== expected.ticketKey)) {
|
|
506
|
+
throw new ConductorValidationError("--binding was rendered for a different command, run, or target; nothing was sent.");
|
|
507
|
+
}
|
|
508
|
+
return binding;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Send one guided repair and render its closed outcome. Success goes to stdout;
|
|
512
|
+
* every failure goes to stderr (JSON mode included), with the exit codes above.
|
|
513
|
+
*/
|
|
514
|
+
async function runGuidedRepairCommand(input, json) {
|
|
515
|
+
const { guidedRepairOnce } = await import("./recovery-operations.js");
|
|
516
|
+
const { guidedPayloadDigest } = await import("./guided-repair.js");
|
|
517
|
+
const outcome = await guidedRepairOnce({
|
|
518
|
+
binding: input.binding,
|
|
519
|
+
payloadDigest: guidedPayloadDigest(input.payload),
|
|
520
|
+
send: input.send,
|
|
521
|
+
unconfirmedMessage: GUIDED_REPAIR_UNCONFIRMED_MESSAGE,
|
|
522
|
+
});
|
|
523
|
+
const verb = input.binding.verb;
|
|
524
|
+
const runId = input.binding.epicRunId;
|
|
525
|
+
if (outcome.kind === "committed" || outcome.kind === "no_op") {
|
|
526
|
+
const result = outcome.result;
|
|
527
|
+
if (outcome.kind === "committed" && !result.event_recorded) {
|
|
528
|
+
console.error(`Warning: ${verb} on run ${runId} (${input.label}) COMMITTED, but its ` +
|
|
529
|
+
"operator_verb_applied audit event was not recorded. The action stands.");
|
|
530
|
+
}
|
|
531
|
+
if (json) {
|
|
532
|
+
console.log(JSON.stringify({
|
|
533
|
+
ok: true,
|
|
534
|
+
outcome: outcome.kind,
|
|
535
|
+
request_id: outcome.requestId,
|
|
536
|
+
reference_reused: outcome.referenceReused,
|
|
537
|
+
result,
|
|
538
|
+
}));
|
|
539
|
+
return GUIDED_REPAIR_EXIT_OK;
|
|
540
|
+
}
|
|
541
|
+
console.log([
|
|
542
|
+
`${verb} (guided) — run ${runId}`,
|
|
543
|
+
` target: ${input.label}`,
|
|
544
|
+
` ${input.stateLabel}: ${result.before ?? "none"} → ${result.after ?? "none"}`,
|
|
545
|
+
` outcome: ${outcome.kind === "committed" ? "committed" : "no-op (already in effect; nothing written)"}`,
|
|
546
|
+
` request: ${outcome.requestId}${outcome.referenceReused ? " (re-sent from the local reference)" : ""}`,
|
|
547
|
+
` event recorded: ${result.event_recorded}`,
|
|
548
|
+
].join("\n"));
|
|
549
|
+
return GUIDED_REPAIR_EXIT_OK;
|
|
550
|
+
}
|
|
551
|
+
if (outcome.kind === "stale" || outcome.kind === "refused") {
|
|
552
|
+
const payload = {
|
|
553
|
+
ok: false,
|
|
554
|
+
outcome: outcome.kind,
|
|
555
|
+
code: outcome.code,
|
|
556
|
+
message: outcome.message,
|
|
557
|
+
request_id: outcome.requestId,
|
|
558
|
+
nothing_written: true,
|
|
559
|
+
};
|
|
560
|
+
if (json) {
|
|
561
|
+
console.error(JSON.stringify(payload));
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
console.error(`Error: ${verb} ${outcome.kind === "stale" ? "is stale" : "refused"} on run ${runId} ` +
|
|
565
|
+
`(${input.label}) — ${outcome.code}: ${outcome.message}` +
|
|
566
|
+
(outcome.kind === "stale" ? ` ${GUIDED_STALE_GUIDANCE}` : ""));
|
|
567
|
+
}
|
|
568
|
+
return GUIDED_REPAIR_EXIT_REFUSED;
|
|
569
|
+
}
|
|
570
|
+
if (outcome.kind === "unconfirmed") {
|
|
571
|
+
if (json) {
|
|
572
|
+
console.error(JSON.stringify({
|
|
573
|
+
ok: false,
|
|
574
|
+
outcome: "unconfirmed",
|
|
575
|
+
message: outcome.message,
|
|
576
|
+
request_id: outcome.requestId,
|
|
577
|
+
reference_kept: true,
|
|
578
|
+
}));
|
|
579
|
+
}
|
|
580
|
+
else {
|
|
581
|
+
console.error(`Error: ${verb} on run ${runId} (${input.label}) — ${outcome.message}.`);
|
|
582
|
+
}
|
|
583
|
+
return GUIDED_REPAIR_EXIT_UNCONFIRMED;
|
|
584
|
+
}
|
|
585
|
+
if (json) {
|
|
586
|
+
console.error(JSON.stringify({ ok: false, outcome: "unavailable", message: outcome.message }));
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
console.error(`Error: ${verb} on run ${runId} (${input.label}) — ${outcome.message}.`);
|
|
590
|
+
}
|
|
591
|
+
return GUIDED_REPAIR_EXIT_FAILED;
|
|
592
|
+
}
|
|
417
593
|
/**
|
|
418
594
|
* Render a repair outcome. Human mode prints the verb and run first, the target
|
|
419
595
|
* second, ONE labeled `Before → After` pair third, then whether this call
|
|
@@ -440,7 +616,7 @@ function renderRepairResult(outcome, input, json, extraLines = () => []) {
|
|
|
440
616
|
}
|
|
441
617
|
console.log(JSON.stringify(outcome.kind === "refused"
|
|
442
618
|
? { ok: false, kind: "refused", code: outcome.code, message: outcome.message }
|
|
443
|
-
: { ok: false, kind: "unavailable", message: outcome.message }));
|
|
619
|
+
: { ok: false, kind: "unavailable", message: input.unavailableMessage ?? outcome.message }));
|
|
444
620
|
return 1;
|
|
445
621
|
}
|
|
446
622
|
if (!outcome.ok) {
|
|
@@ -448,6 +624,10 @@ function renderRepairResult(outcome, input, json, extraLines = () => []) {
|
|
|
448
624
|
console.error(`Error: ${input.verb} refused on run ${input.epicRunId} (${input.targetLabel}) — ` +
|
|
449
625
|
`${outcome.code}: ${outcome.message}`);
|
|
450
626
|
}
|
|
627
|
+
else if (input.unavailableMessage) {
|
|
628
|
+
console.error(`Error: ${input.verb} on run ${input.epicRunId} (${input.targetLabel}) — ` +
|
|
629
|
+
`${input.unavailableMessage}.`);
|
|
630
|
+
}
|
|
451
631
|
else {
|
|
452
632
|
console.error(`Error: ${input.verb} could not complete on run ${input.epicRunId} ` +
|
|
453
633
|
`(${input.targetLabel}) — ${outcome.message}.`);
|
|
@@ -474,6 +654,12 @@ const SCOPE_REPARSE_USAGE = [
|
|
|
474
654
|
"schedule its parse. The scope must be the one bound to the named run, or the",
|
|
475
655
|
"server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints the scope",
|
|
476
656
|
"lifecycle before → after; it does not wait for the parse to finish.",
|
|
657
|
+
"",
|
|
658
|
+
"Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
|
|
659
|
+
"rendered it. The request is pinned to the subject that explanation showed and",
|
|
660
|
+
"is stored locally before it is sent, so re-running the same command after a",
|
|
661
|
+
"lost response re-sends the SAME request. A subject that has since changed is",
|
|
662
|
+
"refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
|
|
477
663
|
].join("\n");
|
|
478
664
|
export async function runScopeReparseCommand(argv) {
|
|
479
665
|
try {
|
|
@@ -482,9 +668,25 @@ export async function runScopeReparseCommand(argv) {
|
|
|
482
668
|
console.log(SCOPE_REPARSE_USAGE);
|
|
483
669
|
return 0;
|
|
484
670
|
}
|
|
671
|
+
const binding = parsed.binding === undefined
|
|
672
|
+
? null
|
|
673
|
+
: checkGuidedBinding(parsed.binding, "scope-reparse", {
|
|
674
|
+
epicRunId: parsed.epicRunId,
|
|
675
|
+
scopeId: parsed.scopeId,
|
|
676
|
+
});
|
|
485
677
|
const access = await resolveRecoveryAccess(parsed.json);
|
|
486
678
|
if (!access)
|
|
487
679
|
return 1;
|
|
680
|
+
if (binding !== null) {
|
|
681
|
+
const { scopeReparse } = await import("./bridge-api-client.js");
|
|
682
|
+
return await runGuidedRepairCommand({
|
|
683
|
+
binding,
|
|
684
|
+
payload: { scope_id: parsed.scopeId },
|
|
685
|
+
send: (guard) => scopeReparse(access, { epicRunId: parsed.epicRunId, scopeId: parsed.scopeId, guard }),
|
|
686
|
+
label: `scope ${parsed.scopeId}`,
|
|
687
|
+
stateLabel: "scope lifecycle",
|
|
688
|
+
}, parsed.json);
|
|
689
|
+
}
|
|
488
690
|
const { scopeReparseRepair } = await import("./recovery-operations.js");
|
|
489
691
|
const outcome = await scopeReparseRepair(access, {
|
|
490
692
|
epicRunId: parsed.epicRunId,
|
|
@@ -516,6 +718,12 @@ const DISPATCH_REMEDIATION_USAGE = [
|
|
|
516
718
|
`--context-file must be non-empty UTF-8 text of at most DRIVER_CONTEXT_MAX_BYTES`,
|
|
517
719
|
`(${DRIVER_CONTEXT_MAX_BYTES} bytes). It is rejected, never truncated, and is never`,
|
|
518
720
|
"printed, logged, or echoed — it reaches only the worker's prompt.",
|
|
721
|
+
"",
|
|
722
|
+
"Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
|
|
723
|
+
"rendered it. The request is pinned to the subject that explanation showed and",
|
|
724
|
+
"is stored locally before it is sent, so re-running the same command after a",
|
|
725
|
+
"lost response re-sends the SAME request. A subject that has since changed is",
|
|
726
|
+
"refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
|
|
519
727
|
].join("\n");
|
|
520
728
|
export async function runDispatchRemediationCommand(argv, deps = {}) {
|
|
521
729
|
try {
|
|
@@ -534,9 +742,31 @@ export async function runDispatchRemediationCommand(argv, deps = {}) {
|
|
|
534
742
|
throw new ConductorValidationError("--context-file could not be read; nothing was sent.");
|
|
535
743
|
}
|
|
536
744
|
const context = decodeDriverContextFile(bytes);
|
|
745
|
+
const binding = parsed.binding === undefined
|
|
746
|
+
? null
|
|
747
|
+
: checkGuidedBinding(parsed.binding, "dispatch-remediation", {
|
|
748
|
+
epicRunId: parsed.epicRunId,
|
|
749
|
+
ticketKey: parsed.ticketKey,
|
|
750
|
+
});
|
|
537
751
|
const access = await resolveRecoveryAccess(parsed.json);
|
|
538
752
|
if (!access)
|
|
539
753
|
return 1;
|
|
754
|
+
if (binding !== null) {
|
|
755
|
+
const { dispatchRemediation } = await import("./bridge-api-client.js");
|
|
756
|
+
return await runGuidedRepairCommand({
|
|
757
|
+
binding,
|
|
758
|
+
// Digested, never stored: the context stays out of the reference file.
|
|
759
|
+
payload: { ticket_key: parsed.ticketKey, context: context },
|
|
760
|
+
send: (guard) => dispatchRemediation(access, {
|
|
761
|
+
epicRunId: parsed.epicRunId,
|
|
762
|
+
ticketKey: parsed.ticketKey,
|
|
763
|
+
context,
|
|
764
|
+
guard,
|
|
765
|
+
}),
|
|
766
|
+
label: `ticket ${parsed.ticketKey}`,
|
|
767
|
+
stateLabel: "code_review gate",
|
|
768
|
+
}, parsed.json);
|
|
769
|
+
}
|
|
540
770
|
const { dispatchRemediationRepair } = await import("./recovery-operations.js");
|
|
541
771
|
const outcome = await dispatchRemediationRepair(access, {
|
|
542
772
|
epicRunId: parsed.epicRunId,
|
|
@@ -604,9 +834,9 @@ export async function runMergeChildCommand(argv) {
|
|
|
604
834
|
// ---------------------------------------------------------------------------
|
|
605
835
|
// BAPI-1176 — repair verbs: scope-catch-up, patch-policy, resume-run
|
|
606
836
|
// ---------------------------------------------------------------------------
|
|
607
|
-
const SCOPE_CATCH_UP_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id"]);
|
|
608
|
-
const PATCH_POLICY_VALUE_FLAGS = new Set(["--epic-run-id", "--patch-file"]);
|
|
609
|
-
const RESUME_RUN_VALUE_FLAGS = new Set(["--epic-run-id"]);
|
|
837
|
+
const SCOPE_CATCH_UP_VALUE_FLAGS = new Set(["--epic-run-id", "--scope-id", "--binding"]);
|
|
838
|
+
const PATCH_POLICY_VALUE_FLAGS = new Set(["--epic-run-id", "--patch-file", "--binding"]);
|
|
839
|
+
const RESUME_RUN_VALUE_FLAGS = new Set(["--epic-run-id", "--binding"]);
|
|
610
840
|
/** `--clear-quarantine` is a `resume-run` switch only; no other verb accepts it. */
|
|
611
841
|
const RESUME_RUN_BOOL_FLAGS = new Set(["--json", "--help", "--clear-quarantine"]);
|
|
612
842
|
export function parseScopeCatchUpArgs(argv) {
|
|
@@ -614,24 +844,24 @@ export function parseScopeCatchUpArgs(argv) {
|
|
|
614
844
|
if (bools.has("--help")) {
|
|
615
845
|
return { epicRunId: "", scopeId: "", json: bools.has("--json"), help: true };
|
|
616
846
|
}
|
|
617
|
-
return {
|
|
847
|
+
return withBinding({
|
|
618
848
|
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
619
849
|
scopeId: requireFlag(values, "--scope-id"),
|
|
620
850
|
json: bools.has("--json"),
|
|
621
851
|
help: false,
|
|
622
|
-
};
|
|
852
|
+
}, values);
|
|
623
853
|
}
|
|
624
854
|
export function parsePatchPolicyArgs(argv) {
|
|
625
855
|
const { values, bools } = tokenizeRecoveryFlags(argv, PATCH_POLICY_VALUE_FLAGS, RECOVERY_BOOL_FLAGS);
|
|
626
856
|
if (bools.has("--help")) {
|
|
627
857
|
return { epicRunId: "", patchFile: "", json: bools.has("--json"), help: true };
|
|
628
858
|
}
|
|
629
|
-
return {
|
|
859
|
+
return withBinding({
|
|
630
860
|
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
631
861
|
patchFile: requireFlag(values, "--patch-file"),
|
|
632
862
|
json: bools.has("--json"),
|
|
633
863
|
help: false,
|
|
634
|
-
};
|
|
864
|
+
}, values);
|
|
635
865
|
}
|
|
636
866
|
export function parseResumeRunArgs(argv) {
|
|
637
867
|
const { values, bools } = tokenizeRecoveryFlags(argv, RESUME_RUN_VALUE_FLAGS, RESUME_RUN_BOOL_FLAGS);
|
|
@@ -643,12 +873,12 @@ export function parseResumeRunArgs(argv) {
|
|
|
643
873
|
help: true,
|
|
644
874
|
};
|
|
645
875
|
}
|
|
646
|
-
return {
|
|
876
|
+
return withBinding({
|
|
647
877
|
epicRunId: requireFlag(values, "--epic-run-id"),
|
|
648
878
|
clearQuarantine: bools.has("--clear-quarantine"),
|
|
649
879
|
json: bools.has("--json"),
|
|
650
880
|
help: false,
|
|
651
|
-
};
|
|
881
|
+
}, values);
|
|
652
882
|
}
|
|
653
883
|
/**
|
|
654
884
|
* Validate and decode a `patch-policy` file's BYTES into a merge-patch object.
|
|
@@ -702,6 +932,12 @@ const SCOPE_CATCH_UP_USAGE = [
|
|
|
702
932
|
"refused (SCOPE_CATCH_UP_REFUSED). The scope must be the one bound to the named",
|
|
703
933
|
"run, or the server refuses it (TICKET_NOT_IN_RUN) with nothing written. Prints",
|
|
704
934
|
"the scope lifecycle before → after; it does not wait for a scheduled parse.",
|
|
935
|
+
"",
|
|
936
|
+
"Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
|
|
937
|
+
"rendered it. The request is pinned to the subject that explanation showed and",
|
|
938
|
+
"is stored locally before it is sent, so re-running the same command after a",
|
|
939
|
+
"lost response re-sends the SAME request. A subject that has since changed is",
|
|
940
|
+
"refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
|
|
705
941
|
].join("\n");
|
|
706
942
|
export async function runScopeCatchUpCommand(argv) {
|
|
707
943
|
try {
|
|
@@ -710,9 +946,25 @@ export async function runScopeCatchUpCommand(argv) {
|
|
|
710
946
|
console.log(SCOPE_CATCH_UP_USAGE);
|
|
711
947
|
return 0;
|
|
712
948
|
}
|
|
949
|
+
const binding = parsed.binding === undefined
|
|
950
|
+
? null
|
|
951
|
+
: checkGuidedBinding(parsed.binding, "scope-catch-up", {
|
|
952
|
+
epicRunId: parsed.epicRunId,
|
|
953
|
+
scopeId: parsed.scopeId,
|
|
954
|
+
});
|
|
713
955
|
const access = await resolveRecoveryAccess(parsed.json);
|
|
714
956
|
if (!access)
|
|
715
957
|
return 1;
|
|
958
|
+
if (binding !== null) {
|
|
959
|
+
const { scopeCatchUp } = await import("./bridge-api-client.js");
|
|
960
|
+
return await runGuidedRepairCommand({
|
|
961
|
+
binding,
|
|
962
|
+
payload: { scope_id: parsed.scopeId },
|
|
963
|
+
send: (guard) => scopeCatchUp(access, { epicRunId: parsed.epicRunId, scopeId: parsed.scopeId, guard }),
|
|
964
|
+
label: `scope ${parsed.scopeId}`,
|
|
965
|
+
stateLabel: "scope lifecycle",
|
|
966
|
+
}, parsed.json);
|
|
967
|
+
}
|
|
716
968
|
const { scopeCatchUpRepair } = await import("./recovery-operations.js");
|
|
717
969
|
const outcome = await scopeCatchUpRepair(access, {
|
|
718
970
|
epicRunId: parsed.epicRunId,
|
|
@@ -745,6 +997,12 @@ const PATCH_POLICY_USAGE = [
|
|
|
745
997
|
"--patch-file must hold a non-empty JSON object of at most POLICY_PATCH_MAX_BYTES",
|
|
746
998
|
`(${POLICY_PATCH_MAX_BYTES} bytes). Its contents are never printed; the result names only`,
|
|
747
999
|
"the changed dotted paths and before/after policy digest tokens.",
|
|
1000
|
+
"",
|
|
1001
|
+
"Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
|
|
1002
|
+
"rendered it. The request is pinned to the subject that explanation showed and",
|
|
1003
|
+
"is stored locally before it is sent, so re-running the same command after a",
|
|
1004
|
+
"lost response re-sends the SAME request. A subject that has since changed is",
|
|
1005
|
+
"refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
|
|
748
1006
|
].join("\n");
|
|
749
1007
|
export async function runPatchPolicyCommand(argv, deps = {}) {
|
|
750
1008
|
try {
|
|
@@ -763,9 +1021,23 @@ export async function runPatchPolicyCommand(argv, deps = {}) {
|
|
|
763
1021
|
throw new ConductorValidationError("--patch-file could not be read; nothing was sent.");
|
|
764
1022
|
}
|
|
765
1023
|
const mergePatch = decodePolicyPatchFile(bytes);
|
|
1024
|
+
const binding = parsed.binding === undefined
|
|
1025
|
+
? null
|
|
1026
|
+
: checkGuidedBinding(parsed.binding, "patch-policy", { epicRunId: parsed.epicRunId });
|
|
766
1027
|
const access = await resolveRecoveryAccess(parsed.json);
|
|
767
1028
|
if (!access)
|
|
768
1029
|
return 1;
|
|
1030
|
+
if (binding !== null) {
|
|
1031
|
+
const { patchPolicy } = await import("./bridge-api-client.js");
|
|
1032
|
+
return await runGuidedRepairCommand({
|
|
1033
|
+
binding,
|
|
1034
|
+
// Digested, never stored: no policy value reaches the reference file.
|
|
1035
|
+
payload: { merge_patch: mergePatch },
|
|
1036
|
+
send: (guard) => patchPolicy(access, { epicRunId: parsed.epicRunId, mergePatch, guard }),
|
|
1037
|
+
label: "run policy",
|
|
1038
|
+
stateLabel: "policy digest",
|
|
1039
|
+
}, parsed.json);
|
|
1040
|
+
}
|
|
769
1041
|
const { patchPolicyRepair } = await import("./recovery-operations.js");
|
|
770
1042
|
const outcome = await patchPolicyRepair(access, {
|
|
771
1043
|
epicRunId: parsed.epicRunId,
|
|
@@ -788,11 +1060,22 @@ export async function runPatchPolicyCommand(argv, deps = {}) {
|
|
|
788
1060
|
const RESUME_RUN_USAGE = [
|
|
789
1061
|
"Usage: epic-implementer resume-run --epic-run-id <id> [--clear-quarantine] [--json]",
|
|
790
1062
|
"",
|
|
791
|
-
"
|
|
792
|
-
"
|
|
793
|
-
"
|
|
794
|
-
"
|
|
795
|
-
"a
|
|
1063
|
+
"Return a held run to active, after the server re-checks the run's activation",
|
|
1064
|
+
"requirements. A paused run's control record is cleared. A run blocked by an",
|
|
1065
|
+
"eligible integration block has that block lifted (there is no control record",
|
|
1066
|
+
"to clear); the server decides eligibility. A quarantined run resumes only with",
|
|
1067
|
+
"--clear-quarantine; a stopped run never resumes (start a new run). A run that",
|
|
1068
|
+
"is already active and unheld is a successful no-op (already current). The",
|
|
1069
|
+
"server retries a concurrent control change itself — never supply a generation.",
|
|
1070
|
+
"",
|
|
1071
|
+
"If the response is lost or cannot be read, the resume may or may not have",
|
|
1072
|
+
"committed: inspect the run read-only before any separately authorized retry.",
|
|
1073
|
+
"",
|
|
1074
|
+
"Guided mode (BAPI-1333): add --binding <token> exactly as the run explanation",
|
|
1075
|
+
"rendered it. The request is pinned to the subject that explanation showed and",
|
|
1076
|
+
"is stored locally before it is sent, so re-running the same command after a",
|
|
1077
|
+
"lost response re-sends the SAME request. A subject that has since changed is",
|
|
1078
|
+
"refused as stale with nothing written (exit 2); an unconfirmed outcome exits 3.",
|
|
796
1079
|
].join("\n");
|
|
797
1080
|
export async function runResumeRunCommand(argv) {
|
|
798
1081
|
try {
|
|
@@ -801,9 +1084,29 @@ export async function runResumeRunCommand(argv) {
|
|
|
801
1084
|
console.log(RESUME_RUN_USAGE);
|
|
802
1085
|
return 0;
|
|
803
1086
|
}
|
|
1087
|
+
const binding = parsed.binding === undefined
|
|
1088
|
+
? null
|
|
1089
|
+
: checkGuidedBinding(parsed.binding, "resume-run", { epicRunId: parsed.epicRunId });
|
|
1090
|
+
if (binding !== null && binding.controlState === "quarantined" && !parsed.clearQuarantine) {
|
|
1091
|
+
throw new ConductorValidationError("This binding names a quarantined run; --clear-quarantine is required. Nothing was sent.");
|
|
1092
|
+
}
|
|
804
1093
|
const access = await resolveRecoveryAccess(parsed.json);
|
|
805
1094
|
if (!access)
|
|
806
1095
|
return 1;
|
|
1096
|
+
if (binding !== null) {
|
|
1097
|
+
const { resumeRun } = await import("./bridge-api-client.js");
|
|
1098
|
+
return await runGuidedRepairCommand({
|
|
1099
|
+
binding,
|
|
1100
|
+
payload: { clear_quarantine: parsed.clearQuarantine },
|
|
1101
|
+
send: (guard) => resumeRun(access, {
|
|
1102
|
+
epicRunId: parsed.epicRunId,
|
|
1103
|
+
clearQuarantine: parsed.clearQuarantine,
|
|
1104
|
+
guard,
|
|
1105
|
+
}),
|
|
1106
|
+
label: parsed.clearQuarantine ? "run (clear quarantine)" : "run",
|
|
1107
|
+
stateLabel: "run state",
|
|
1108
|
+
}, parsed.json);
|
|
1109
|
+
}
|
|
807
1110
|
const { resumeRunRepair } = await import("./recovery-operations.js");
|
|
808
1111
|
const outcome = await resumeRunRepair(access, {
|
|
809
1112
|
epicRunId: parsed.epicRunId,
|
|
@@ -812,8 +1115,9 @@ export async function runResumeRunCommand(argv) {
|
|
|
812
1115
|
return renderRepairResult(outcome, {
|
|
813
1116
|
verb: "resume-run",
|
|
814
1117
|
epicRunId: parsed.epicRunId,
|
|
815
|
-
targetLabel: parsed.clearQuarantine ? "run
|
|
816
|
-
stateLabel: "run
|
|
1118
|
+
targetLabel: parsed.clearQuarantine ? "run (clear quarantine)" : "run",
|
|
1119
|
+
stateLabel: "run state",
|
|
1120
|
+
unavailableMessage: RESUME_RUN_UNCONFIRMED_MESSAGE,
|
|
817
1121
|
}, parsed.json);
|
|
818
1122
|
}
|
|
819
1123
|
catch (error) {
|
|
@@ -821,3 +1125,531 @@ export async function runResumeRunCommand(argv) {
|
|
|
821
1125
|
return 1;
|
|
822
1126
|
}
|
|
823
1127
|
}
|
|
1128
|
+
// ---------------------------------------------------------------------------
|
|
1129
|
+
// continue-child / continuation-receipt (BAPI-1300)
|
|
1130
|
+
// ---------------------------------------------------------------------------
|
|
1131
|
+
const CONTINUE_CHILD_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--reason"]);
|
|
1132
|
+
const CONTINUATION_RECEIPT_VALUE_FLAGS = new Set(["--epic-run-id", "--ticket-key", "--request-id"]);
|
|
1133
|
+
const MAX_REASON_CHARS = 500;
|
|
1134
|
+
const CONTINUE_CHILD_USAGE = [
|
|
1135
|
+
"Usage: epic-implementer continue-child --epic-run-id <id> --ticket-key <key> [--reason <text>] [--json]",
|
|
1136
|
+
"",
|
|
1137
|
+
"Continue a parked child's CURRENT park episode without resetting it: the same",
|
|
1138
|
+
"attempt resumes on its retained work with only the allowance it has left (no",
|
|
1139
|
+
"fresh budget, no counter or clock reset — use unpark for that). The server",
|
|
1140
|
+
"commits one job and one durable receipt together, at most once per episode.",
|
|
1141
|
+
"",
|
|
1142
|
+
"If the response is lost, the outcome is reported UNCONFIRMED, never refused or",
|
|
1143
|
+
"committed. Re-running continue-child reuses the SAME request (it never",
|
|
1144
|
+
"approves a new head or episode); continuation-receipt looks the receipt up.",
|
|
1145
|
+
"Never accepts a version counter or a park generation.",
|
|
1146
|
+
].join("\n");
|
|
1147
|
+
const CONTINUATION_RECEIPT_USAGE = [
|
|
1148
|
+
"Usage: epic-implementer continuation-receipt --epic-run-id <id> --ticket-key <key> [--request-id <id>] [--json]",
|
|
1149
|
+
"",
|
|
1150
|
+
"Read-only. Show the child's current park episode and, for the pending local",
|
|
1151
|
+
"continuation request (or --request-id), whether its receipt proves a commit.",
|
|
1152
|
+
"A missing receipt is not a refusal. Looking history up authorizes nothing.",
|
|
1153
|
+
].join("\n");
|
|
1154
|
+
function parseContinuationArgs(argv, valueFlags) {
|
|
1155
|
+
const { values, bools } = tokenizeRecoveryFlags(argv, valueFlags, RECOVERY_BOOL_FLAGS);
|
|
1156
|
+
if (bools.has("--help")) {
|
|
1157
|
+
return { epicRunId: "", ticketKey: "", json: bools.has("--json"), help: true };
|
|
1158
|
+
}
|
|
1159
|
+
const epicRunId = requireFlag(values, "--epic-run-id");
|
|
1160
|
+
const ticketKey = requireFlag(values, "--ticket-key");
|
|
1161
|
+
const reason = values.get("--reason")?.trim();
|
|
1162
|
+
if (reason !== undefined && reason.length > MAX_REASON_CHARS) {
|
|
1163
|
+
throw new ConductorValidationError(`Flag "--reason" must be at most ${MAX_REASON_CHARS} characters.`);
|
|
1164
|
+
}
|
|
1165
|
+
const requestId = values.get("--request-id")?.trim();
|
|
1166
|
+
if (requestId !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/.test(requestId)) {
|
|
1167
|
+
throw new ConductorValidationError('Flag "--request-id" is not a valid request id.');
|
|
1168
|
+
}
|
|
1169
|
+
return {
|
|
1170
|
+
epicRunId,
|
|
1171
|
+
ticketKey,
|
|
1172
|
+
...(reason ? { reason } : {}),
|
|
1173
|
+
...(requestId ? { requestId } : {}),
|
|
1174
|
+
json: bools.has("--json"),
|
|
1175
|
+
help: false,
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/** Exported for tests: parse `continue-child` arguments. */
|
|
1179
|
+
export function parseContinueChildArgs(argv) {
|
|
1180
|
+
return parseContinuationArgs(argv, CONTINUE_CHILD_VALUE_FLAGS);
|
|
1181
|
+
}
|
|
1182
|
+
/** Exported for tests: parse `continuation-receipt` arguments. */
|
|
1183
|
+
export function parseContinuationReceiptArgs(argv) {
|
|
1184
|
+
return parseContinuationArgs(argv, CONTINUATION_RECEIPT_VALUE_FLAGS);
|
|
1185
|
+
}
|
|
1186
|
+
function receiptSummary(receipt) {
|
|
1187
|
+
return {
|
|
1188
|
+
receipt_id: receipt.receipt_id,
|
|
1189
|
+
request_id: receipt.request_id,
|
|
1190
|
+
gate: receipt.gate,
|
|
1191
|
+
job_type: receipt.job_type,
|
|
1192
|
+
continuation_job_id: receipt.continuation_job_id,
|
|
1193
|
+
original_job_id: receipt.original_job_id,
|
|
1194
|
+
source_park_generation: receipt.source_park_generation,
|
|
1195
|
+
resulting_park_generation: receipt.resulting_park_generation,
|
|
1196
|
+
committed_at: receipt.committed_at,
|
|
1197
|
+
runtime_outcome: receipt.runtime_outcome,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
function currentSummary(current) {
|
|
1201
|
+
if (current === null)
|
|
1202
|
+
return null;
|
|
1203
|
+
return {
|
|
1204
|
+
status: current.status,
|
|
1205
|
+
parked: current.parked,
|
|
1206
|
+
parked_gate: current.parked_gate,
|
|
1207
|
+
park_generation: current.park_generation,
|
|
1208
|
+
parked_age_seconds: current.parked_age_seconds,
|
|
1209
|
+
needs_human_reason: current.needs_human_reason,
|
|
1210
|
+
unresolved_obligations: current.unresolved_obligations,
|
|
1211
|
+
continuations_used: current.continuations_used,
|
|
1212
|
+
continuation_limit: current.continuation_limit,
|
|
1213
|
+
continuation_available: current.continuation_available,
|
|
1214
|
+
continuation_unavailable_reason: current.continuation_unavailable_reason,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
function currentLine(current) {
|
|
1218
|
+
if (!current.parked) {
|
|
1219
|
+
return `Current: status ${current.status} (not parked).`;
|
|
1220
|
+
}
|
|
1221
|
+
const age = current.parked_age_seconds === null ? "" : `, parked ${current.parked_age_seconds}s`;
|
|
1222
|
+
const available = current.continuation_available
|
|
1223
|
+
? "continuation available"
|
|
1224
|
+
: `continuation unavailable (${current.continuation_unavailable_reason ?? "unknown"})`;
|
|
1225
|
+
return (`Current: parked at gate ${current.parked_gate} (episode ${current.park_generation}${age}); ` +
|
|
1226
|
+
`${available}.`);
|
|
1227
|
+
}
|
|
1228
|
+
/** Exported for tests: render a `continue-child` result. Returns the exit code. */
|
|
1229
|
+
export function renderContinueChildResult(result, target, json) {
|
|
1230
|
+
const label = `Ticket ${target.ticketKey} (run ${target.epicRunId})`;
|
|
1231
|
+
if (result.kind === "decided") {
|
|
1232
|
+
const r = result.result;
|
|
1233
|
+
const ok = r.outcome === "committed" || r.outcome === "already_continued";
|
|
1234
|
+
if (json) {
|
|
1235
|
+
console.log(JSON.stringify({
|
|
1236
|
+
ok,
|
|
1237
|
+
kind: r.outcome,
|
|
1238
|
+
reason_code: r.reason_code,
|
|
1239
|
+
explanation: r.explanation,
|
|
1240
|
+
request_id: result.requestId,
|
|
1241
|
+
replayed: r.replayed,
|
|
1242
|
+
retryable: r.retryable,
|
|
1243
|
+
reference_reused: result.reusedReference,
|
|
1244
|
+
receipt: r.receipt === null ? null : receiptSummary(r.receipt),
|
|
1245
|
+
current: currentSummary(r.current),
|
|
1246
|
+
}));
|
|
1247
|
+
return ok ? 0 : 1;
|
|
1248
|
+
}
|
|
1249
|
+
const receipt = r.receipt;
|
|
1250
|
+
switch (r.outcome) {
|
|
1251
|
+
case "committed":
|
|
1252
|
+
console.log(`${label}: continuation COMMITTED${r.replayed ? " (replayed — already committed earlier)" : ""} — ` +
|
|
1253
|
+
`job #${receipt?.continuation_job_id} (${receipt?.job_type}) dispatched for park episode ` +
|
|
1254
|
+
`${receipt?.source_park_generation}; receipt ${receipt?.receipt_id}. This proves the dispatch, ` +
|
|
1255
|
+
"not that the work has finished.");
|
|
1256
|
+
return 0;
|
|
1257
|
+
case "already_continued":
|
|
1258
|
+
console.log(`${label}: this park episode was ALREADY CONTINUED by another request — job ` +
|
|
1259
|
+
`#${receipt?.continuation_job_id}, receipt ${receipt?.receipt_id}. Nothing was dispatched ` +
|
|
1260
|
+
"for this request.");
|
|
1261
|
+
return 0;
|
|
1262
|
+
case "stale_historical_episode":
|
|
1263
|
+
console.error(`${label}: STALE HISTORICAL EPISODE — receipt ${receipt?.receipt_id} (job ` +
|
|
1264
|
+
`#${receipt?.continuation_job_id}) belongs to park episode ${receipt?.source_park_generation}, ` +
|
|
1265
|
+
"which is history. The child has parked again and was NOT resumed by it.");
|
|
1266
|
+
if (r.current)
|
|
1267
|
+
console.error(currentLine(r.current));
|
|
1268
|
+
return 1;
|
|
1269
|
+
case "unsupported":
|
|
1270
|
+
console.error(`${label}: continuation UNSUPPORTED (${r.reason_code}) — ${r.explanation}`);
|
|
1271
|
+
return 1;
|
|
1272
|
+
default:
|
|
1273
|
+
console.error(`${label}: continuation REFUSED (${r.reason_code}) — ${r.explanation}`);
|
|
1274
|
+
if (r.retryable)
|
|
1275
|
+
console.error("This refusal is retryable; nothing was written.");
|
|
1276
|
+
return 1;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (result.kind === "refused_http") {
|
|
1280
|
+
if (json) {
|
|
1281
|
+
console.log(JSON.stringify({
|
|
1282
|
+
ok: false,
|
|
1283
|
+
kind: "refused",
|
|
1284
|
+
status: result.status,
|
|
1285
|
+
code: result.errorCode,
|
|
1286
|
+
request_id: result.requestId,
|
|
1287
|
+
}));
|
|
1288
|
+
return 1;
|
|
1289
|
+
}
|
|
1290
|
+
console.error(`${label}: request REJECTED before anything was written (status ${result.status}` +
|
|
1291
|
+
`${result.errorCode ? `, ${result.errorCode}` : ""}).`);
|
|
1292
|
+
return 1;
|
|
1293
|
+
}
|
|
1294
|
+
if (result.kind === "not_continuable") {
|
|
1295
|
+
if (json) {
|
|
1296
|
+
console.log(JSON.stringify({ ok: false, kind: "not_continuable", current: currentSummary(result.current) }));
|
|
1297
|
+
return 1;
|
|
1298
|
+
}
|
|
1299
|
+
console.error(`${label}: not continuable right now. Nothing was sent.`);
|
|
1300
|
+
console.error(currentLine(result.current));
|
|
1301
|
+
// BAPI-1344: the park's own guidance names the exit that remains (for a
|
|
1302
|
+
// first attempt with no recorded retained head, the run operator).
|
|
1303
|
+
if (result.current.parked && result.current.needs_human_reason) {
|
|
1304
|
+
console.error(`Park: ${result.current.needs_human_reason}`);
|
|
1305
|
+
}
|
|
1306
|
+
return 1;
|
|
1307
|
+
}
|
|
1308
|
+
if (result.kind === "unconfirmed") {
|
|
1309
|
+
if (json) {
|
|
1310
|
+
console.log(JSON.stringify({
|
|
1311
|
+
ok: false,
|
|
1312
|
+
kind: "unconfirmed",
|
|
1313
|
+
transport: result.transport,
|
|
1314
|
+
request_id: result.requestId,
|
|
1315
|
+
reference_reused: result.reusedReference,
|
|
1316
|
+
}));
|
|
1317
|
+
return 1;
|
|
1318
|
+
}
|
|
1319
|
+
console.error(`${label}: continuation UNCONFIRMED after ${result.transport} — the request may or may not have ` +
|
|
1320
|
+
`committed, and no receipt proves it yet (request ${result.requestId}).`);
|
|
1321
|
+
console.error(`Look it up: epic-implementer continuation-receipt --epic-run-id ${target.epicRunId} ` +
|
|
1322
|
+
`--ticket-key ${target.ticketKey}. Re-running continue-child reuses this same request; it ` +
|
|
1323
|
+
"never starts a new one.");
|
|
1324
|
+
return 1;
|
|
1325
|
+
}
|
|
1326
|
+
if (json) {
|
|
1327
|
+
console.log(JSON.stringify({ ok: false, kind: "unavailable", message: result.message }));
|
|
1328
|
+
return 1;
|
|
1329
|
+
}
|
|
1330
|
+
console.error(`${label}: could not continue — ${result.message}. Nothing was sent.`);
|
|
1331
|
+
return 1;
|
|
1332
|
+
}
|
|
1333
|
+
/** Exported for tests: render a `continuation-receipt` lookup. */
|
|
1334
|
+
export function renderContinuationReceiptResult(result, target, json) {
|
|
1335
|
+
const label = `Ticket ${target.ticketKey} (run ${target.epicRunId})`;
|
|
1336
|
+
if (result.kind === "unavailable") {
|
|
1337
|
+
if (json) {
|
|
1338
|
+
console.log(JSON.stringify({ ok: false, kind: "unavailable", message: result.message }));
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
console.error(`${label}: could not read — ${result.message}.`);
|
|
1342
|
+
}
|
|
1343
|
+
return 1;
|
|
1344
|
+
}
|
|
1345
|
+
const status = result.status;
|
|
1346
|
+
if (json) {
|
|
1347
|
+
console.log(JSON.stringify({
|
|
1348
|
+
ok: true,
|
|
1349
|
+
kind: result.kind,
|
|
1350
|
+
request_id: result.requestId,
|
|
1351
|
+
outcome: status.outcome,
|
|
1352
|
+
receipt: status.receipt === null ? null : receiptSummary(status.receipt),
|
|
1353
|
+
current: currentSummary(status.current),
|
|
1354
|
+
}));
|
|
1355
|
+
return 0;
|
|
1356
|
+
}
|
|
1357
|
+
if (result.kind === "found" && status.receipt !== null) {
|
|
1358
|
+
const r = status.receipt;
|
|
1359
|
+
console.log(`${label}: receipt ${r.receipt_id} — continuation COMMITTED at ${r.committed_at}: job ` +
|
|
1360
|
+
`#${r.continuation_job_id} (${r.job_type}, runtime ${r.runtime_outcome ?? "unknown"}) for park ` +
|
|
1361
|
+
`episode ${r.source_park_generation}.`);
|
|
1362
|
+
if (status.outcome === "stale_historical_episode") {
|
|
1363
|
+
console.log("That episode is history: the child has parked again since.");
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
else if (result.requestId !== null) {
|
|
1367
|
+
console.log(`${label}: no receipt for request ${result.requestId} — its commit is NOT proven (this is not a refusal).`);
|
|
1368
|
+
}
|
|
1369
|
+
else {
|
|
1370
|
+
console.log(`${label}: no pending local continuation request.`);
|
|
1371
|
+
}
|
|
1372
|
+
console.log(currentLine(status.current));
|
|
1373
|
+
return 0;
|
|
1374
|
+
}
|
|
1375
|
+
export async function runContinueChildCommand(argv) {
|
|
1376
|
+
try {
|
|
1377
|
+
const parsed = parseContinueChildArgs(argv);
|
|
1378
|
+
if (parsed.help) {
|
|
1379
|
+
console.log(CONTINUE_CHILD_USAGE);
|
|
1380
|
+
return 0;
|
|
1381
|
+
}
|
|
1382
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
1383
|
+
if (!access)
|
|
1384
|
+
return 1;
|
|
1385
|
+
const { continueChildOnce } = await import("./recovery-operations.js");
|
|
1386
|
+
const result = await continueChildOnce(access, {
|
|
1387
|
+
epicRunId: parsed.epicRunId,
|
|
1388
|
+
ticketKey: parsed.ticketKey,
|
|
1389
|
+
...(parsed.reason ? { reason: parsed.reason } : {}),
|
|
1390
|
+
});
|
|
1391
|
+
return renderContinueChildResult(result, parsed, parsed.json);
|
|
1392
|
+
}
|
|
1393
|
+
catch (error) {
|
|
1394
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
1395
|
+
return 1;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
export async function runContinuationReceiptCommand(argv) {
|
|
1399
|
+
try {
|
|
1400
|
+
const parsed = parseContinuationReceiptArgs(argv);
|
|
1401
|
+
if (parsed.help) {
|
|
1402
|
+
console.log(CONTINUATION_RECEIPT_USAGE);
|
|
1403
|
+
return 0;
|
|
1404
|
+
}
|
|
1405
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
1406
|
+
if (!access)
|
|
1407
|
+
return 1;
|
|
1408
|
+
const { lookupContinuationReceipt } = await import("./recovery-operations.js");
|
|
1409
|
+
const result = await lookupContinuationReceipt(access, {
|
|
1410
|
+
epicRunId: parsed.epicRunId,
|
|
1411
|
+
ticketKey: parsed.ticketKey,
|
|
1412
|
+
...(parsed.requestId ? { requestId: parsed.requestId } : {}),
|
|
1413
|
+
});
|
|
1414
|
+
return renderContinuationReceiptResult(result, parsed, parsed.json);
|
|
1415
|
+
}
|
|
1416
|
+
catch (error) {
|
|
1417
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
1418
|
+
return 1;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
// ---------------------------------------------------------------------------
|
|
1422
|
+
// BAPI-1326 — resume-ci
|
|
1423
|
+
// ---------------------------------------------------------------------------
|
|
1424
|
+
const RESUME_CI_USAGE = [
|
|
1425
|
+
"Usage: epic-implementer resume-ci --epic-run-id <id> --ticket-key <key> [--json]",
|
|
1426
|
+
"",
|
|
1427
|
+
"Resume a child parked on a qualified CI stop (an expired CI wait, exhausted",
|
|
1428
|
+
"required-check repairs, or an unavailable verdict) once CI has finished green.",
|
|
1429
|
+
"The server re-reads the pull request, the specification, and the required",
|
|
1430
|
+
"checks, then returns CI to evaluation at the same head. It creates no job,",
|
|
1431
|
+
"grants no repair or new wait, keeps the original deadline, and does not mark",
|
|
1432
|
+
"CI passed or authorize review or merge.",
|
|
1433
|
+
"",
|
|
1434
|
+
"The operation is pinned locally before it is sent; re-running after a lost",
|
|
1435
|
+
"response reuses it and reports the original result. Nothing else is tried.",
|
|
1436
|
+
"",
|
|
1437
|
+
"Exit codes: 0 committed or replayed; 2 refused; 3 unconfirmed; 1 other failure.",
|
|
1438
|
+
].join("\n");
|
|
1439
|
+
/** Distinct exit codes, so automation can tell a refusal from an unknown outcome. */
|
|
1440
|
+
export const RESUME_CI_EXIT_OK = 0;
|
|
1441
|
+
export const RESUME_CI_EXIT_FAILED = 1;
|
|
1442
|
+
export const RESUME_CI_EXIT_REFUSED = 2;
|
|
1443
|
+
export const RESUME_CI_EXIT_UNCONFIRMED = 3;
|
|
1444
|
+
/**
|
|
1445
|
+
* Fixed operator sentences, chosen by the server's structured code. No server,
|
|
1446
|
+
* provider, or exception text ever reaches the terminal.
|
|
1447
|
+
*/
|
|
1448
|
+
const RESUME_CI_REFUSAL_SENTENCES = {
|
|
1449
|
+
TICKET_NOT_IN_RUN: "the ticket is not part of this run",
|
|
1450
|
+
CI_STOP_NOT_ELIGIBLE: "this CI park has no complete qualified stop record, so resume-ci does not apply",
|
|
1451
|
+
CI_RESUME_STALE_OPERATION: "the ticket is no longer on the CI stop this operation was made for, and its result is not retained",
|
|
1452
|
+
CI_RESUME_OPERATION_CONFLICT: "this operation already names a different CI recovery",
|
|
1453
|
+
ROW_VERSION_CONFLICT: "the ticket kept changing while the resume was being applied",
|
|
1454
|
+
RUN_NOT_DISPATCHABLE: "the run is paused, stopped, quarantined, or not active",
|
|
1455
|
+
CI_RESUME_BUDGET_EXHAUSTED: "the run's wall-clock or cost budget is spent, or could not be read",
|
|
1456
|
+
CI_RESUME_HELD: "the ticket is held by a completion, permission, declassification, or stale-specification hold",
|
|
1457
|
+
CI_RESUME_LIVE_WORK: "corrective work is, or may be, live for this ticket",
|
|
1458
|
+
CI_RESUME_SPEC_CHANGED: "the ticket's specification changed or could not be verified",
|
|
1459
|
+
CI_RESUME_POLICY_CHANGED: "the run's required-check policy changed, or is unavailable or unconfigured",
|
|
1460
|
+
CI_RESUME_BINDING_CHANGED: "the pull request, its head, or its target branch changed since the stop",
|
|
1461
|
+
CI_RESUME_CHECKS_NOT_GREEN: "the bound head's required checks are not currently green",
|
|
1462
|
+
CI_RESUME_VERDICT_UNAVAILABLE: "the CI provider's verdict could not be read in time",
|
|
1463
|
+
CI_RESUME_BUSY: "the ticket or run stayed busy for the whole bounded wait",
|
|
1464
|
+
};
|
|
1465
|
+
/** The next step for a refusal, where one is useful. */
|
|
1466
|
+
const RESUME_CI_REFUSAL_GUIDANCE = {
|
|
1467
|
+
CI_STOP_NOT_ELIGIBLE: "The existing explicit path is `epic-implementer unpark`, which RESETS the CI gate's " +
|
|
1468
|
+
"clock and repair allowance. If the park's origin is unclear, ask the run operator " +
|
|
1469
|
+
"to assess it first.",
|
|
1470
|
+
CI_RESUME_STALE_OPERATION: "Read `epic-implementer explain-run` for the ticket; start a new resume-ci only if " +
|
|
1471
|
+
"its current stop independently qualifies.",
|
|
1472
|
+
CI_RESUME_CHECKS_NOT_GREEN: "Wait for the required checks to finish green, then retry.",
|
|
1473
|
+
CI_RESUME_VERDICT_UNAVAILABLE: "Retry later. If the provider stays unavailable, escalate to the CI-platform operator.",
|
|
1474
|
+
CI_RESUME_BUSY: "Retry the same command; it reuses the same operation.",
|
|
1475
|
+
ROW_VERSION_CONFLICT: "Retry the same command; it reuses the same operation.",
|
|
1476
|
+
RUN_NOT_DISPATCHABLE: "Resume the run first (`epic-implementer resume-run`), then retry.",
|
|
1477
|
+
CI_RESUME_LIVE_WORK: "Retry when the running work has finished.",
|
|
1478
|
+
};
|
|
1479
|
+
const RESUME_CI_STOP_REASON_TEXT = {
|
|
1480
|
+
wait_expired: "an expired CI wait",
|
|
1481
|
+
repair_exhausted: "exhausted required-check repairs",
|
|
1482
|
+
verdict_unavailable: "an unavailable CI verdict",
|
|
1483
|
+
};
|
|
1484
|
+
const RESUME_CI_NOT_ELIGIBLE_TEXT = {
|
|
1485
|
+
ticket_not_found: "the ticket is not part of this run",
|
|
1486
|
+
not_parked: "the ticket is not parked",
|
|
1487
|
+
not_ci_park: "the ticket is not parked at the CI gate",
|
|
1488
|
+
no_typed_stop: "the CI park has no complete qualified stop record (a legacy or incomplete park)",
|
|
1489
|
+
stop_superseded: "the CI stop belongs to an earlier park episode",
|
|
1490
|
+
};
|
|
1491
|
+
/** The one consequence statement every committed result carries. */
|
|
1492
|
+
const RESUME_CI_CONSEQUENCE = "CI is back in evaluation at the same head; its verdict is pending. This action did NOT " +
|
|
1493
|
+
"mark CI passed, authorize review or merge, create a job, extend the deadline, or refund " +
|
|
1494
|
+
"any repair.";
|
|
1495
|
+
export function parseResumeCiArgs(argv) {
|
|
1496
|
+
return parseTicketRecoveryArgs(argv);
|
|
1497
|
+
}
|
|
1498
|
+
function resumeCiOperationSummary(op) {
|
|
1499
|
+
return {
|
|
1500
|
+
resumed_at: op.resumed_at,
|
|
1501
|
+
stop_reason: op.stop_reason,
|
|
1502
|
+
park_generation: op.park_generation,
|
|
1503
|
+
resumed_gate: op.resumed_gate,
|
|
1504
|
+
ci_gate_status: op.ci_gate_status,
|
|
1505
|
+
allowance_effect: op.allowance_effect,
|
|
1506
|
+
preserved_attempts: op.preserved_attempts,
|
|
1507
|
+
preserved_ci_infra_rerun_attempts_used: op.preserved_ci_infra_rerun_attempts_used,
|
|
1508
|
+
preserved_entry_attempts: op.preserved_entry_attempts,
|
|
1509
|
+
deadline_at: op.deadline_at,
|
|
1510
|
+
deadline_source: op.deadline_source,
|
|
1511
|
+
deadline_extended: op.deadline_extended,
|
|
1512
|
+
jobs_created: op.jobs_created,
|
|
1513
|
+
ci_passed_by_action: op.ci_passed_by_action,
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
function allowanceLine(op) {
|
|
1517
|
+
const infra = op.preserved_ci_infra_rerun_attempts_used ?? 0;
|
|
1518
|
+
const entry = op.preserved_entry_attempts ?? 0;
|
|
1519
|
+
return (`Preserved allowances: repair attempts spent ${op.preserved_attempts}, ` +
|
|
1520
|
+
`infrastructure retries used ${infra}, entry attempts ${entry}.`);
|
|
1521
|
+
}
|
|
1522
|
+
function deadlineLine(op) {
|
|
1523
|
+
if (op.deadline_at === null)
|
|
1524
|
+
return "Deadline unchanged (not recorded).";
|
|
1525
|
+
const source = op.deadline_source === "post_repair" ? "post-repair window" : "original gate window";
|
|
1526
|
+
return `Deadline unchanged: ${op.deadline_at} (${source}).`;
|
|
1527
|
+
}
|
|
1528
|
+
function currentStateLine(current) {
|
|
1529
|
+
return (`Current state (read now, separate from the history above): ticket ${current.ticket_status ?? "unknown"}, ` +
|
|
1530
|
+
`gate ${current.current_gate ?? "unknown"}, CI gate ${current.ci_gate_status ?? "unknown"}, ` +
|
|
1531
|
+
`held by run control: ${current.held_by_run_control ? "yes" : "no"}.`);
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Exported for tests: render a `resume-ci` result.
|
|
1535
|
+
*
|
|
1536
|
+
* The outcome word leads the first line. Committed and replayed results go to
|
|
1537
|
+
* stdout with exit 0; refused, unconfirmed, and failed outcomes go to stderr with
|
|
1538
|
+
* their own non-zero codes. No operation key, stop binding, row version, SHA,
|
|
1539
|
+
* credential, provider error, or URL is ever printed, in either mode.
|
|
1540
|
+
*/
|
|
1541
|
+
export function renderResumeCiResult(result, target, json) {
|
|
1542
|
+
const label = `ticket ${target.ticketKey} (run ${target.epicRunId})`;
|
|
1543
|
+
if (result.kind === "committed" || result.kind === "replayed") {
|
|
1544
|
+
const r = result.result;
|
|
1545
|
+
if (json) {
|
|
1546
|
+
console.log(JSON.stringify({
|
|
1547
|
+
ok: true,
|
|
1548
|
+
outcome: result.kind,
|
|
1549
|
+
historical: result.kind === "replayed",
|
|
1550
|
+
epic_run_id: r.epic_run_id,
|
|
1551
|
+
ticket_key: r.ticket_key,
|
|
1552
|
+
operation: resumeCiOperationSummary(r.operation),
|
|
1553
|
+
current: r.current,
|
|
1554
|
+
audit_recorded: r.audit_recorded,
|
|
1555
|
+
audit_warning: r.audit_warning,
|
|
1556
|
+
reference_reused: result.reusedReference,
|
|
1557
|
+
}));
|
|
1558
|
+
return RESUME_CI_EXIT_OK;
|
|
1559
|
+
}
|
|
1560
|
+
const reason = RESUME_CI_STOP_REASON_TEXT[r.operation.stop_reason] ?? "a CI stop";
|
|
1561
|
+
if (result.kind === "committed") {
|
|
1562
|
+
console.log(`Committed: resume-ci for ${label}, which was parked on ${reason}.`);
|
|
1563
|
+
console.log(RESUME_CI_CONSEQUENCE);
|
|
1564
|
+
console.log(allowanceLine(r.operation));
|
|
1565
|
+
console.log(deadlineLine(r.operation));
|
|
1566
|
+
console.log("Next: the reconciler's next identical green poll confirms CI; review and merge checks still apply.");
|
|
1567
|
+
}
|
|
1568
|
+
else {
|
|
1569
|
+
console.log(`Replayed historical result: resume-ci for ${label} committed earlier, at ` +
|
|
1570
|
+
`${r.operation.resumed_at}. Nothing was changed by this call.`);
|
|
1571
|
+
console.log(`History: the ticket was parked on ${reason} and was returned to CI evaluation.`);
|
|
1572
|
+
console.log(allowanceLine(r.operation));
|
|
1573
|
+
console.log(deadlineLine(r.operation));
|
|
1574
|
+
console.log(currentStateLine(r.current));
|
|
1575
|
+
console.log("This history does not show that CI is green now, or that review or merge may proceed.");
|
|
1576
|
+
}
|
|
1577
|
+
if (!r.audit_recorded && r.audit_warning) {
|
|
1578
|
+
console.log(`Warning: ${r.audit_warning}`);
|
|
1579
|
+
}
|
|
1580
|
+
return RESUME_CI_EXIT_OK;
|
|
1581
|
+
}
|
|
1582
|
+
if (result.kind === "refused" || result.kind === "not_eligible" || result.kind === "stale_reference") {
|
|
1583
|
+
const code = result.kind === "refused"
|
|
1584
|
+
? result.errorCode
|
|
1585
|
+
: result.kind === "stale_reference"
|
|
1586
|
+
? "CI_RESUME_STALE_OPERATION"
|
|
1587
|
+
: "CI_STOP_NOT_ELIGIBLE";
|
|
1588
|
+
const sentence = result.kind === "not_eligible"
|
|
1589
|
+
? RESUME_CI_NOT_ELIGIBLE_TEXT[result.reason] ?? "resume-ci does not apply"
|
|
1590
|
+
: RESUME_CI_REFUSAL_SENTENCES[code] ?? "the server refused the request";
|
|
1591
|
+
const guidance = RESUME_CI_REFUSAL_GUIDANCE[code];
|
|
1592
|
+
if (json) {
|
|
1593
|
+
console.error(JSON.stringify({
|
|
1594
|
+
ok: false,
|
|
1595
|
+
outcome: "refused",
|
|
1596
|
+
refusal_code: code,
|
|
1597
|
+
...(result.kind === "not_eligible" ? { local_reason: result.reason } : {}),
|
|
1598
|
+
message: sentence,
|
|
1599
|
+
...(guidance ? { guidance } : {}),
|
|
1600
|
+
nothing_written: true,
|
|
1601
|
+
}));
|
|
1602
|
+
return RESUME_CI_EXIT_REFUSED;
|
|
1603
|
+
}
|
|
1604
|
+
console.error(`Refused: resume-ci for ${label} — ${sentence}. Nothing was written.`);
|
|
1605
|
+
if (guidance)
|
|
1606
|
+
console.error(guidance);
|
|
1607
|
+
return RESUME_CI_EXIT_REFUSED;
|
|
1608
|
+
}
|
|
1609
|
+
if (result.kind === "unconfirmed") {
|
|
1610
|
+
if (json) {
|
|
1611
|
+
console.error(JSON.stringify({
|
|
1612
|
+
ok: false,
|
|
1613
|
+
outcome: "unconfirmed",
|
|
1614
|
+
transport: result.transport,
|
|
1615
|
+
attempts: result.attempts,
|
|
1616
|
+
}));
|
|
1617
|
+
return RESUME_CI_EXIT_UNCONFIRMED;
|
|
1618
|
+
}
|
|
1619
|
+
console.error(`Unconfirmed: resume-ci for ${label} — no durable answer arrived after ${result.attempts} ` +
|
|
1620
|
+
`attempts (${result.transport}). The resume may or may not have committed.`);
|
|
1621
|
+
console.error("Re-run the same command later: it reuses this same operation and reports its original " +
|
|
1622
|
+
"result. Do not use unpark or another recovery in the meantime.");
|
|
1623
|
+
return RESUME_CI_EXIT_UNCONFIRMED;
|
|
1624
|
+
}
|
|
1625
|
+
const message = result.kind === "unavailable" ? result.message : "the outcome could not be read";
|
|
1626
|
+
if (json) {
|
|
1627
|
+
console.error(JSON.stringify({ ok: false, outcome: "failed", message }));
|
|
1628
|
+
}
|
|
1629
|
+
else {
|
|
1630
|
+
console.error(`Failed: resume-ci for ${label} — ${message}.`);
|
|
1631
|
+
}
|
|
1632
|
+
return RESUME_CI_EXIT_FAILED;
|
|
1633
|
+
}
|
|
1634
|
+
export async function runResumeCiCommand(argv) {
|
|
1635
|
+
try {
|
|
1636
|
+
const parsed = parseResumeCiArgs(argv);
|
|
1637
|
+
if (parsed.help) {
|
|
1638
|
+
console.log(RESUME_CI_USAGE);
|
|
1639
|
+
return 0;
|
|
1640
|
+
}
|
|
1641
|
+
const access = await resolveRecoveryAccess(parsed.json);
|
|
1642
|
+
if (!access)
|
|
1643
|
+
return RESUME_CI_EXIT_FAILED;
|
|
1644
|
+
const { resumeCiOnce } = await import("./recovery-operations.js");
|
|
1645
|
+
const result = await resumeCiOnce(access, {
|
|
1646
|
+
epicRunId: parsed.epicRunId,
|
|
1647
|
+
ticketKey: parsed.ticketKey,
|
|
1648
|
+
});
|
|
1649
|
+
return renderResumeCiResult(result, parsed, parsed.json);
|
|
1650
|
+
}
|
|
1651
|
+
catch (error) {
|
|
1652
|
+
console.error(`Error: ${toConductorErrorEnvelope(error).message}`);
|
|
1653
|
+
return RESUME_CI_EXIT_FAILED;
|
|
1654
|
+
}
|
|
1655
|
+
}
|