@bridge_gpt/mcp-server 0.2.65 → 0.2.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -13
- package/build/agent-launchers/claude-executor-adapter.js +12 -18
- package/build/agent-launchers/executor-adapter.js +27 -0
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +230 -10
- package/build/conductor/epic-child-continuation.js +61 -34
- package/build/conductor/epic-implementer-cli.js +12 -0
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness.js +23 -1
- package/build/conductor/recovery-cli.js +507 -16
- package/build/conductor/recovery-operations.js +325 -2
- package/build/conductor/repair-contract.js +61 -3
- package/build/credential-store.js +5 -4
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +70 -10
- package/build/drive-epic.js +395 -78
- package/build/epic-implementer-bin.js +24 -22
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +44 -14
- package/build/executor/completion-supervision.js +5 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/env.js +6 -0
- package/build/executor/http-client.js +15 -0
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +13 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +260 -33
- package/build/executor/job-types.js +8 -13
- package/build/executor/preflight.js +91 -2
- package/build/executor/prompt-spec.js +113 -9
- package/build/executor/runner.js +23 -0
- 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 +80 -15
- package/build/executor/worker-guard-hook.js +46 -10
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/index.js +177 -329
- package/build/init.js +107 -4
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +1 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +4 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/setup-epic.js +180 -108
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +4 -3
- package/build/worktree-core.js +6 -5
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +1 -1
- package/pipelines/implement-ticket.json +1 -1
package/build/drive-epic.js
CHANGED
|
@@ -56,10 +56,10 @@
|
|
|
56
56
|
import { readdir, readFile } from "node:fs/promises";
|
|
57
57
|
import path, { dirname, join } from "node:path";
|
|
58
58
|
import { CONDUCTOR_DEFAULT_BASE_URL, ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
59
|
-
import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, renderEpicLaunchCommand, shellQuote, } from "./epic-launch-profile.js";
|
|
59
|
+
import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, epicLaunchPolicyDigest, pinEpicLaunchPolicy, renderEpicLaunchCommand, resolveEpicLaunchSelection, shellQuote, } from "./epic-launch-profile.js";
|
|
60
60
|
import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
|
|
61
61
|
import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
|
|
62
|
-
import { policyDeclaresNotifyWebhook, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
62
|
+
import { policyDeclaresNotifyWebhook, effectivePolicyFacts, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
63
63
|
import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
|
|
64
64
|
import { discoverEpicPlan, } from "./plan-discovery.js";
|
|
65
65
|
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
@@ -120,8 +120,9 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
120
120
|
// The repository-scoped `reconciler` section ("is this repository's work
|
|
121
121
|
// moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
|
|
122
122
|
// too old to report `bring_up`.
|
|
123
|
-
satisfied: (r) => r.bring_up
|
|
124
|
-
? r.bring_up.heartbeat_readable && r.bring_up.worker_present
|
|
123
|
+
satisfied: (r) => r.bring_up
|
|
124
|
+
? r.bring_up.heartbeat_readable && r.bring_up.worker_present &&
|
|
125
|
+
r.bring_up.single_transition_authority
|
|
125
126
|
: r.reconciler.liveness_readable && !r.reconciler.stale,
|
|
126
127
|
},
|
|
127
128
|
{
|
|
@@ -238,6 +239,24 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
|
|
|
238
239
|
* already encodes that (setup, then config, then credentials).
|
|
239
240
|
*/
|
|
240
241
|
export function classifyV2Readiness(readiness, options = {}) {
|
|
242
|
+
for (const diagnostic of readiness.diagnostics ?? []) {
|
|
243
|
+
if (diagnostic.severity === null || diagnostic.applicability === null) {
|
|
244
|
+
return {
|
|
245
|
+
kind: "operator_blocked",
|
|
246
|
+
id: "diagnostic-metadata-unknown",
|
|
247
|
+
describe: "declared diagnostic severity and applicability",
|
|
248
|
+
remedy: "Upgrade or repair the readiness producer, then retry the selected target.",
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (diagnostic.applicability === "applicable" && diagnostic.severity === "mandatory" && diagnostic.verdict !== "passed") {
|
|
252
|
+
return {
|
|
253
|
+
kind: "operator_blocked",
|
|
254
|
+
id: "mandatory-diagnostic-unverified",
|
|
255
|
+
describe: `mandatory diagnostic ${diagnostic.id}`,
|
|
256
|
+
remedy: "Resolve the mandatory diagnostic at the selected Bridge API target and retry.",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
}
|
|
241
260
|
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended-only
|
|
242
261
|
// requirements INAPPLICABLE, not merely tolerated. They authorize unattended
|
|
243
262
|
// operation and automatic merge behavior; an attended run asks for neither, so
|
|
@@ -253,7 +272,9 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
253
272
|
// which reads the policy and refuses if it declares none. Nothing is weakened:
|
|
254
273
|
// `setup-epic` gates before the create, and the server re-checks the predicate
|
|
255
274
|
// at approval and probes the URL there.
|
|
256
|
-
const deferred = options.
|
|
275
|
+
const deferred = options.approvedStoredPolicy ||
|
|
276
|
+
options.effectivePolicy?.escalationChannel === "configured webhook" ||
|
|
277
|
+
(options.effectivePolicy === undefined && options.hasPolicyFile)
|
|
257
278
|
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
|
|
258
279
|
: V2_READINESS_REQUIREMENTS;
|
|
259
280
|
// `--into-base` is the second opt-out, and it is structural rather than
|
|
@@ -270,8 +291,28 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
270
291
|
? gating.filter((req) => req.unattendedOnly !== true)
|
|
271
292
|
: gating;
|
|
272
293
|
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
273
|
-
if (unmet.length === 0)
|
|
294
|
+
if (unmet.length === 0) {
|
|
295
|
+
// Older deployments have no bring-up block; their existing reconciler
|
|
296
|
+
// liveness predicate remains the available evidence. For deployments that
|
|
297
|
+
// do report process facts, an unreadable owner never becomes a green launch.
|
|
298
|
+
if (readiness.bring_up && !readiness.bring_up.heartbeat_readable) {
|
|
299
|
+
return {
|
|
300
|
+
kind: "operator_blocked",
|
|
301
|
+
id: "process-owner-unverified",
|
|
302
|
+
describe: "a readable reconciler process-owner heartbeat",
|
|
303
|
+
remedy: "Restore the process heartbeat source before launch.",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (readiness.bring_up?.worker_present && !readiness.bring_up.single_transition_authority) {
|
|
307
|
+
return {
|
|
308
|
+
kind: "operator_blocked",
|
|
309
|
+
id: "process-owner-conflict",
|
|
310
|
+
describe: "one verified reconciler process owner",
|
|
311
|
+
remedy: "Resolve the missing or duplicate reconciler process before launch.",
|
|
312
|
+
};
|
|
313
|
+
}
|
|
274
314
|
return { kind: "green" };
|
|
315
|
+
}
|
|
275
316
|
const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
|
|
276
317
|
if (blocker) {
|
|
277
318
|
return {
|
|
@@ -406,6 +447,10 @@ export function getDriveEpicUsage() {
|
|
|
406
447
|
" non-production server and for durable hosts.",
|
|
407
448
|
" --base-url <url> Explicit Bridge API target. With a profile it must",
|
|
408
449
|
" agree with it — an override never rebinds a profile.",
|
|
450
|
+
"BAPI_EPIC_LAUNCH_PROFILE selects a host-local launch profile for epic",
|
|
451
|
+
"launches. --launch-profile takes precedence. For an environment-selected",
|
|
452
|
+
"profile, BAPI_BASE_URL remains the general CLI endpoint and does not",
|
|
453
|
+
"conflict with the epic launch target; --base-url still must agree.",
|
|
409
454
|
"Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
|
|
410
455
|
`targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
|
|
411
456
|
"stored bapi:<repo> key when one is available. A stored key approved for a",
|
|
@@ -1077,7 +1122,14 @@ export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalTh
|
|
|
1077
1122
|
typeof run.status !== "string") {
|
|
1078
1123
|
return { kind: "unknown", reason: "the run state response was malformed" };
|
|
1079
1124
|
}
|
|
1080
|
-
return {
|
|
1125
|
+
return {
|
|
1126
|
+
kind: "live",
|
|
1127
|
+
epicRunId: run.epic_run_id,
|
|
1128
|
+
status: run.status,
|
|
1129
|
+
...(run.policy_json !== undefined
|
|
1130
|
+
? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored") }
|
|
1131
|
+
: {}),
|
|
1132
|
+
};
|
|
1081
1133
|
}
|
|
1082
1134
|
catch (err) {
|
|
1083
1135
|
if (err instanceof ConductorBridgeApiError) {
|
|
@@ -1104,8 +1156,8 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1104
1156
|
...(cwd ? { cwd } : {}),
|
|
1105
1157
|
});
|
|
1106
1158
|
return result.ok
|
|
1107
|
-
? { ok: true, access: result.access, profile: result.profile }
|
|
1108
|
-
: { ok: false, error: result.error };
|
|
1159
|
+
? { ok: true, access: result.access, profile: result.profile, targetSource: result.targetSource }
|
|
1160
|
+
: { ok: false, error: result.error, kind: result.kind, ...(result.baseUrl ? { baseUrl: result.baseUrl } : {}) };
|
|
1109
1161
|
},
|
|
1110
1162
|
// Non-secret, and only ever a value the resolver just agreed with the
|
|
1111
1163
|
// environment on (a conflicting BAPI_BASE_URL was refused above), so the
|
|
@@ -1114,7 +1166,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1114
1166
|
process.env.BAPI_BASE_URL = baseUrl;
|
|
1115
1167
|
},
|
|
1116
1168
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
1117
|
-
runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
|
|
1169
|
+
runSetupEpic: (argv, handoff) => runSetupEpicWorkflow(argv, {}, handoff),
|
|
1118
1170
|
readEligibilityArtifact: (path) => readFile(path, "utf8"),
|
|
1119
1171
|
runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
|
|
1120
1172
|
runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
|
|
@@ -1167,7 +1219,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1167
1219
|
readPlanDag: (filePath) => readFile(filePath, "utf8"),
|
|
1168
1220
|
readPolicyFile: (filePath) => readFile(filePath, "utf8"),
|
|
1169
1221
|
readLiveEpicRun: (access, epicKey) => readLiveEpicRunByKey(access, epicKey),
|
|
1170
|
-
previewSetupEpic: async ({ argv, baseUrl }) => {
|
|
1222
|
+
previewSetupEpic: async ({ argv, baseUrl, handoff }) => {
|
|
1171
1223
|
const stdout = [];
|
|
1172
1224
|
const stderr = [];
|
|
1173
1225
|
const result = await runSetupEpicWorkflow(argv, {
|
|
@@ -1176,7 +1228,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1176
1228
|
env: { ...process.env, BAPI_BASE_URL: baseUrl },
|
|
1177
1229
|
log: (message) => stdout.push(message),
|
|
1178
1230
|
errorLog: (message) => stderr.push(message),
|
|
1179
|
-
});
|
|
1231
|
+
}, handoff);
|
|
1180
1232
|
return { exitCode: result.exitCode, stdout, stderr };
|
|
1181
1233
|
},
|
|
1182
1234
|
// Read-only by construction: `prepareRunExecutor` runs `git rev-parse` and
|
|
@@ -1251,6 +1303,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1251
1303
|
options: parsed.options,
|
|
1252
1304
|
access: accessResult.access,
|
|
1253
1305
|
profile: launchProfile,
|
|
1306
|
+
targetSource: accessResult.targetSource ?? "default",
|
|
1254
1307
|
};
|
|
1255
1308
|
// BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
|
|
1256
1309
|
// discovered plan must count as a supplied one for the BAPI-1153 cold-start
|
|
@@ -1319,6 +1372,16 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1319
1372
|
}
|
|
1320
1373
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
1321
1374
|
}
|
|
1375
|
+
if (ctx.options.planFile !== undefined) {
|
|
1376
|
+
const prepared = await prepareSetupSelection(ctx);
|
|
1377
|
+
if (!prepared.ok) {
|
|
1378
|
+
return refuse(deps, [
|
|
1379
|
+
`${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
|
|
1380
|
+
"Nothing was created or started.",
|
|
1381
|
+
]);
|
|
1382
|
+
}
|
|
1383
|
+
ctx = { ...ctx, handoff: prepared.handoff };
|
|
1384
|
+
}
|
|
1322
1385
|
// --- Route. Exactly one path from here on. -------------------------------
|
|
1323
1386
|
const selection = selectConductor(readiness);
|
|
1324
1387
|
// BAPI-1022: surface any containment-hazard advisory before dispatch or
|
|
@@ -1377,7 +1440,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1377
1440
|
const classification = classifyV2Readiness(readiness, {
|
|
1378
1441
|
attended: parsed.options.attended === true,
|
|
1379
1442
|
intoBase: parsed.options.intoBase === true,
|
|
1380
|
-
|
|
1443
|
+
effectivePolicy: ctx.handoff?.selection,
|
|
1381
1444
|
});
|
|
1382
1445
|
// Green: create the run, then make sure something will claim its jobs
|
|
1383
1446
|
// (BAPI-1279). Before this, green delegated to setup-epic and nothing
|
|
@@ -1594,6 +1657,61 @@ function escalate(deps, epicKey, reason) {
|
|
|
1594
1657
|
deps.errorLog(text);
|
|
1595
1658
|
return 1;
|
|
1596
1659
|
}
|
|
1660
|
+
/** Resolve policy intent once for the setup delegation, after a fresh run read. */
|
|
1661
|
+
async function prepareSetupSelection(ctx, coldStart = false) {
|
|
1662
|
+
const live = coldStart ? { kind: "none" } : await ctx.deps.readLiveEpicRun(ctx.access, ctx.epicKey);
|
|
1663
|
+
if (live.kind === "conflict")
|
|
1664
|
+
return { ok: false, reasonId: "live-run-ambiguous" };
|
|
1665
|
+
if (live.kind === "unknown")
|
|
1666
|
+
return { ok: false, reasonId: "live-run-unknown" };
|
|
1667
|
+
const explicit = ctx.options.policyFile;
|
|
1668
|
+
const selectedPath = explicit ?? (live.kind === "none" ? ctx.profile?.policyRef?.path : undefined);
|
|
1669
|
+
let pinnedPolicy;
|
|
1670
|
+
let digest = null;
|
|
1671
|
+
if (selectedPath !== undefined) {
|
|
1672
|
+
let raw = "";
|
|
1673
|
+
const read = await readSetupEpicPolicyFile(selectedPath, async (filePath) => {
|
|
1674
|
+
raw = await ctx.deps.readPolicyFile(filePath);
|
|
1675
|
+
return raw;
|
|
1676
|
+
});
|
|
1677
|
+
if (!read.ok)
|
|
1678
|
+
return { ok: false, reasonId: "policy-file-unreadable" };
|
|
1679
|
+
pinnedPolicy = read.policy;
|
|
1680
|
+
digest = epicLaunchPolicyDigest(raw);
|
|
1681
|
+
}
|
|
1682
|
+
const policy = coldStart
|
|
1683
|
+
? { kind: "deferred_cold_preview", source: "deferred", digest: null }
|
|
1684
|
+
: live.kind === "live"
|
|
1685
|
+
? explicit && digest
|
|
1686
|
+
? { kind: "explicit_replacement_request", source: "explicit", digest }
|
|
1687
|
+
: { kind: "approved_stored", source: "stored", digest: null }
|
|
1688
|
+
: { kind: "new_run", source: explicit ? "explicit" : selectedPath ? "profile" : "default", digest };
|
|
1689
|
+
const selection = resolveEpicLaunchSelection({
|
|
1690
|
+
baseUrl: ctx.access.baseUrl,
|
|
1691
|
+
repoName: ctx.access.repoName,
|
|
1692
|
+
targetSource: ctx.targetSource,
|
|
1693
|
+
profile: ctx.profile,
|
|
1694
|
+
topology: planeEndpointForBaseUrl(ctx.deps, ctx.access.baseUrl) ? "local_plane" : "remote",
|
|
1695
|
+
policy,
|
|
1696
|
+
escalationChannel: pinnedPolicy
|
|
1697
|
+
? policyDeclaresNotifyWebhook(pinnedPolicy) ? "configured webhook" : "account email"
|
|
1698
|
+
: live.kind === "live" ? "unavailable" : "account email",
|
|
1699
|
+
});
|
|
1700
|
+
if (pinnedPolicy)
|
|
1701
|
+
pinEpicLaunchPolicy(selection, pinnedPolicy);
|
|
1702
|
+
return { ok: true, handoff: { selection, access: ctx.access, profile: ctx.profile } };
|
|
1703
|
+
}
|
|
1704
|
+
async function runSelectedSetupEpic(ctx) {
|
|
1705
|
+
const prepared = ctx.handoff
|
|
1706
|
+
? { ok: true, handoff: ctx.handoff }
|
|
1707
|
+
: await prepareSetupSelection(ctx);
|
|
1708
|
+
if (!prepared.ok) {
|
|
1709
|
+
ctx.deps.errorLog(`${ctx.epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`);
|
|
1710
|
+
return { exitCode: 1 };
|
|
1711
|
+
}
|
|
1712
|
+
const result = await ctx.deps.runSetupEpic(buildSetupEpicArgv(ctx.options), prepared.handoff);
|
|
1713
|
+
return result;
|
|
1714
|
+
}
|
|
1597
1715
|
/** Emit one refusal, guarded so it can never name a second conductor. */
|
|
1598
1716
|
function refuse(deps, lines) {
|
|
1599
1717
|
const text = lines.join("\n");
|
|
@@ -1601,6 +1719,11 @@ function refuse(deps, lines) {
|
|
|
1601
1719
|
deps.errorLog(text);
|
|
1602
1720
|
return 1;
|
|
1603
1721
|
}
|
|
1722
|
+
function durableActorAdmissionDenied(readiness) {
|
|
1723
|
+
const evidence = readiness.admission_evidence;
|
|
1724
|
+
return evidence?.schema_version === 1 &&
|
|
1725
|
+
evidence.records.some((record) => record.predicate === "durable_actor" && record.verdict === "denied");
|
|
1726
|
+
}
|
|
1604
1727
|
/**
|
|
1605
1728
|
* The plane endpoint, when `baseUrl` names it; otherwise `null`.
|
|
1606
1729
|
*
|
|
@@ -1706,10 +1829,18 @@ async function coldStartPlane(ctx) {
|
|
|
1706
1829
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1707
1830
|
]);
|
|
1708
1831
|
}
|
|
1832
|
+
const prepared = await prepareSetupSelection(ctx);
|
|
1833
|
+
if (!prepared.ok) {
|
|
1834
|
+
return refuse(deps, [
|
|
1835
|
+
`${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
|
|
1836
|
+
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1837
|
+
]);
|
|
1838
|
+
}
|
|
1839
|
+
ctx = { ...ctx, handoff: prepared.handoff };
|
|
1709
1840
|
const classification = classifyV2Readiness(readiness, {
|
|
1710
1841
|
attended: ctx.options.attended === true,
|
|
1711
1842
|
intoBase: ctx.options.intoBase === true,
|
|
1712
|
-
|
|
1843
|
+
effectivePolicy: ctx.handoff?.selection,
|
|
1713
1844
|
});
|
|
1714
1845
|
if (classification.kind === "operator_blocked") {
|
|
1715
1846
|
return refuse(deps, [
|
|
@@ -1755,6 +1886,12 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1755
1886
|
"Nothing was started. Fix the credential and run drive-epic again.",
|
|
1756
1887
|
]);
|
|
1757
1888
|
}
|
|
1889
|
+
if (rooted.access.repoName !== ctx.access.repoName || rooted.access.baseUrl !== ctx.access.baseUrl) {
|
|
1890
|
+
return refuse(deps, [
|
|
1891
|
+
`${epicKey}: REFUSED (selection-conflict) — manifest-root credentials resolved a different repository or endpoint.`,
|
|
1892
|
+
"Nothing was started or changed.",
|
|
1893
|
+
]);
|
|
1894
|
+
}
|
|
1758
1895
|
const attachCtx = { ...ctx, access: rooted.access };
|
|
1759
1896
|
// R-7, decided BEFORE any create request. It binds the LANES only
|
|
1760
1897
|
// (BAPI-1279): a plane whose lanes serve another run still has a reconciler
|
|
@@ -1816,7 +1953,7 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1816
1953
|
const { deps, epicKey } = ctx;
|
|
1817
1954
|
const launched = plane.origin === "launched";
|
|
1818
1955
|
// ---- PHASE TWO, part one: create the run ------------------------------
|
|
1819
|
-
const setup = await
|
|
1956
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
1820
1957
|
if (setup.exitCode !== 0) {
|
|
1821
1958
|
// The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
|
|
1822
1959
|
// was spawned — there is nothing to claim a job that may not exist.
|
|
@@ -2107,11 +2244,17 @@ function liveRunUnknownRefusal(epicKey, reason) {
|
|
|
2107
2244
|
async function createRunWithRunExecutor(ctx, readiness) {
|
|
2108
2245
|
const { deps, epicKey, access, profile } = ctx;
|
|
2109
2246
|
if (profile !== null && profile.executionMode === "durable") {
|
|
2247
|
+
if (durableActorAdmissionDenied(readiness)) {
|
|
2248
|
+
return refuse(deps, [
|
|
2249
|
+
`${epicKey}: REFUSED (actor-admission-denied) — durable actor admission was denied at the selected target.`,
|
|
2250
|
+
"No run was created and no local executor was started.",
|
|
2251
|
+
]);
|
|
2252
|
+
}
|
|
2110
2253
|
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2111
2254
|
if (coverage !== true) {
|
|
2112
2255
|
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
|
|
2113
2256
|
}
|
|
2114
|
-
const setup = await
|
|
2257
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
2115
2258
|
if (setup.exitCode === 0) {
|
|
2116
2259
|
deps.log([
|
|
2117
2260
|
"",
|
|
@@ -2126,7 +2269,7 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2126
2269
|
if (!prepared.ok) {
|
|
2127
2270
|
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
|
|
2128
2271
|
}
|
|
2129
|
-
const setup = await
|
|
2272
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
2130
2273
|
if (setup.exitCode !== 0) {
|
|
2131
2274
|
// setup-epic already said why. Nothing was started for a run that may not exist.
|
|
2132
2275
|
deps.errorLog(`\n${epicKey}: no executor was started.`);
|
|
@@ -2164,6 +2307,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2164
2307
|
function reportExecutorOutcome(ctx, ensured, epicRunId, framing) {
|
|
2165
2308
|
const { deps, epicKey, access } = ctx;
|
|
2166
2309
|
if (!ensured.ok) {
|
|
2310
|
+
deps.log(JSON.stringify({
|
|
2311
|
+
outcome: "run_exists_executor_unavailable",
|
|
2312
|
+
epic_key: epicKey,
|
|
2313
|
+
epic_run_id: epicRunId,
|
|
2314
|
+
}));
|
|
2167
2315
|
return refuse(deps, [
|
|
2168
2316
|
"",
|
|
2169
2317
|
framing === "created"
|
|
@@ -2213,6 +2361,9 @@ async function planeServesLiveRun(ctx, live) {
|
|
|
2213
2361
|
const manifest = await findAttachablePlane(ctx);
|
|
2214
2362
|
if (manifest === null || manifest.epicRunId !== live.epicRunId)
|
|
2215
2363
|
return false;
|
|
2364
|
+
const owner = await ctx.deps.readPlaneReconcilerState({ access: ctx.access, manifest });
|
|
2365
|
+
if (owner !== "fresh")
|
|
2366
|
+
return false;
|
|
2216
2367
|
const bound = await ctx.deps.readRunState(ctx.access, manifest.epicRunId);
|
|
2217
2368
|
return bound.epicKey === ctx.epicKey && !isTerminalEpicRunStatus(bound.status);
|
|
2218
2369
|
}
|
|
@@ -2236,7 +2387,7 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2236
2387
|
const classification = classifyV2Readiness(readiness, {
|
|
2237
2388
|
attended: ctx.options.attended === true,
|
|
2238
2389
|
intoBase: ctx.options.intoBase === true,
|
|
2239
|
-
|
|
2390
|
+
approvedStoredPolicy: true,
|
|
2240
2391
|
});
|
|
2241
2392
|
if (classification.kind === "operator_blocked") {
|
|
2242
2393
|
return refuse(deps, [
|
|
@@ -2252,9 +2403,28 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2252
2403
|
return refuseRemoteRuntime(ctx, readiness);
|
|
2253
2404
|
return refuse(deps, localReconcilerRefusal(epicKey, readiness).lines);
|
|
2254
2405
|
}
|
|
2406
|
+
if (planeTarget) {
|
|
2407
|
+
const manifest = await findAttachablePlane(ctx).catch(() => null);
|
|
2408
|
+
if (manifest?.epicRunId === live.epicRunId) {
|
|
2409
|
+
const admission = await checkPlaneAdmission(ctx, readiness, manifest);
|
|
2410
|
+
if (admission !== null)
|
|
2411
|
+
return refuse(deps, admission);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2255
2414
|
deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
|
|
2256
2415
|
"found, so nothing will be created; making sure it has an executor.");
|
|
2416
|
+
if (live.effectivePolicy) {
|
|
2417
|
+
deps.log(`Effective policy: stored; serial execution; caps ${live.effectivePolicy.caps ? JSON.stringify(live.effectivePolicy.caps) : "unavailable"}; ` +
|
|
2418
|
+
`timeouts ${live.effectivePolicy.job_timeouts ? JSON.stringify(live.effectivePolicy.job_timeouts) : "unavailable"}; ` +
|
|
2419
|
+
`channel ${live.effectivePolicy.escalation_channel}.`);
|
|
2420
|
+
}
|
|
2257
2421
|
if (profile !== null && profile.executionMode === "durable") {
|
|
2422
|
+
if (durableActorAdmissionDenied(readiness)) {
|
|
2423
|
+
return refuse(deps, [
|
|
2424
|
+
`${epicKey}: REFUSED (actor-admission-denied) — durable actor admission was denied at the selected target.`,
|
|
2425
|
+
"No run was changed and no local executor was started.",
|
|
2426
|
+
]);
|
|
2427
|
+
}
|
|
2258
2428
|
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2259
2429
|
if (coverage !== true) {
|
|
2260
2430
|
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was changed").lines);
|
|
@@ -2339,6 +2509,16 @@ function reportControlPlaneLaunchFailure(ctx, controlPlane) {
|
|
|
2339
2509
|
"No run was created.",
|
|
2340
2510
|
]);
|
|
2341
2511
|
}
|
|
2512
|
+
export const DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS = {
|
|
2513
|
+
"profile-unusable": "access-profile-unusable",
|
|
2514
|
+
"target-missing": "access-target-missing",
|
|
2515
|
+
"target-conflict": "access-target-conflict",
|
|
2516
|
+
"target-invalid": "access-target-invalid",
|
|
2517
|
+
"repository-conflict": "access-repository-conflict",
|
|
2518
|
+
"repo-missing": "access-repo-missing",
|
|
2519
|
+
"credentials-unavailable": "access-credentials-unavailable",
|
|
2520
|
+
"credential-unbound": "access-credential-unbound",
|
|
2521
|
+
};
|
|
2342
2522
|
/** The readiness reason a preview reports for a generic not-ready engine. */
|
|
2343
2523
|
const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
|
|
2344
2524
|
const ENGINE_NOT_READY_REMEDY = "Resolve conductor readiness (`install-bridge conductor`, then `doctor`) and run drive-epic again.";
|
|
@@ -2370,12 +2550,48 @@ function readSetupPreviewPayload(stdout) {
|
|
|
2370
2550
|
if (existing !== null && existing !== undefined && typeof existing !== "string")
|
|
2371
2551
|
return null;
|
|
2372
2552
|
const branch = doc.feature_branch;
|
|
2373
|
-
if (branch !== undefined && typeof branch !== "string")
|
|
2553
|
+
if (branch !== undefined && branch !== null && typeof branch !== "string")
|
|
2374
2554
|
return null;
|
|
2555
|
+
const rawFacts = doc.effective_policy;
|
|
2556
|
+
let effectivePolicy = null;
|
|
2557
|
+
if (rawFacts !== undefined && rawFacts !== null) {
|
|
2558
|
+
if (typeof rawFacts !== "object" || Array.isArray(rawFacts))
|
|
2559
|
+
return null;
|
|
2560
|
+
const facts = rawFacts;
|
|
2561
|
+
if (!["explicit", "profile", "default", "stored"].includes(String(facts.source)) ||
|
|
2562
|
+
facts.serial_execution !== "serial" ||
|
|
2563
|
+
!["attended", "unattended"].includes(String(facts.posture)) ||
|
|
2564
|
+
!["configured webhook", "account email", "unavailable"].includes(String(facts.escalation_channel)))
|
|
2565
|
+
return null;
|
|
2566
|
+
const numericMap = (value) => {
|
|
2567
|
+
if (value === null)
|
|
2568
|
+
return null;
|
|
2569
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2570
|
+
return undefined;
|
|
2571
|
+
const entries = Object.entries(value);
|
|
2572
|
+
if (entries.some(([key, number]) => !/^[a-z][a-z0-9_]{0,63}$/.test(key) ||
|
|
2573
|
+
typeof number !== "number" || !Number.isFinite(number)))
|
|
2574
|
+
return undefined;
|
|
2575
|
+
return Object.fromEntries(entries);
|
|
2576
|
+
};
|
|
2577
|
+
const caps = numericMap(facts.caps);
|
|
2578
|
+
const jobTimeouts = numericMap(facts.job_timeouts);
|
|
2579
|
+
if (caps === undefined || jobTimeouts === undefined)
|
|
2580
|
+
return null;
|
|
2581
|
+
effectivePolicy = {
|
|
2582
|
+
source: facts.source,
|
|
2583
|
+
serial_execution: "serial",
|
|
2584
|
+
posture: facts.posture,
|
|
2585
|
+
caps,
|
|
2586
|
+
job_timeouts: jobTimeouts,
|
|
2587
|
+
escalation_channel: facts.escalation_channel,
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2375
2590
|
return {
|
|
2376
2591
|
existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
|
|
2377
2592
|
featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
|
|
2378
2593
|
serverValidated: doc.server_validated,
|
|
2594
|
+
effectivePolicy,
|
|
2379
2595
|
};
|
|
2380
2596
|
}
|
|
2381
2597
|
/** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
|
|
@@ -2452,62 +2668,135 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2452
2668
|
});
|
|
2453
2669
|
// --- Target: resolved, never adopted. -------------------------------------
|
|
2454
2670
|
const accessResult = await deps.resolveAccess(options.repo, undefined, launchSelectionOf(options));
|
|
2455
|
-
if (!accessResult.ok)
|
|
2456
|
-
|
|
2671
|
+
if (!accessResult.ok) {
|
|
2672
|
+
if (!accessResult.kind ||
|
|
2673
|
+
!Object.prototype.hasOwnProperty.call(DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS, accessResult.kind))
|
|
2674
|
+
return unknownReadiness();
|
|
2675
|
+
unknownReadiness();
|
|
2676
|
+
if (accessResult.baseUrl) {
|
|
2677
|
+
preview.target = {
|
|
2678
|
+
base_url: accessResult.baseUrl,
|
|
2679
|
+
kind: planeEndpointForBaseUrl(deps, accessResult.baseUrl) !== null ? "local_plane" : "remote",
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
refuseTopLevel({
|
|
2683
|
+
reasonId: DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS[accessResult.kind],
|
|
2684
|
+
remedy: accessResult.error,
|
|
2685
|
+
});
|
|
2686
|
+
return;
|
|
2687
|
+
}
|
|
2457
2688
|
const access = accessResult.access;
|
|
2458
2689
|
const profile = accessResult.profile ?? null;
|
|
2459
|
-
const
|
|
2460
|
-
|
|
2461
|
-
|
|
2690
|
+
const endpoint = planeEndpointForBaseUrl(deps, access.baseUrl);
|
|
2691
|
+
const planeTarget = endpoint !== null;
|
|
2692
|
+
preview.target = {
|
|
2693
|
+
base_url: access.baseUrl,
|
|
2694
|
+
kind: planeTarget ? "local_plane" : "remote",
|
|
2695
|
+
...(accessResult.targetSource === "profile" || accessResult.targetSource === "profile-env"
|
|
2696
|
+
? { source: accessResult.targetSource }
|
|
2697
|
+
: {}),
|
|
2698
|
+
};
|
|
2699
|
+
const ctx = { deps, epicKey, options, access, profile, targetSource: accessResult.targetSource ?? "default" };
|
|
2700
|
+
// Discover before readiness so a selected local plane can preview its guarded
|
|
2701
|
+
// cold start. Keep the candidate out of ordinary failure documents.
|
|
2702
|
+
let planPath = options.planFile;
|
|
2703
|
+
let plan = { status: "not_found" };
|
|
2704
|
+
let docsRootUnresolved = false;
|
|
2705
|
+
if (planPath !== undefined) {
|
|
2706
|
+
plan = { status: "supplied", path: planPath };
|
|
2707
|
+
}
|
|
2708
|
+
else {
|
|
2709
|
+
const docs = await deps.resolveDocsDir();
|
|
2710
|
+
if (!docs.ok) {
|
|
2711
|
+
docsRootUnresolved = true;
|
|
2712
|
+
}
|
|
2713
|
+
else {
|
|
2714
|
+
const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
2715
|
+
plan = {
|
|
2716
|
+
status: discovered.status === "found" ? "found" : discovered.status === "plan_ambiguous" ? "ambiguous" : "not_found",
|
|
2717
|
+
...(discovered.status === "found" ? { path: discovered.path } : {}),
|
|
2718
|
+
...(discovered.status === "plan_ambiguous" ? { candidates: [...discovered.candidates] } : {}),
|
|
2719
|
+
...(discovered.skipped.length > 0
|
|
2720
|
+
? { skipped: discovered.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })) }
|
|
2721
|
+
: {}),
|
|
2722
|
+
epic_plans_dir: discovered.epic_plans_dir,
|
|
2723
|
+
};
|
|
2724
|
+
if (discovered.status === "found")
|
|
2725
|
+
planPath = discovered.path;
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2462
2728
|
// --- Readiness. -----------------------------------------------------------
|
|
2463
2729
|
let readiness;
|
|
2464
2730
|
try {
|
|
2465
2731
|
readiness = await deps.readReadiness(access);
|
|
2466
2732
|
}
|
|
2467
|
-
catch {
|
|
2733
|
+
catch (err) {
|
|
2734
|
+
if (planPath !== undefined && endpoint !== null && profile?.executionMode !== "durable" &&
|
|
2735
|
+
err instanceof ConductorBridgeApiError && err.kind === "network") {
|
|
2736
|
+
const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
|
|
2737
|
+
if (probe.kind === "refused") {
|
|
2738
|
+
const preflight = await deps.runPlanePreflight(deps.cwd());
|
|
2739
|
+
const blocker = firstPreconditionRefusal(preflight) ??
|
|
2740
|
+
preflight.diagnostics.find((diagnostic) => diagnostic.severity === "blocking");
|
|
2741
|
+
preview.plan = plan;
|
|
2742
|
+
if (blocker) {
|
|
2743
|
+
return refuseTopLevel({
|
|
2744
|
+
reasonId: blocker.check,
|
|
2745
|
+
remedy: `The local plane preflight refused ${blocker.check}. Fix that condition and preview again.`,
|
|
2746
|
+
});
|
|
2747
|
+
}
|
|
2748
|
+
if (!preflight.ok) {
|
|
2749
|
+
return refuseTopLevel({
|
|
2750
|
+
reasonId: "plane-preflight-failed",
|
|
2751
|
+
remedy: "The local plane preflight could not admit guarded startup. Fix its reported condition and preview again.",
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
preview.readiness = { kind: "cold_start" };
|
|
2755
|
+
await finalizeCreatePreview(ctx, preview, planPath, true, refuseTopLevel);
|
|
2756
|
+
if (preview.decision === "create") {
|
|
2757
|
+
preview.cold_start = { plane_endpoint: access.baseUrl, will_start_plane: true, live_run_checked: false };
|
|
2758
|
+
}
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2468
2762
|
return unknownReadiness();
|
|
2469
2763
|
}
|
|
2764
|
+
preview.plan = plan;
|
|
2765
|
+
if (docsRootUnresolved)
|
|
2766
|
+
return refuseTopLevel(docsRootRefusal(epicKey));
|
|
2767
|
+
const live = await deps.readLiveEpicRun(access, epicKey);
|
|
2768
|
+
if (live.kind === "conflict")
|
|
2769
|
+
return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
|
|
2770
|
+
if (live.kind === "unknown")
|
|
2771
|
+
return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
|
|
2772
|
+
const previewCtx = { ...ctx, options: { ...options, ...(planPath ? { planFile: planPath } : {}) } };
|
|
2773
|
+
const prepared = planPath ? await prepareSetupSelection(previewCtx) : null;
|
|
2774
|
+
if (prepared !== null && !prepared.ok) {
|
|
2775
|
+
return refuseTopLevel({
|
|
2776
|
+
reasonId: prepared.reasonId,
|
|
2777
|
+
remedy: "The selected run or policy could not be verified. Nothing was created or started.",
|
|
2778
|
+
});
|
|
2779
|
+
}
|
|
2470
2780
|
const classification = classifyV2Readiness(readiness, {
|
|
2471
2781
|
attended: options.attended === true,
|
|
2472
2782
|
intoBase: options.intoBase === true,
|
|
2473
|
-
|
|
2783
|
+
effectivePolicy: prepared?.ok ? prepared.handoff.selection : undefined,
|
|
2784
|
+
approvedStoredPolicy: live.kind === "live" && !planPath,
|
|
2474
2785
|
});
|
|
2475
2786
|
preview.readiness = { kind: classification.kind };
|
|
2476
2787
|
const selection = selectConductor(readiness);
|
|
2477
|
-
// --- The plan: supplied, or discovered. -----------------------------------
|
|
2478
|
-
let planPath = options.planFile;
|
|
2479
|
-
if (planPath !== undefined) {
|
|
2480
|
-
preview.plan = { status: "supplied", path: planPath };
|
|
2481
|
-
}
|
|
2482
|
-
else {
|
|
2483
|
-
const docs = await deps.resolveDocsDir();
|
|
2484
|
-
if (!docs.ok)
|
|
2485
|
-
return refuseTopLevel(docsRootRefusal(epicKey));
|
|
2486
|
-
const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
2487
|
-
preview.plan = {
|
|
2488
|
-
status: discovered.status === "found"
|
|
2489
|
-
? "found"
|
|
2490
|
-
: discovered.status === "plan_ambiguous"
|
|
2491
|
-
? "ambiguous"
|
|
2492
|
-
: "not_found",
|
|
2493
|
-
...(discovered.status === "found" ? { path: discovered.path } : {}),
|
|
2494
|
-
...(discovered.status === "plan_ambiguous" ? { candidates: [...discovered.candidates] } : {}),
|
|
2495
|
-
...(discovered.skipped.length > 0
|
|
2496
|
-
? { skipped: discovered.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })) }
|
|
2497
|
-
: {}),
|
|
2498
|
-
epic_plans_dir: discovered.epic_plans_dir,
|
|
2499
|
-
};
|
|
2500
|
-
if (discovered.status === "found")
|
|
2501
|
-
planPath = discovered.path;
|
|
2502
|
-
}
|
|
2503
2788
|
// --- The live run. Conflict and unknown refuse whatever the plan says. ------
|
|
2504
|
-
const live = await deps.readLiveEpicRun(access, epicKey);
|
|
2505
|
-
if (live.kind === "conflict")
|
|
2506
|
-
return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
|
|
2507
|
-
if (live.kind === "unknown")
|
|
2508
|
-
return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
|
|
2509
2789
|
if (live.kind === "live") {
|
|
2510
2790
|
preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
|
|
2791
|
+
if (live.effectivePolicy) {
|
|
2792
|
+
preview.summary.policy_source = live.effectivePolicy.source;
|
|
2793
|
+
preview.summary.serial_execution = live.effectivePolicy.serial_execution;
|
|
2794
|
+
preview.summary.caps = live.effectivePolicy.caps;
|
|
2795
|
+
preview.summary.job_timeouts = live.effectivePolicy.job_timeouts;
|
|
2796
|
+
preview.summary.escalation_channel_label = live.effectivePolicy.escalation_channel;
|
|
2797
|
+
preview.summary.posture = live.effectivePolicy.posture;
|
|
2798
|
+
preview.summary.escalation_channel = null;
|
|
2799
|
+
}
|
|
2511
2800
|
}
|
|
2512
2801
|
// --- No plan: attach to a live run, or stop. --------------------------------
|
|
2513
2802
|
if (planPath === undefined) {
|
|
@@ -2537,31 +2826,32 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2537
2826
|
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
|
|
2538
2827
|
if (facts !== null)
|
|
2539
2828
|
return refuseWithReadiness(classification.kind, facts, true);
|
|
2829
|
+
await finalizeCreatePreview(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, preview, planPath, false, refuseTopLevel, live.kind === "live");
|
|
2830
|
+
}
|
|
2831
|
+
async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTopLevel, liveRunKnown = false) {
|
|
2832
|
+
const { deps, options, access } = ctx;
|
|
2833
|
+
const prepared = ctx.handoff
|
|
2834
|
+
? { ok: true, handoff: ctx.handoff }
|
|
2835
|
+
: await prepareSetupSelection(ctx, coldStart);
|
|
2836
|
+
if (!prepared.ok) {
|
|
2837
|
+
return refuseTopLevel({
|
|
2838
|
+
reasonId: prepared.reasonId,
|
|
2839
|
+
remedy: "The selected run or policy could not be verified. Fix it and preview again.",
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2540
2842
|
// Escalation channel, decided with setup-epic's own exported policy helpers. A
|
|
2541
2843
|
// policy that cannot be read is one fixed refusal; its path, contents, and any
|
|
2542
2844
|
// webhook URL never leave this function.
|
|
2543
2845
|
if (preview.summary.posture === "unattended") {
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
return refuseTopLevel({
|
|
2548
|
-
reasonId: "policy-file-unreadable",
|
|
2549
|
-
remedy: "The --policy-file could not be read as a JSON object. Fix it or pass a different " +
|
|
2550
|
-
"file, then run the preview again.",
|
|
2551
|
-
});
|
|
2552
|
-
}
|
|
2553
|
-
preview.summary.escalation_channel = policyDeclaresNotifyWebhook(policy.policy)
|
|
2554
|
-
? "webhook"
|
|
2555
|
-
: "email";
|
|
2556
|
-
}
|
|
2557
|
-
else {
|
|
2558
|
-
preview.summary.escalation_channel = "email";
|
|
2559
|
-
}
|
|
2846
|
+
preview.summary.escalation_channel = prepared.handoff.selection.escalationChannel === "configured webhook"
|
|
2847
|
+
? "webhook"
|
|
2848
|
+
: prepared.handoff.selection.escalationChannel === "account email" ? "email" : null;
|
|
2560
2849
|
}
|
|
2561
2850
|
preview.summary.child_count = await readPlanChildCount(deps, planPath);
|
|
2562
2851
|
const setup = await deps.previewSetupEpic({
|
|
2563
|
-
argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), "--dry-run", "--json"],
|
|
2852
|
+
argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
|
|
2564
2853
|
baseUrl: access.baseUrl,
|
|
2854
|
+
handoff: prepared.handoff,
|
|
2565
2855
|
});
|
|
2566
2856
|
if (setup.exitCode !== 0) {
|
|
2567
2857
|
preview.summary.server_validation = "failed";
|
|
@@ -2582,6 +2872,18 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2582
2872
|
}
|
|
2583
2873
|
preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
|
|
2584
2874
|
preview.summary.branch = payload.featureBranch;
|
|
2875
|
+
if (payload.effectivePolicy) {
|
|
2876
|
+
const facts = payload.effectivePolicy;
|
|
2877
|
+
preview.summary.policy_source = facts.source;
|
|
2878
|
+
preview.summary.serial_execution = facts.serial_execution;
|
|
2879
|
+
preview.summary.caps = facts.caps;
|
|
2880
|
+
preview.summary.job_timeouts = facts.job_timeouts;
|
|
2881
|
+
preview.summary.escalation_channel_label = facts.escalation_channel;
|
|
2882
|
+
if (!coldStart)
|
|
2883
|
+
preview.summary.posture = facts.posture;
|
|
2884
|
+
preview.summary.escalation_channel = facts.escalation_channel === "configured webhook"
|
|
2885
|
+
? "webhook" : facts.escalation_channel === "account email" ? "email" : null;
|
|
2886
|
+
}
|
|
2585
2887
|
preview.summary.auto_merge_scope =
|
|
2586
2888
|
preview.summary.posture === "unattended" ? payload.featureBranch : null;
|
|
2587
2889
|
// The bootstrap reusing a run IS an attach: it reports the run's id.
|
|
@@ -2593,7 +2895,7 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2593
2895
|
preview.decision = "attach";
|
|
2594
2896
|
return;
|
|
2595
2897
|
}
|
|
2596
|
-
preview.decision =
|
|
2898
|
+
preview.decision = coldStart ? "create" : liveRunKnown ? "attach" : "create";
|
|
2597
2899
|
}
|
|
2598
2900
|
/**
|
|
2599
2901
|
* The readiness refusal a real launch would print, as a reason id and remedy, or
|
|
@@ -2623,6 +2925,12 @@ function previewReadinessRefusal(ctx, classification, readiness, planeTarget, pa
|
|
|
2623
2925
|
async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
|
|
2624
2926
|
const { deps, epicKey, access, profile } = ctx;
|
|
2625
2927
|
if (profile !== null && profile.executionMode === "durable") {
|
|
2928
|
+
if (durableActorAdmissionDenied(readiness)) {
|
|
2929
|
+
return {
|
|
2930
|
+
reasonId: "actor-admission-denied",
|
|
2931
|
+
remedy: "Durable actor admission was denied at the selected target. No run was created and no local executor was started.",
|
|
2932
|
+
};
|
|
2933
|
+
}
|
|
2626
2934
|
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2627
2935
|
return coverage === true
|
|
2628
2936
|
? null
|
|
@@ -2630,6 +2938,13 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
|
|
|
2630
2938
|
}
|
|
2631
2939
|
if (planeTarget) {
|
|
2632
2940
|
if (live !== null) {
|
|
2941
|
+
const manifest = await findAttachablePlane(ctx).catch(() => null);
|
|
2942
|
+
if (manifest?.epicRunId === live.epicRunId) {
|
|
2943
|
+
const admission = await checkPlaneAdmission(ctx, readiness, manifest);
|
|
2944
|
+
if (admission !== null) {
|
|
2945
|
+
return { reasonId: "plane-reconciler-unconfirmed", remedy: admission.slice(1).join(" ") };
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2633
2948
|
if (await planeServesLiveRun(ctx, live))
|
|
2634
2949
|
return null;
|
|
2635
2950
|
}
|
|
@@ -2650,11 +2965,12 @@ function emitPreview(deps, options, preview) {
|
|
|
2650
2965
|
}
|
|
2651
2966
|
const lines = [
|
|
2652
2967
|
`${preview.epic_key}: launch preview (read-only; nothing was created or started)`,
|
|
2968
|
+
...(preview.cold_start ? [" Decision: conditional create — drive-epic will attempt guarded startup, recheck the live run, and create only if still appropriate."] : []),
|
|
2653
2969
|
` Target: ${preview.target.base_url ?? "unresolved"}${preview.target.kind ? ` (${preview.target.kind})` : ""}`,
|
|
2654
2970
|
` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
|
|
2655
2971
|
` Live run: ${preview.live_run
|
|
2656
2972
|
? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
|
|
2657
|
-
: "none"}`,
|
|
2973
|
+
: preview.cold_start ? "unchecked (plane not yet running)" : "none"}`,
|
|
2658
2974
|
` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
|
|
2659
2975
|
];
|
|
2660
2976
|
for (const candidate of preview.plan.candidates ?? [])
|
|
@@ -2663,9 +2979,10 @@ function emitPreview(deps, options, preview) {
|
|
|
2663
2979
|
lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
|
|
2664
2980
|
}
|
|
2665
2981
|
const s = preview.summary;
|
|
2666
|
-
lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
|
|
2667
|
-
if (preview.readiness.remedy)
|
|
2982
|
+
lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${preview.cold_start ? "Partial — plane is not yet running" : s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
|
|
2983
|
+
if (preview.readiness.remedy && !preview.refusal?.reason_id.startsWith("access-")) {
|
|
2668
2984
|
lines.push(` Remedy: ${preview.readiness.remedy}`);
|
|
2985
|
+
}
|
|
2669
2986
|
if (preview.refusal) {
|
|
2670
2987
|
lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
|
|
2671
2988
|
}
|