@bridge_gpt/mcp-server 0.2.38 → 0.2.39
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 +189 -14
- package/build/agent-capabilities/probe-context.js +2 -1
- package/build/agent-launchers/claude-executor-adapter.js +392 -0
- package/build/agent-launchers/executor-adapter-inspection.js +163 -0
- package/build/agent-launchers/executor-adapter-registry.js +90 -0
- package/build/agent-launchers/executor-adapter.js +136 -0
- package/build/agent-registry.js +28 -0
- package/build/agents.generated.js +1 -1
- package/build/claude-login.js +85 -0
- package/build/claude-user-config-doctor.js +59 -33
- package/build/commands.generated.js +12 -11
- package/build/conduct-epic/bridge-client.js +345 -0
- package/build/conduct-epic/checkpoint-store.js +423 -0
- package/build/conduct-epic/cli.js +1732 -0
- package/build/conduct-epic/lock.js +302 -0
- package/build/conduct-epic/pr-state.js +197 -0
- package/build/conduct-epic/spawn.js +101 -0
- package/build/conductor/bridge-api-client.js +37 -2
- package/build/conductor/doctor.js +11 -1
- package/build/conductor/install-doctor.js +184 -10
- package/build/conductor-bin.js +7 -7
- package/build/credential-store.js +10 -4
- package/build/credentials-cli.js +34 -19
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +579 -88
- package/build/executor/agent-identity.js +32 -0
- package/build/executor/cli.js +50 -39
- package/build/executor/deps.js +15 -1
- package/build/executor/env.js +56 -45
- package/build/executor/index.js +9 -1
- package/build/executor/install-preflight.js +138 -0
- package/build/executor/job-errors.js +200 -0
- package/build/executor/job-runner.js +619 -268
- package/build/executor/observation.js +165 -0
- package/build/executor/permissions.js +163 -36
- package/build/executor/platform.js +54 -0
- package/build/executor/preflight.js +175 -67
- package/build/executor/process.js +39 -7
- package/build/executor/runner.js +19 -0
- package/build/executor/service-lifecycle.js +269 -0
- package/build/executor/service-unit.js +121 -12
- package/build/executor/stale-artifacts.js +70 -0
- package/build/executor/test-clock.js +188 -24
- package/build/executor/worker-command.js +22 -58
- package/build/executor/worker-log.js +82 -0
- package/build/executor/worktree-lock.js +264 -0
- package/build/index.js +527 -357
- package/build/install-bridge-conductor.js +376 -38
- package/build/install-bridge.js +414 -114
- package/build/install-doctor.js +13 -0
- package/build/install-reexec.js +5 -3
- package/build/mcp-install-state.js +130 -0
- package/build/mcp-profile.js +11 -2
- package/build/mcp-provisioning.js +15 -0
- package/build/merge-pull-request.js +562 -0
- package/build/phase-result-artifacts.js +450 -0
- package/build/pipeline-orchestrator.js +4 -0
- package/build/pipeline-utils.js +16 -0
- package/build/pipelines.generated.js +7 -7
- package/build/plane/preflight.js +18 -14
- package/build/plane/supervisor.js +8 -1
- package/build/project-root.js +34 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +36 -9
- package/build/setup-epic.js +57 -4
- package/build/sfcc/permissions.js +25 -6
- package/build/sfcc/reads-site-preference.js +6 -0
- package/build/sfcc/register.js +61 -23
- package/build/sfcc/registration-inventory.js +89 -0
- package/build/sfcc/setup-status.js +18 -34
- package/build/sfcc/tool-wrapper.js +294 -17
- package/build/sfcc/write-grants.js +33 -1
- package/build/sfcc/write-guard.js +41 -12
- package/build/sfcc/writes-custom-object-def.js +6 -2
- package/build/sfcc/writes-site-preference.js +6 -1
- package/build/sfcc/writes-system-object.js +11 -2
- package/build/sfcc/writes.js +13 -8
- package/build/start-tickets-prereqs.js +25 -15
- package/build/start-tickets.js +123 -21
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +9 -3
- package/docs/install/mcp-tool-integrations.md +54 -9
- package/docs/install/sfcc-integration.md +71 -24
- package/package.json +3 -3
- package/build/executor/worker-config-isolation.js +0 -287
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BAPI-823 — the durable execution ledger for INLINE recipe execution.
|
|
3
|
+
*
|
|
4
|
+
* {@link ./plan-execution-ledger.js} holds the domain rules and
|
|
5
|
+
* {@link ./plan-phase-routing.js} connects them to the server-side orchestrator.
|
|
6
|
+
* Neither was reachable from `get_pipeline_recipe`, the path every recipe-driven
|
|
7
|
+
* slash command actually takes: `injectPhaseRoutingContext` and
|
|
8
|
+
* `settlePhaseResult` are called only from `pipeline-orchestrator.ts`, so on the
|
|
9
|
+
* `/implement-ticket` path there was no routed context, no ledger, and nothing
|
|
10
|
+
* that parsed the `bapi-phase-result` envelope a phase emitted. The instructions
|
|
11
|
+
* asserted all three anyway, and a named downstream consumer plus a terminal
|
|
12
|
+
* *text* deliverable is what ended the agent's turn at every phase boundary.
|
|
13
|
+
*
|
|
14
|
+
* This module is the missing consumer. It gives the inline path the same
|
|
15
|
+
* guarantees the orchestrator provides, backed by files under the configured
|
|
16
|
+
* docs directory rather than by pipeline state:
|
|
17
|
+
*
|
|
18
|
+
* - **The envelope becomes a tool call.** A phase records its result by calling a
|
|
19
|
+
* tool that lands here, so the step's last act is a tool call and the turn does
|
|
20
|
+
* not terminate on it.
|
|
21
|
+
* - **The ledger is real.** {@link loadInlinePhaseContext} merges every prior
|
|
22
|
+
* phase artifact through the same {@link mergePlanStepRecords}, so a re-entering
|
|
23
|
+
* phase reads what earlier phases actually settled.
|
|
24
|
+
* - **Validation is real.** {@link writeInlinePhaseResult} runs the envelope
|
|
25
|
+
* through {@link validatePhaseResult} before anything is written, so "a step you
|
|
26
|
+
* owned but did not report will be rejected" is a fact rather than a claim.
|
|
27
|
+
*
|
|
28
|
+
* Two properties are load-bearing and easy to lose in a later edit:
|
|
29
|
+
*
|
|
30
|
+
* - **Fail closed on corrupt state.** An absent artifact is an empty ledger; a
|
|
31
|
+
* malformed, mis-versioned, or plan-mismatched artifact is an error. Treating
|
|
32
|
+
* corruption as absence would re-run work already recorded as done, which is
|
|
33
|
+
* how a "safe" fallback becomes a double-apply.
|
|
34
|
+
* - **Plan-bound records.** Each artifact carries a fingerprint of the plan
|
|
35
|
+
* metadata it was settled against. A regenerated plan renumbers steps, so
|
|
36
|
+
* reusing terminal records across plans would mark the wrong work complete.
|
|
37
|
+
*
|
|
38
|
+
* Filesystem only: no MCP transport, no network, no git, no subprocesses, and no
|
|
39
|
+
* process-level state. The caller supplies the trusted docs directory and the
|
|
40
|
+
* already-fetched plan metadata.
|
|
41
|
+
*/
|
|
42
|
+
import { createHash } from "crypto";
|
|
43
|
+
import { mkdir, readFile, rename, rm, writeFile } from "fs/promises";
|
|
44
|
+
import path from "path";
|
|
45
|
+
import { PLAN_PHASES, PLAN_STEP_DISPOSITIONS, PlanLedgerError, currentLedgerState, resolveOwnedParts, resolveOwnedSteps, sanitizePlanStepRecords, unresolvedLedgerRecords, validatePhaseResult, validatePlanMetadata, } from "./plan-execution-ledger.js";
|
|
46
|
+
import { assertPlanIsRoutable } from "./plan-phase-routing.js";
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Layout
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
/** Directory, relative to the configured docs dir, holding every phase artifact. */
|
|
51
|
+
export const PHASE_RESULT_SUBDIR = "phase-results";
|
|
52
|
+
/** Artifact schema version. Bumping it invalidates on-disk artifacts by design. */
|
|
53
|
+
export const PHASE_RESULT_ARTIFACT_VERSION = 1;
|
|
54
|
+
/**
|
|
55
|
+
* A ticket key that is safe to embed in a filename.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately narrower than "a Jira key": the value becomes a path segment, so
|
|
58
|
+
* anything that could traverse (`/`, `\`, `.`, `..`) or expand is rejected before
|
|
59
|
+
* it reaches the filesystem, rather than being sanitized into something that
|
|
60
|
+
* silently addresses a different ticket's artifact.
|
|
61
|
+
*/
|
|
62
|
+
const TICKET_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
|
|
63
|
+
/** Terminal dispositions that mean "this step is done; do not redo it". */
|
|
64
|
+
const REENTRY_TERMINAL_DISPOSITIONS = new Set([
|
|
65
|
+
"executed",
|
|
66
|
+
"adapted",
|
|
67
|
+
]);
|
|
68
|
+
/**
|
|
69
|
+
* How inline routing was derived, announced by the phase that used it.
|
|
70
|
+
*
|
|
71
|
+
* The BAPI-815 run reported owning 21 of 31 steps without saying where that came
|
|
72
|
+
* from, which made a correct answer indistinguishable from the prose-guessing the
|
|
73
|
+
* instructions forbid. Naming the source is what makes the claim auditable.
|
|
74
|
+
*/
|
|
75
|
+
export const INLINE_ROUTING_SOURCE = "inline:get_plan_metadata+phase_result_artifacts";
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Path safety
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
function assertValidTicketKey(ticketKey) {
|
|
80
|
+
if (typeof ticketKey !== "string" || !TICKET_KEY_PATTERN.test(ticketKey.trim())) {
|
|
81
|
+
throw new PlanLedgerError("ticket key must match PROJ-123; it becomes a filename segment and is never sanitized into a different one");
|
|
82
|
+
}
|
|
83
|
+
return ticketKey.trim().toUpperCase();
|
|
84
|
+
}
|
|
85
|
+
function assertValidPhase(phase) {
|
|
86
|
+
if (typeof phase !== "string" || !PLAN_PHASES.includes(phase)) {
|
|
87
|
+
throw new PlanLedgerError(`unknown phase '${String(phase)}'; expected one of ${PLAN_PHASES.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
return phase;
|
|
90
|
+
}
|
|
91
|
+
function assertDocsDir(docsDir) {
|
|
92
|
+
if (typeof docsDir !== "string" || docsDir.trim() === "") {
|
|
93
|
+
throw new PlanLedgerError("docs directory is required and must be a non-empty path");
|
|
94
|
+
}
|
|
95
|
+
if (!path.isAbsolute(docsDir)) {
|
|
96
|
+
throw new PlanLedgerError("docs directory must be an absolute, server-resolved path");
|
|
97
|
+
}
|
|
98
|
+
return path.resolve(docsDir);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve `relative` under `root` and prove the result stays inside it.
|
|
102
|
+
*
|
|
103
|
+
* Checked before any `mkdir`, read, temporary write, or rename — a containment
|
|
104
|
+
* check that runs after the directory exists has already had its side effect.
|
|
105
|
+
*/
|
|
106
|
+
function resolveWithinRoot(root, ...segments) {
|
|
107
|
+
const resolved = path.resolve(root, ...segments);
|
|
108
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
109
|
+
if (resolved !== root && !resolved.startsWith(rootWithSep)) {
|
|
110
|
+
throw new PlanLedgerError("refusing to read or write a phase artifact outside the configured docs directory");
|
|
111
|
+
}
|
|
112
|
+
return resolved;
|
|
113
|
+
}
|
|
114
|
+
/** `phase-results/<TICKET>-<phase>.json`, relative to the docs dir. */
|
|
115
|
+
export function phaseResultRelativePath(ticketKey, phase) {
|
|
116
|
+
return `${PHASE_RESULT_SUBDIR}/${assertValidTicketKey(ticketKey)}-${assertValidPhase(phase)}.json`;
|
|
117
|
+
}
|
|
118
|
+
/** `phase-results/<TICKET>-checkpoint.json`, relative to the docs dir. */
|
|
119
|
+
export function checkpointRelativePath(ticketKey) {
|
|
120
|
+
return `${PHASE_RESULT_SUBDIR}/${assertValidTicketKey(ticketKey)}-checkpoint.json`;
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Plan binding
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
/**
|
|
126
|
+
* A stable fingerprint of the routing-relevant shape of a plan.
|
|
127
|
+
*
|
|
128
|
+
* Only the fields routing depends on are hashed — part ids, classes, step ranges,
|
|
129
|
+
* advisory flags, and the total — so cosmetic prose changes do not invalidate a
|
|
130
|
+
* ledger while a renumbered plan does.
|
|
131
|
+
*/
|
|
132
|
+
export function planFingerprint(metadata) {
|
|
133
|
+
const canonical = JSON.stringify({
|
|
134
|
+
version: metadata.version,
|
|
135
|
+
total_steps: metadata.total_steps,
|
|
136
|
+
parts: metadata.parts.map((part) => [
|
|
137
|
+
part.part_id,
|
|
138
|
+
part.provenance_class,
|
|
139
|
+
part.start_step,
|
|
140
|
+
part.end_step,
|
|
141
|
+
part.declared_advisory,
|
|
142
|
+
]),
|
|
143
|
+
});
|
|
144
|
+
return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Reading
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
function isMissingFileError(err) {
|
|
150
|
+
return (typeof err === "object" &&
|
|
151
|
+
err !== null &&
|
|
152
|
+
err.code === "ENOENT");
|
|
153
|
+
}
|
|
154
|
+
async function readJsonArtifact(absolutePath, relativePath) {
|
|
155
|
+
let raw;
|
|
156
|
+
try {
|
|
157
|
+
raw = await readFile(absolutePath, "utf8");
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
if (isMissingFileError(err))
|
|
161
|
+
return undefined;
|
|
162
|
+
throw new PlanLedgerError(`could not read ${relativePath}`);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(raw);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// Fail closed. An unparseable artifact is not an empty one: treating it as
|
|
169
|
+
// empty would silently re-run work a previous phase already settled.
|
|
170
|
+
throw new PlanLedgerError(`${relativePath} is not valid JSON; refusing to treat a corrupt ledger as an empty one`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function validateArtifactEnvelope(parsed, relativePath, ticketKey, phase, fingerprint) {
|
|
174
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
175
|
+
throw new PlanLedgerError(`${relativePath} is not a phase-result artifact object`);
|
|
176
|
+
}
|
|
177
|
+
const artifact = parsed;
|
|
178
|
+
if (artifact.artifact_version !== PHASE_RESULT_ARTIFACT_VERSION) {
|
|
179
|
+
throw new PlanLedgerError(`${relativePath} has unsupported artifact_version ${String(artifact.artifact_version)}; expected ${PHASE_RESULT_ARTIFACT_VERSION}`);
|
|
180
|
+
}
|
|
181
|
+
if (artifact.ticket_key !== ticketKey) {
|
|
182
|
+
throw new PlanLedgerError(`${relativePath} records ticket '${String(artifact.ticket_key)}' but was read for '${ticketKey}'`);
|
|
183
|
+
}
|
|
184
|
+
if (artifact.phase !== phase) {
|
|
185
|
+
throw new PlanLedgerError(`${relativePath} declares phase '${String(artifact.phase)}' but was read as '${phase}'`);
|
|
186
|
+
}
|
|
187
|
+
if (artifact.plan_fingerprint !== fingerprint) {
|
|
188
|
+
throw new PlanLedgerError(`${relativePath} was settled against a different plan; a regenerated plan renumbers steps, so its terminal records must not be reused`);
|
|
189
|
+
}
|
|
190
|
+
if (!Array.isArray(artifact.records)) {
|
|
191
|
+
throw new PlanLedgerError(`${relativePath} has no records array`);
|
|
192
|
+
}
|
|
193
|
+
return artifact;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Check stored records against the plan in play, without the once-only rule.
|
|
197
|
+
*
|
|
198
|
+
* {@link validatePhaseResult} is the right gate for an incoming envelope, which
|
|
199
|
+
* must settle every owned step exactly once. It is the wrong gate for reading a
|
|
200
|
+
* stored artifact back: the artifact is a chronological log, so a re-entry
|
|
201
|
+
* legitimately holds more than one record per step and `validatePhaseResult`
|
|
202
|
+
* would reject its own accepted output. Ownership and the closed disposition
|
|
203
|
+
* vocabulary are still enforced — a record naming a step this phase does not own,
|
|
204
|
+
* or a disposition outside the set, means the artifact does not describe this
|
|
205
|
+
* plan and is refused rather than partially trusted.
|
|
206
|
+
*/
|
|
207
|
+
function assertStoredRecordsRoutable(records, metadata, phase, relativePath) {
|
|
208
|
+
const owned = new Set(resolveOwnedSteps(metadata, phase));
|
|
209
|
+
for (const record of records) {
|
|
210
|
+
if (typeof record !== "object" || record === null) {
|
|
211
|
+
throw new PlanLedgerError(`${relativePath} holds a record that is not an object`);
|
|
212
|
+
}
|
|
213
|
+
if (!owned.has(record.stepId)) {
|
|
214
|
+
throw new PlanLedgerError(`${relativePath} records step ${String(record.stepId)}, which phase '${phase}' does not own under the current plan`);
|
|
215
|
+
}
|
|
216
|
+
if (!PLAN_STEP_DISPOSITIONS.includes(record.disposition)) {
|
|
217
|
+
throw new PlanLedgerError(`${relativePath} records step ${record.stepId} with unsupported disposition '${String(record.disposition)}'`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return records.map((record) => ({ ...record, phase }));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Append `incoming` to `existing`, refusing to walk a settled step backwards.
|
|
224
|
+
*
|
|
225
|
+
* This is deliberately more permissive than {@link mergePlanStepRecords}, and the
|
|
226
|
+
* difference is the re-entry case. That function treats any re-report of a
|
|
227
|
+
* settled step as ambiguous, which is right for the orchestrated ledger where a
|
|
228
|
+
* phase reports once per run. Inline, a recovery run re-enters the same phase and
|
|
229
|
+
* {@link validatePhaseResult} requires it to settle *every* owned step — including
|
|
230
|
+
* the ones it correctly skipped because they were already terminal. Rejecting
|
|
231
|
+
* that honest re-affirmation would make re-entry impossible.
|
|
232
|
+
*
|
|
233
|
+
* What is still refused is the dangerous direction: a step recorded as `executed`
|
|
234
|
+
* or `adapted` coming back as `escalated` or `unrun-advisory` with no statement of
|
|
235
|
+
* what invalidated the earlier evidence. That is a completion claim being quietly
|
|
236
|
+
* withdrawn.
|
|
237
|
+
*/
|
|
238
|
+
function appendInlineRecords(existing, incoming) {
|
|
239
|
+
const latest = currentLedgerState(existing);
|
|
240
|
+
for (const record of incoming) {
|
|
241
|
+
const prior = latest.get(record.stepId);
|
|
242
|
+
if (!prior)
|
|
243
|
+
continue;
|
|
244
|
+
const priorTerminal = REENTRY_TERMINAL_DISPOSITIONS.has(prior.disposition);
|
|
245
|
+
const incomingTerminal = REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition);
|
|
246
|
+
if (priorTerminal && !incomingTerminal && !record.invalidatedBy) {
|
|
247
|
+
throw new PlanLedgerError(`step ${record.stepId} is already recorded as '${prior.disposition}'; re-recording it as '${record.disposition}' requires an explicit invalidatedBy note stating what invalidated the prior evidence`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...existing, ...incoming.map((record) => ({ ...record }))];
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Read every phase artifact for a ticket and derive this phase's routing.
|
|
254
|
+
*
|
|
255
|
+
* Ownership comes from {@link resolveOwnedSteps} — the same function the
|
|
256
|
+
* orchestrator uses — never from matching plan prose. Reading creates nothing:
|
|
257
|
+
* a run that only inspects its context leaves no artifact behind.
|
|
258
|
+
*/
|
|
259
|
+
export async function loadInlinePhaseContext(options) {
|
|
260
|
+
const docsDir = assertDocsDir(options.docsDir);
|
|
261
|
+
const ticketKey = assertValidTicketKey(options.ticketKey);
|
|
262
|
+
const phase = assertValidPhase(options.phase);
|
|
263
|
+
const metadata = validatePlanMetadata(options.planMetadata);
|
|
264
|
+
assertPlanIsRoutable(metadata);
|
|
265
|
+
const fingerprint = planFingerprint(metadata);
|
|
266
|
+
let ledger = [];
|
|
267
|
+
const artifactPaths = [];
|
|
268
|
+
// Canonical phase order, not directory order: the ledger is chronological and
|
|
269
|
+
// production always precedes the phases that verify it.
|
|
270
|
+
for (const priorPhase of PLAN_PHASES) {
|
|
271
|
+
const relativePath = phaseResultRelativePath(ticketKey, priorPhase);
|
|
272
|
+
const absolutePath = resolveWithinRoot(docsDir, relativePath);
|
|
273
|
+
const parsed = await readJsonArtifact(absolutePath, relativePath);
|
|
274
|
+
if (parsed === undefined)
|
|
275
|
+
continue;
|
|
276
|
+
const artifact = validateArtifactEnvelope(parsed, relativePath, ticketKey, priorPhase, fingerprint);
|
|
277
|
+
// Re-check against the CURRENT metadata: an artifact that was valid when
|
|
278
|
+
// written must still describe steps this phase owns under the plan in play now.
|
|
279
|
+
const routable = assertStoredRecordsRoutable(artifact.records, metadata, priorPhase, relativePath);
|
|
280
|
+
ledger = [...ledger, ...sanitizePlanStepRecords(routable)];
|
|
281
|
+
artifactPaths.push(relativePath);
|
|
282
|
+
}
|
|
283
|
+
const checkpoint = await readInlineCheckpoint(docsDir, ticketKey);
|
|
284
|
+
if (checkpoint)
|
|
285
|
+
artifactPaths.push(checkpointRelativePath(ticketKey));
|
|
286
|
+
const current = currentLedgerState(ledger);
|
|
287
|
+
const currentRecordsByStep = {};
|
|
288
|
+
const terminalStepIds = [];
|
|
289
|
+
for (const [stepId, record] of current) {
|
|
290
|
+
currentRecordsByStep[stepId] = record;
|
|
291
|
+
if (REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition))
|
|
292
|
+
terminalStepIds.push(stepId);
|
|
293
|
+
}
|
|
294
|
+
terminalStepIds.sort((a, b) => a - b);
|
|
295
|
+
return {
|
|
296
|
+
phase,
|
|
297
|
+
routingSource: INLINE_ROUTING_SOURCE,
|
|
298
|
+
ownedSteps: resolveOwnedSteps(metadata, phase),
|
|
299
|
+
ownedRanges: resolveOwnedParts(metadata, phase).map((part) => ({
|
|
300
|
+
partId: part.part_id,
|
|
301
|
+
provenanceClass: part.provenance_class,
|
|
302
|
+
startStep: part.start_step,
|
|
303
|
+
endStep: part.end_step,
|
|
304
|
+
declaredAdvisory: part.declared_advisory,
|
|
305
|
+
})),
|
|
306
|
+
ledger,
|
|
307
|
+
currentRecordsByStep,
|
|
308
|
+
terminalStepIds,
|
|
309
|
+
unresolved: unresolvedLedgerRecords(ledger),
|
|
310
|
+
...(checkpoint ? { checkpoint } : {}),
|
|
311
|
+
artifactPaths,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async function readInlineCheckpoint(docsDir, ticketKey) {
|
|
315
|
+
const relativePath = checkpointRelativePath(ticketKey);
|
|
316
|
+
const absolutePath = resolveWithinRoot(docsDir, relativePath);
|
|
317
|
+
const parsed = await readJsonArtifact(absolutePath, relativePath);
|
|
318
|
+
if (parsed === undefined)
|
|
319
|
+
return undefined;
|
|
320
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
321
|
+
throw new PlanLedgerError(`${relativePath} is not a checkpoint artifact object`);
|
|
322
|
+
}
|
|
323
|
+
const artifact = parsed;
|
|
324
|
+
if (artifact.artifact_version !== PHASE_RESULT_ARTIFACT_VERSION) {
|
|
325
|
+
throw new PlanLedgerError(`${relativePath} has unsupported artifact_version ${String(artifact.artifact_version)}`);
|
|
326
|
+
}
|
|
327
|
+
if (artifact.ticket_key !== ticketKey) {
|
|
328
|
+
throw new PlanLedgerError(`${relativePath} records ticket '${String(artifact.ticket_key)}' but was read for '${ticketKey}'`);
|
|
329
|
+
}
|
|
330
|
+
if (typeof artifact.branch !== "string" ||
|
|
331
|
+
artifact.branch.trim() === "" ||
|
|
332
|
+
typeof artifact.sha !== "string" ||
|
|
333
|
+
artifact.sha.trim() === "") {
|
|
334
|
+
throw new PlanLedgerError(`${relativePath} is missing its branch or sha`);
|
|
335
|
+
}
|
|
336
|
+
return { branch: artifact.branch, sha: artifact.sha };
|
|
337
|
+
}
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Writing
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
/**
|
|
342
|
+
* Write `payload` to `absolutePath` via an adjacent temp file and a rename.
|
|
343
|
+
*
|
|
344
|
+
* The rename is what makes replacement atomic: a reader never observes a
|
|
345
|
+
* half-written ledger. The temp file is removed on failure so a rejected write
|
|
346
|
+
* leaves nothing behind to be mistaken for state.
|
|
347
|
+
*/
|
|
348
|
+
async function writeAtomically(absolutePath, relativePath, payload) {
|
|
349
|
+
const tempPath = `${absolutePath}.tmp`;
|
|
350
|
+
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
351
|
+
try {
|
|
352
|
+
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
353
|
+
await rename(tempPath, absolutePath);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
357
|
+
throw new PlanLedgerError(`could not durably write ${relativePath}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Validate a phase's envelope and persist it as this phase's durable record.
|
|
362
|
+
*
|
|
363
|
+
* Everything is checked before the artifact is touched: the metadata, the
|
|
364
|
+
* envelope against the steps the phase owns, and the monotonic merge against what
|
|
365
|
+
* is already recorded. A rejected write leaves any existing valid artifact
|
|
366
|
+
* exactly as it was — the phase made a claim that does not hold, and overwriting
|
|
367
|
+
* good state with it would record that claim as the durable truth.
|
|
368
|
+
*/
|
|
369
|
+
export async function writeInlinePhaseResult(options) {
|
|
370
|
+
const docsDir = assertDocsDir(options.docsDir);
|
|
371
|
+
const ticketKey = assertValidTicketKey(options.ticketKey);
|
|
372
|
+
const metadata = validatePlanMetadata(options.planMetadata);
|
|
373
|
+
assertPlanIsRoutable(metadata);
|
|
374
|
+
const fingerprint = planFingerprint(metadata);
|
|
375
|
+
const envelope = options.phaseResult;
|
|
376
|
+
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope)) {
|
|
377
|
+
throw new PlanLedgerError("phase result must be an object");
|
|
378
|
+
}
|
|
379
|
+
const phase = assertValidPhase(envelope.phase);
|
|
380
|
+
const validated = validatePhaseResult(envelope, metadata, phase);
|
|
381
|
+
const sanitized = sanitizePlanStepRecords(validated);
|
|
382
|
+
const relativePath = phaseResultRelativePath(ticketKey, phase);
|
|
383
|
+
const absolutePath = resolveWithinRoot(docsDir, relativePath);
|
|
384
|
+
// Merge against what this phase already recorded, refusing to walk a settled
|
|
385
|
+
// step backwards. This runs before the artifact is touched, so a rejected
|
|
386
|
+
// second pass leaves the first pass's record exactly as it was.
|
|
387
|
+
const existing = await readJsonArtifact(absolutePath, relativePath);
|
|
388
|
+
let priorRecords = [];
|
|
389
|
+
if (existing !== undefined) {
|
|
390
|
+
priorRecords = assertStoredRecordsRoutable(validateArtifactEnvelope(existing, relativePath, ticketKey, phase, fingerprint).records, metadata, phase, relativePath);
|
|
391
|
+
}
|
|
392
|
+
const merged = appendInlineRecords(priorRecords, sanitized);
|
|
393
|
+
const artifact = {
|
|
394
|
+
artifact_version: PHASE_RESULT_ARTIFACT_VERSION,
|
|
395
|
+
ticket_key: ticketKey,
|
|
396
|
+
phase,
|
|
397
|
+
plan_fingerprint: fingerprint,
|
|
398
|
+
records: merged,
|
|
399
|
+
};
|
|
400
|
+
await writeAtomically(absolutePath, relativePath, artifact);
|
|
401
|
+
const terminalStepIds = [...currentLedgerState(merged).values()]
|
|
402
|
+
.filter((record) => REENTRY_TERMINAL_DISPOSITIONS.has(record.disposition))
|
|
403
|
+
.map((record) => record.stepId)
|
|
404
|
+
.sort((a, b) => a - b);
|
|
405
|
+
return {
|
|
406
|
+
phase,
|
|
407
|
+
routingSource: INLINE_ROUTING_SOURCE,
|
|
408
|
+
recordCount: merged.length,
|
|
409
|
+
artifactPath: relativePath,
|
|
410
|
+
terminalStepIds,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Persist the checkpoint the produce phase's work was pushed at.
|
|
415
|
+
*
|
|
416
|
+
* Only a checkpoint that reports its work durable on origin is accepted, matching
|
|
417
|
+
* {@link ../plan-phase-routing.js `parseCheckpointResult`}: a block claiming
|
|
418
|
+
* `pushed: false` or `remoteMatchesHead: false` describes a checkpoint that did
|
|
419
|
+
* not achieve the one thing it exists for.
|
|
420
|
+
*/
|
|
421
|
+
export async function writeInlineCheckpoint(options) {
|
|
422
|
+
const docsDir = assertDocsDir(options.docsDir);
|
|
423
|
+
const ticketKey = assertValidTicketKey(options.ticketKey);
|
|
424
|
+
const value = options.checkpoint;
|
|
425
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
426
|
+
throw new PlanLedgerError("checkpoint must be an object");
|
|
427
|
+
}
|
|
428
|
+
const checkpoint = value;
|
|
429
|
+
const branch = typeof checkpoint.branch === "string" ? checkpoint.branch.trim() : "";
|
|
430
|
+
const sha = typeof checkpoint.sha === "string" ? checkpoint.sha.trim() : "";
|
|
431
|
+
if (branch === "")
|
|
432
|
+
throw new PlanLedgerError("checkpoint branch must be a non-empty string");
|
|
433
|
+
if (sha === "")
|
|
434
|
+
throw new PlanLedgerError("checkpoint sha must be a non-empty string");
|
|
435
|
+
if (checkpoint.pushed !== true || checkpoint.remoteMatchesHead !== true) {
|
|
436
|
+
throw new PlanLedgerError("checkpoint must report pushed and remoteMatchesHead true; a checkpoint that is not on origin is not a checkpoint");
|
|
437
|
+
}
|
|
438
|
+
const relativePath = checkpointRelativePath(ticketKey);
|
|
439
|
+
const absolutePath = resolveWithinRoot(docsDir, relativePath);
|
|
440
|
+
const artifact = {
|
|
441
|
+
artifact_version: PHASE_RESULT_ARTIFACT_VERSION,
|
|
442
|
+
ticket_key: ticketKey,
|
|
443
|
+
branch,
|
|
444
|
+
sha,
|
|
445
|
+
pushed: true,
|
|
446
|
+
remote_matches_head: true,
|
|
447
|
+
};
|
|
448
|
+
await writeAtomically(absolutePath, relativePath, artifact);
|
|
449
|
+
return { branch, sha, artifactPath: relativePath };
|
|
450
|
+
}
|
|
@@ -227,6 +227,10 @@ export async function runPipeline(deps, input) {
|
|
|
227
227
|
try {
|
|
228
228
|
recipe = resolveRecipe(pipelineDef, deps.instructions, mergedVariables, undefined, autoApprove, {
|
|
229
229
|
includeUpgradeAdviceSurfacing: deps.includeUpgradeAdviceSurfacing !== false,
|
|
230
|
+
// BAPI-823: this IS the orchestrator. The routed phase context below and
|
|
231
|
+
// `settlePhaseResult` / `parseCheckpointResult` are reachable only from
|
|
232
|
+
// here, so only this call site may claim the orchestrated mode.
|
|
233
|
+
executionMode: "orchestrated",
|
|
230
234
|
});
|
|
231
235
|
}
|
|
232
236
|
catch (err) {
|
package/build/pipeline-utils.js
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readdir, readFile } from "fs/promises";
|
|
7
7
|
import path from "path";
|
|
8
|
+
/**
|
|
9
|
+
* The mode substitution token instruction files branch on.
|
|
10
|
+
*
|
|
11
|
+
* Reserved: {@link resolveRecipe} always overwrites any caller-supplied value, so
|
|
12
|
+
* a recipe variable can never claim a mode the resolver did not choose. A phase
|
|
13
|
+
* that believed a spoofed `"orchestrated"` would emit a text envelope into the
|
|
14
|
+
* void and skip the durable write — the halt this ticket removes.
|
|
15
|
+
*/
|
|
16
|
+
export const EXECUTION_MODE_VARIABLE = "execution_mode";
|
|
8
17
|
// ---------------------------------------------------------------------------
|
|
9
18
|
// Schema Validation
|
|
10
19
|
// ---------------------------------------------------------------------------
|
|
@@ -175,6 +184,12 @@ export function resolveRecipe(pipeline, instructions, variables, skipSteps, auto
|
|
|
175
184
|
`Pipeline "${pipeline.name}" declares: [${declared.join(", ")}].`);
|
|
176
185
|
}
|
|
177
186
|
const skip = new Set(skipSteps ?? []);
|
|
187
|
+
// BAPI-823: the mode is resolver-owned, never caller-owned. Overwriting rather
|
|
188
|
+
// than defaulting is the point — a variables map carrying its own
|
|
189
|
+
// `execution_mode` must not be able to make a phase believe an orchestrator is
|
|
190
|
+
// watching when none is.
|
|
191
|
+
const executionMode = options?.executionMode ?? "inline";
|
|
192
|
+
variables = { ...variables, [EXECUTION_MODE_VARIABLE]: executionMode };
|
|
178
193
|
// Filter and resolve steps
|
|
179
194
|
const resolvedSteps = [];
|
|
180
195
|
let stepIndex = 1;
|
|
@@ -254,6 +269,7 @@ export function resolveRecipe(pipeline, instructions, variables, skipSteps, auto
|
|
|
254
269
|
total_steps: resolvedSteps.length,
|
|
255
270
|
agent_instructions: baseInstructions + upgradeAdviceConvention + autoApproveSuffix,
|
|
256
271
|
auto_approve: !!autoApprove,
|
|
272
|
+
execution_mode: executionMode,
|
|
257
273
|
steps: resolvedSteps,
|
|
258
274
|
};
|
|
259
275
|
}
|