@bridge_gpt/mcp-server 0.2.65 → 0.2.67
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 +269 -82
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +55 -34
- package/build/agent-launchers/executor-adapter.js +34 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +372 -10
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-child-continuation.js +61 -34
- package/build/conductor/epic-implementer-cli.js +34 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +327 -0
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +46 -9
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +204 -30
- package/build/conductor/recovery-cli.js +703 -20
- package/build/conductor/recovery-operations.js +392 -3
- package/build/conductor/repair-contract.js +112 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/credential-store.js +5 -4
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +8 -3
- package/build/doctor.js +70 -10
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +479 -131
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +30 -26
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +44 -14
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +9 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/env.js +25 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +35 -0
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +38 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +485 -74
- package/build/executor/job-types.js +8 -13
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +136 -10
- package/build/executor/prompt-spec.js +113 -9
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +31 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/types.js +1 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +43 -43
- package/build/executor/worker-finalization.js +161 -43
- package/build/executor/worker-guard-hook.js +152 -12
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +82 -1
- package/build/index.js +482 -660
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +287 -23
- 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/model-thresholds-tool.js +185 -0
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +19 -25
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +145 -6
- package/build/plane/supervisor.js +588 -177
- package/build/plane/types.js +54 -2
- package/build/plane/worker-exit-code.js +2 -0
- package/build/printed-remedy-commands.js +37 -0
- package/build/project-root.js +99 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/review-tickets.js +156 -13
- package/build/setup-epic.js +224 -127
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +41 -43
- package/build/sfcc/tools-configured.js +96 -0
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +332 -79
- package/build/tool-error-envelope.js +61 -0
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +3 -5
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +136 -9
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +25 -15
- package/docs/install/sfcc-integration.md +21 -8
- package/package.json +1 -1
- package/pipelines/implement-ticket.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
|
@@ -18,10 +18,31 @@ import { normalizePrNumber, normalizeSha } from "./git-ci-types.js";
|
|
|
18
18
|
import { ConductorValidationError } from "./errors.js";
|
|
19
19
|
import { parseContinuationStatusResult, parseContinueParkedChildResult, } from "./epic-child-continuation.js";
|
|
20
20
|
import { resolveDeclaredRunBaseBranch } from "./run-branch.js";
|
|
21
|
+
import { safeEpicLaunchTargetUrl } from "../epic-launch-diagnostic.js";
|
|
22
|
+
import { validateHttpBaseUrl } from "../base-url.js";
|
|
23
|
+
import { RERUN_CI_CHECK_STATUSES, RERUN_CI_NOT_RUNNABLE_REASONS, RERUN_CI_OUTCOMES, isCiResumeRefusalCode, } from "./repair-contract.js";
|
|
21
24
|
/** Default Bridge API base URL when `BAPI_BASE_URL` is unset. */
|
|
22
25
|
export const CONDUCTOR_DEFAULT_BASE_URL = "https://bridgegpt-api.com";
|
|
23
26
|
/** Default per-request timeout for conductor Bridge API calls. */
|
|
24
27
|
export const CONDUCTOR_FETCH_TIMEOUT_MS = 30_000;
|
|
28
|
+
/** Keep an explicit readiness target from borrowing a key bound to another ambient endpoint. */
|
|
29
|
+
export function readinessEndpointEnvironments(env, baseUrl) {
|
|
30
|
+
if (baseUrl === undefined)
|
|
31
|
+
return { reportEnv: env, resolverEnv: env };
|
|
32
|
+
const ambientBase = env.BAPI_BASE_URL ? validateHttpBaseUrl(env.BAPI_BASE_URL) : null;
|
|
33
|
+
const ambientMatchesOverride = ambientBase?.ok && ambientBase.baseUrl === baseUrl;
|
|
34
|
+
return {
|
|
35
|
+
reportEnv: { ...env, BAPI_BASE_URL: baseUrl, BAPI_EPIC_LAUNCH_PROFILE: undefined,
|
|
36
|
+
BAPI_API_KEY: ambientMatchesOverride ? env.BAPI_API_KEY : undefined },
|
|
37
|
+
resolverEnv: {
|
|
38
|
+
...env,
|
|
39
|
+
BAPI_EPIC_LAUNCH_PROFILE: undefined,
|
|
40
|
+
// Never manufacture an ambient key's endpoint pairing. A stored key
|
|
41
|
+
// still passes its own binding check against the explicit target.
|
|
42
|
+
BAPI_BASE_URL: ambientMatchesOverride ? baseUrl : undefined,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
25
46
|
/**
|
|
26
47
|
* Resolve `{ repoName, apiKey, baseUrl }` from the environment, the shared repo
|
|
27
48
|
* resolver, and the shared credential store. Never throws and never embeds the
|
|
@@ -91,9 +112,11 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
91
112
|
const platform = deps.platform ?? process.platform;
|
|
92
113
|
const readFileImpl = deps.readFile ?? ((p) => readFile(p, "utf-8"));
|
|
93
114
|
const statImpl = deps.stat ?? ((p) => stat(p));
|
|
115
|
+
const environmentProfile = env.BAPI_EPIC_LAUNCH_PROFILE?.trim();
|
|
116
|
+
const profileName = selection.profileName ?? (environmentProfile || undefined);
|
|
94
117
|
let profile = null;
|
|
95
|
-
if (
|
|
96
|
-
const read = await readEpicLaunchProfile(
|
|
118
|
+
if (profileName !== undefined) {
|
|
119
|
+
const read = await readEpicLaunchProfile(profileName, {
|
|
97
120
|
env,
|
|
98
121
|
homedir,
|
|
99
122
|
readFile: readFileImpl,
|
|
@@ -107,11 +130,13 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
107
130
|
flagBaseUrl: selection.baseUrl,
|
|
108
131
|
flagRepo: deps.repoName,
|
|
109
132
|
profile,
|
|
133
|
+
profileSource: selection.profileName !== undefined ? "profile" : profile ? "profile-env" : undefined,
|
|
110
134
|
env,
|
|
111
135
|
defaultBaseUrl: CONDUCTOR_DEFAULT_BASE_URL,
|
|
112
136
|
});
|
|
113
137
|
if (!target.ok)
|
|
114
138
|
return { ok: false, kind: target.kind, error: target.message };
|
|
139
|
+
const reportableTarget = safeEpicLaunchTargetUrl(target.baseUrl) ? { baseUrl: target.baseUrl } : {};
|
|
115
140
|
const repoName = target.repoName ||
|
|
116
141
|
deps.repoName?.trim() ||
|
|
117
142
|
(await resolveStartTicketsRepoName({ env, cwd, readFile: readFileImpl }));
|
|
@@ -120,6 +145,7 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
120
145
|
ok: false,
|
|
121
146
|
kind: "repo-missing",
|
|
122
147
|
error: "could not resolve repo name (set BAPI_REPO_NAME or add a valid .bridge/config)",
|
|
148
|
+
...reportableTarget,
|
|
123
149
|
};
|
|
124
150
|
}
|
|
125
151
|
let cred;
|
|
@@ -127,15 +153,15 @@ export async function resolveEpicLaunchBridgeApiAccess(selection, deps = {}) {
|
|
|
127
153
|
cred = await resolveEpicLaunchCredential(repoName, target.baseUrl, { env, homedir, platform, readFile: readFileImpl, stat: statImpl }, { productionBaseUrl: CONDUCTOR_DEFAULT_BASE_URL });
|
|
128
154
|
}
|
|
129
155
|
catch {
|
|
130
|
-
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials" };
|
|
156
|
+
return { ok: false, kind: "credentials-unavailable", error: "failed to resolve Bridge API credentials", ...reportableTarget };
|
|
131
157
|
}
|
|
132
158
|
if (!cred.ok) {
|
|
133
159
|
const bindingFailure = cred.kind === "binding-unbound" || cred.kind === "binding-mismatch" || cred.kind === "ambient-key-unbound";
|
|
134
160
|
// The binding messages are fixed sentences naming the target KEY and the
|
|
135
161
|
// store path only; the generic store failures stay generic as before.
|
|
136
162
|
return bindingFailure
|
|
137
|
-
? { ok: false, kind: "credential-unbound", error: cred.error }
|
|
138
|
-
: { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable" };
|
|
163
|
+
? { ok: false, kind: "credential-unbound", error: cred.error, ...reportableTarget }
|
|
164
|
+
: { ok: false, kind: "credentials-unavailable", error: "Bridge API credentials unavailable", ...reportableTarget };
|
|
139
165
|
}
|
|
140
166
|
return {
|
|
141
167
|
ok: true,
|
|
@@ -1478,6 +1504,9 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
|
|
|
1478
1504
|
// The server's refusal explanation is the operator-facing message here.
|
|
1479
1505
|
{ readErrorField: true });
|
|
1480
1506
|
}
|
|
1507
|
+
function withGuard(body, guard) {
|
|
1508
|
+
return guard === undefined ? body : { ...body, guard };
|
|
1509
|
+
}
|
|
1481
1510
|
/**
|
|
1482
1511
|
* POST `/jira/epic-runs/runs/{epic_run_id}/scope-reparse` — re-drive the run's
|
|
1483
1512
|
* OWN index scope through the retry-reparse lifecycle. Body: `{repo_name,
|
|
@@ -1487,7 +1516,7 @@ async function postRepairRequest(access, apiPath, body, fetchImpl) {
|
|
|
1487
1516
|
export async function scopeReparse(access, request, fetchImpl = globalThis.fetch) {
|
|
1488
1517
|
requireNonEmptyString(request.epicRunId);
|
|
1489
1518
|
requireNonEmptyString(request.scopeId);
|
|
1490
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-reparse`, { scope_id: request.scopeId }, fetchImpl);
|
|
1519
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-reparse`, withGuard({ scope_id: request.scopeId }, request.guard), fetchImpl);
|
|
1491
1520
|
return parseScopeReparseResult(parsed);
|
|
1492
1521
|
}
|
|
1493
1522
|
/**
|
|
@@ -1500,9 +1529,120 @@ export async function dispatchRemediation(access, request, fetchImpl = globalThi
|
|
|
1500
1529
|
requireNonEmptyString(request.epicRunId);
|
|
1501
1530
|
requireNonEmptyString(request.ticketKey);
|
|
1502
1531
|
requireNonEmptyString(request.context);
|
|
1503
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, { context: request.context }, fetchImpl);
|
|
1532
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/dispatch-remediation`, withGuard({ context: request.context }, request.guard), fetchImpl);
|
|
1504
1533
|
return parseDispatchRemediationResult(parsed);
|
|
1505
1534
|
}
|
|
1535
|
+
// ---------------------------------------------------------------------------
|
|
1536
|
+
// BAPI-1388 — `rerun-ci`
|
|
1537
|
+
// ---------------------------------------------------------------------------
|
|
1538
|
+
/** The synthetic code an old server's missing route is reported under. */
|
|
1539
|
+
export const RERUN_CI_ROUTE_UNAVAILABLE_CODE = "RERUN_CI_ROUTE_UNAVAILABLE";
|
|
1540
|
+
/** The fixed operator text for a server that has no `rerun-ci` route. */
|
|
1541
|
+
export const RERUN_CI_ROUTE_UNAVAILABLE_MESSAGE = "rerun-ci is not available on this server; upgrade the Bridge API server before using it";
|
|
1542
|
+
const RERUN_CI_HEAD_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
1543
|
+
function parseRerunCiCheck(value) {
|
|
1544
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1545
|
+
throw new ConductorBridgeApiError("server");
|
|
1546
|
+
}
|
|
1547
|
+
const c = value;
|
|
1548
|
+
const status = c["status"];
|
|
1549
|
+
if (typeof status !== "string" ||
|
|
1550
|
+
!RERUN_CI_CHECK_STATUSES.includes(status)) {
|
|
1551
|
+
throw new ConductorBridgeApiError("server");
|
|
1552
|
+
}
|
|
1553
|
+
const reason = c["reason"];
|
|
1554
|
+
if (reason !== undefined &&
|
|
1555
|
+
reason !== null &&
|
|
1556
|
+
(typeof reason !== "string" ||
|
|
1557
|
+
!RERUN_CI_NOT_RUNNABLE_REASONS.includes(reason))) {
|
|
1558
|
+
throw new ConductorBridgeApiError("server");
|
|
1559
|
+
}
|
|
1560
|
+
const optionalPositive = (field) => {
|
|
1561
|
+
const v = c[field];
|
|
1562
|
+
if (v === undefined || v === null)
|
|
1563
|
+
return null;
|
|
1564
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v <= 0) {
|
|
1565
|
+
throw new ConductorBridgeApiError("server");
|
|
1566
|
+
}
|
|
1567
|
+
return v;
|
|
1568
|
+
};
|
|
1569
|
+
const optionalText = (field) => {
|
|
1570
|
+
const v = c[field];
|
|
1571
|
+
if (v === undefined || v === null)
|
|
1572
|
+
return null;
|
|
1573
|
+
if (typeof v !== "string")
|
|
1574
|
+
throw new ConductorBridgeApiError("server");
|
|
1575
|
+
return v;
|
|
1576
|
+
};
|
|
1577
|
+
return {
|
|
1578
|
+
name: optionalText("name"),
|
|
1579
|
+
workflow_identity: optionalText("workflow_identity"),
|
|
1580
|
+
run_id: optionalPositive("run_id"),
|
|
1581
|
+
status: status,
|
|
1582
|
+
reason: (reason ?? null),
|
|
1583
|
+
job_id: optionalPositive("job_id"),
|
|
1584
|
+
};
|
|
1585
|
+
}
|
|
1586
|
+
/**
|
|
1587
|
+
* Strictly parse a `rerun-ci` success body. Anything short of the contract — an
|
|
1588
|
+
* unknown outcome or per-check status, a malformed head, a non-list `checks` —
|
|
1589
|
+
* throws, so a malformed answer is never read as a success.
|
|
1590
|
+
*/
|
|
1591
|
+
export function parseRerunCiResult(parsed) {
|
|
1592
|
+
const p = parseRepairBase(parsed);
|
|
1593
|
+
const outcome = p["outcome"];
|
|
1594
|
+
const checks = p["checks"];
|
|
1595
|
+
const head = p["head_sha"];
|
|
1596
|
+
if (typeof p["ticket_key"] !== "string" ||
|
|
1597
|
+
typeof outcome !== "string" ||
|
|
1598
|
+
!RERUN_CI_OUTCOMES.includes(outcome) ||
|
|
1599
|
+
!Array.isArray(checks) ||
|
|
1600
|
+
(head !== undefined && head !== null && (typeof head !== "string" || !RERUN_CI_HEAD_RE.test(head)))) {
|
|
1601
|
+
throw new ConductorBridgeApiError("server");
|
|
1602
|
+
}
|
|
1603
|
+
return {
|
|
1604
|
+
epic_run_id: p["epic_run_id"],
|
|
1605
|
+
ticket_key: p["ticket_key"],
|
|
1606
|
+
before: parseRepairNullableString(p["before"]),
|
|
1607
|
+
after: parseRepairNullableString(p["after"]),
|
|
1608
|
+
committed: p["committed"],
|
|
1609
|
+
event_recorded: p["event_recorded"],
|
|
1610
|
+
outcome: outcome,
|
|
1611
|
+
head_sha: typeof head === "string" ? head : null,
|
|
1612
|
+
checks: checks.map(parseRerunCiCheck),
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/rerun-ci` — body
|
|
1617
|
+
* `{repo_name, guard}` and nothing else: no context, check list, or run id, because
|
|
1618
|
+
* the SERVER decides what is re-runnable from its own fresh, head-bound CI read.
|
|
1619
|
+
*
|
|
1620
|
+
* A generic `{"detail":"Not Found"}` 404 or a 405 means the server predates the
|
|
1621
|
+
* route; it is reported under {@link RERUN_CI_ROUTE_UNAVAILABLE_CODE} so no caller
|
|
1622
|
+
* can read it as a refusal of THIS ticket or as a success (following
|
|
1623
|
+
* {@link classifySupervisorBootstrapFailure}). A structured 404 — notably
|
|
1624
|
+
* `TICKET_NOT_IN_RUN` — keeps its own code and stays a refusal.
|
|
1625
|
+
*/
|
|
1626
|
+
export async function rerunCi(access, request, fetchImpl = globalThis.fetch) {
|
|
1627
|
+
requireNonEmptyString(request.epicRunId);
|
|
1628
|
+
requireNonEmptyString(request.ticketKey);
|
|
1629
|
+
let parsed;
|
|
1630
|
+
try {
|
|
1631
|
+
parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/tickets/${encodeURIComponent(request.ticketKey)}/rerun-ci`, { guard: request.guard }, fetchImpl);
|
|
1632
|
+
}
|
|
1633
|
+
catch (err) {
|
|
1634
|
+
if (err instanceof ConductorBridgeApiError &&
|
|
1635
|
+
!err.errorCode &&
|
|
1636
|
+
(err.status === 404 || err.status === 405)) {
|
|
1637
|
+
throw new ConductorBridgeApiError("http", err.status, {
|
|
1638
|
+
errorCode: RERUN_CI_ROUTE_UNAVAILABLE_CODE,
|
|
1639
|
+
bodyPreview: RERUN_CI_ROUTE_UNAVAILABLE_MESSAGE,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
throw err;
|
|
1643
|
+
}
|
|
1644
|
+
return parseRerunCiResult(parsed);
|
|
1645
|
+
}
|
|
1506
1646
|
/**
|
|
1507
1647
|
* POST `/jira/epic-runs/runs/{epic_run_id}/merge-child` — body EXACTLY
|
|
1508
1648
|
* `{repo_name, pr_number}`. The server composes the head, gate, and action key.
|
|
@@ -1662,7 +1802,7 @@ function parseResumeRunResult(parsed) {
|
|
|
1662
1802
|
export async function scopeCatchUp(access, request, fetchImpl = globalThis.fetch) {
|
|
1663
1803
|
requireNonEmptyString(request.epicRunId);
|
|
1664
1804
|
requireNonEmptyString(request.scopeId);
|
|
1665
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-catch-up`, { scope_id: request.scopeId }, fetchImpl);
|
|
1805
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/scope-catch-up`, withGuard({ scope_id: request.scopeId }, request.guard), fetchImpl);
|
|
1666
1806
|
return parseScopeCatchUpResult(parsed);
|
|
1667
1807
|
}
|
|
1668
1808
|
/**
|
|
@@ -1682,7 +1822,7 @@ export async function patchPolicy(access, request, fetchImpl = globalThis.fetch)
|
|
|
1682
1822
|
Object.keys(patch).length === 0) {
|
|
1683
1823
|
throw new ConductorBridgeApiError("invalid-input");
|
|
1684
1824
|
}
|
|
1685
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/patch-policy`, { merge_patch: patch }, fetchImpl);
|
|
1825
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/patch-policy`, withGuard({ merge_patch: patch }, request.guard), fetchImpl);
|
|
1686
1826
|
return parsePatchPolicyResult(parsed);
|
|
1687
1827
|
}
|
|
1688
1828
|
/**
|
|
@@ -1695,7 +1835,7 @@ export async function resumeRun(access, request, fetchImpl = globalThis.fetch) {
|
|
|
1695
1835
|
if (typeof request.clearQuarantine !== "boolean") {
|
|
1696
1836
|
throw new ConductorBridgeApiError("invalid-input");
|
|
1697
1837
|
}
|
|
1698
|
-
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/resume-run`, { clear_quarantine: request.clearQuarantine }, fetchImpl);
|
|
1838
|
+
const parsed = await postRepairRequest(access, `${epicRunApiPath(request.epicRunId)}/resume-run`, withGuard({ clear_quarantine: request.clearQuarantine }, request.guard), fetchImpl);
|
|
1699
1839
|
return parseResumeRunResult(parsed);
|
|
1700
1840
|
}
|
|
1701
1841
|
/**
|
|
@@ -2440,6 +2580,20 @@ function requireBool(o, key) {
|
|
|
2440
2580
|
throw readinessShapeError();
|
|
2441
2581
|
return v;
|
|
2442
2582
|
}
|
|
2583
|
+
/**
|
|
2584
|
+
* An optional boolean field as a spreadable fragment: `{}` when the key is absent,
|
|
2585
|
+
* `{ [key]: value }` when it is a boolean, and a shape error for anything else —
|
|
2586
|
+
* including an explicit `null`. Spreading keeps an absent field ABSENT rather than
|
|
2587
|
+
* present-as-`undefined`.
|
|
2588
|
+
*/
|
|
2589
|
+
function optionalBoolField(o, key) {
|
|
2590
|
+
if (!(key in o) || o[key] === undefined)
|
|
2591
|
+
return {};
|
|
2592
|
+
const v = o[key];
|
|
2593
|
+
if (typeof v !== "boolean")
|
|
2594
|
+
throw readinessShapeError();
|
|
2595
|
+
return { [key]: v };
|
|
2596
|
+
}
|
|
2443
2597
|
function requireInt(o, key) {
|
|
2444
2598
|
const v = o[key];
|
|
2445
2599
|
if (typeof v !== "number" || !Number.isInteger(v))
|
|
@@ -2588,6 +2742,15 @@ function parseRequiredChecksVerification(sup, requiredChecksCount) {
|
|
|
2588
2742
|
required_checks_evidence_fresh: fresh,
|
|
2589
2743
|
};
|
|
2590
2744
|
}
|
|
2745
|
+
/** Optional boolean: absent/null from an older server, a non-boolean fails closed. */
|
|
2746
|
+
function parseOptionalBool(o, key) {
|
|
2747
|
+
const v = o[key];
|
|
2748
|
+
if (v === undefined || v === null)
|
|
2749
|
+
return null;
|
|
2750
|
+
if (typeof v !== "boolean")
|
|
2751
|
+
throw readinessShapeError();
|
|
2752
|
+
return v;
|
|
2753
|
+
}
|
|
2591
2754
|
/** Optional positive-integer threshold: absent/null from an older server. */
|
|
2592
2755
|
function parseOptionalPositiveInt(o, key) {
|
|
2593
2756
|
const v = o[key];
|
|
@@ -2637,6 +2800,9 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2637
2800
|
// Absent on a pre-BAPI-694 server, where no repository default can exist.
|
|
2638
2801
|
review_policy_present: sup.review_policy_present === true,
|
|
2639
2802
|
...parseRequiredChecksVerification(sup, requiredChecksCount),
|
|
2803
|
+
required_checks_retired_gate_configured: parseOptionalBool(sup, "required_checks_retired_gate_configured"),
|
|
2804
|
+
required_checks_ci_workflow_absent: parseOptionalBool(sup, "required_checks_ci_workflow_absent"),
|
|
2805
|
+
required_checks_review_workflow_absent: parseOptionalBool(sup, "required_checks_review_workflow_absent"),
|
|
2640
2806
|
},
|
|
2641
2807
|
github: {
|
|
2642
2808
|
credentials_readable: requireBool(gh, "credentials_readable"),
|
|
@@ -2678,8 +2844,29 @@ export function parseConductorReadinessResponse(body) {
|
|
|
2678
2844
|
const evidence = parseAdmissionEvidence(root);
|
|
2679
2845
|
return evidence === undefined ? {} : { admission_evidence: evidence };
|
|
2680
2846
|
})(),
|
|
2847
|
+
...(() => {
|
|
2848
|
+
const diagnostics = parseReadinessDiagnostics(root);
|
|
2849
|
+
return diagnostics === undefined ? {} : { diagnostics };
|
|
2850
|
+
})(),
|
|
2681
2851
|
};
|
|
2682
2852
|
}
|
|
2853
|
+
function parseReadinessDiagnostics(body) {
|
|
2854
|
+
if (body.diagnostics === undefined || body.diagnostics === null)
|
|
2855
|
+
return undefined;
|
|
2856
|
+
if (!Array.isArray(body.diagnostics) || body.diagnostics.length > 64)
|
|
2857
|
+
throw readinessShapeError();
|
|
2858
|
+
return body.diagnostics.map((entry) => {
|
|
2859
|
+
const item = requireObject(entry);
|
|
2860
|
+
if (typeof item.id !== "string" || !/^[a-z][a-z0-9_.-]{0,79}$/.test(item.id))
|
|
2861
|
+
throw readinessShapeError();
|
|
2862
|
+
const severity = item.severity === "mandatory" || item.severity === "advisory" ? item.severity : null;
|
|
2863
|
+
const applicability = item.applicability === "applicable" || item.applicability === "inapplicable"
|
|
2864
|
+
? item.applicability : null;
|
|
2865
|
+
const verdict = item.verdict === "passed" || item.verdict === "failed" || item.verdict === "unknown"
|
|
2866
|
+
? item.verdict : "unknown";
|
|
2867
|
+
return { id: item.id, severity, applicability, verdict };
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2683
2870
|
const ADMISSION_EVIDENCE_OWNERS = new Set(["operator", "repository_admin", "bridge_operations"]);
|
|
2684
2871
|
const BOUNDED_TOKEN = /^[a-z0-9][a-z0-9_.:-]{0,79}$/;
|
|
2685
2872
|
/**
|
|
@@ -2841,6 +3028,11 @@ function parseConductorCiWorkflow(body) {
|
|
|
2841
3028
|
probe_succeeded: requireBool(o, "probe_succeeded"),
|
|
2842
3029
|
workflow_present: requireBool(o, "workflow_present"),
|
|
2843
3030
|
migration_guard_present: requireBool(o, "migration_guard_present"),
|
|
3031
|
+
// BAPI-1359: OPTIONAL so a newer CLI still reads an older server. An absent
|
|
3032
|
+
// key stays `undefined` — never materialized as a default — and a present key
|
|
3033
|
+
// must be a real boolean (`null`, strings, numbers, and objects all reject).
|
|
3034
|
+
...optionalBoolField(o, "migration_guard_required"),
|
|
3035
|
+
...optionalBoolField(o, "migration_chain_probe_succeeded"),
|
|
2844
3036
|
};
|
|
2845
3037
|
}
|
|
2846
3038
|
/**
|
|
@@ -3086,3 +3278,173 @@ export function classifySupervisorBootstrapFailure(err) {
|
|
|
3086
3278
|
return "server";
|
|
3087
3279
|
return "rejected";
|
|
3088
3280
|
}
|
|
3281
|
+
const CI_STOP_REASONS = new Set(["wait_expired", "verdict_unavailable", "repair_exhausted"]);
|
|
3282
|
+
const RESUME_CI_AUDIT_WARNING_MAX = 200;
|
|
3283
|
+
function isPlainRecord(value) {
|
|
3284
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3285
|
+
}
|
|
3286
|
+
function isNonNegativeInt(value) {
|
|
3287
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
3288
|
+
}
|
|
3289
|
+
function nullableNonNegativeInt(value) {
|
|
3290
|
+
return value === null || value === undefined || isNonNegativeInt(value);
|
|
3291
|
+
}
|
|
3292
|
+
function nullableString(value) {
|
|
3293
|
+
return value === null || value === undefined || typeof value === "string";
|
|
3294
|
+
}
|
|
3295
|
+
function parseCiResumeOperation(value) {
|
|
3296
|
+
if (!isPlainRecord(value))
|
|
3297
|
+
throw new ConductorBridgeApiError("server");
|
|
3298
|
+
const v = value;
|
|
3299
|
+
if (typeof v.resumed_at !== "string" ||
|
|
3300
|
+
!CI_STOP_REASONS.has(v.stop_reason) ||
|
|
3301
|
+
!isNonNegativeInt(v.park_generation) ||
|
|
3302
|
+
!isNonNegativeInt(v.resulting_park_generation) ||
|
|
3303
|
+
v.resulting_park_generation !== v.park_generation + 1 ||
|
|
3304
|
+
v.resumed_gate !== "ci" ||
|
|
3305
|
+
v.ci_gate_status !== "in_progress" ||
|
|
3306
|
+
v.allowance_effect !== "repair_allowances_preserved" ||
|
|
3307
|
+
!isNonNegativeInt(v.preserved_attempts) ||
|
|
3308
|
+
!nullableNonNegativeInt(v.preserved_ci_infra_rerun_attempts_used) ||
|
|
3309
|
+
!nullableNonNegativeInt(v.preserved_entry_attempts) ||
|
|
3310
|
+
!nullableString(v.deadline_at) ||
|
|
3311
|
+
!(v.deadline_source === null || v.deadline_source === undefined ||
|
|
3312
|
+
v.deadline_source === "original_gate" || v.deadline_source === "post_repair") ||
|
|
3313
|
+
// The three fixed consequences are part of the contract, not decoration: a
|
|
3314
|
+
// response claiming an extended deadline, a created job, or a CI pass is not
|
|
3315
|
+
// a resume-ci success and is refused as malformed.
|
|
3316
|
+
v.deadline_extended !== false ||
|
|
3317
|
+
v.jobs_created !== 0 ||
|
|
3318
|
+
v.ci_passed_by_action !== false) {
|
|
3319
|
+
throw new ConductorBridgeApiError("server");
|
|
3320
|
+
}
|
|
3321
|
+
return {
|
|
3322
|
+
resumed_at: v.resumed_at,
|
|
3323
|
+
stop_reason: v.stop_reason,
|
|
3324
|
+
park_generation: v.park_generation,
|
|
3325
|
+
resulting_park_generation: v.resulting_park_generation,
|
|
3326
|
+
resumed_gate: "ci",
|
|
3327
|
+
ci_gate_status: "in_progress",
|
|
3328
|
+
allowance_effect: "repair_allowances_preserved",
|
|
3329
|
+
preserved_attempts: v.preserved_attempts,
|
|
3330
|
+
preserved_ci_infra_rerun_attempts_used: (v.preserved_ci_infra_rerun_attempts_used ?? null),
|
|
3331
|
+
preserved_entry_attempts: (v.preserved_entry_attempts ?? null),
|
|
3332
|
+
deadline_at: (v.deadline_at ?? null),
|
|
3333
|
+
deadline_source: (v.deadline_source ?? null),
|
|
3334
|
+
deadline_extended: false,
|
|
3335
|
+
jobs_created: 0,
|
|
3336
|
+
ci_passed_by_action: false,
|
|
3337
|
+
};
|
|
3338
|
+
}
|
|
3339
|
+
function parseCiResumeCurrentState(value) {
|
|
3340
|
+
if (!isPlainRecord(value))
|
|
3341
|
+
throw new ConductorBridgeApiError("server");
|
|
3342
|
+
const v = value;
|
|
3343
|
+
if (!nullableString(v.ticket_status) ||
|
|
3344
|
+
!nullableString(v.current_gate) ||
|
|
3345
|
+
!nullableString(v.ci_gate_status) ||
|
|
3346
|
+
typeof v.held_by_run_control !== "boolean") {
|
|
3347
|
+
throw new ConductorBridgeApiError("server");
|
|
3348
|
+
}
|
|
3349
|
+
return {
|
|
3350
|
+
ticket_status: (v.ticket_status ?? null),
|
|
3351
|
+
current_gate: (v.current_gate ?? null),
|
|
3352
|
+
ci_gate_status: (v.ci_gate_status ?? null),
|
|
3353
|
+
held_by_run_control: v.held_by_run_control,
|
|
3354
|
+
};
|
|
3355
|
+
}
|
|
3356
|
+
/**
|
|
3357
|
+
* Strictly parse a `resume-ci` success body. Anything short of the full
|
|
3358
|
+
* contract — an unknown outcome, a missing history or current-state block, a
|
|
3359
|
+
* non-boolean audit flag, an over-long warning — throws, and the caller treats
|
|
3360
|
+
* that as an uncertain outcome, never as success.
|
|
3361
|
+
*/
|
|
3362
|
+
export function parseResumeCiResult(value) {
|
|
3363
|
+
if (!isPlainRecord(value))
|
|
3364
|
+
throw new ConductorBridgeApiError("server");
|
|
3365
|
+
const v = value;
|
|
3366
|
+
if (typeof v.epic_run_id !== "string" || !v.epic_run_id ||
|
|
3367
|
+
typeof v.ticket_key !== "string" || !v.ticket_key ||
|
|
3368
|
+
(v.outcome !== "committed" && v.outcome !== "replayed") ||
|
|
3369
|
+
typeof v.audit_recorded !== "boolean" ||
|
|
3370
|
+
!(v.audit_warning === null || v.audit_warning === undefined ||
|
|
3371
|
+
(typeof v.audit_warning === "string" && v.audit_warning.length <= RESUME_CI_AUDIT_WARNING_MAX))) {
|
|
3372
|
+
throw new ConductorBridgeApiError("server");
|
|
3373
|
+
}
|
|
3374
|
+
return {
|
|
3375
|
+
epic_run_id: v.epic_run_id,
|
|
3376
|
+
ticket_key: v.ticket_key,
|
|
3377
|
+
outcome: v.outcome,
|
|
3378
|
+
operation: parseCiResumeOperation(v.operation),
|
|
3379
|
+
current: parseCiResumeCurrentState(v.current),
|
|
3380
|
+
audit_recorded: v.audit_recorded,
|
|
3381
|
+
audit_warning: (v.audit_warning ?? null),
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
/**
|
|
3385
|
+
* POST `/jira/epic-runs/runs/{epic_run_id}/tickets/{ticket_key}/resume-ci`.
|
|
3386
|
+
*
|
|
3387
|
+
* Sends the pinned operation key and stop binding only in the authenticated
|
|
3388
|
+
* body. Never throws for an HTTP or transport outcome: every response is
|
|
3389
|
+
* classified into a {@link ResumeCiPostOutcome}, so the caller — not this
|
|
3390
|
+
* function — decides whether an uncertain outcome is retried with the SAME
|
|
3391
|
+
* operation.
|
|
3392
|
+
*/
|
|
3393
|
+
export async function resumeCiTicket(access, request, fetchImpl = globalThis.fetch, timeoutMs = CONDUCTOR_FETCH_TIMEOUT_MS) {
|
|
3394
|
+
requireNonEmptyString(request.epicRunId);
|
|
3395
|
+
requireNonEmptyString(request.ticketKey);
|
|
3396
|
+
requireNonEmptyString(request.operationKey);
|
|
3397
|
+
requireNonNegativeSafeInteger(request.expectedRowVersion);
|
|
3398
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${continuationChildPath(request.epicRunId, request.ticketKey)}/resume-ci`);
|
|
3399
|
+
const body = JSON.stringify({
|
|
3400
|
+
repo_name: access.repoName,
|
|
3401
|
+
expected_row_version: request.expectedRowVersion,
|
|
3402
|
+
operation_key: request.operationKey,
|
|
3403
|
+
stop_binding: request.stopBinding,
|
|
3404
|
+
});
|
|
3405
|
+
const controller = new AbortController();
|
|
3406
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
3407
|
+
try {
|
|
3408
|
+
let resp;
|
|
3409
|
+
try {
|
|
3410
|
+
resp = await fetchImpl(url, {
|
|
3411
|
+
method: "POST",
|
|
3412
|
+
headers: conductorPostHeaders(access),
|
|
3413
|
+
body,
|
|
3414
|
+
signal: controller.signal,
|
|
3415
|
+
});
|
|
3416
|
+
}
|
|
3417
|
+
catch {
|
|
3418
|
+
return { kind: "uncertain", reason: controller.signal.aborted ? "timeout" : "network" };
|
|
3419
|
+
}
|
|
3420
|
+
let parsed;
|
|
3421
|
+
let parsedOk = true;
|
|
3422
|
+
try {
|
|
3423
|
+
parsed = await resp.json();
|
|
3424
|
+
}
|
|
3425
|
+
catch {
|
|
3426
|
+
parsedOk = false;
|
|
3427
|
+
}
|
|
3428
|
+
if (resp.status >= 200 && resp.status < 300) {
|
|
3429
|
+
if (!parsedOk)
|
|
3430
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
3431
|
+
try {
|
|
3432
|
+
return { kind: "result", result: parseResumeCiResult(parsed) };
|
|
3433
|
+
}
|
|
3434
|
+
catch {
|
|
3435
|
+
return { kind: "uncertain", reason: "malformed", status: resp.status };
|
|
3436
|
+
}
|
|
3437
|
+
}
|
|
3438
|
+
const errorCode = parsedOk ? safeDetailErrorCode(parsed) : null;
|
|
3439
|
+
if (errorCode !== null && isCiResumeRefusalCode(errorCode)) {
|
|
3440
|
+
return { kind: "refused", status: resp.status, errorCode };
|
|
3441
|
+
}
|
|
3442
|
+
if (resp.status >= 500) {
|
|
3443
|
+
return { kind: "uncertain", reason: "server", status: resp.status };
|
|
3444
|
+
}
|
|
3445
|
+
return { kind: "rejected", status: resp.status, errorCode };
|
|
3446
|
+
}
|
|
3447
|
+
finally {
|
|
3448
|
+
clearTimeout(timer);
|
|
3449
|
+
}
|
|
3450
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The pinned operator runbook every remedy below points into. */
|
|
2
|
-
export const BRING_UP_RUNBOOK = "docs/
|
|
2
|
+
export const BRING_UP_RUNBOOK = "docs/epic-implementer/operator-runbook.md";
|
|
3
3
|
/**
|
|
4
4
|
* The client-derived refusal vocabulary, IN REFUSAL ORDER.
|
|
5
5
|
*
|
|
@@ -822,9 +822,9 @@ export function formatConductorDoctorReport(report) {
|
|
|
822
822
|
lines.push(` - ${w}`);
|
|
823
823
|
}
|
|
824
824
|
lines.push("");
|
|
825
|
-
lines.push("Epic
|
|
826
|
-
lines.push("locally. To execute claimed jobs on this machine, run:");
|
|
827
|
-
lines.push(` npx -y ${MCP_PACKAGE_NAME} executor --repo <name>`);
|
|
825
|
+
lines.push("The Epic Implementer reconciles epics server-side — nothing to schedule");
|
|
826
|
+
lines.push("locally. To execute a run's claimed jobs on this machine, run:");
|
|
827
|
+
lines.push(` npx -y ${MCP_PACKAGE_NAME} executor --repo <name> --epic-run-id <run-id>`);
|
|
828
828
|
lines.push("");
|
|
829
829
|
lines.push("MCP Profile (optional, local)");
|
|
830
830
|
lines.push("─────────────────────────────");
|
|
@@ -222,21 +222,74 @@ export function parseContinuationStatusResult(value) {
|
|
|
222
222
|
receipt,
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
-
/**
|
|
226
|
-
export
|
|
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 = {}) {
|
|
227
234
|
const credentials = getPrimaryCredentialStorePath({
|
|
228
235
|
env: deps.env ?? process.env,
|
|
229
236
|
homedir: deps.homedir ?? os.homedir,
|
|
230
237
|
});
|
|
231
|
-
return path.join(path.dirname(credentials),
|
|
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);
|
|
232
243
|
}
|
|
233
244
|
function referenceFileName(epicRunId, ticketKey) {
|
|
234
245
|
// Hashed so an arbitrary run id or ticket key can never form a path segment.
|
|
235
246
|
const digest = createHash("sha256").update(`${epicRunId}\u0000${ticketKey}`, "utf8").digest("hex");
|
|
236
247
|
return `${digest.slice(0, 32)}.json`;
|
|
237
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
|
+
}
|
|
238
253
|
export function getContinuationReferencePath(epicRunId, ticketKey, deps = {}) {
|
|
239
|
-
return
|
|
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
|
+
}
|
|
240
293
|
}
|
|
241
294
|
function parseReference(raw) {
|
|
242
295
|
if (!isRecord(raw) || raw.version !== 1 || !isRecord(raw.bindings))
|
|
@@ -276,44 +329,18 @@ function parseReference(raw) {
|
|
|
276
329
|
}
|
|
277
330
|
/** Read the in-flight reference for a child, or `null` when none (or unusable). */
|
|
278
331
|
export async function readContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
text = (await read(getContinuationReferencePath(epicRunId, ticketKey, deps), "utf8"));
|
|
283
|
-
}
|
|
284
|
-
catch {
|
|
285
|
-
return null;
|
|
286
|
-
}
|
|
287
|
-
try {
|
|
288
|
-
return parseReference(JSON.parse(text));
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
332
|
+
const raw = await readRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, { epicRunId, ticketKey }, deps);
|
|
333
|
+
return raw === null ? null : parseReference(raw);
|
|
293
334
|
}
|
|
294
335
|
/** Durably write a reference (0700 directory, 0600 file, write-then-rename). */
|
|
295
336
|
export async function writeContinuationReference(reference, deps = {}, key = reference.bindings) {
|
|
296
|
-
const mkdir = deps.mkdir ?? fsMkdir;
|
|
297
|
-
const writeFile = deps.writeFile ?? fsWriteFile;
|
|
298
|
-
const rename = deps.rename ?? fsRename;
|
|
299
|
-
const dir = getContinuationReferenceDirectory(deps);
|
|
300
|
-
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
301
337
|
// ``key`` only names the file (the caller may address the run by its epic key);
|
|
302
338
|
// the stored bindings are always the exact, fingerprinted ones.
|
|
303
|
-
|
|
304
|
-
const temp = `${target}.${process.pid}.tmp`;
|
|
305
|
-
await writeFile(temp, `${JSON.stringify(reference, null, 2)}\n`, { mode: 0o600 });
|
|
306
|
-
await rename(temp, target);
|
|
339
|
+
await writeRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, key, reference, deps);
|
|
307
340
|
}
|
|
308
341
|
/** Remove a child's reference once its outcome is authoritative. Never throws. */
|
|
309
342
|
export async function clearContinuationReference(epicRunId, ticketKey, deps = {}) {
|
|
310
|
-
|
|
311
|
-
try {
|
|
312
|
-
await unlink(getContinuationReferencePath(epicRunId, ticketKey, deps));
|
|
313
|
-
}
|
|
314
|
-
catch {
|
|
315
|
-
// Already gone — nothing to clear.
|
|
316
|
-
}
|
|
343
|
+
await clearRecoveryReferenceFile(CONTINUATION_REFERENCE_NAMESPACE, { epicRunId, ticketKey }, deps);
|
|
317
344
|
}
|
|
318
345
|
/** A fresh request id: a UUID, which satisfies the server's request-id bound. */
|
|
319
346
|
export function newContinuationRequestId() {
|