@bridge_gpt/mcp-server 0.2.36 → 0.2.37
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 +44 -6
- package/build/base-url.js +79 -0
- package/build/bridge-api-urls.js +9 -0
- package/build/chain-orchestrator.js +93 -15
- package/build/claude-user-config-doctor.js +317 -0
- package/build/commands.generated.js +2 -1
- package/build/conductor/bridge-api-client.js +178 -4
- package/build/conductor-bin.js +1 -1
- package/build/conductor-bundle-artifacts.js +7 -6
- package/build/credential-store.js +205 -4
- package/build/direct-ticket-tools.js +70 -0
- package/build/doctor.js +239 -80
- package/build/executor/cli.js +51 -1
- package/build/executor/credentials.js +1 -7
- package/build/executor/deps.js +18 -1
- package/build/executor/env.js +51 -25
- package/build/executor/heartbeat.js +138 -17
- package/build/executor/http-client.js +49 -8
- package/build/executor/job-errors.js +4 -0
- package/build/executor/job-runner.js +422 -22
- package/build/executor/observation.js +130 -0
- package/build/executor/permissions.js +104 -8
- package/build/executor/preflight.js +32 -0
- package/build/executor/runner.js +8 -0
- package/build/executor/test-clock.js +67 -3
- package/build/executor/types.js +4 -1
- package/build/executor/worker-command.js +11 -3
- package/build/executor/worker-config-isolation.js +287 -0
- package/build/executor/worker-finalization.js +68 -14
- package/build/executor/worktree.js +46 -4
- package/build/index.js +614 -244
- package/build/init.js +363 -73
- package/build/install-bridge.js +568 -80
- package/build/launcher-config-inspection.js +351 -0
- package/build/mcp-invoke.js +49 -6
- package/build/mcp-provisioning.js +30 -7
- package/build/mcp-registration-doctor.js +14 -5
- package/build/notifications.js +553 -0
- package/build/pipeline-orchestrator.js +146 -4
- package/build/pipeline-utils.js +3 -0
- package/build/pipelines.generated.js +22 -9
- package/build/plan-execution-ledger.js +550 -0
- package/build/plan-phase-routing.js +272 -0
- package/build/plane/alembic-head.js +110 -0
- package/build/plane/build-freshness.js +167 -0
- package/build/plane/cli.js +480 -0
- package/build/plane/defaults.js +266 -0
- package/build/plane/manifest.js +377 -0
- package/build/plane/member-logs.js +147 -0
- package/build/plane/member-roster.js +147 -0
- package/build/plane/preflight.js +289 -0
- package/build/plane/shutdown.js +195 -0
- package/build/plane/status.js +125 -0
- package/build/plane/supervisor.js +569 -0
- package/build/plane/test-fakes.js +156 -0
- package/build/plane/types.js +75 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -0
- package/build/setup-epic.js +662 -27
- package/build/sfcc/log-gate.js +38 -11
- package/build/sfcc/log-query.js +55 -15
- package/build/sfcc/ocapi-shape.js +51 -14
- package/build/sfcc/output.js +41 -11
- package/build/sfcc/permissions.js +24 -2
- package/build/sfcc/read-projection.js +181 -0
- package/build/sfcc/read-result.js +158 -0
- package/build/sfcc/reads-custom-object-def.js +29 -18
- package/build/sfcc/reads-site-preference.js +75 -29
- package/build/sfcc/reads-system-object.js +40 -34
- package/build/sfcc/sfcc-result.js +106 -0
- package/build/sfcc/tool-wrapper.js +56 -13
- package/build/sfcc/write-grants.js +45 -22
- package/build/sfcc/write-guard.js +21 -13
- package/build/sfcc/write-result.js +61 -14
- package/build/sfcc/write-tool-common.js +126 -32
- package/build/sfcc/writes-system-object.js +11 -50
- package/build/start-tickets-prereqs.js +129 -0
- package/build/start-tickets.js +17 -13
- package/build/ticket-backend-metadata.js +59 -0
- package/build/ticket-key-utils.js +92 -0
- package/build/tool-error-envelope.js +71 -0
- package/build/tool-surface-gating.js +72 -0
- package/build/update-status.js +102 -0
- package/build/upgrade-advice.js +47 -0
- package/build/upgrade-cli.js +417 -101
- package/build/version.generated.js +1 -1
- package/build/worktree-core.js +73 -0
- package/docs/CONDUCTOR.md +23 -8
- package/package.json +3 -3
- package/pipelines/implement-ticket.json +15 -5
package/build/setup-epic.js
CHANGED
|
@@ -8,24 +8,35 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Ordering is load-bearing:
|
|
10
10
|
*
|
|
11
|
-
* 0.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
11
|
+
* 0. Local sidecar checks. Cheap, offline, and they produce a legible message
|
|
12
|
+
* naming the offending node instead of a bare HTTP 400.
|
|
13
|
+
* 1. GET the run state. `POST /epic-runs/runs` cannot be probed with a "create
|
|
14
|
+
* and tolerate 409" — it never returned 409, so a blind repeat create used to
|
|
15
|
+
* mint a SECOND active run, wedging the epic permanently (every later plan
|
|
16
|
+
* call 409s on "Multiple active runs") and double-charging billing. The
|
|
17
|
+
* server is idempotent now, but the pre-check is still what lets us report
|
|
18
|
+
* honestly and skip work.
|
|
19
|
+
* 2. SERVER validation (BAPI-754). The local checks in step 0 are a mirror of
|
|
20
|
+
* the server's rules, and a mirror drifts — it had already lost
|
|
21
|
+
* `nodes[].status`. Creating first meant a plan the server would refuse
|
|
22
|
+
* still cost a run row and an automation-start debit before anything
|
|
23
|
+
* noticed. So the authoritative validator runs BEFORE any create, and the
|
|
24
|
+
* only sanctioned exception is an older server that lacks the lane (404),
|
|
25
|
+
* which warns and falls back to the legacy order.
|
|
26
|
+
* 3. Create only when no live run exists, carrying the complete policy from
|
|
27
|
+
* `--policy-file` so no post-create PATCH is needed. A create that returns
|
|
28
|
+
* 200 reused a live run and IGNORED that policy — reconcile, do not assume.
|
|
29
|
+
* 4. Hash locally — ADVISORY ONLY. The server re-hashes after applying
|
|
19
30
|
* file-overlap serialization, so a divergence is legitimate, not corruption.
|
|
20
|
-
*
|
|
31
|
+
* 5. Store, then approve.
|
|
21
32
|
*
|
|
22
|
-
* Read-only against the local filesystem: it reads the plan sidecar and
|
|
23
|
-
* nothing.
|
|
33
|
+
* Read-only against the local filesystem: it reads the plan sidecar and the
|
|
34
|
+
* optional policy file, and writes nothing.
|
|
24
35
|
*/
|
|
25
36
|
import { readFile as fsReadFile, stat as fsStat } from "node:fs/promises";
|
|
26
37
|
import os from "node:os";
|
|
27
38
|
import readline from "node:readline";
|
|
28
|
-
import { approveEpicPlan,
|
|
39
|
+
import { approveEpicPlan, createEpicRunWithDisposition, fetchEpicRunState, replaceEpicRunPolicy, resolveConductorBridgeApiAccess, storeEpicPlan, validateEpicPlan, ConductorBridgeApiError, } from "./conductor/bridge-api-client.js";
|
|
29
40
|
import { validateBranchName } from "./base-ref.js";
|
|
30
41
|
import { hashPlan } from "./conductor/plan.js";
|
|
31
42
|
/** Accepted `policy_json.review_policy.source` values (the `ReviewPolicy` surface). */
|
|
@@ -94,10 +105,31 @@ export function getSetupEpicUsage() {
|
|
|
94
105
|
" This setting is per-run: repository-level review-policy",
|
|
95
106
|
" defaults are NOT persisted in supervisor project",
|
|
96
107
|
" defaults yet — that is BAPI-694.",
|
|
97
|
-
" --dry-run Validate and preview; make no mutating calls",
|
|
98
108
|
" --json Emit a single JSON result object on stdout",
|
|
99
109
|
" -h, --help Show this help",
|
|
100
110
|
"",
|
|
111
|
+
"Policy controls:",
|
|
112
|
+
" --policy-file <path> JSON file holding the COMPLETE run policy. Applied in",
|
|
113
|
+
" the same invocation that creates the run, so no",
|
|
114
|
+
" post-create PATCH is needed. Contradicts nothing",
|
|
115
|
+
" silently: if the file and --feature-branch or",
|
|
116
|
+
" --review-policy disagree, setup-epic errors naming both.",
|
|
117
|
+
" --replace-policy Authorize replacing a LIVE run's stored policy with the",
|
|
118
|
+
" policy file, as a complete replacement. Without it, a",
|
|
119
|
+
" divergent policy on a reused run is refused with a",
|
|
120
|
+
" redacted diff rather than applied. Requires",
|
|
121
|
+
" --policy-file.",
|
|
122
|
+
"",
|
|
123
|
+
"Validation controls:",
|
|
124
|
+
" --dry-run Run the SERVER's real plan validator (including",
|
|
125
|
+
" file-overlap serialization) and stop. Creates no run,",
|
|
126
|
+
" stores no plan, and incurs no automation-start charge.",
|
|
127
|
+
" --local-only Skip server validation and run only the local sidecar",
|
|
128
|
+
" checks. Valid ONLY with --dry-run, and the result is",
|
|
129
|
+
" labeled 'partial (local checks only)': the local checks",
|
|
130
|
+
" do not cover everything the server enforces, so this is",
|
|
131
|
+
" never a substitute for authoritative validation.",
|
|
132
|
+
"",
|
|
101
133
|
"After setup, the server-side reconciler picks the run up within ~30s.",
|
|
102
134
|
"To execute claimed jobs on this machine, run:",
|
|
103
135
|
" npx -y @bridge_gpt/mcp-server executor --repo <name>",
|
|
@@ -156,10 +188,21 @@ export function parseSetupEpicArgs(argv) {
|
|
|
156
188
|
let planVersion;
|
|
157
189
|
let featureBranch;
|
|
158
190
|
let reviewPolicy;
|
|
191
|
+
let policyFile;
|
|
192
|
+
let replacePolicy = false;
|
|
193
|
+
let localOnly = false;
|
|
159
194
|
let dryRun = false;
|
|
160
195
|
let json = false;
|
|
161
196
|
for (let i = 0; i < argv.length; i++) {
|
|
162
197
|
const arg = argv[i];
|
|
198
|
+
// `--policy-file=<path>` inline form (BAPI-754).
|
|
199
|
+
if (arg.startsWith("--policy-file=")) {
|
|
200
|
+
const raw = arg.slice("--policy-file=".length).trim();
|
|
201
|
+
if (raw === "")
|
|
202
|
+
return { status: "error", message: "--policy-file requires a value." };
|
|
203
|
+
policyFile = raw;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
163
206
|
// `--feature-branch=<name>` inline form (handled before the exact-match switch).
|
|
164
207
|
if (arg.startsWith("--feature-branch=")) {
|
|
165
208
|
const parsedFb = parseFeatureBranchValue(arg.slice("--feature-branch=".length));
|
|
@@ -238,6 +281,23 @@ export function parseSetupEpicArgs(argv) {
|
|
|
238
281
|
i++;
|
|
239
282
|
break;
|
|
240
283
|
}
|
|
284
|
+
case "--policy-file": {
|
|
285
|
+
const v = takeValue(argv, i, arg);
|
|
286
|
+
if (v === null)
|
|
287
|
+
return { status: "error", message: "--policy-file requires a value." };
|
|
288
|
+
policyFile = v.trim();
|
|
289
|
+
if (policyFile === "") {
|
|
290
|
+
return { status: "error", message: "--policy-file requires a value." };
|
|
291
|
+
}
|
|
292
|
+
i++;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
case "--replace-policy":
|
|
296
|
+
replacePolicy = true;
|
|
297
|
+
break;
|
|
298
|
+
case "--local-only":
|
|
299
|
+
localOnly = true;
|
|
300
|
+
break;
|
|
241
301
|
case "--dry-run":
|
|
242
302
|
dryRun = true;
|
|
243
303
|
break;
|
|
@@ -255,11 +315,306 @@ export function parseSetupEpicArgs(argv) {
|
|
|
255
315
|
return { status: "error", message: "setup-epic requires --epic-key <KEY>." };
|
|
256
316
|
if (!planFile)
|
|
257
317
|
return { status: "error", message: "setup-epic requires --plan-file <path>." };
|
|
318
|
+
// BAPI-754 — reject contradictory combinations HERE, before any file read,
|
|
319
|
+
// credential resolution, or network call, so a mistyped invocation cannot reach
|
|
320
|
+
// a mutating request.
|
|
321
|
+
if (localOnly && !dryRun) {
|
|
322
|
+
return {
|
|
323
|
+
status: "error",
|
|
324
|
+
message: "--local-only is only valid together with --dry-run. It downgrades the run to " +
|
|
325
|
+
"local checks only, which is never a substitute for the server's validation " +
|
|
326
|
+
"on a real setup.",
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (replacePolicy && policyFile === undefined) {
|
|
330
|
+
return {
|
|
331
|
+
status: "error",
|
|
332
|
+
message: "--replace-policy requires --policy-file. It authorizes replacing a live run's " +
|
|
333
|
+
"stored policy with the file's contents, so without a file there is nothing to " +
|
|
334
|
+
"replace it with.",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
258
337
|
return {
|
|
259
338
|
status: "ok",
|
|
260
|
-
options: {
|
|
339
|
+
options: {
|
|
340
|
+
epicKey,
|
|
341
|
+
planFile,
|
|
342
|
+
repo,
|
|
343
|
+
planVersion,
|
|
344
|
+
featureBranch,
|
|
345
|
+
reviewPolicy,
|
|
346
|
+
policyFile,
|
|
347
|
+
replacePolicy,
|
|
348
|
+
localOnly,
|
|
349
|
+
dryRun,
|
|
350
|
+
json,
|
|
351
|
+
},
|
|
261
352
|
};
|
|
262
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Read and parse the `--policy-file` JSON.
|
|
356
|
+
*
|
|
357
|
+
* Every failure names the PATH and the failing action and nothing else. The file
|
|
358
|
+
* can carry `notify.webhook_url`, so echoing its contents — or even a JSON parse
|
|
359
|
+
* error's excerpt of them — into a terminal or a CI log would defeat the whole
|
|
360
|
+
* redaction discipline downstream. A caller who needs to see the file can open it.
|
|
361
|
+
*/
|
|
362
|
+
export async function readSetupEpicPolicyFile(filePath, readFile) {
|
|
363
|
+
let raw;
|
|
364
|
+
try {
|
|
365
|
+
raw = await readFile(filePath);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return { ok: false, error: `Could not read policy file '${filePath}'.` };
|
|
369
|
+
}
|
|
370
|
+
let parsed;
|
|
371
|
+
try {
|
|
372
|
+
parsed = JSON.parse(raw);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return {
|
|
376
|
+
ok: false,
|
|
377
|
+
error: `Policy file '${filePath}' is not valid JSON.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
error: `Policy file '${filePath}' must contain a JSON object (the run policy).`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return { ok: true, policy: parsed };
|
|
387
|
+
}
|
|
388
|
+
/** Read `review_policy.source` from a policy object, or a structural verdict. */
|
|
389
|
+
function readReviewPolicySource(policy) {
|
|
390
|
+
if (!("review_policy" in policy))
|
|
391
|
+
return { kind: "absent" };
|
|
392
|
+
const block = policy["review_policy"];
|
|
393
|
+
if (block === undefined || block === null)
|
|
394
|
+
return { kind: "absent" };
|
|
395
|
+
if (typeof block !== "object" || Array.isArray(block))
|
|
396
|
+
return { kind: "ambiguous" };
|
|
397
|
+
const source = block["source"];
|
|
398
|
+
if (source === undefined || source === null)
|
|
399
|
+
return { kind: "absent" };
|
|
400
|
+
if (typeof source !== "string" || source.trim() === "")
|
|
401
|
+
return { kind: "ambiguous" };
|
|
402
|
+
return { kind: "source", value: source };
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Compose the `--feature-branch` and `--review-policy` selections onto the policy
|
|
406
|
+
* file's object.
|
|
407
|
+
*
|
|
408
|
+
* The rule is "no silent winner". Where the file is silent, the CLI selection
|
|
409
|
+
* fills the gap. Where both speak and AGREE, the file's existing spelling is kept
|
|
410
|
+
* untouched — adding a second spelling of the same setting would leave the server
|
|
411
|
+
* with two keys claiming one truth. Where both speak and DISAGREE, this fails with
|
|
412
|
+
* an error naming both sources, because either choice would mean running an epic
|
|
413
|
+
* under a policy the operator did not pick and could not see us pick.
|
|
414
|
+
*
|
|
415
|
+
* Unknown keys and wrong-typed values are passed through EXACTLY as written. The
|
|
416
|
+
* authoritative `RunPolicy` schema lives on the server, and re-implementing it here
|
|
417
|
+
* is how the two drift; the server's field-named `422` is the better error anyway.
|
|
418
|
+
*/
|
|
419
|
+
export function composeSetupEpicPolicy(filePolicy, selections) {
|
|
420
|
+
const composed = { ...filePolicy };
|
|
421
|
+
// `baseBranch` is not a key the server accepts — it is the camelCase slip an
|
|
422
|
+
// author makes when hand-writing the file. Treat it as a DECLARATION for
|
|
423
|
+
// contradiction purposes so a conflicting value is reported rather than
|
|
424
|
+
// silently ignored, but never invent it and never rewrite it into snake_case:
|
|
425
|
+
// the server's strict boundary should be the one to reject it.
|
|
426
|
+
const declaredBranches = [];
|
|
427
|
+
for (const key of ["base_branch", "baseBranch"]) {
|
|
428
|
+
if (key in composed && composed[key] !== undefined && composed[key] !== null) {
|
|
429
|
+
declaredBranches.push({ key, value: composed[key] });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (selections.featureBranch !== undefined) {
|
|
433
|
+
const conflicting = declaredBranches.filter((entry) => entry.value !== selections.featureBranch);
|
|
434
|
+
if (conflicting.length > 0) {
|
|
435
|
+
const names = conflicting.map((entry) => entry.key).join(" and ");
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
error: `--feature-branch selected '${selections.featureBranch}', but --policy-file ` +
|
|
439
|
+
`declares a different branch under ${names}. setup-epic will not pick a ` +
|
|
440
|
+
`winner between them: drop the flag, or fix the policy file so both agree.`,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (declaredBranches.length === 0)
|
|
444
|
+
composed.base_branch = selections.featureBranch;
|
|
445
|
+
}
|
|
446
|
+
const fileReview = readReviewPolicySource(composed);
|
|
447
|
+
if (selections.reviewPolicy !== undefined) {
|
|
448
|
+
if (fileReview.kind === "ambiguous") {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
error: `--review-policy selected '${selections.reviewPolicy}', but --policy-file's ` +
|
|
452
|
+
`review_policy is not an object with a non-empty string 'source'. setup-epic ` +
|
|
453
|
+
`will not guess which one you meant: drop the flag, or fix review_policy in ` +
|
|
454
|
+
`the policy file.`,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
if (fileReview.kind === "source" && fileReview.value !== selections.reviewPolicy) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
error: `--review-policy selected '${selections.reviewPolicy}', but --policy-file ` +
|
|
461
|
+
`declares review_policy.source '${fileReview.value}'. setup-epic will not pick ` +
|
|
462
|
+
`a winner between them: drop the flag, or fix the policy file so both agree.`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (fileReview.kind === "absent") {
|
|
466
|
+
const existing = composed["review_policy"];
|
|
467
|
+
composed.review_policy =
|
|
468
|
+
existing && typeof existing === "object" && !Array.isArray(existing)
|
|
469
|
+
? { ...existing, source: selections.reviewPolicy }
|
|
470
|
+
: { source: selections.reviewPolicy };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return { ok: true, policy: composed };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Does this policy object explicitly supply the `notify.webhook_url` leaf?
|
|
477
|
+
*
|
|
478
|
+
* Explicit `null` counts — that is the documented way to CLEAR a stored URL, so it
|
|
479
|
+
* is a real change even though it looks like an absence.
|
|
480
|
+
*/
|
|
481
|
+
export function policySuppliesWebhookUrl(policy) {
|
|
482
|
+
const notify = policy["notify"];
|
|
483
|
+
if (!notify || typeof notify !== "object" || Array.isArray(notify))
|
|
484
|
+
return false;
|
|
485
|
+
return "webhook_url" in notify;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Project a policy into the shape both sides of a comparison can actually see.
|
|
489
|
+
*
|
|
490
|
+
* Two keys are removed from every operand:
|
|
491
|
+
*
|
|
492
|
+
* - `notify.webhook_url` — never present in a server response, so comparing it
|
|
493
|
+
* would report a difference on every run that has one. The empty `notify` object
|
|
494
|
+
* is KEPT after removal, mirroring the server's `_redacted_policy_json`, so a
|
|
495
|
+
* policy that declares an empty notify block still compares equal to itself.
|
|
496
|
+
* - `feature_branch_provisioning` — server-minted provenance. It appears on the
|
|
497
|
+
* stored side and never on the requested side, and re-sending it is a conflict
|
|
498
|
+
* the server rejects outright.
|
|
499
|
+
*
|
|
500
|
+
* Returns a deep copy; both operands are left unmodified.
|
|
501
|
+
*/
|
|
502
|
+
export function projectPolicyForComparison(policy) {
|
|
503
|
+
if (Array.isArray(policy))
|
|
504
|
+
return policy.map((item) => projectPolicyForComparison(item));
|
|
505
|
+
if (!policy || typeof policy !== "object")
|
|
506
|
+
return policy;
|
|
507
|
+
const out = {};
|
|
508
|
+
for (const [key, value] of Object.entries(policy)) {
|
|
509
|
+
if (key === "feature_branch_provisioning")
|
|
510
|
+
continue;
|
|
511
|
+
if (key === "notify" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
512
|
+
const notify = {};
|
|
513
|
+
for (const [nKey, nValue] of Object.entries(value)) {
|
|
514
|
+
if (nKey === "webhook_url")
|
|
515
|
+
continue;
|
|
516
|
+
notify[nKey] = projectPolicyForComparison(nValue);
|
|
517
|
+
}
|
|
518
|
+
out.notify = notify;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
out[key] = projectPolicyForComparison(value);
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
/** Render a value for a diff line, bounded so a large nested block stays readable. */
|
|
526
|
+
function renderDiffValue(value) {
|
|
527
|
+
const text = JSON.stringify(value ?? null);
|
|
528
|
+
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Compare two PROJECTED policies and return a deterministic structural diff.
|
|
532
|
+
*
|
|
533
|
+
* Ordering is by sorted path, not by insertion order, so two files that differ
|
|
534
|
+
* only in key order produce an identical (empty) diff and a real difference always
|
|
535
|
+
* renders in the same place.
|
|
536
|
+
*
|
|
537
|
+
* This function must only ever be handed projected operands — `webhook_url` has
|
|
538
|
+
* already been removed by then, so no diff line can carry it. That ordering is the
|
|
539
|
+
* safety property: the URL is never rendered and then masked, it is never present.
|
|
540
|
+
*/
|
|
541
|
+
export function diffProjectedPolicies(stored, requested, basePath = "") {
|
|
542
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
543
|
+
if (isPlainObject(stored) && isPlainObject(requested)) {
|
|
544
|
+
const keys = [...new Set([...Object.keys(stored), ...Object.keys(requested)])].sort();
|
|
545
|
+
const entries = [];
|
|
546
|
+
for (const key of keys) {
|
|
547
|
+
const path = basePath ? `${basePath}.${key}` : key;
|
|
548
|
+
const inStored = key in stored;
|
|
549
|
+
const inRequested = key in requested;
|
|
550
|
+
if (inStored && !inRequested) {
|
|
551
|
+
entries.push({ path, kind: "removed" });
|
|
552
|
+
}
|
|
553
|
+
else if (!inStored && inRequested) {
|
|
554
|
+
entries.push({ path, kind: "added" });
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
entries.push(...diffProjectedPolicies(stored[key], requested[key], path));
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return entries;
|
|
561
|
+
}
|
|
562
|
+
if (JSON.stringify(stored ?? null) === JSON.stringify(requested ?? null))
|
|
563
|
+
return [];
|
|
564
|
+
return [{ path: basePath || "(policy)", kind: "changed" }];
|
|
565
|
+
}
|
|
566
|
+
/** Render a diff as operator-readable lines. Never receives a webhook URL. */
|
|
567
|
+
export function renderPolicyDiff(entries, stored, requested) {
|
|
568
|
+
const read = (source, path) => {
|
|
569
|
+
let cursor = source;
|
|
570
|
+
for (const part of path.split(".")) {
|
|
571
|
+
if (!cursor || typeof cursor !== "object")
|
|
572
|
+
return undefined;
|
|
573
|
+
cursor = cursor[part];
|
|
574
|
+
}
|
|
575
|
+
return cursor;
|
|
576
|
+
};
|
|
577
|
+
return entries.map((entry) => {
|
|
578
|
+
if (entry.kind === "added") {
|
|
579
|
+
return ` + ${entry.path}: ${renderDiffValue(read(requested, entry.path))} (not in the stored policy)`;
|
|
580
|
+
}
|
|
581
|
+
if (entry.kind === "removed") {
|
|
582
|
+
return ` - ${entry.path}: ${renderDiffValue(read(stored, entry.path))} (absent from the policy file)`;
|
|
583
|
+
}
|
|
584
|
+
return (` ~ ${entry.path}: stored ${renderDiffValue(read(stored, entry.path))} ` +
|
|
585
|
+
`→ file ${renderDiffValue(read(requested, entry.path))}`);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* The one permitted rendering of a supplied webhook URL. The value NEVER appears.
|
|
590
|
+
*
|
|
591
|
+
* The stored URL is invisible to a client by design, so a file that supplies one
|
|
592
|
+
* cannot be compared against it. Rather than report "no change" on a policy we
|
|
593
|
+
* cannot see, this is counted as divergence and described in words.
|
|
594
|
+
*/
|
|
595
|
+
export const WEBHOOK_SUPPLIED_DIFF_LINE = " ~ notify.webhook_url: (supplied — will replace stored value on apply)";
|
|
596
|
+
/**
|
|
597
|
+
* Decide what to do with a policy file when create returned an EXISTING run.
|
|
598
|
+
*
|
|
599
|
+
* Create is idempotent: a `200` means the server kept the live run and ignored the
|
|
600
|
+
* policy we sent. Silently continuing would leave the operator believing their
|
|
601
|
+
* file is in force when it is not — so the divergence is surfaced, and applying it
|
|
602
|
+
* requires `--replace-policy` because a mid-run policy change is a real decision
|
|
603
|
+
* about a run that is already executing tickets.
|
|
604
|
+
*/
|
|
605
|
+
export function reconcileReusedRunPolicy(args) {
|
|
606
|
+
const storedProjected = projectPolicyForComparison(args.storedPolicy ?? {});
|
|
607
|
+
const requestedProjected = projectPolicyForComparison(args.requestedPolicy);
|
|
608
|
+
const diffLines = renderPolicyDiff(diffProjectedPolicies(storedProjected, requestedProjected), storedProjected, requestedProjected);
|
|
609
|
+
if (policySuppliesWebhookUrl(args.requestedPolicy)) {
|
|
610
|
+
diffLines.push(WEBHOOK_SUPPLIED_DIFF_LINE);
|
|
611
|
+
}
|
|
612
|
+
if (diffLines.length === 0)
|
|
613
|
+
return { kind: "noop" };
|
|
614
|
+
return args.replacePolicy
|
|
615
|
+
? { kind: "replace", diffLines }
|
|
616
|
+
: { kind: "refused_divergent", diffLines };
|
|
617
|
+
}
|
|
263
618
|
/**
|
|
264
619
|
* Validate the plan DAG locally, mirroring the server's `validate_epic_plan_dag`.
|
|
265
620
|
*
|
|
@@ -398,15 +753,20 @@ function proposeFeatureBranchName(epicKey) {
|
|
|
398
753
|
* parse time and is returned unchanged (no prompt).
|
|
399
754
|
* - A non-interactive (piped/CI) run or a `--json` run never prompts and never
|
|
400
755
|
* blocks — it retains the absent-branch behavior.
|
|
756
|
+
* - A `--policy-file` that already declares a branch has ALREADY answered this
|
|
757
|
+
* question (BAPI-754). Prompting anyway would invite an operator to type a
|
|
758
|
+
* competing value that composition would then have to refuse.
|
|
401
759
|
* - An interactive run is shown the proposal (branch name, "create from the
|
|
402
760
|
* repository base branch" strategy, and the child-PR-targeting consequence)
|
|
403
761
|
* and may accept it, type a custom name, or decline to use the base branch.
|
|
404
762
|
* An edited nonblank value is re-validated; an invalid value is redisplayed
|
|
405
763
|
* for correction rather than silently rewritten.
|
|
406
764
|
*/
|
|
407
|
-
async function resolveFeatureBranchSelection(opts, repoName, deps) {
|
|
765
|
+
async function resolveFeatureBranchSelection(opts, repoName, deps, policyDeclaresBranch = false) {
|
|
408
766
|
if (opts.featureBranch !== undefined)
|
|
409
767
|
return opts.featureBranch;
|
|
768
|
+
if (policyDeclaresBranch)
|
|
769
|
+
return undefined;
|
|
410
770
|
if (!deps.isTTY || opts.json)
|
|
411
771
|
return undefined;
|
|
412
772
|
const proposed = proposeFeatureBranchName(opts.epicKey);
|
|
@@ -441,6 +801,64 @@ function errorDetail(err) {
|
|
|
441
801
|
}
|
|
442
802
|
return err instanceof Error ? err.message : String(err);
|
|
443
803
|
}
|
|
804
|
+
/**
|
|
805
|
+
* Reconcile a policy file against a REUSED run's stored policy, applying it only
|
|
806
|
+
* under `--replace-policy`.
|
|
807
|
+
*
|
|
808
|
+
* Returns `true` to continue the workflow (`noop` or a successful `replaced`) and
|
|
809
|
+
* `false` when the run's policy diverges and was left alone — the caller then
|
|
810
|
+
* stops, because storing and approving a plan under a policy the operator did not
|
|
811
|
+
* choose is worse than doing nothing.
|
|
812
|
+
*
|
|
813
|
+
* Mutates `result.policy_applied` so the JSON summary reports the same
|
|
814
|
+
* disposition the human output describes.
|
|
815
|
+
*/
|
|
816
|
+
async function applyReusedRunPolicy(args) {
|
|
817
|
+
const outcome = reconcileReusedRunPolicy({
|
|
818
|
+
storedPolicy: args.storedPolicy,
|
|
819
|
+
requestedPolicy: args.requestedPolicy,
|
|
820
|
+
replacePolicy: args.replacePolicy,
|
|
821
|
+
});
|
|
822
|
+
if (outcome.kind === "noop") {
|
|
823
|
+
args.result.policy_applied = "noop";
|
|
824
|
+
args.say("Policy: policy unchanged");
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
if (outcome.kind === "refused_divergent") {
|
|
828
|
+
args.result.policy_applied = "refused_divergent";
|
|
829
|
+
args.deps.errorLog(`Epic run ${args.epicRunId} is live and its stored policy differs from the ` +
|
|
830
|
+
`policy file. setup-epic will not silently change a running epic's policy.\n` +
|
|
831
|
+
`${outcome.diffLines.join("\n")}\n` +
|
|
832
|
+
`Re-run with --replace-policy to apply the file as the complete replacement, ` +
|
|
833
|
+
`or drop --policy-file to reuse the run unchanged.`);
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
await replaceEpicRunPolicy(args.access, { epicRunId: args.epicRunId, policyJson: args.requestedPolicy }, args.deps.fetch);
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
args.result.policy_applied = "refused_divergent";
|
|
841
|
+
args.deps.errorLog(`Failed to replace the run policy: ${errorDetail(err)}`);
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
args.result.policy_applied = "replaced";
|
|
845
|
+
args.say("Policy: policy applied: replaced");
|
|
846
|
+
for (const line of outcome.diffLines)
|
|
847
|
+
args.say(line);
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Emit the post-access refusal summary and the non-zero exit status.
|
|
852
|
+
*
|
|
853
|
+
* A refusal happens after the run state is known, so `--json` consumers still get
|
|
854
|
+
* the canonical result object — `policy_applied: "refused_divergent"` is a real
|
|
855
|
+
* outcome to record, not an absence of one.
|
|
856
|
+
*/
|
|
857
|
+
function emitRefusal(deps, opts, result) {
|
|
858
|
+
if (opts.json)
|
|
859
|
+
deps.log(JSON.stringify(result, null, 2));
|
|
860
|
+
return 1;
|
|
861
|
+
}
|
|
444
862
|
export async function runSetupEpicCli(argv, overrides = {}) {
|
|
445
863
|
const deps = { ...createDefaultSetupEpicDeps(), ...overrides };
|
|
446
864
|
const parsed = parseSetupEpicArgs(argv);
|
|
@@ -488,6 +906,60 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
488
906
|
return 1;
|
|
489
907
|
}
|
|
490
908
|
const localHash = hashPlan(plan);
|
|
909
|
+
// --- Read the policy file (still no network) ------------------------------
|
|
910
|
+
// BAPI-754: parsed here so a malformed file, or a contradiction between the
|
|
911
|
+
// file and the CLI flags, fails before credentials are resolved or any request
|
|
912
|
+
// is dispatched.
|
|
913
|
+
let filePolicy;
|
|
914
|
+
if (opts.policyFile !== undefined) {
|
|
915
|
+
const policyRead = await readSetupEpicPolicyFile(opts.policyFile, deps.readFile);
|
|
916
|
+
if (!policyRead.ok) {
|
|
917
|
+
deps.errorLog(policyRead.error);
|
|
918
|
+
return 1;
|
|
919
|
+
}
|
|
920
|
+
filePolicy = policyRead.policy;
|
|
921
|
+
}
|
|
922
|
+
const policyDeclaresBranch = filePolicy !== undefined &&
|
|
923
|
+
["base_branch", "baseBranch"].some((key) => key in filePolicy && filePolicy[key] !== undefined && filePolicy[key] !== null);
|
|
924
|
+
// --- --local-only --dry-run: stop here, offline and explicitly partial ----
|
|
925
|
+
// Nothing below this point is reachable without the network, and the whole
|
|
926
|
+
// point of the flag is to not touch it — not even to resolve a credential.
|
|
927
|
+
if (opts.localOnly) {
|
|
928
|
+
if (filePolicy !== undefined) {
|
|
929
|
+
const composed = composeSetupEpicPolicy(filePolicy, {
|
|
930
|
+
featureBranch: opts.featureBranch,
|
|
931
|
+
reviewPolicy: opts.reviewPolicy,
|
|
932
|
+
});
|
|
933
|
+
if (!composed.ok) {
|
|
934
|
+
deps.errorLog(composed.error);
|
|
935
|
+
return 1;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
say(`Epic: ${opts.epicKey}`);
|
|
939
|
+
say(`Plan: v${plan.plan_version}, ${plan.nodes.length} node(s), ${plan.edges.length} edge(s)`);
|
|
940
|
+
say(`Local hash: ${localHash}`);
|
|
941
|
+
for (const w of warnings)
|
|
942
|
+
say(` [warn] ${w}`);
|
|
943
|
+
say("");
|
|
944
|
+
say("Result: partial (local checks only)");
|
|
945
|
+
say(" The server's plan validator did NOT run, so this says nothing " +
|
|
946
|
+
"about whether the server would accept this plan. Re-run without " +
|
|
947
|
+
"--local-only for authoritative validation.");
|
|
948
|
+
if (opts.json) {
|
|
949
|
+
deps.log(JSON.stringify({
|
|
950
|
+
dry_run: true,
|
|
951
|
+
local_only: true,
|
|
952
|
+
result: "partial (local checks only)",
|
|
953
|
+
epic_key: opts.epicKey,
|
|
954
|
+
plan_version: plan.plan_version,
|
|
955
|
+
local_plan_hash: localHash,
|
|
956
|
+
server_validated: false,
|
|
957
|
+
policy_applied: "absent",
|
|
958
|
+
warnings,
|
|
959
|
+
}, null, 2));
|
|
960
|
+
}
|
|
961
|
+
return 0;
|
|
962
|
+
}
|
|
491
963
|
// --- Resolve access ------------------------------------------------------
|
|
492
964
|
const accessResult = await resolveConductorBridgeApiAccess({
|
|
493
965
|
env: deps.env,
|
|
@@ -513,8 +985,8 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
513
985
|
// Resolved AFTER local/access context is known but BEFORE any run-state read
|
|
514
986
|
// or mutating request, so malformed interactive input fails before network
|
|
515
987
|
// dispatch. Returns undefined to continue on the repository base branch.
|
|
516
|
-
const promptedInteractively = opts.featureBranch === undefined && deps.isTTY && !opts.json;
|
|
517
|
-
const featureBranch = await resolveFeatureBranchSelection(opts, access.repoName, deps);
|
|
988
|
+
const promptedInteractively = opts.featureBranch === undefined && !policyDeclaresBranch && deps.isTTY && !opts.json;
|
|
989
|
+
const featureBranch = await resolveFeatureBranchSelection(opts, access.repoName, deps, policyDeclaresBranch);
|
|
518
990
|
if (featureBranch !== undefined) {
|
|
519
991
|
say(`Feature: ${featureBranch} (create from repository base branch on origin)`);
|
|
520
992
|
}
|
|
@@ -522,17 +994,38 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
522
994
|
// Ordinary, non-warning notice — only when an interactive operator declined.
|
|
523
995
|
say("Feature: none — continue using the repository base branch");
|
|
524
996
|
}
|
|
997
|
+
// --- Compose the requested run policy (BAPI-754) -------------------------
|
|
998
|
+
// A contradiction between the file and the flags is refused here, before the
|
|
999
|
+
// pre-check and long before any create, so no request is dispatched under a
|
|
1000
|
+
// policy nobody chose.
|
|
1001
|
+
let requestedPolicy;
|
|
1002
|
+
if (filePolicy !== undefined) {
|
|
1003
|
+
const composed = composeSetupEpicPolicy(filePolicy, {
|
|
1004
|
+
featureBranch,
|
|
1005
|
+
reviewPolicy: opts.reviewPolicy,
|
|
1006
|
+
});
|
|
1007
|
+
if (!composed.ok) {
|
|
1008
|
+
deps.errorLog(composed.error);
|
|
1009
|
+
return 1;
|
|
1010
|
+
}
|
|
1011
|
+
requestedPolicy = composed.policy;
|
|
1012
|
+
say(`Policy: ${opts.policyFile} (complete run policy, applied at creation)`);
|
|
1013
|
+
}
|
|
525
1014
|
// --- Step 0: pre-check ---------------------------------------------------
|
|
526
1015
|
// Never create on an ambiguous read. A wrong answer here mints a duplicate run.
|
|
527
1016
|
let existingRunId = null;
|
|
528
1017
|
let existingStatus = null;
|
|
529
1018
|
let existingBaseBranch = null;
|
|
530
1019
|
let existingReviewPolicy = null;
|
|
1020
|
+
// BAPI-754: the live run's SERVER-REDACTED stored policy, kept for the reuse
|
|
1021
|
+
// comparison. Redacted is the only form a client can ever see.
|
|
1022
|
+
let existingPolicyJson = null;
|
|
531
1023
|
try {
|
|
532
1024
|
const state = await fetchEpicRunState(access, opts.epicKey, deps.fetch);
|
|
533
1025
|
existingRunId = state.epic_run?.epic_run_id ?? null;
|
|
534
1026
|
existingStatus = state.epic_run?.status ?? null;
|
|
535
1027
|
const existingPolicy = state.epic_run?.policy_json;
|
|
1028
|
+
existingPolicyJson = existingPolicy ?? null;
|
|
536
1029
|
const existingBase = existingPolicy && typeof existingPolicy === "object"
|
|
537
1030
|
? existingPolicy.base_branch
|
|
538
1031
|
: undefined;
|
|
@@ -603,9 +1096,71 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
603
1096
|
if (opts.reviewPolicy !== undefined) {
|
|
604
1097
|
say(`Review: ${opts.reviewPolicy} (per-run; repository defaults pending BAPI-694)`);
|
|
605
1098
|
}
|
|
1099
|
+
// --- Authoritative server validation (BAPI-754) --------------------------
|
|
1100
|
+
// Runs BEFORE any create. A plan the server would refuse must not cost a run
|
|
1101
|
+
// row and an automation-start debit on the way to finding that out.
|
|
1102
|
+
let validated = null;
|
|
1103
|
+
let validateLaneMissing = false;
|
|
1104
|
+
try {
|
|
1105
|
+
validated = await validateEpicPlan(access, {
|
|
1106
|
+
planVersion: plan.plan_version,
|
|
1107
|
+
planBlob: plan,
|
|
1108
|
+
epicKey: opts.epicKey,
|
|
1109
|
+
}, deps.fetch);
|
|
1110
|
+
}
|
|
1111
|
+
catch (err) {
|
|
1112
|
+
const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
|
|
1113
|
+
if (status === 404) {
|
|
1114
|
+
// The ONE sanctioned fallback: an older deployment without the lane. Warn
|
|
1115
|
+
// loudly (a wrong BAPI_BASE_URL looks exactly like this) and continue on
|
|
1116
|
+
// the legacy create-then-store order.
|
|
1117
|
+
validateLaneMissing = true;
|
|
1118
|
+
const msg = `This server has no POST /jira/epic-runs/plan/validate, so the plan could NOT ` +
|
|
1119
|
+
`be validated before creating the run. Either the deployment predates that ` +
|
|
1120
|
+
`capability, or BAPI_BASE_URL is pointing at an older one. Falling back to the ` +
|
|
1121
|
+
`legacy create-then-store order: an invalid plan may create a run and incur an ` +
|
|
1122
|
+
`automation-start charge before failing.`;
|
|
1123
|
+
warnings.push(msg);
|
|
1124
|
+
say(`Validation: [warn] ${msg}`);
|
|
1125
|
+
}
|
|
1126
|
+
else if (status === undefined) {
|
|
1127
|
+
deps.errorLog(`server unreachable, cannot fully validate — the plan was not checked against ` +
|
|
1128
|
+
`the server's validator.\nNo run was created and no automation-start charge ` +
|
|
1129
|
+
`occurred.\nDetail: ${errorDetail(err)}`);
|
|
1130
|
+
return 1;
|
|
1131
|
+
}
|
|
1132
|
+
else if (status === 409) {
|
|
1133
|
+
// The epic is WEDGED (multiple active runs), which says nothing about the
|
|
1134
|
+
// plan. Reporting this as "server validation: failed" would send the
|
|
1135
|
+
// operator to fix a sidecar that is perfectly fine. The pre-check above
|
|
1136
|
+
// normally catches this first; this branch covers the race where a
|
|
1137
|
+
// duplicate run appears between the pre-check and the validate call.
|
|
1138
|
+
deps.errorLog(`Epic ${opts.epicKey} has MULTIPLE active runs — the plan could not be ` +
|
|
1139
|
+
`validated because the epic itself is wedged, and every plan call will ` +
|
|
1140
|
+
`keep failing. This is NOT a problem with your plan. Abandon the ` +
|
|
1141
|
+
`duplicate before retrying:\n` +
|
|
1142
|
+
` PATCH /jira/epic-runs/runs/<epic_run_id> {"status": "abandoned"}\n` +
|
|
1143
|
+
`No run was created and no automation-start charge occurred.\n` +
|
|
1144
|
+
`Detail: ${errorDetail(err)}`);
|
|
1145
|
+
return 1;
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
deps.errorLog(`server validation: failed\n${errorDetail(err)}\n` +
|
|
1149
|
+
`No run was created and no automation-start charge occurred.`);
|
|
1150
|
+
return 1;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (validated) {
|
|
1154
|
+
say("Validation: server validation: passed");
|
|
1155
|
+
say(` plan hash ${validated.planHash}`);
|
|
1156
|
+
say(` serialization ${validated.serializationEnabled ? "enabled" : "disabled"}, ` +
|
|
1157
|
+
`${validated.insertedEdges} edge(s) inserted`);
|
|
1158
|
+
}
|
|
606
1159
|
if (opts.dryRun) {
|
|
607
1160
|
say("");
|
|
608
|
-
say(
|
|
1161
|
+
say(validated
|
|
1162
|
+
? "[dry-run] Server validation passed. No changes made. Would:"
|
|
1163
|
+
: "[dry-run] partial (local checks only) — the server's validator did not run. Would:");
|
|
609
1164
|
if (existingRunId) {
|
|
610
1165
|
say(` - reuse existing run ${existingRunId} (status: ${existingStatus})`);
|
|
611
1166
|
}
|
|
@@ -635,6 +1190,17 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
635
1190
|
...(opts.reviewPolicy !== undefined
|
|
636
1191
|
? { review_policy: { source: opts.reviewPolicy }, review_policy_scope: "per-run" }
|
|
637
1192
|
: {}),
|
|
1193
|
+
// BAPI-754 — a dry run applies nothing, so the disposition is always
|
|
1194
|
+
// "absent"; server_validated says whether the check was authoritative.
|
|
1195
|
+
server_validated: validated !== null,
|
|
1196
|
+
policy_applied: "absent",
|
|
1197
|
+
...(validated
|
|
1198
|
+
? {
|
|
1199
|
+
server_plan_hash: validated.planHash,
|
|
1200
|
+
serialization_enabled: validated.serializationEnabled,
|
|
1201
|
+
inserted_edges: validated.insertedEdges,
|
|
1202
|
+
}
|
|
1203
|
+
: { result: "partial (local checks only)" }),
|
|
638
1204
|
warnings,
|
|
639
1205
|
}, null, 2));
|
|
640
1206
|
}
|
|
@@ -651,6 +1217,8 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
651
1217
|
run_created: false,
|
|
652
1218
|
plan_stored: false,
|
|
653
1219
|
plan_approved: false,
|
|
1220
|
+
server_validated: validated !== null,
|
|
1221
|
+
policy_applied: "absent",
|
|
654
1222
|
warnings,
|
|
655
1223
|
};
|
|
656
1224
|
if (featureBranch !== undefined)
|
|
@@ -658,6 +1226,22 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
658
1226
|
// --- Step 1: create (only when there is no live run) ---------------------
|
|
659
1227
|
if (existingRunId) {
|
|
660
1228
|
say(`Run: reusing ${existingRunId} (status: ${existingStatus})`);
|
|
1229
|
+
// A pre-check that already found a live run means create would answer 200
|
|
1230
|
+
// anyway; reconcile the file against that run's stored policy directly.
|
|
1231
|
+
if (requestedPolicy !== undefined) {
|
|
1232
|
+
const outcome = await applyReusedRunPolicy({
|
|
1233
|
+
access,
|
|
1234
|
+
deps,
|
|
1235
|
+
say,
|
|
1236
|
+
result,
|
|
1237
|
+
epicRunId: existingRunId,
|
|
1238
|
+
storedPolicy: existingPolicyJson,
|
|
1239
|
+
requestedPolicy,
|
|
1240
|
+
replacePolicy: opts.replacePolicy,
|
|
1241
|
+
});
|
|
1242
|
+
if (!outcome)
|
|
1243
|
+
return emitRefusal(deps, opts, result);
|
|
1244
|
+
}
|
|
661
1245
|
}
|
|
662
1246
|
else {
|
|
663
1247
|
try {
|
|
@@ -666,22 +1250,65 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
666
1250
|
// BAPI-679: base_branch and review_policy COMPOSE — neither replaces the
|
|
667
1251
|
// other, and when neither is selected the legacy create request shape is
|
|
668
1252
|
// preserved byte-for-byte so the pre-feature contract is unchanged.
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1253
|
+
//
|
|
1254
|
+
// BAPI-754: a --policy-file supersedes that ad-hoc composition entirely —
|
|
1255
|
+
// `requestedPolicy` IS the composed result, flags included — so the whole
|
|
1256
|
+
// policy reaches the server in the create that mints the run.
|
|
1257
|
+
let policyJson;
|
|
1258
|
+
if (requestedPolicy !== undefined) {
|
|
1259
|
+
policyJson = requestedPolicy;
|
|
674
1260
|
}
|
|
675
|
-
|
|
1261
|
+
else {
|
|
1262
|
+
policyJson = {};
|
|
1263
|
+
if (featureBranch !== undefined)
|
|
1264
|
+
policyJson.base_branch = featureBranch;
|
|
1265
|
+
if (opts.reviewPolicy !== undefined) {
|
|
1266
|
+
policyJson.review_policy = { source: opts.reviewPolicy };
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
const createRequest = Object.keys(policyJson).length > 0 || requestedPolicy !== undefined
|
|
676
1270
|
? { epicKey: opts.epicKey, policyJson }
|
|
677
1271
|
: { epicKey: opts.epicKey };
|
|
678
|
-
const run = await
|
|
1272
|
+
const { run, created } = await createEpicRunWithDisposition(access, createRequest, deps.fetch);
|
|
679
1273
|
result.epic_run_id = run.epic_run_id;
|
|
680
1274
|
result.status = run.status;
|
|
681
|
-
result.run_created =
|
|
682
|
-
|
|
1275
|
+
result.run_created = created;
|
|
1276
|
+
if (created) {
|
|
1277
|
+
say(`Run: created ${run.epic_run_id}`);
|
|
1278
|
+
if (requestedPolicy !== undefined) {
|
|
1279
|
+
result.policy_applied = "created";
|
|
1280
|
+
say("Policy: policy applied: created");
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
else {
|
|
1284
|
+
// The pre-check said there was no live run, but create found one — a
|
|
1285
|
+
// concurrent setup won the race. Treat it exactly like the reuse path.
|
|
1286
|
+
say(`Run: reusing ${run.epic_run_id} (status: ${run.status})`);
|
|
1287
|
+
if (requestedPolicy !== undefined) {
|
|
1288
|
+
const outcome = await applyReusedRunPolicy({
|
|
1289
|
+
access,
|
|
1290
|
+
deps,
|
|
1291
|
+
say,
|
|
1292
|
+
result,
|
|
1293
|
+
epicRunId: run.epic_run_id,
|
|
1294
|
+
storedPolicy: run.policy_json,
|
|
1295
|
+
requestedPolicy,
|
|
1296
|
+
replacePolicy: opts.replacePolicy,
|
|
1297
|
+
});
|
|
1298
|
+
if (!outcome)
|
|
1299
|
+
return emitRefusal(deps, opts, result);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
683
1302
|
}
|
|
684
1303
|
catch (err) {
|
|
1304
|
+
const status = err instanceof ConductorBridgeApiError ? err.status : undefined;
|
|
1305
|
+
if (status === 422 && requestedPolicy !== undefined) {
|
|
1306
|
+
deps.errorLog(`The run policy from '${opts.policyFile}' was rejected by the server.\n` +
|
|
1307
|
+
`${errorDetail(err)}\n` +
|
|
1308
|
+
`No run was created and no automation-start charge occurred. Fix the ` +
|
|
1309
|
+
`named field in the policy file and re-run.`);
|
|
1310
|
+
return 1;
|
|
1311
|
+
}
|
|
685
1312
|
deps.errorLog(`Failed to create the epic run: ${errorDetail(err)}`);
|
|
686
1313
|
return 1;
|
|
687
1314
|
}
|
|
@@ -777,6 +1404,14 @@ export async function runSetupEpicCli(argv, overrides = {}) {
|
|
|
777
1404
|
}
|
|
778
1405
|
else {
|
|
779
1406
|
say("");
|
|
1407
|
+
// BAPI-754: repeat the capability warning in the summary. An operator who
|
|
1408
|
+
// scrolled past it at detection time would otherwise finish the run believing
|
|
1409
|
+
// the plan had been validated.
|
|
1410
|
+
if (validateLaneMissing) {
|
|
1411
|
+
say("[warn] This server has no POST /jira/epic-runs/plan/validate — the plan was " +
|
|
1412
|
+
"NOT validated before the run was created, and the legacy create-then-store " +
|
|
1413
|
+
"order was used. Check that BAPI_BASE_URL points at the intended deployment.");
|
|
1414
|
+
}
|
|
780
1415
|
say(`Epic run ${result.epic_run_id} is ${result.status ?? "unknown"}.`);
|
|
781
1416
|
say("The server-side reconciler will pick it up within ~30s.");
|
|
782
1417
|
say("To execute claimed jobs on this machine, run:");
|