@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
|
@@ -35,7 +35,9 @@ export const WORKER_NOT_PRESENT_REMEDIATION = "start a worker (`python worker.py
|
|
|
35
35
|
export const SECOND_RECONCILER_REMEDIATION = "find and stop the extra reconciler: check `ps aux | grep worker.py` for a reparented " +
|
|
36
36
|
`survivor of an earlier plane (${BRING_UP_RUNBOOK} §6), and note that a dev server started ` +
|
|
37
37
|
"without DISABLE_SCHEDULER=true runs a reconciler too. A reconciler stopped in the last 120 " +
|
|
38
|
-
"seconds still counts — heartbeat rows are not deleted on shutdown — so wait and retry."
|
|
38
|
+
"seconds still counts — heartbeat rows are not deleted on shutdown — so wait and retry. " +
|
|
39
|
+
"On a hosted deployment, check that exactly one worker process is scaled (for example one " +
|
|
40
|
+
"Heroku `worker` dyno): each worker runs its own reconciler.";
|
|
39
41
|
// ---------------------------------------------------------------------------
|
|
40
42
|
// Classification
|
|
41
43
|
// ---------------------------------------------------------------------------
|
|
@@ -82,8 +82,9 @@ export const MCP_PROFILE_UNSET = "(unset)";
|
|
|
82
82
|
/**
|
|
83
83
|
* The CLI-only verbs that call the Bridge API from a shell-spawned process and
|
|
84
84
|
* therefore resolve credentials themselves (CLAUDE.md "Shell-Spawned Credential
|
|
85
|
-
* Resolution"): the four BAPI-872 recovery verbs
|
|
86
|
-
* verbs (BAPI-1154's three and BAPI-1176's three)
|
|
85
|
+
* Resolution"): the four BAPI-872 recovery verbs, the six run-addressed repair
|
|
86
|
+
* verbs (BAPI-1154's three and BAPI-1176's three), and BAPI-1300's parked-child
|
|
87
|
+
* continuation pair. `.mcp.json` env is invisible to
|
|
87
88
|
* them — only `BAPI_API_KEY` in the parent env and the user-scoped credential
|
|
88
89
|
* store count.
|
|
89
90
|
*/
|
|
@@ -98,6 +99,9 @@ export const CREDENTIALED_RECOVERY_COMMANDS = [
|
|
|
98
99
|
"scope-catch-up",
|
|
99
100
|
"patch-policy",
|
|
100
101
|
"resume-run",
|
|
102
|
+
// BAPI-1300: parked-child continuation and its read-only receipt lookup.
|
|
103
|
+
"continue-child",
|
|
104
|
+
"continuation-receipt",
|
|
101
105
|
];
|
|
102
106
|
/** Remediation for recovery/repair verbs that cannot resolve Bridge credentials. */
|
|
103
107
|
export const REPAIR_CREDENTIAL_REMEDIATION = "export BAPI_API_KEY in the shell that runs `conductor`, or store a credential for this " +
|
|
@@ -803,11 +807,14 @@ export function formatConductorDoctorReport(report) {
|
|
|
803
807
|
lines.push(`hooks dir: ${git_hooks.hooks_dir ?? "n/a"}`);
|
|
804
808
|
lines.push(`degraded: ${git_hooks.degraded}`);
|
|
805
809
|
for (const hook of git_hooks.hooks) {
|
|
806
|
-
// BAPI-772
|
|
807
|
-
//
|
|
808
|
-
//
|
|
810
|
+
// BAPI-772 made a dead embedded target visible here; BAPI-1290 replaces the
|
|
811
|
+
// raw path with the shared per-hook verdict, because a file that EXISTS can
|
|
812
|
+
// still be a prunable cache entry or a runtime whose native binding does not
|
|
813
|
+
// load. The embedded path stays in `--json` output only. Advisory only:
|
|
814
|
+
// never changes the exit code. Read defensively — a literal fixture may
|
|
815
|
+
// predate `state`/`usable`, and unknown is never healthy.
|
|
809
816
|
lines.push(` ${hook.name}: exists=${hook.exists} executable=${hook.executable} managed=${hook.managed_block_present} ` +
|
|
810
|
-
`
|
|
817
|
+
`state=${hook.state ?? "unknown"} usable=${hook.usable === true}`);
|
|
811
818
|
}
|
|
812
819
|
if (git_hooks.warnings.length > 0) {
|
|
813
820
|
lines.push("git hook warnings:");
|
|
@@ -962,8 +969,8 @@ export const CONDUCTOR_READINESS_DESCRIPTORS = [
|
|
|
962
969
|
export const CONDUCTOR_LEDGER_REMEDIATION = "run `epic-implementer doctor` for the ledger detail, then re-create the local ledger directory with " +
|
|
963
970
|
"owner-only permissions; the ledger is local observability and never blocks a run.";
|
|
964
971
|
/** Remediation for degraded or missing managed git hooks. */
|
|
965
|
-
export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `epic-implementer install-git-hooks` to install the managed post-commit and
|
|
966
|
-
"hooks; they are local, opportunistic, and bypassable.";
|
|
972
|
+
export const CONDUCTOR_GIT_HOOKS_REMEDIATION = "run `epic-implementer install-git-hooks` from a persistent install to install the managed post-commit and " +
|
|
973
|
+
"reference-transaction hooks; they are local, opportunistic, and bypassable, and never gate engine admission.";
|
|
967
974
|
/** Remediation for a Claude login this host cannot confirm. */
|
|
968
975
|
export const CONDUCTOR_CLAUDE_LOGIN_REMEDIATION = "run `claude login` on the host that will run conductor workers; Bridge stores no Anthropic " +
|
|
969
976
|
"credential and can only advise here.";
|
|
@@ -1014,22 +1021,24 @@ function gitHooksOutcome(report) {
|
|
|
1014
1021
|
};
|
|
1015
1022
|
}
|
|
1016
1023
|
// Hook NAMES and booleans only — `hooks_dir` is absolute and never projected.
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1024
|
+
// BAPI-1290: the boolean is the shared per-hook `usable` verdict (qualified
|
|
1025
|
+
// persistent runtime, loadable native binding), not mere file presence; a
|
|
1026
|
+
// hook without that verdict is unknown, and unknown is never healthy.
|
|
1027
|
+
const rendered = hooks.hooks.map((hook) => `${hook.name}=${hook.usable === true}`).join(", ");
|
|
1028
|
+
const allUsable = hooks.hooks.length > 0 && hooks.hooks.every((hook) => hook.usable === true);
|
|
1029
|
+
return hooks.degraded || !allUsable
|
|
1021
1030
|
? {
|
|
1022
1031
|
id: "git-hooks",
|
|
1023
1032
|
label: "Conductor git hooks",
|
|
1024
1033
|
status: "warn",
|
|
1025
|
-
detail: `managed hooks
|
|
1034
|
+
detail: `managed hooks usable: ${rendered}`,
|
|
1026
1035
|
remediation: CONDUCTOR_GIT_HOOKS_REMEDIATION,
|
|
1027
1036
|
}
|
|
1028
1037
|
: {
|
|
1029
1038
|
id: "git-hooks",
|
|
1030
1039
|
label: "Conductor git hooks",
|
|
1031
1040
|
status: "pass",
|
|
1032
|
-
detail: `managed hooks
|
|
1041
|
+
detail: `managed hooks usable: ${rendered}`,
|
|
1033
1042
|
};
|
|
1034
1043
|
}
|
|
1035
1044
|
/**
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parked-child continuation — the CLI-side contract (BAPI-1300, D4b).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `api/models/epic_child_continuation.py`. Three things live here:
|
|
5
|
+
*
|
|
6
|
+
* - the canonical request FINGERPRINT, computed byte-for-byte like the server
|
|
7
|
+
* (see `docs/epic-child-continuation-wire-contract.md` and its golden vectors);
|
|
8
|
+
* - STRICT parsing of the server's typed result bodies — an unknown outcome, a
|
|
9
|
+
* missing receipt identity, or an invalid runtime outcome is a parse failure,
|
|
10
|
+
* which the caller treats as transport uncertainty, never as success;
|
|
11
|
+
* - the local RECOVERY REFERENCE: one small file per in-flight request, written
|
|
12
|
+
* BEFORE the request is sent, so a lost response or a restarted process can
|
|
13
|
+
* look the receipt up with the ORIGINAL bindings and request id instead of
|
|
14
|
+
* minting a new request.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here performs HTTP. This module never imports the Bridge API client, so
|
|
17
|
+
* it stays free of network and credential concerns.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { mkdir as fsMkdir, readFile as fsReadFile, rename as fsRename, unlink as fsUnlink, writeFile as fsWriteFile, } from "node:fs/promises";
|
|
23
|
+
import { getPrimaryCredentialStorePath } from "../credential-store.js";
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Wire constants (must equal the Python module)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
export const CONTINUATION_WIRE_VERSION = 1;
|
|
28
|
+
export const CONTINUATION_OPERATION = "continue_parked_child";
|
|
29
|
+
export const CONTINUATION_OUTCOMES = [
|
|
30
|
+
"committed",
|
|
31
|
+
"already_continued",
|
|
32
|
+
"stale_historical_episode",
|
|
33
|
+
"refused",
|
|
34
|
+
"unsupported",
|
|
35
|
+
"unconfirmed",
|
|
36
|
+
];
|
|
37
|
+
export const CONTINUATION_RUNTIME_OUTCOMES = [
|
|
38
|
+
"queued",
|
|
39
|
+
"running",
|
|
40
|
+
"succeeded",
|
|
41
|
+
"failed",
|
|
42
|
+
"cancelled",
|
|
43
|
+
"dead_letter",
|
|
44
|
+
"unavailable",
|
|
45
|
+
];
|
|
46
|
+
const REQUEST_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,63}$/;
|
|
47
|
+
const FULL_SHA_RE = /^[0-9a-f]{40}$/;
|
|
48
|
+
const FINGERPRINT_RE = /^[0-9a-f]{64}$/;
|
|
49
|
+
const REASON_CODE_RE = /^[a-z_]{1,64}$/;
|
|
50
|
+
const MAX_TEXT = 500;
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Canonical fingerprint
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
function canonicalize(value) {
|
|
55
|
+
if (Array.isArray(value))
|
|
56
|
+
return value.map((item) => canonicalize(item));
|
|
57
|
+
if (value !== null && typeof value === "object") {
|
|
58
|
+
const record = value;
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const key of Object.keys(record).sort()) {
|
|
61
|
+
out[key] = canonicalize(record[key]);
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value === "number" && !Number.isSafeInteger(value)) {
|
|
66
|
+
// The contract carries integers only; a float would serialize differently
|
|
67
|
+
// across runtimes, so it is refused rather than silently hashed.
|
|
68
|
+
throw new TypeError("canonical JSON accepts integers only");
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
/** Canonical UTF-8 JSON text: sorted keys at every depth, array order kept. */
|
|
73
|
+
export function canonicalJson(value) {
|
|
74
|
+
return JSON.stringify(canonicalize(value));
|
|
75
|
+
}
|
|
76
|
+
/** Lowercase SHA-256 hex of {@link canonicalJson}. */
|
|
77
|
+
export function canonicalSha256(value) {
|
|
78
|
+
return createHash("sha256").update(canonicalJson(value), "utf8").digest("hex");
|
|
79
|
+
}
|
|
80
|
+
/** The fingerprint input document (identical to the Python builder). */
|
|
81
|
+
export function continuationFingerprintInput(b) {
|
|
82
|
+
return {
|
|
83
|
+
v: CONTINUATION_WIRE_VERSION,
|
|
84
|
+
operation: CONTINUATION_OPERATION,
|
|
85
|
+
target: {
|
|
86
|
+
repo_name: b.repoName,
|
|
87
|
+
epic_run_id: b.epicRunId,
|
|
88
|
+
ticket_key: b.ticketKey,
|
|
89
|
+
gate: b.parkedGate,
|
|
90
|
+
},
|
|
91
|
+
bindings: {
|
|
92
|
+
expected_row_version: b.expectedRowVersion,
|
|
93
|
+
expected_head_sha: b.expectedHeadSha,
|
|
94
|
+
source_park_generation: b.sourceParkGeneration,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function computeContinuationFingerprint(b) {
|
|
99
|
+
return canonicalSha256(continuationFingerprintInput(b));
|
|
100
|
+
}
|
|
101
|
+
/** A body that does not satisfy the typed contract. Never read as success. */
|
|
102
|
+
export class ContinuationParseError extends Error {
|
|
103
|
+
constructor(message) {
|
|
104
|
+
super(`malformed continuation response: ${message}`);
|
|
105
|
+
this.name = "ContinuationParseError";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function isRecord(value) {
|
|
109
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
110
|
+
}
|
|
111
|
+
function req(ok, value, what) {
|
|
112
|
+
if (!ok)
|
|
113
|
+
throw new ContinuationParseError(what);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
function nonNegInt(value) {
|
|
117
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
118
|
+
}
|
|
119
|
+
function posInt(value) {
|
|
120
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
121
|
+
}
|
|
122
|
+
function boundedText(value) {
|
|
123
|
+
return typeof value === "string" && value.length <= MAX_TEXT;
|
|
124
|
+
}
|
|
125
|
+
function nullableText(value) {
|
|
126
|
+
if (value === null || value === undefined)
|
|
127
|
+
return null;
|
|
128
|
+
return req(boundedText(value), value, "text field");
|
|
129
|
+
}
|
|
130
|
+
export function parseContinuationReceipt(value) {
|
|
131
|
+
const r = req(isRecord(value), value, "receipt is not an object");
|
|
132
|
+
const runtime = r.runtime_outcome ?? null;
|
|
133
|
+
return {
|
|
134
|
+
receipt_id: req(typeof r.receipt_id === "string" && r.receipt_id.length > 0 && r.receipt_id.length <= 128, r.receipt_id, "receipt_id"),
|
|
135
|
+
request_id: req(typeof r.request_id === "string" && REQUEST_ID_RE.test(r.request_id), r.request_id, "request_id"),
|
|
136
|
+
epic_run_id: req(typeof r.epic_run_id === "string" && r.epic_run_id.length > 0, r.epic_run_id, "epic_run_id"),
|
|
137
|
+
ticket_key: req(typeof r.ticket_key === "string" && r.ticket_key.length > 0, r.ticket_key, "ticket_key"),
|
|
138
|
+
gate: req(typeof r.gate === "string" && r.gate.length > 0, r.gate, "gate"),
|
|
139
|
+
job_type: req(typeof r.job_type === "string" && r.job_type.length > 0, r.job_type, "job_type"),
|
|
140
|
+
source_park_generation: req(nonNegInt(r.source_park_generation), r.source_park_generation, "source_park_generation"),
|
|
141
|
+
resulting_park_generation: req(nonNegInt(r.resulting_park_generation), r.resulting_park_generation, "resulting_park_generation"),
|
|
142
|
+
original_job_id: r.original_job_id === null || r.original_job_id === undefined
|
|
143
|
+
? null
|
|
144
|
+
: req(posInt(r.original_job_id), r.original_job_id, "original_job_id"),
|
|
145
|
+
continuation_job_id: req(posInt(r.continuation_job_id), r.continuation_job_id, "continuation_job_id"),
|
|
146
|
+
bound_head_sha: r.bound_head_sha === null || r.bound_head_sha === undefined
|
|
147
|
+
? null
|
|
148
|
+
: req(typeof r.bound_head_sha === "string" && FULL_SHA_RE.test(r.bound_head_sha), r.bound_head_sha, "bound_head_sha"),
|
|
149
|
+
authorization_kind: req(r.authorization_kind === "operator" || r.authorization_kind === "driver", r.authorization_kind, "authorization_kind"),
|
|
150
|
+
authorization_ref: req(typeof r.authorization_ref === "string" && r.authorization_ref.length > 0 && r.authorization_ref.length <= 128, r.authorization_ref, "authorization_ref"),
|
|
151
|
+
committed_at: req(typeof r.committed_at === "string" && r.committed_at.length > 0, r.committed_at, "committed_at"),
|
|
152
|
+
runtime_outcome: runtime === null
|
|
153
|
+
? null
|
|
154
|
+
: req(CONTINUATION_RUNTIME_OUTCOMES.includes(runtime), runtime, "runtime_outcome"),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export function parseChildEpisodeView(value) {
|
|
158
|
+
const r = req(isRecord(value), value, "current is not an object");
|
|
159
|
+
const obligations = r.unresolved_obligations ?? [];
|
|
160
|
+
return {
|
|
161
|
+
status: req(typeof r.status === "string", r.status, "status"),
|
|
162
|
+
row_version: req(nonNegInt(r.row_version), r.row_version, "row_version"),
|
|
163
|
+
parked: req(typeof r.parked === "boolean", r.parked, "parked"),
|
|
164
|
+
parked_gate: nullableText(r.parked_gate),
|
|
165
|
+
park_generation: r.park_generation === null || r.park_generation === undefined
|
|
166
|
+
? null
|
|
167
|
+
: req(nonNegInt(r.park_generation), r.park_generation, "park_generation"),
|
|
168
|
+
expected_head_sha: nullableText(r.expected_head_sha),
|
|
169
|
+
parked_at: nullableText(r.parked_at),
|
|
170
|
+
parked_age_seconds: r.parked_age_seconds === null || r.parked_age_seconds === undefined
|
|
171
|
+
? null
|
|
172
|
+
: req(nonNegInt(r.parked_age_seconds), r.parked_age_seconds, "parked_age_seconds"),
|
|
173
|
+
needs_human_reason: nullableText(r.needs_human_reason),
|
|
174
|
+
unresolved_obligations: req(Array.isArray(obligations) && obligations.every((item) => typeof item === "string" && item.length <= 128), obligations, "unresolved_obligations"),
|
|
175
|
+
continuations_used: r.continuations_used === null || r.continuations_used === undefined
|
|
176
|
+
? null
|
|
177
|
+
: req(nonNegInt(r.continuations_used), r.continuations_used, "continuations_used"),
|
|
178
|
+
continuation_limit: r.continuation_limit === null || r.continuation_limit === undefined
|
|
179
|
+
? null
|
|
180
|
+
: req(nonNegInt(r.continuation_limit), r.continuation_limit, "continuation_limit"),
|
|
181
|
+
continuation_available: req(typeof r.continuation_available === "boolean", r.continuation_available, "continuation_available"),
|
|
182
|
+
continuation_unavailable_reason: nullableText(r.continuation_unavailable_reason),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function parseOutcome(value) {
|
|
186
|
+
return req(CONTINUATION_OUTCOMES.includes(value), value, "unknown outcome");
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Parse a continuation result body strictly. A committed or already-continued
|
|
190
|
+
* outcome MUST carry a receipt; anything less is malformed.
|
|
191
|
+
*/
|
|
192
|
+
export function parseContinueParkedChildResult(value) {
|
|
193
|
+
const r = req(isRecord(value), value, "body is not an object");
|
|
194
|
+
const outcome = parseOutcome(r.outcome);
|
|
195
|
+
const reason = r.reason_code ?? null;
|
|
196
|
+
const receipt = r.receipt === null || r.receipt === undefined ? null : parseContinuationReceipt(r.receipt);
|
|
197
|
+
if ((outcome === "committed" || outcome === "already_continued" || outcome === "stale_historical_episode") && receipt === null) {
|
|
198
|
+
throw new ContinuationParseError(`${outcome} without a receipt`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
outcome,
|
|
202
|
+
reason_code: reason === null ? null : req(typeof reason === "string" && REASON_CODE_RE.test(reason), reason, "reason_code"),
|
|
203
|
+
explanation: req(boundedText(r.explanation), r.explanation, "explanation"),
|
|
204
|
+
replayed: r.replayed === true,
|
|
205
|
+
retryable: r.retryable === true,
|
|
206
|
+
receipt,
|
|
207
|
+
current: r.current === null || r.current === undefined ? null : parseChildEpisodeView(r.current),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
export function parseContinuationStatusResult(value) {
|
|
211
|
+
const r = req(isRecord(value), value, "body is not an object");
|
|
212
|
+
const found = req(typeof r.found === "boolean", r.found, "found");
|
|
213
|
+
const receipt = r.receipt === null || r.receipt === undefined ? null : parseContinuationReceipt(r.receipt);
|
|
214
|
+
if (found && receipt === null)
|
|
215
|
+
throw new ContinuationParseError("found without a receipt");
|
|
216
|
+
return {
|
|
217
|
+
epic_run_id: req(typeof r.epic_run_id === "string" && r.epic_run_id.length > 0, r.epic_run_id, "epic_run_id"),
|
|
218
|
+
ticket_key: req(typeof r.ticket_key === "string" && r.ticket_key.length > 0, r.ticket_key, "ticket_key"),
|
|
219
|
+
current: parseChildEpisodeView(r.current),
|
|
220
|
+
found,
|
|
221
|
+
outcome: r.outcome === null || r.outcome === undefined ? null : parseOutcome(r.outcome),
|
|
222
|
+
receipt,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
/** The namespace (subdirectory) of the parked-child continuation references. */
|
|
226
|
+
export const CONTINUATION_REFERENCE_NAMESPACE = "epic-child-continuations";
|
|
227
|
+
/**
|
|
228
|
+
* Directory holding one namespace of recovery references, beside the user
|
|
229
|
+
* credential store. BAPI-1326's `resume-ci` keeps its pinned operations in its
|
|
230
|
+
* own namespace through this same store, so the two recoveries share one file
|
|
231
|
+
* format, one permission model, and one atomic-write path.
|
|
232
|
+
*/
|
|
233
|
+
export function getRecoveryReferenceDirectory(namespace, deps = {}) {
|
|
234
|
+
const credentials = getPrimaryCredentialStorePath({
|
|
235
|
+
env: deps.env ?? process.env,
|
|
236
|
+
homedir: deps.homedir ?? os.homedir,
|
|
237
|
+
});
|
|
238
|
+
return path.join(path.dirname(credentials), namespace);
|
|
239
|
+
}
|
|
240
|
+
/** Directory holding continuation recovery references. */
|
|
241
|
+
export function getContinuationReferenceDirectory(deps = {}) {
|
|
242
|
+
return getRecoveryReferenceDirectory(CONTINUATION_REFERENCE_NAMESPACE, deps);
|
|
243
|
+
}
|
|
244
|
+
function referenceFileName(epicRunId, ticketKey) {
|
|
245
|
+
// Hashed so an arbitrary run id or ticket key can never form a path segment.
|
|
246
|
+
const digest = createHash("sha256").update(`${epicRunId}\u0000${ticketKey}`, "utf8").digest("hex");
|
|
247
|
+
return `${digest.slice(0, 32)}.json`;
|
|
248
|
+
}
|
|
249
|
+
/** The file one (run, ticket) reference occupies within a namespace. */
|
|
250
|
+
export function getRecoveryReferencePath(namespace, epicRunId, ticketKey, deps = {}) {
|
|
251
|
+
return path.join(getRecoveryReferenceDirectory(namespace, deps), referenceFileName(epicRunId, ticketKey));
|
|
252
|
+
}
|
|
253
|
+
export function getContinuationReferencePath(epicRunId, ticketKey, deps = {}) {
|
|
254
|
+
return getRecoveryReferencePath(CONTINUATION_REFERENCE_NAMESPACE, epicRunId, ticketKey, deps);
|
|
255
|
+
}
|
|
256
|
+
/** Read one namespaced reference as parsed JSON, or `null` when absent or unreadable. */
|
|
257
|
+
export async function readRecoveryReferenceFile(namespace, key, deps = {}) {
|
|
258
|
+
const read = deps.readFile ?? fsReadFile;
|
|
259
|
+
let text;
|
|
260
|
+
try {
|
|
261
|
+
text = (await read(getRecoveryReferencePath(namespace, key.epicRunId, key.ticketKey, deps), "utf8"));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(text);
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Durably write one namespaced reference (0700 directory, 0600 file, write-then-rename). */
|
|
274
|
+
export async function writeRecoveryReferenceFile(namespace, key, content, deps = {}) {
|
|
275
|
+
const mkdir = deps.mkdir ?? fsMkdir;
|
|
276
|
+
const writeFile = deps.writeFile ?? fsWriteFile;
|
|
277
|
+
const rename = deps.rename ?? fsRename;
|
|
278
|
+
await mkdir(getRecoveryReferenceDirectory(namespace, deps), { recursive: true, mode: 0o700 });
|
|
279
|
+
const target = getRecoveryReferencePath(namespace, key.epicRunId, key.ticketKey, deps);
|
|
280
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
281
|
+
await writeFile(temp, `${JSON.stringify(content, null, 2)}\n`, { mode: 0o600 });
|
|
282
|
+
await rename(temp, target);
|
|
283
|
+
}
|
|
284
|
+
/** Remove one namespaced reference. Never throws. */
|
|
285
|
+
export async function clearRecoveryReferenceFile(namespace, key, deps = {}) {
|
|
286
|
+
const unlink = deps.unlink ?? fsUnlink;
|
|
287
|
+
try {
|
|
288
|
+
await unlink(getRecoveryReferencePath(namespace, key.epicRunId, key.ticketKey, deps));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Already gone — nothing to clear.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function parseReference(raw) {
|
|
295
|
+
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.bindings))
|
|
296
|
+
return null;
|
|
297
|
+
const b = raw.bindings;
|
|
298
|
+
const bindings = {
|
|
299
|
+
repoName: String(b.repoName ?? ""),
|
|
300
|
+
epicRunId: String(b.epicRunId ?? ""),
|
|
301
|
+
ticketKey: String(b.ticketKey ?? ""),
|
|
302
|
+
parkedGate: String(b.parkedGate ?? ""),
|
|
303
|
+
expectedRowVersion: b.expectedRowVersion,
|
|
304
|
+
expectedHeadSha: (b.expectedHeadSha ?? null),
|
|
305
|
+
sourceParkGeneration: b.sourceParkGeneration,
|
|
306
|
+
};
|
|
307
|
+
if (!bindings.repoName || !bindings.epicRunId || !bindings.ticketKey || !bindings.parkedGate ||
|
|
308
|
+
!nonNegInt(bindings.expectedRowVersion) || !nonNegInt(bindings.sourceParkGeneration) ||
|
|
309
|
+
(bindings.expectedHeadSha !== null && !FULL_SHA_RE.test(bindings.expectedHeadSha))) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const requestId = raw.requestId;
|
|
313
|
+
const fingerprint = raw.requestFingerprint;
|
|
314
|
+
if (typeof requestId !== "string" || !REQUEST_ID_RE.test(requestId))
|
|
315
|
+
return null;
|
|
316
|
+
if (typeof fingerprint !== "string" || !FINGERPRINT_RE.test(fingerprint))
|
|
317
|
+
return null;
|
|
318
|
+
// A stored reference whose fingerprint no longer matches its own bindings was
|
|
319
|
+
// tampered with or truncated; it is never trusted to authorize a replay.
|
|
320
|
+
if (computeContinuationFingerprint(bindings) !== fingerprint)
|
|
321
|
+
return null;
|
|
322
|
+
return {
|
|
323
|
+
version: 1,
|
|
324
|
+
bindings,
|
|
325
|
+
requestId,
|
|
326
|
+
requestFingerprint: fingerprint,
|
|
327
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** Read the in-flight reference for a child, or `null` when none (or unusable). */
|
|
331
|
+
export async function readContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
332
|
+
const raw = await readRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, { epicRunId, ticketKey }, deps);
|
|
333
|
+
return raw === null ? null : parseReference(raw);
|
|
334
|
+
}
|
|
335
|
+
/** Durably write a reference (0700 directory, 0600 file, write-then-rename). */
|
|
336
|
+
export async function writeContinuationReference(reference, deps = {}, key = reference.bindings) {
|
|
337
|
+
// ``key`` only names the file (the caller may address the run by its epic key);
|
|
338
|
+
// the stored bindings are always the exact, fingerprinted ones.
|
|
339
|
+
await writeRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, key, reference, deps);
|
|
340
|
+
}
|
|
341
|
+
/** Remove a child's reference once its outcome is authoritative. Never throws. */
|
|
342
|
+
export async function clearContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
343
|
+
await clearRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, { epicRunId, ticketKey }, deps);
|
|
344
|
+
}
|
|
345
|
+
/** A fresh request id: a UUID, which satisfies the server's request-id bound. */
|
|
346
|
+
export function newContinuationRequestId() {
|
|
347
|
+
return randomUUID();
|
|
348
|
+
}
|
|
349
|
+
export function isFullSha(value) {
|
|
350
|
+
return typeof value === "string" && FULL_SHA_RE.test(value);
|
|
351
|
+
}
|