@bridge_gpt/mcp-server 0.2.65 → 0.2.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +269 -82
- package/build/agent-capabilities/probes.js +15 -5
- package/build/agent-launchers/claude-executor-adapter.js +55 -34
- package/build/agent-launchers/executor-adapter.js +34 -4
- package/build/agents.generated.js +1 -1
- package/build/bounded-wait.js +67 -10
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +12 -12
- package/build/conductor/bridge-api-client.js +372 -10
- package/build/conductor/bring-up-facts.js +1 -1
- package/build/conductor/doctor.js +3 -3
- package/build/conductor/epic-child-continuation.js +61 -34
- package/build/conductor/epic-implementer-cli.js +34 -6
- package/build/conductor/errors.js +4 -3
- package/build/conductor/guided-repair.js +327 -0
- package/build/conductor/hook-runtime-qualification.js +1 -1
- package/build/conductor/merge-execution-contract.js +27 -8
- package/build/conductor/merge-execution-eligibility.js +3 -2
- package/build/conductor/pr-ci-producer.js +1 -1
- package/build/conductor/readiness-cli.js +46 -9
- package/build/conductor/readiness-sections.js +41 -6
- package/build/conductor/readiness.js +204 -30
- package/build/conductor/recovery-cli.js +703 -20
- package/build/conductor/recovery-operations.js +392 -3
- package/build/conductor/repair-contract.js +112 -3
- package/build/conductor/repository-workflow-remedy.js +125 -0
- package/build/conductor/tools.js +2 -2
- package/build/conductor-bundle-artifacts.js +8 -0
- package/build/credential-store.js +5 -4
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +8 -3
- package/build/doctor.js +70 -10
- package/build/drive-epic-key.js +6 -5
- package/build/drive-epic.js +479 -131
- package/build/epic-coherence-input.js +2 -1
- package/build/epic-implementer-bin.js +30 -26
- package/build/epic-implementer-bundle-cli.js +2 -2
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +44 -14
- package/build/executor/cli.js +3 -2
- package/build/executor/completion-contract.js +48 -5
- package/build/executor/completion-supervision.js +9 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/env.js +25 -0
- package/build/executor/heartbeat.js +1 -1
- package/build/executor/http-client.js +35 -0
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +38 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +485 -74
- package/build/executor/job-types.js +8 -13
- package/build/executor/observation.js +6 -1
- package/build/executor/preflight.js +136 -10
- package/build/executor/prompt-spec.js +113 -9
- package/build/executor/required-work-ledger.js +6 -1
- package/build/executor/resume-pre-spawn.js +2 -0
- package/build/executor/runner.js +31 -2
- package/build/executor/service-unit.js +7 -1
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/types.js +1 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +43 -43
- package/build/executor/worker-finalization.js +161 -43
- package/build/executor/worker-guard-hook.js +152 -12
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +82 -1
- package/build/index.js +482 -660
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +8 -6
- package/build/install-bridge.js +287 -23
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/model-thresholds-tool.js +185 -0
- package/build/phase-context-git-probe.js +68 -0
- package/build/phase-result-artifacts.js +173 -5
- package/build/pipelines.generated.js +19 -25
- package/build/plane/alembic-head.js +12 -3
- package/build/plane/base-worktree-guard.js +147 -0
- package/build/plane/cli.js +217 -17
- package/build/plane/code-snapshot.js +468 -0
- package/build/plane/defaults.js +48 -3
- package/build/plane/endpoint.js +41 -0
- package/build/plane/manifest.js +120 -1
- package/build/plane/member-roster.js +94 -19
- package/build/plane/preflight.js +30 -2
- package/build/plane/process-cwd.js +67 -0
- package/build/plane/restart.js +542 -0
- package/build/plane/shutdown.js +31 -0
- package/build/plane/status.js +145 -6
- package/build/plane/supervisor.js +588 -177
- package/build/plane/types.js +54 -2
- package/build/plane/worker-exit-code.js +2 -0
- package/build/printed-remedy-commands.js +37 -0
- package/build/project-root.js +99 -0
- package/build/readiness-check.js +1 -1
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +22 -0
- package/build/review-tickets.js +156 -13
- package/build/setup-epic.js +224 -127
- package/build/sfcc/config.js +9 -0
- package/build/sfcc/credentials.js +100 -38
- package/build/sfcc/hostname.js +67 -0
- package/build/sfcc/permissions.js +17 -13
- package/build/sfcc/register.js +1 -1
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +41 -43
- package/build/sfcc/tools-configured.js +96 -0
- package/build/sfcc/write-grants.js +17 -11
- package/build/sfcc/write-result.js +4 -3
- package/build/sfcc/writes-site-preference.js +20 -8
- package/build/start-tickets.js +332 -79
- package/build/tool-error-envelope.js +61 -0
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/visual-diff.js +22 -1
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +3 -5
- package/build/worker-guard-hook-main.js +47 -0
- package/build/worktree-core.js +136 -9
- package/docs/EPIC-IMPLEMENTER-INSTALL.md +849 -0
- package/docs/EPIC-IMPLEMENTER.md +662 -0
- package/docs/install/mcp-tool-integrations.md +25 -15
- package/docs/install/sfcc-integration.md +21 -8
- package/package.json +1 -1
- package/pipelines/implement-ticket.json +1 -1
- package/pipelines/plan-epic.json +0 -5
- package/pipelines/review-ticket.json +1 -1
- package/docs/CONDUCTOR.md +0 -605
- package/pipelines/idea-to-pr.json +0 -49
package/build/drive-epic.js
CHANGED
|
@@ -56,16 +56,18 @@
|
|
|
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 {
|
|
62
|
+
import { repositoryWorkflowRemedy } from "./conductor/repository-workflow-remedy.js";
|
|
63
|
+
import { policyDeclaresNotifyWebhook, effectivePolicyFacts, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
63
64
|
import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
|
|
64
65
|
import { discoverEpicPlan, } from "./plan-discovery.js";
|
|
65
66
|
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
66
67
|
import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
|
|
67
68
|
import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
|
|
68
69
|
import { readPlaneManifest } from "./plane/manifest.js";
|
|
70
|
+
import { baseUrlNamesPlaneEndpoint, planeEndpointForTarget } from "./plane/endpoint.js";
|
|
69
71
|
import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
|
|
70
72
|
import { resolveMemberInstanceId } from "./plane/supervisor.js";
|
|
71
73
|
import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
|
|
@@ -75,6 +77,7 @@ import { isTerminalEpicRunStatus } from "./epic-run-status.js";
|
|
|
75
77
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
76
78
|
// the printed invocation interpolates it rather than repeating it.
|
|
77
79
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
80
|
+
import { PRINTED_CHECK_NAME_INSTRUCTION, PRINTED_CONDUCTOR_INSTALL_COMMAND, PRINTED_DOCTOR_COMMAND, } from "./printed-remedy-commands.js";
|
|
78
81
|
/** The conductor paths. Derived types below come from this constant. */
|
|
79
82
|
export const DRIVE_EPIC_CONDUCTORS = ["v2"];
|
|
80
83
|
/**
|
|
@@ -120,8 +123,9 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
120
123
|
// The repository-scoped `reconciler` section ("is this repository's work
|
|
121
124
|
// moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
|
|
122
125
|
// too old to report `bring_up`.
|
|
123
|
-
satisfied: (r) => r.bring_up
|
|
124
|
-
? r.bring_up.heartbeat_readable && r.bring_up.worker_present
|
|
126
|
+
satisfied: (r) => r.bring_up
|
|
127
|
+
? r.bring_up.heartbeat_readable && r.bring_up.worker_present &&
|
|
128
|
+
r.bring_up.single_transition_authority
|
|
125
129
|
: r.reconciler.liveness_readable && !r.reconciler.stale,
|
|
126
130
|
},
|
|
127
131
|
{
|
|
@@ -146,7 +150,7 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
146
150
|
// set — the server simply never said (BAPI-1102 review).
|
|
147
151
|
{
|
|
148
152
|
id: "unattended_consent",
|
|
149
|
-
describe: "repository consent to unattended
|
|
153
|
+
describe: "repository consent to unattended Epic Implementer runs",
|
|
150
154
|
unattendedOnly: true,
|
|
151
155
|
// No fixed `remedy`: this requirement's action always depends on WHY.
|
|
152
156
|
remedyFor: (r) => r.unattended === null || r.unattended === undefined
|
|
@@ -160,7 +164,7 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
160
164
|
},
|
|
161
165
|
{
|
|
162
166
|
id: "unattended_notify_default",
|
|
163
|
-
describe: "a verified notify webhook
|
|
167
|
+
describe: "an escalation channel (a verified notify webhook or the account email)",
|
|
164
168
|
unattendedOnly: true,
|
|
165
169
|
remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
|
|
166
170
|
"to the account so escalation alerts can be emailed, or pass --attended. " +
|
|
@@ -172,13 +176,14 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
172
176
|
},
|
|
173
177
|
{
|
|
174
178
|
id: "repository_readiness",
|
|
175
|
-
describe: "confirmed review and
|
|
179
|
+
describe: "confirmed review and epic-implementer-ci workflows on the default branch",
|
|
176
180
|
unattendedOnly: true,
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
|
|
181
|
+
// BAPI-1359 (Miss 38): the remedy names the SPECIFIC missing item, from the
|
|
182
|
+
// same shared formatter the readiness row and the setup-epic refusal use. The
|
|
183
|
+
// fixed install-command sentence that stood here could never clear a missing
|
|
184
|
+
// migration-guard job — no install step creates one — so it is gone from this
|
|
185
|
+
// refusal.
|
|
186
|
+
remedyFor: (r) => repositoryWorkflowRemedy(r),
|
|
182
187
|
satisfied: (r) => r.unattended?.repository_readiness_confirmed === true,
|
|
183
188
|
},
|
|
184
189
|
// BAPI-1135 — merge-execution eligibility is deliberately NOT a requirement here.
|
|
@@ -238,6 +243,24 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
|
|
|
238
243
|
* already encodes that (setup, then config, then credentials).
|
|
239
244
|
*/
|
|
240
245
|
export function classifyV2Readiness(readiness, options = {}) {
|
|
246
|
+
for (const diagnostic of readiness.diagnostics ?? []) {
|
|
247
|
+
if (diagnostic.severity === null || diagnostic.applicability === null) {
|
|
248
|
+
return {
|
|
249
|
+
kind: "operator_blocked",
|
|
250
|
+
id: "diagnostic-metadata-unknown",
|
|
251
|
+
describe: "declared diagnostic severity and applicability",
|
|
252
|
+
remedy: "Upgrade or repair the readiness producer, then retry the selected target.",
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
if (diagnostic.applicability === "applicable" && diagnostic.severity === "mandatory" && diagnostic.verdict !== "passed") {
|
|
256
|
+
return {
|
|
257
|
+
kind: "operator_blocked",
|
|
258
|
+
id: "mandatory-diagnostic-unverified",
|
|
259
|
+
describe: `mandatory diagnostic ${diagnostic.id}`,
|
|
260
|
+
remedy: "Resolve the mandatory diagnostic at the selected Bridge API target and retry.",
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
241
264
|
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended-only
|
|
242
265
|
// requirements INAPPLICABLE, not merely tolerated. They authorize unattended
|
|
243
266
|
// operation and automatic merge behavior; an attended run asks for neither, so
|
|
@@ -253,7 +276,9 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
253
276
|
// which reads the policy and refuses if it declares none. Nothing is weakened:
|
|
254
277
|
// `setup-epic` gates before the create, and the server re-checks the predicate
|
|
255
278
|
// at approval and probes the URL there.
|
|
256
|
-
const deferred = options.
|
|
279
|
+
const deferred = options.approvedStoredPolicy ||
|
|
280
|
+
options.effectivePolicy?.escalationChannel === "configured webhook" ||
|
|
281
|
+
(options.effectivePolicy === undefined && options.hasPolicyFile)
|
|
257
282
|
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
|
|
258
283
|
: V2_READINESS_REQUIREMENTS;
|
|
259
284
|
// `--into-base` is the second opt-out, and it is structural rather than
|
|
@@ -270,8 +295,28 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
270
295
|
? gating.filter((req) => req.unattendedOnly !== true)
|
|
271
296
|
: gating;
|
|
272
297
|
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
273
|
-
if (unmet.length === 0)
|
|
298
|
+
if (unmet.length === 0) {
|
|
299
|
+
// Older deployments have no bring-up block; their existing reconciler
|
|
300
|
+
// liveness predicate remains the available evidence. For deployments that
|
|
301
|
+
// do report process facts, an unreadable owner never becomes a green launch.
|
|
302
|
+
if (readiness.bring_up && !readiness.bring_up.heartbeat_readable) {
|
|
303
|
+
return {
|
|
304
|
+
kind: "operator_blocked",
|
|
305
|
+
id: "process-owner-unverified",
|
|
306
|
+
describe: "a readable reconciler process-owner heartbeat",
|
|
307
|
+
remedy: "Restore the process heartbeat source before launch.",
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (readiness.bring_up?.worker_present && !readiness.bring_up.single_transition_authority) {
|
|
311
|
+
return {
|
|
312
|
+
kind: "operator_blocked",
|
|
313
|
+
id: "process-owner-conflict",
|
|
314
|
+
describe: "one verified reconciler process owner",
|
|
315
|
+
remedy: "Resolve the missing or duplicate reconciler process before launch.",
|
|
316
|
+
};
|
|
317
|
+
}
|
|
274
318
|
return { kind: "green" };
|
|
319
|
+
}
|
|
275
320
|
const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
|
|
276
321
|
if (blocker) {
|
|
277
322
|
return {
|
|
@@ -307,10 +352,11 @@ export function getDriveEpicUsage() {
|
|
|
307
352
|
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
308
353
|
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
309
354
|
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
310
|
-
"unattended run has
|
|
311
|
-
"default and switched on the Bridge admin panel's Setup page
|
|
312
|
-
"
|
|
313
|
-
"
|
|
355
|
+
"unattended run has three repository prerequisites (consent, which is ON by",
|
|
356
|
+
"default and switched on the Bridge admin panel's Setup page; an escalation",
|
|
357
|
+
"channel, either a verified notify webhook or the account's email address; and",
|
|
358
|
+
"confirmed review and CI workflows on the default branch) that readiness lists",
|
|
359
|
+
"with their state and that are refused BY NAME before any run is created.",
|
|
314
360
|
"",
|
|
315
361
|
"Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
|
|
316
362
|
"created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
|
|
@@ -406,6 +452,10 @@ export function getDriveEpicUsage() {
|
|
|
406
452
|
" non-production server and for durable hosts.",
|
|
407
453
|
" --base-url <url> Explicit Bridge API target. With a profile it must",
|
|
408
454
|
" agree with it — an override never rebinds a profile.",
|
|
455
|
+
"BAPI_EPIC_LAUNCH_PROFILE selects a host-local launch profile for epic",
|
|
456
|
+
"launches. --launch-profile takes precedence. For an environment-selected",
|
|
457
|
+
"profile, BAPI_BASE_URL remains the general CLI endpoint and does not",
|
|
458
|
+
"conflict with the epic launch target; --base-url still must agree.",
|
|
409
459
|
"Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
|
|
410
460
|
`targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
|
|
411
461
|
"stored bapi:<repo> key when one is available. A stored key approved for a",
|
|
@@ -814,7 +864,7 @@ export function assertSingleConductorInvocation(text) {
|
|
|
814
864
|
// commands (say, one per profile or target) would hand the reader a choice of
|
|
815
865
|
// authority just as surely as naming two conductors. The same command
|
|
816
866
|
// repeated is still one invocation.
|
|
817
|
-
const runnable = new Set((text.match(/\bdrive-epic\s+[A-Z][A-Z0-
|
|
867
|
+
const runnable = new Set((text.match(/\bdrive-epic\s+[A-Z][A-Z0-9_]{0,19}-[0-9]+[^\n]*/g) ?? []).map((line) => line.trim()));
|
|
818
868
|
if (runnable.size > 1) {
|
|
819
869
|
throw new Error(`drive-epic emitted ${runnable.size} different runnable launch commands. ` +
|
|
820
870
|
"Exactly one path may ever be presented.");
|
|
@@ -879,19 +929,32 @@ export function parseContainmentEligibilityArtifact(raw) {
|
|
|
879
929
|
return null;
|
|
880
930
|
return { containmentChildren: containmentChildren, flagged };
|
|
881
931
|
}
|
|
932
|
+
/** Sidecar name `assess-epic-implementer-eligibility` writes. */
|
|
933
|
+
export const ELIGIBILITY_ARTIFACT_NAME = "epic-implementer-eligibility.json";
|
|
934
|
+
/** Pre-rename sidecar name, read only when the current name is absent. */
|
|
935
|
+
export const LEGACY_ELIGIBILITY_ARTIFACT_NAME = "conductor-eligibility.json";
|
|
882
936
|
/**
|
|
883
937
|
* Locate and read the containment-eligibility sidecar for a supplied plan
|
|
884
|
-
* file, then parse it.
|
|
885
|
-
*
|
|
886
|
-
*
|
|
938
|
+
* file, then parse it. The sidecar is always a sibling of the plan file —
|
|
939
|
+
* `dirname(planFile)/epic-implementer-eligibility.json` — the same directory
|
|
940
|
+
* `assess-epic-implementer-eligibility` writes it to. When that read fails,
|
|
941
|
+
* the legacy `conductor-eligibility.json` name is tried, so plans written
|
|
942
|
+
* before the rename still surface their advisory. Fails open on every
|
|
887
943
|
* boundary: a resolution failure, a rejected read (missing file, permission
|
|
888
944
|
* error), invalid JSON, or a malformed/inconsistent artifact all resolve to
|
|
889
945
|
* `null`, never a thrown error.
|
|
890
946
|
*/
|
|
891
947
|
export async function readContainmentEligibilityAdvisory(planFile, readEligibilityArtifact) {
|
|
892
948
|
try {
|
|
893
|
-
const
|
|
894
|
-
|
|
949
|
+
const planDir = dirname(planFile);
|
|
950
|
+
let raw;
|
|
951
|
+
try {
|
|
952
|
+
raw = await readEligibilityArtifact(join(planDir, ELIGIBILITY_ARTIFACT_NAME));
|
|
953
|
+
}
|
|
954
|
+
catch {
|
|
955
|
+
// Plans written before the rename carry only the legacy sidecar name.
|
|
956
|
+
raw = await readEligibilityArtifact(join(planDir, LEGACY_ELIGIBILITY_ARTIFACT_NAME));
|
|
957
|
+
}
|
|
895
958
|
const parsed = JSON.parse(raw);
|
|
896
959
|
return parseContainmentEligibilityArtifact(parsed);
|
|
897
960
|
}
|
|
@@ -961,6 +1024,10 @@ export function firstPreconditionRefusal(preflight, options = {}) {
|
|
|
961
1024
|
// ---------------------------------------------------------------------------
|
|
962
1025
|
// BAPI-1153 — cold start, attach, or one named refusal
|
|
963
1026
|
// ---------------------------------------------------------------------------
|
|
1027
|
+
// BAPI-1360: the predicate moved to the `plane/endpoint.ts` leaf so the readiness
|
|
1028
|
+
// command can share it; it is re-exported here so every existing import and launch
|
|
1029
|
+
// decision is unchanged.
|
|
1030
|
+
export { baseUrlNamesPlaneEndpoint };
|
|
964
1031
|
/**
|
|
965
1032
|
* The refusals drive-epic itself names on the cold-start and attach paths.
|
|
966
1033
|
*
|
|
@@ -975,31 +1042,6 @@ export const DRIVE_EPIC_PLANE_REFUSALS = [
|
|
|
975
1042
|
/** The plane's own reconciler is not confirmed as the fresh one. */
|
|
976
1043
|
"plane-reconciler-unconfirmed",
|
|
977
1044
|
];
|
|
978
|
-
/** Loopback spellings that name the plane's `127.0.0.1` host. */
|
|
979
|
-
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
980
|
-
/**
|
|
981
|
-
* Does `baseUrl` name the plane's own loopback endpoint? Pure.
|
|
982
|
-
*
|
|
983
|
-
* Cold start and attach are decisions about THE PLANE, so they only make sense
|
|
984
|
-
* when the Bridge API the command talks to IS the plane's server. The conductor
|
|
985
|
-
* default base URL is production; a readiness failure against it says nothing
|
|
986
|
-
* about a local port, and must keep escalating exactly as before. Loopback
|
|
987
|
-
* spellings are equivalent (`localhost` and `127.0.0.1` reach the same socket);
|
|
988
|
-
* the port must match exactly.
|
|
989
|
-
*/
|
|
990
|
-
export function baseUrlNamesPlaneEndpoint(baseUrl, endpoint) {
|
|
991
|
-
let url;
|
|
992
|
-
try {
|
|
993
|
-
url = new URL(baseUrl);
|
|
994
|
-
}
|
|
995
|
-
catch {
|
|
996
|
-
return false;
|
|
997
|
-
}
|
|
998
|
-
if (url.protocol !== "http:")
|
|
999
|
-
return false;
|
|
1000
|
-
const port = url.port === "" ? 80 : Number(url.port);
|
|
1001
|
-
return LOOPBACK_HOSTNAMES.has(url.hostname) && port === endpoint.port;
|
|
1002
|
-
}
|
|
1003
1045
|
/**
|
|
1004
1046
|
* Rebuild the identity the plane's reconciler publishes its heartbeat under. Pure.
|
|
1005
1047
|
*
|
|
@@ -1077,7 +1119,14 @@ export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalTh
|
|
|
1077
1119
|
typeof run.status !== "string") {
|
|
1078
1120
|
return { kind: "unknown", reason: "the run state response was malformed" };
|
|
1079
1121
|
}
|
|
1080
|
-
return {
|
|
1122
|
+
return {
|
|
1123
|
+
kind: "live",
|
|
1124
|
+
epicRunId: run.epic_run_id,
|
|
1125
|
+
status: run.status,
|
|
1126
|
+
...(run.policy_json !== undefined
|
|
1127
|
+
? { effectivePolicy: effectivePolicyFacts(run.policy_json, "stored") }
|
|
1128
|
+
: {}),
|
|
1129
|
+
};
|
|
1081
1130
|
}
|
|
1082
1131
|
catch (err) {
|
|
1083
1132
|
if (err instanceof ConductorBridgeApiError) {
|
|
@@ -1104,8 +1153,8 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1104
1153
|
...(cwd ? { cwd } : {}),
|
|
1105
1154
|
});
|
|
1106
1155
|
return result.ok
|
|
1107
|
-
? { ok: true, access: result.access, profile: result.profile }
|
|
1108
|
-
: { ok: false, error: result.error };
|
|
1156
|
+
? { ok: true, access: result.access, profile: result.profile, targetSource: result.targetSource }
|
|
1157
|
+
: { ok: false, error: result.error, kind: result.kind, ...(result.baseUrl ? { baseUrl: result.baseUrl } : {}) };
|
|
1109
1158
|
},
|
|
1110
1159
|
// Non-secret, and only ever a value the resolver just agreed with the
|
|
1111
1160
|
// environment on (a conflicting BAPI_BASE_URL was refused above), so the
|
|
@@ -1114,7 +1163,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1114
1163
|
process.env.BAPI_BASE_URL = baseUrl;
|
|
1115
1164
|
},
|
|
1116
1165
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
1117
|
-
runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
|
|
1166
|
+
runSetupEpic: (argv, handoff) => runSetupEpicWorkflow(argv, {}, handoff),
|
|
1118
1167
|
readEligibilityArtifact: (path) => readFile(path, "utf8"),
|
|
1119
1168
|
runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
|
|
1120
1169
|
runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
|
|
@@ -1167,7 +1216,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1167
1216
|
readPlanDag: (filePath) => readFile(filePath, "utf8"),
|
|
1168
1217
|
readPolicyFile: (filePath) => readFile(filePath, "utf8"),
|
|
1169
1218
|
readLiveEpicRun: (access, epicKey) => readLiveEpicRunByKey(access, epicKey),
|
|
1170
|
-
previewSetupEpic: async ({ argv, baseUrl }) => {
|
|
1219
|
+
previewSetupEpic: async ({ argv, baseUrl, handoff }) => {
|
|
1171
1220
|
const stdout = [];
|
|
1172
1221
|
const stderr = [];
|
|
1173
1222
|
const result = await runSetupEpicWorkflow(argv, {
|
|
@@ -1176,7 +1225,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
1176
1225
|
env: { ...process.env, BAPI_BASE_URL: baseUrl },
|
|
1177
1226
|
log: (message) => stdout.push(message),
|
|
1178
1227
|
errorLog: (message) => stderr.push(message),
|
|
1179
|
-
});
|
|
1228
|
+
}, handoff);
|
|
1180
1229
|
return { exitCode: result.exitCode, stdout, stderr };
|
|
1181
1230
|
},
|
|
1182
1231
|
// Read-only by construction: `prepareRunExecutor` runs `git rev-parse` and
|
|
@@ -1251,6 +1300,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1251
1300
|
options: parsed.options,
|
|
1252
1301
|
access: accessResult.access,
|
|
1253
1302
|
profile: launchProfile,
|
|
1303
|
+
targetSource: accessResult.targetSource ?? "default",
|
|
1254
1304
|
};
|
|
1255
1305
|
// BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
|
|
1256
1306
|
// discovered plan must count as a supplied one for the BAPI-1153 cold-start
|
|
@@ -1319,6 +1369,16 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1319
1369
|
}
|
|
1320
1370
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
1321
1371
|
}
|
|
1372
|
+
if (ctx.options.planFile !== undefined) {
|
|
1373
|
+
const prepared = await prepareSetupSelection(ctx);
|
|
1374
|
+
if (!prepared.ok) {
|
|
1375
|
+
return refuse(deps, [
|
|
1376
|
+
`${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
|
|
1377
|
+
"Nothing was created or started.",
|
|
1378
|
+
]);
|
|
1379
|
+
}
|
|
1380
|
+
ctx = { ...ctx, handoff: prepared.handoff };
|
|
1381
|
+
}
|
|
1322
1382
|
// --- Route. Exactly one path from here on. -------------------------------
|
|
1323
1383
|
const selection = selectConductor(readiness);
|
|
1324
1384
|
// BAPI-1022: surface any containment-hazard advisory before dispatch or
|
|
@@ -1377,7 +1437,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1377
1437
|
const classification = classifyV2Readiness(readiness, {
|
|
1378
1438
|
attended: parsed.options.attended === true,
|
|
1379
1439
|
intoBase: parsed.options.intoBase === true,
|
|
1380
|
-
|
|
1440
|
+
effectivePolicy: ctx.handoff?.selection,
|
|
1381
1441
|
});
|
|
1382
1442
|
// Green: create the run, then make sure something will claim its jobs
|
|
1383
1443
|
// (BAPI-1279). Before this, green delegated to setup-epic and nothing
|
|
@@ -1574,7 +1634,7 @@ const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
|
|
|
1574
1634
|
*/
|
|
1575
1635
|
function operatorBlockedRemedy(classification) {
|
|
1576
1636
|
return (classification.remedy ??
|
|
1577
|
-
|
|
1637
|
+
`Resolve it with \`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\` (${PRINTED_CHECK_NAME_INSTRUCTION}), then run drive-epic again.`);
|
|
1578
1638
|
}
|
|
1579
1639
|
/**
|
|
1580
1640
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
@@ -1594,6 +1654,61 @@ function escalate(deps, epicKey, reason) {
|
|
|
1594
1654
|
deps.errorLog(text);
|
|
1595
1655
|
return 1;
|
|
1596
1656
|
}
|
|
1657
|
+
/** Resolve policy intent once for the setup delegation, after a fresh run read. */
|
|
1658
|
+
async function prepareSetupSelection(ctx, coldStart = false) {
|
|
1659
|
+
const live = coldStart ? { kind: "none" } : await ctx.deps.readLiveEpicRun(ctx.access, ctx.epicKey);
|
|
1660
|
+
if (live.kind === "conflict")
|
|
1661
|
+
return { ok: false, reasonId: "live-run-ambiguous" };
|
|
1662
|
+
if (live.kind === "unknown")
|
|
1663
|
+
return { ok: false, reasonId: "live-run-unknown" };
|
|
1664
|
+
const explicit = ctx.options.policyFile;
|
|
1665
|
+
const selectedPath = explicit ?? (live.kind === "none" ? ctx.profile?.policyRef?.path : undefined);
|
|
1666
|
+
let pinnedPolicy;
|
|
1667
|
+
let digest = null;
|
|
1668
|
+
if (selectedPath !== undefined) {
|
|
1669
|
+
let raw = "";
|
|
1670
|
+
const read = await readSetupEpicPolicyFile(selectedPath, async (filePath) => {
|
|
1671
|
+
raw = await ctx.deps.readPolicyFile(filePath);
|
|
1672
|
+
return raw;
|
|
1673
|
+
});
|
|
1674
|
+
if (!read.ok)
|
|
1675
|
+
return { ok: false, reasonId: "policy-file-unreadable" };
|
|
1676
|
+
pinnedPolicy = read.policy;
|
|
1677
|
+
digest = epicLaunchPolicyDigest(raw);
|
|
1678
|
+
}
|
|
1679
|
+
const policy = coldStart
|
|
1680
|
+
? { kind: "deferred_cold_preview", source: "deferred", digest: null }
|
|
1681
|
+
: live.kind === "live"
|
|
1682
|
+
? explicit && digest
|
|
1683
|
+
? { kind: "explicit_replacement_request", source: "explicit", digest }
|
|
1684
|
+
: { kind: "approved_stored", source: "stored", digest: null }
|
|
1685
|
+
: { kind: "new_run", source: explicit ? "explicit" : selectedPath ? "profile" : "default", digest };
|
|
1686
|
+
const selection = resolveEpicLaunchSelection({
|
|
1687
|
+
baseUrl: ctx.access.baseUrl,
|
|
1688
|
+
repoName: ctx.access.repoName,
|
|
1689
|
+
targetSource: ctx.targetSource,
|
|
1690
|
+
profile: ctx.profile,
|
|
1691
|
+
topology: planeEndpointForBaseUrl(ctx.deps, ctx.access.baseUrl) ? "local_plane" : "remote",
|
|
1692
|
+
policy,
|
|
1693
|
+
escalationChannel: pinnedPolicy
|
|
1694
|
+
? policyDeclaresNotifyWebhook(pinnedPolicy) ? "configured webhook" : "account email"
|
|
1695
|
+
: live.kind === "live" ? "unavailable" : "account email",
|
|
1696
|
+
});
|
|
1697
|
+
if (pinnedPolicy)
|
|
1698
|
+
pinEpicLaunchPolicy(selection, pinnedPolicy);
|
|
1699
|
+
return { ok: true, handoff: { selection, access: ctx.access, profile: ctx.profile } };
|
|
1700
|
+
}
|
|
1701
|
+
async function runSelectedSetupEpic(ctx) {
|
|
1702
|
+
const prepared = ctx.handoff
|
|
1703
|
+
? { ok: true, handoff: ctx.handoff }
|
|
1704
|
+
: await prepareSetupSelection(ctx);
|
|
1705
|
+
if (!prepared.ok) {
|
|
1706
|
+
ctx.deps.errorLog(`${ctx.epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`);
|
|
1707
|
+
return { exitCode: 1 };
|
|
1708
|
+
}
|
|
1709
|
+
const result = await ctx.deps.runSetupEpic(buildSetupEpicArgv(ctx.options), prepared.handoff);
|
|
1710
|
+
return result;
|
|
1711
|
+
}
|
|
1597
1712
|
/** Emit one refusal, guarded so it can never name a second conductor. */
|
|
1598
1713
|
function refuse(deps, lines) {
|
|
1599
1714
|
const text = lines.join("\n");
|
|
@@ -1601,6 +1716,11 @@ function refuse(deps, lines) {
|
|
|
1601
1716
|
deps.errorLog(text);
|
|
1602
1717
|
return 1;
|
|
1603
1718
|
}
|
|
1719
|
+
function durableActorAdmissionDenied(readiness) {
|
|
1720
|
+
const evidence = readiness.admission_evidence;
|
|
1721
|
+
return evidence?.schema_version === 1 &&
|
|
1722
|
+
evidence.records.some((record) => record.predicate === "durable_actor" && record.verdict === "denied");
|
|
1723
|
+
}
|
|
1604
1724
|
/**
|
|
1605
1725
|
* The plane endpoint, when `baseUrl` names it; otherwise `null`.
|
|
1606
1726
|
*
|
|
@@ -1609,10 +1729,7 @@ function refuse(deps, lines) {
|
|
|
1609
1729
|
* applies.
|
|
1610
1730
|
*/
|
|
1611
1731
|
function planeEndpointForBaseUrl(deps, baseUrl) {
|
|
1612
|
-
|
|
1613
|
-
if (!resolution.ok)
|
|
1614
|
-
return null;
|
|
1615
|
-
return baseUrlNamesPlaneEndpoint(baseUrl, resolution.endpoint) ? resolution.endpoint : null;
|
|
1732
|
+
return planeEndpointForTarget(deps.resolvePlaneEndpoint(), baseUrl);
|
|
1616
1733
|
}
|
|
1617
1734
|
/** What to tell the operator about a plane THIS invocation started and left up. */
|
|
1618
1735
|
const LAUNCHED_PLANE_LEFT_RUNNING = [
|
|
@@ -1706,10 +1823,18 @@ async function coldStartPlane(ctx) {
|
|
|
1706
1823
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1707
1824
|
]);
|
|
1708
1825
|
}
|
|
1826
|
+
const prepared = await prepareSetupSelection(ctx);
|
|
1827
|
+
if (!prepared.ok) {
|
|
1828
|
+
return refuse(deps, [
|
|
1829
|
+
`${epicKey}: REFUSED (${prepared.reasonId}) — the selected run or policy could not be verified.`,
|
|
1830
|
+
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1831
|
+
]);
|
|
1832
|
+
}
|
|
1833
|
+
ctx = { ...ctx, handoff: prepared.handoff };
|
|
1709
1834
|
const classification = classifyV2Readiness(readiness, {
|
|
1710
1835
|
attended: ctx.options.attended === true,
|
|
1711
1836
|
intoBase: ctx.options.intoBase === true,
|
|
1712
|
-
|
|
1837
|
+
effectivePolicy: ctx.handoff?.selection,
|
|
1713
1838
|
});
|
|
1714
1839
|
if (classification.kind === "operator_blocked") {
|
|
1715
1840
|
return refuse(deps, [
|
|
@@ -1755,6 +1880,12 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1755
1880
|
"Nothing was started. Fix the credential and run drive-epic again.",
|
|
1756
1881
|
]);
|
|
1757
1882
|
}
|
|
1883
|
+
if (rooted.access.repoName !== ctx.access.repoName || rooted.access.baseUrl !== ctx.access.baseUrl) {
|
|
1884
|
+
return refuse(deps, [
|
|
1885
|
+
`${epicKey}: REFUSED (selection-conflict) — manifest-root credentials resolved a different repository or endpoint.`,
|
|
1886
|
+
"Nothing was started or changed.",
|
|
1887
|
+
]);
|
|
1888
|
+
}
|
|
1758
1889
|
const attachCtx = { ...ctx, access: rooted.access };
|
|
1759
1890
|
// R-7, decided BEFORE any create request. It binds the LANES only
|
|
1760
1891
|
// (BAPI-1279): a plane whose lanes serve another run still has a reconciler
|
|
@@ -1816,7 +1947,7 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1816
1947
|
const { deps, epicKey } = ctx;
|
|
1817
1948
|
const launched = plane.origin === "launched";
|
|
1818
1949
|
// ---- PHASE TWO, part one: create the run ------------------------------
|
|
1819
|
-
const setup = await
|
|
1950
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
1820
1951
|
if (setup.exitCode !== 0) {
|
|
1821
1952
|
// The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
|
|
1822
1953
|
// was spawned — there is nothing to claim a job that may not exist.
|
|
@@ -2107,11 +2238,17 @@ function liveRunUnknownRefusal(epicKey, reason) {
|
|
|
2107
2238
|
async function createRunWithRunExecutor(ctx, readiness) {
|
|
2108
2239
|
const { deps, epicKey, access, profile } = ctx;
|
|
2109
2240
|
if (profile !== null && profile.executionMode === "durable") {
|
|
2241
|
+
if (durableActorAdmissionDenied(readiness)) {
|
|
2242
|
+
return refuse(deps, [
|
|
2243
|
+
`${epicKey}: REFUSED (actor-admission-denied) — durable actor admission was denied at the selected target.`,
|
|
2244
|
+
"No run was created and no local executor was started.",
|
|
2245
|
+
]);
|
|
2246
|
+
}
|
|
2110
2247
|
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2111
2248
|
if (coverage !== true) {
|
|
2112
2249
|
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
|
|
2113
2250
|
}
|
|
2114
|
-
const setup = await
|
|
2251
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
2115
2252
|
if (setup.exitCode === 0) {
|
|
2116
2253
|
deps.log([
|
|
2117
2254
|
"",
|
|
@@ -2126,7 +2263,7 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2126
2263
|
if (!prepared.ok) {
|
|
2127
2264
|
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
|
|
2128
2265
|
}
|
|
2129
|
-
const setup = await
|
|
2266
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
2130
2267
|
if (setup.exitCode !== 0) {
|
|
2131
2268
|
// setup-epic already said why. Nothing was started for a run that may not exist.
|
|
2132
2269
|
deps.errorLog(`\n${epicKey}: no executor was started.`);
|
|
@@ -2164,6 +2301,11 @@ async function createRunWithRunExecutor(ctx, readiness) {
|
|
|
2164
2301
|
function reportExecutorOutcome(ctx, ensured, epicRunId, framing) {
|
|
2165
2302
|
const { deps, epicKey, access } = ctx;
|
|
2166
2303
|
if (!ensured.ok) {
|
|
2304
|
+
deps.log(JSON.stringify({
|
|
2305
|
+
outcome: "run_exists_executor_unavailable",
|
|
2306
|
+
epic_key: epicKey,
|
|
2307
|
+
epic_run_id: epicRunId,
|
|
2308
|
+
}));
|
|
2167
2309
|
return refuse(deps, [
|
|
2168
2310
|
"",
|
|
2169
2311
|
framing === "created"
|
|
@@ -2213,6 +2355,9 @@ async function planeServesLiveRun(ctx, live) {
|
|
|
2213
2355
|
const manifest = await findAttachablePlane(ctx);
|
|
2214
2356
|
if (manifest === null || manifest.epicRunId !== live.epicRunId)
|
|
2215
2357
|
return false;
|
|
2358
|
+
const owner = await ctx.deps.readPlaneReconcilerState({ access: ctx.access, manifest });
|
|
2359
|
+
if (owner !== "fresh")
|
|
2360
|
+
return false;
|
|
2216
2361
|
const bound = await ctx.deps.readRunState(ctx.access, manifest.epicRunId);
|
|
2217
2362
|
return bound.epicKey === ctx.epicKey && !isTerminalEpicRunStatus(bound.status);
|
|
2218
2363
|
}
|
|
@@ -2236,7 +2381,7 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2236
2381
|
const classification = classifyV2Readiness(readiness, {
|
|
2237
2382
|
attended: ctx.options.attended === true,
|
|
2238
2383
|
intoBase: ctx.options.intoBase === true,
|
|
2239
|
-
|
|
2384
|
+
approvedStoredPolicy: true,
|
|
2240
2385
|
});
|
|
2241
2386
|
if (classification.kind === "operator_blocked") {
|
|
2242
2387
|
return refuse(deps, [
|
|
@@ -2252,9 +2397,28 @@ async function attachToLiveRun(ctx, readiness, live) {
|
|
|
2252
2397
|
return refuseRemoteRuntime(ctx, readiness);
|
|
2253
2398
|
return refuse(deps, localReconcilerRefusal(epicKey, readiness).lines);
|
|
2254
2399
|
}
|
|
2400
|
+
if (planeTarget) {
|
|
2401
|
+
const manifest = await findAttachablePlane(ctx).catch(() => null);
|
|
2402
|
+
if (manifest?.epicRunId === live.epicRunId) {
|
|
2403
|
+
const admission = await checkPlaneAdmission(ctx, readiness, manifest);
|
|
2404
|
+
if (admission !== null)
|
|
2405
|
+
return refuse(deps, admission);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2255
2408
|
deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
|
|
2256
2409
|
"found, so nothing will be created; making sure it has an executor.");
|
|
2410
|
+
if (live.effectivePolicy) {
|
|
2411
|
+
deps.log(`Effective policy: stored; serial execution; caps ${live.effectivePolicy.caps ? JSON.stringify(live.effectivePolicy.caps) : "unavailable"}; ` +
|
|
2412
|
+
`timeouts ${live.effectivePolicy.job_timeouts ? JSON.stringify(live.effectivePolicy.job_timeouts) : "unavailable"}; ` +
|
|
2413
|
+
`channel ${live.effectivePolicy.escalation_channel}.`);
|
|
2414
|
+
}
|
|
2257
2415
|
if (profile !== null && profile.executionMode === "durable") {
|
|
2416
|
+
if (durableActorAdmissionDenied(readiness)) {
|
|
2417
|
+
return refuse(deps, [
|
|
2418
|
+
`${epicKey}: REFUSED (actor-admission-denied) — durable actor admission was denied at the selected target.`,
|
|
2419
|
+
"No run was changed and no local executor was started.",
|
|
2420
|
+
]);
|
|
2421
|
+
}
|
|
2258
2422
|
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2259
2423
|
if (coverage !== true) {
|
|
2260
2424
|
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was changed").lines);
|
|
@@ -2339,9 +2503,19 @@ function reportControlPlaneLaunchFailure(ctx, controlPlane) {
|
|
|
2339
2503
|
"No run was created.",
|
|
2340
2504
|
]);
|
|
2341
2505
|
}
|
|
2506
|
+
export const DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS = {
|
|
2507
|
+
"profile-unusable": "access-profile-unusable",
|
|
2508
|
+
"target-missing": "access-target-missing",
|
|
2509
|
+
"target-conflict": "access-target-conflict",
|
|
2510
|
+
"target-invalid": "access-target-invalid",
|
|
2511
|
+
"repository-conflict": "access-repository-conflict",
|
|
2512
|
+
"repo-missing": "access-repo-missing",
|
|
2513
|
+
"credentials-unavailable": "access-credentials-unavailable",
|
|
2514
|
+
"credential-unbound": "access-credential-unbound",
|
|
2515
|
+
};
|
|
2342
2516
|
/** The readiness reason a preview reports for a generic not-ready engine. */
|
|
2343
2517
|
const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
|
|
2344
|
-
const ENGINE_NOT_READY_REMEDY =
|
|
2518
|
+
const ENGINE_NOT_READY_REMEDY = `Resolve conductor readiness (\`${PRINTED_CONDUCTOR_INSTALL_COMMAND}\`, then \`${PRINTED_DOCTOR_COMMAND}\`) and run drive-epic again. ${PRINTED_CHECK_NAME_INSTRUCTION}`;
|
|
2345
2519
|
/**
|
|
2346
2520
|
* Read exactly one well-shaped setup-epic dry-run JSON document out of captured
|
|
2347
2521
|
* `log` output. Anything else — none, several, or one with the wrong shape — is
|
|
@@ -2370,12 +2544,48 @@ function readSetupPreviewPayload(stdout) {
|
|
|
2370
2544
|
if (existing !== null && existing !== undefined && typeof existing !== "string")
|
|
2371
2545
|
return null;
|
|
2372
2546
|
const branch = doc.feature_branch;
|
|
2373
|
-
if (branch !== undefined && typeof branch !== "string")
|
|
2547
|
+
if (branch !== undefined && branch !== null && typeof branch !== "string")
|
|
2374
2548
|
return null;
|
|
2549
|
+
const rawFacts = doc.effective_policy;
|
|
2550
|
+
let effectivePolicy = null;
|
|
2551
|
+
if (rawFacts !== undefined && rawFacts !== null) {
|
|
2552
|
+
if (typeof rawFacts !== "object" || Array.isArray(rawFacts))
|
|
2553
|
+
return null;
|
|
2554
|
+
const facts = rawFacts;
|
|
2555
|
+
if (!["explicit", "profile", "default", "stored"].includes(String(facts.source)) ||
|
|
2556
|
+
facts.serial_execution !== "serial" ||
|
|
2557
|
+
!["attended", "unattended"].includes(String(facts.posture)) ||
|
|
2558
|
+
!["configured webhook", "account email", "unavailable"].includes(String(facts.escalation_channel)))
|
|
2559
|
+
return null;
|
|
2560
|
+
const numericMap = (value) => {
|
|
2561
|
+
if (value === null)
|
|
2562
|
+
return null;
|
|
2563
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2564
|
+
return undefined;
|
|
2565
|
+
const entries = Object.entries(value);
|
|
2566
|
+
if (entries.some(([key, number]) => !/^[a-z][a-z0-9_]{0,63}$/.test(key) ||
|
|
2567
|
+
typeof number !== "number" || !Number.isFinite(number)))
|
|
2568
|
+
return undefined;
|
|
2569
|
+
return Object.fromEntries(entries);
|
|
2570
|
+
};
|
|
2571
|
+
const caps = numericMap(facts.caps);
|
|
2572
|
+
const jobTimeouts = numericMap(facts.job_timeouts);
|
|
2573
|
+
if (caps === undefined || jobTimeouts === undefined)
|
|
2574
|
+
return null;
|
|
2575
|
+
effectivePolicy = {
|
|
2576
|
+
source: facts.source,
|
|
2577
|
+
serial_execution: "serial",
|
|
2578
|
+
posture: facts.posture,
|
|
2579
|
+
caps,
|
|
2580
|
+
job_timeouts: jobTimeouts,
|
|
2581
|
+
escalation_channel: facts.escalation_channel,
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2375
2584
|
return {
|
|
2376
2585
|
existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
|
|
2377
2586
|
featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
|
|
2378
2587
|
serverValidated: doc.server_validated,
|
|
2588
|
+
effectivePolicy,
|
|
2379
2589
|
};
|
|
2380
2590
|
}
|
|
2381
2591
|
/** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
|
|
@@ -2416,11 +2626,16 @@ async function runDriveEpicPreview(deps, options) {
|
|
|
2416
2626
|
decision: "refuse",
|
|
2417
2627
|
refusal: null,
|
|
2418
2628
|
};
|
|
2419
|
-
/**
|
|
2420
|
-
|
|
2629
|
+
/**
|
|
2630
|
+
* A refusal carried by the readiness block AND mirrored into the top-level
|
|
2631
|
+
* `refusal` (BAPI-1360): every readiness refusal now has one primary explanation
|
|
2632
|
+
* a consumer reads from a single place. The nested readiness pair is unchanged.
|
|
2633
|
+
* `_alsoTopLevel` is retained so existing call sites keep their signature; it no
|
|
2634
|
+
* longer selects anything.
|
|
2635
|
+
*/
|
|
2636
|
+
const refuseWithReadiness = (kind, named, _alsoTopLevel = false) => {
|
|
2421
2637
|
preview.readiness = { kind, reason_id: named.reasonId, remedy: named.remedy };
|
|
2422
|
-
|
|
2423
|
-
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2638
|
+
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2424
2639
|
preview.decision = "refuse";
|
|
2425
2640
|
};
|
|
2426
2641
|
/** A refusal that is not about readiness: only the top-level block carries it. */
|
|
@@ -2440,9 +2655,39 @@ async function runDriveEpicPreview(deps, options) {
|
|
|
2440
2655
|
remedy: "The preview could not complete. Run it again; nothing was created or started.",
|
|
2441
2656
|
});
|
|
2442
2657
|
}
|
|
2658
|
+
ensureRefusalExplained(preview);
|
|
2443
2659
|
emitPreview(deps, options, preview);
|
|
2444
2660
|
return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
|
|
2445
2661
|
}
|
|
2662
|
+
/** The fixed explanation used when a refusal reached the end with none of its own. */
|
|
2663
|
+
export const PREVIEW_REFUSAL_FALLBACK_REASON_ID = "preview-refused";
|
|
2664
|
+
const PREVIEW_REFUSAL_FALLBACK_REMEDY = "The preview refused this launch without naming a specific cause. Resolve conductor readiness and preview again; nothing was created or started.";
|
|
2665
|
+
function isNamedRefusal(value) {
|
|
2666
|
+
if (value === null || typeof value !== "object")
|
|
2667
|
+
return false;
|
|
2668
|
+
const { reason_id: reasonId, remedy } = value;
|
|
2669
|
+
return typeof reasonId === "string" && reasonId.trim() !== "" && typeof remedy === "string" && remedy.trim() !== "";
|
|
2670
|
+
}
|
|
2671
|
+
/**
|
|
2672
|
+
* Every `refuse` decision carries a primary explanation (BAPI-1360).
|
|
2673
|
+
*
|
|
2674
|
+
* Runs after `previewDecision`, whatever path it took. A still-absent or malformed
|
|
2675
|
+
* top-level `refusal` on a refusing preview is filled from the readiness pair when
|
|
2676
|
+
* that pair is valid, and otherwise from a fixed generic reason and remedy — never
|
|
2677
|
+
* from anything thrown or received. An already-valid refusal is left untouched.
|
|
2678
|
+
* The exit code does not depend on this: a refusing decision exits non-zero either
|
|
2679
|
+
* way, so a malformed field can never turn a refusal into a success.
|
|
2680
|
+
*/
|
|
2681
|
+
export function ensureRefusalExplained(preview) {
|
|
2682
|
+
if (preview.decision !== "refuse")
|
|
2683
|
+
return;
|
|
2684
|
+
if (isNamedRefusal(preview.refusal))
|
|
2685
|
+
return;
|
|
2686
|
+
const { reason_id: reasonId, remedy } = preview.readiness;
|
|
2687
|
+
preview.refusal = isNamedRefusal({ reason_id: reasonId, remedy })
|
|
2688
|
+
? { reason_id: reasonId, remedy: remedy }
|
|
2689
|
+
: { reason_id: PREVIEW_REFUSAL_FALLBACK_REASON_ID, remedy: PREVIEW_REFUSAL_FALLBACK_REMEDY };
|
|
2690
|
+
}
|
|
2446
2691
|
/** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
|
|
2447
2692
|
async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
|
|
2448
2693
|
const { epicKey } = options;
|
|
@@ -2452,62 +2697,135 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2452
2697
|
});
|
|
2453
2698
|
// --- Target: resolved, never adopted. -------------------------------------
|
|
2454
2699
|
const accessResult = await deps.resolveAccess(options.repo, undefined, launchSelectionOf(options));
|
|
2455
|
-
if (!accessResult.ok)
|
|
2456
|
-
|
|
2700
|
+
if (!accessResult.ok) {
|
|
2701
|
+
if (!accessResult.kind ||
|
|
2702
|
+
!Object.prototype.hasOwnProperty.call(DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS, accessResult.kind))
|
|
2703
|
+
return unknownReadiness();
|
|
2704
|
+
unknownReadiness();
|
|
2705
|
+
if (accessResult.baseUrl) {
|
|
2706
|
+
preview.target = {
|
|
2707
|
+
base_url: accessResult.baseUrl,
|
|
2708
|
+
kind: planeEndpointForBaseUrl(deps, accessResult.baseUrl) !== null ? "local_plane" : "remote",
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
refuseTopLevel({
|
|
2712
|
+
reasonId: DRIVE_EPIC_ACCESS_REFUSAL_REASON_IDS[accessResult.kind],
|
|
2713
|
+
remedy: accessResult.error,
|
|
2714
|
+
});
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2457
2717
|
const access = accessResult.access;
|
|
2458
2718
|
const profile = accessResult.profile ?? null;
|
|
2459
|
-
const
|
|
2460
|
-
|
|
2461
|
-
|
|
2719
|
+
const endpoint = planeEndpointForBaseUrl(deps, access.baseUrl);
|
|
2720
|
+
const planeTarget = endpoint !== null;
|
|
2721
|
+
preview.target = {
|
|
2722
|
+
base_url: access.baseUrl,
|
|
2723
|
+
kind: planeTarget ? "local_plane" : "remote",
|
|
2724
|
+
...(accessResult.targetSource === "profile" || accessResult.targetSource === "profile-env"
|
|
2725
|
+
? { source: accessResult.targetSource }
|
|
2726
|
+
: {}),
|
|
2727
|
+
};
|
|
2728
|
+
const ctx = { deps, epicKey, options, access, profile, targetSource: accessResult.targetSource ?? "default" };
|
|
2729
|
+
// Discover before readiness so a selected local plane can preview its guarded
|
|
2730
|
+
// cold start. Keep the candidate out of ordinary failure documents.
|
|
2731
|
+
let planPath = options.planFile;
|
|
2732
|
+
let plan = { status: "not_found" };
|
|
2733
|
+
let docsRootUnresolved = false;
|
|
2734
|
+
if (planPath !== undefined) {
|
|
2735
|
+
plan = { status: "supplied", path: planPath };
|
|
2736
|
+
}
|
|
2737
|
+
else {
|
|
2738
|
+
const docs = await deps.resolveDocsDir();
|
|
2739
|
+
if (!docs.ok) {
|
|
2740
|
+
docsRootUnresolved = true;
|
|
2741
|
+
}
|
|
2742
|
+
else {
|
|
2743
|
+
const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
2744
|
+
plan = {
|
|
2745
|
+
status: discovered.status === "found" ? "found" : discovered.status === "plan_ambiguous" ? "ambiguous" : "not_found",
|
|
2746
|
+
...(discovered.status === "found" ? { path: discovered.path } : {}),
|
|
2747
|
+
...(discovered.status === "plan_ambiguous" ? { candidates: [...discovered.candidates] } : {}),
|
|
2748
|
+
...(discovered.skipped.length > 0
|
|
2749
|
+
? { skipped: discovered.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })) }
|
|
2750
|
+
: {}),
|
|
2751
|
+
epic_plans_dir: discovered.epic_plans_dir,
|
|
2752
|
+
};
|
|
2753
|
+
if (discovered.status === "found")
|
|
2754
|
+
planPath = discovered.path;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2462
2757
|
// --- Readiness. -----------------------------------------------------------
|
|
2463
2758
|
let readiness;
|
|
2464
2759
|
try {
|
|
2465
2760
|
readiness = await deps.readReadiness(access);
|
|
2466
2761
|
}
|
|
2467
|
-
catch {
|
|
2762
|
+
catch (err) {
|
|
2763
|
+
if (planPath !== undefined && endpoint !== null && profile?.executionMode !== "durable" &&
|
|
2764
|
+
err instanceof ConductorBridgeApiError && err.kind === "network") {
|
|
2765
|
+
const probe = await deps.probePlanePort(endpoint.host, endpoint.port).catch(() => ({ kind: "error" }));
|
|
2766
|
+
if (probe.kind === "refused") {
|
|
2767
|
+
const preflight = await deps.runPlanePreflight(deps.cwd());
|
|
2768
|
+
const blocker = firstPreconditionRefusal(preflight) ??
|
|
2769
|
+
preflight.diagnostics.find((diagnostic) => diagnostic.severity === "blocking");
|
|
2770
|
+
preview.plan = plan;
|
|
2771
|
+
if (blocker) {
|
|
2772
|
+
return refuseTopLevel({
|
|
2773
|
+
reasonId: blocker.check,
|
|
2774
|
+
remedy: `The local plane preflight refused ${blocker.check}. Fix that condition and preview again.`,
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
if (!preflight.ok) {
|
|
2778
|
+
return refuseTopLevel({
|
|
2779
|
+
reasonId: "plane-preflight-failed",
|
|
2780
|
+
remedy: "The local plane preflight could not admit guarded startup. Fix its reported condition and preview again.",
|
|
2781
|
+
});
|
|
2782
|
+
}
|
|
2783
|
+
preview.readiness = { kind: "cold_start" };
|
|
2784
|
+
await finalizeCreatePreview(ctx, preview, planPath, true, refuseTopLevel);
|
|
2785
|
+
if (preview.decision === "create") {
|
|
2786
|
+
preview.cold_start = { plane_endpoint: access.baseUrl, will_start_plane: true, live_run_checked: false };
|
|
2787
|
+
}
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2468
2791
|
return unknownReadiness();
|
|
2469
2792
|
}
|
|
2793
|
+
preview.plan = plan;
|
|
2794
|
+
if (docsRootUnresolved)
|
|
2795
|
+
return refuseTopLevel(docsRootRefusal(epicKey));
|
|
2796
|
+
const live = await deps.readLiveEpicRun(access, epicKey);
|
|
2797
|
+
if (live.kind === "conflict")
|
|
2798
|
+
return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
|
|
2799
|
+
if (live.kind === "unknown")
|
|
2800
|
+
return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
|
|
2801
|
+
const previewCtx = { ...ctx, options: { ...options, ...(planPath ? { planFile: planPath } : {}) } };
|
|
2802
|
+
const prepared = planPath ? await prepareSetupSelection(previewCtx) : null;
|
|
2803
|
+
if (prepared !== null && !prepared.ok) {
|
|
2804
|
+
return refuseTopLevel({
|
|
2805
|
+
reasonId: prepared.reasonId,
|
|
2806
|
+
remedy: "The selected run or policy could not be verified. Nothing was created or started.",
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2470
2809
|
const classification = classifyV2Readiness(readiness, {
|
|
2471
2810
|
attended: options.attended === true,
|
|
2472
2811
|
intoBase: options.intoBase === true,
|
|
2473
|
-
|
|
2812
|
+
effectivePolicy: prepared?.ok ? prepared.handoff.selection : undefined,
|
|
2813
|
+
approvedStoredPolicy: live.kind === "live" && !planPath,
|
|
2474
2814
|
});
|
|
2475
2815
|
preview.readiness = { kind: classification.kind };
|
|
2476
2816
|
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
2817
|
// --- 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
2818
|
if (live.kind === "live") {
|
|
2510
2819
|
preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
|
|
2820
|
+
if (live.effectivePolicy) {
|
|
2821
|
+
preview.summary.policy_source = live.effectivePolicy.source;
|
|
2822
|
+
preview.summary.serial_execution = live.effectivePolicy.serial_execution;
|
|
2823
|
+
preview.summary.caps = live.effectivePolicy.caps;
|
|
2824
|
+
preview.summary.job_timeouts = live.effectivePolicy.job_timeouts;
|
|
2825
|
+
preview.summary.escalation_channel_label = live.effectivePolicy.escalation_channel;
|
|
2826
|
+
preview.summary.posture = live.effectivePolicy.posture;
|
|
2827
|
+
preview.summary.escalation_channel = null;
|
|
2828
|
+
}
|
|
2511
2829
|
}
|
|
2512
2830
|
// --- No plan: attach to a live run, or stop. --------------------------------
|
|
2513
2831
|
if (planPath === undefined) {
|
|
@@ -2537,31 +2855,32 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2537
2855
|
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
|
|
2538
2856
|
if (facts !== null)
|
|
2539
2857
|
return refuseWithReadiness(classification.kind, facts, true);
|
|
2858
|
+
await finalizeCreatePreview(prepared?.ok ? { ...previewCtx, handoff: prepared.handoff } : previewCtx, preview, planPath, false, refuseTopLevel, live.kind === "live");
|
|
2859
|
+
}
|
|
2860
|
+
async function finalizeCreatePreview(ctx, preview, planPath, coldStart, refuseTopLevel, liveRunKnown = false) {
|
|
2861
|
+
const { deps, options, access } = ctx;
|
|
2862
|
+
const prepared = ctx.handoff
|
|
2863
|
+
? { ok: true, handoff: ctx.handoff }
|
|
2864
|
+
: await prepareSetupSelection(ctx, coldStart);
|
|
2865
|
+
if (!prepared.ok) {
|
|
2866
|
+
return refuseTopLevel({
|
|
2867
|
+
reasonId: prepared.reasonId,
|
|
2868
|
+
remedy: "The selected run or policy could not be verified. Fix it and preview again.",
|
|
2869
|
+
});
|
|
2870
|
+
}
|
|
2540
2871
|
// Escalation channel, decided with setup-epic's own exported policy helpers. A
|
|
2541
2872
|
// policy that cannot be read is one fixed refusal; its path, contents, and any
|
|
2542
2873
|
// webhook URL never leave this function.
|
|
2543
2874
|
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
|
-
}
|
|
2875
|
+
preview.summary.escalation_channel = prepared.handoff.selection.escalationChannel === "configured webhook"
|
|
2876
|
+
? "webhook"
|
|
2877
|
+
: prepared.handoff.selection.escalationChannel === "account email" ? "email" : null;
|
|
2560
2878
|
}
|
|
2561
2879
|
preview.summary.child_count = await readPlanChildCount(deps, planPath);
|
|
2562
2880
|
const setup = await deps.previewSetupEpic({
|
|
2563
|
-
argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), "--dry-run", "--json"],
|
|
2881
|
+
argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
|
|
2564
2882
|
baseUrl: access.baseUrl,
|
|
2883
|
+
handoff: prepared.handoff,
|
|
2565
2884
|
});
|
|
2566
2885
|
if (setup.exitCode !== 0) {
|
|
2567
2886
|
preview.summary.server_validation = "failed";
|
|
@@ -2582,6 +2901,18 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2582
2901
|
}
|
|
2583
2902
|
preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
|
|
2584
2903
|
preview.summary.branch = payload.featureBranch;
|
|
2904
|
+
if (payload.effectivePolicy) {
|
|
2905
|
+
const facts = payload.effectivePolicy;
|
|
2906
|
+
preview.summary.policy_source = facts.source;
|
|
2907
|
+
preview.summary.serial_execution = facts.serial_execution;
|
|
2908
|
+
preview.summary.caps = facts.caps;
|
|
2909
|
+
preview.summary.job_timeouts = facts.job_timeouts;
|
|
2910
|
+
preview.summary.escalation_channel_label = facts.escalation_channel;
|
|
2911
|
+
if (!coldStart)
|
|
2912
|
+
preview.summary.posture = facts.posture;
|
|
2913
|
+
preview.summary.escalation_channel = facts.escalation_channel === "configured webhook"
|
|
2914
|
+
? "webhook" : facts.escalation_channel === "account email" ? "email" : null;
|
|
2915
|
+
}
|
|
2585
2916
|
preview.summary.auto_merge_scope =
|
|
2586
2917
|
preview.summary.posture === "unattended" ? payload.featureBranch : null;
|
|
2587
2918
|
// The bootstrap reusing a run IS an attach: it reports the run's id.
|
|
@@ -2593,7 +2924,7 @@ async function previewDecision(deps, options, preview, refuseWithReadiness, refu
|
|
|
2593
2924
|
preview.decision = "attach";
|
|
2594
2925
|
return;
|
|
2595
2926
|
}
|
|
2596
|
-
preview.decision =
|
|
2927
|
+
preview.decision = coldStart ? "create" : liveRunKnown ? "attach" : "create";
|
|
2597
2928
|
}
|
|
2598
2929
|
/**
|
|
2599
2930
|
* The readiness refusal a real launch would print, as a reason id and remedy, or
|
|
@@ -2623,6 +2954,12 @@ function previewReadinessRefusal(ctx, classification, readiness, planeTarget, pa
|
|
|
2623
2954
|
async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
|
|
2624
2955
|
const { deps, epicKey, access, profile } = ctx;
|
|
2625
2956
|
if (profile !== null && profile.executionMode === "durable") {
|
|
2957
|
+
if (durableActorAdmissionDenied(readiness)) {
|
|
2958
|
+
return {
|
|
2959
|
+
reasonId: "actor-admission-denied",
|
|
2960
|
+
remedy: "Durable actor admission was denied at the selected target. No run was created and no local executor was started.",
|
|
2961
|
+
};
|
|
2962
|
+
}
|
|
2626
2963
|
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2627
2964
|
return coverage === true
|
|
2628
2965
|
? null
|
|
@@ -2630,6 +2967,13 @@ async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome)
|
|
|
2630
2967
|
}
|
|
2631
2968
|
if (planeTarget) {
|
|
2632
2969
|
if (live !== null) {
|
|
2970
|
+
const manifest = await findAttachablePlane(ctx).catch(() => null);
|
|
2971
|
+
if (manifest?.epicRunId === live.epicRunId) {
|
|
2972
|
+
const admission = await checkPlaneAdmission(ctx, readiness, manifest);
|
|
2973
|
+
if (admission !== null) {
|
|
2974
|
+
return { reasonId: "plane-reconciler-unconfirmed", remedy: admission.slice(1).join(" ") };
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2633
2977
|
if (await planeServesLiveRun(ctx, live))
|
|
2634
2978
|
return null;
|
|
2635
2979
|
}
|
|
@@ -2650,11 +2994,12 @@ function emitPreview(deps, options, preview) {
|
|
|
2650
2994
|
}
|
|
2651
2995
|
const lines = [
|
|
2652
2996
|
`${preview.epic_key}: launch preview (read-only; nothing was created or started)`,
|
|
2997
|
+
...(preview.cold_start ? [" Decision: conditional create — drive-epic will attempt guarded startup, recheck the live run, and create only if still appropriate."] : []),
|
|
2653
2998
|
` Target: ${preview.target.base_url ?? "unresolved"}${preview.target.kind ? ` (${preview.target.kind})` : ""}`,
|
|
2654
2999
|
` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
|
|
2655
3000
|
` Live run: ${preview.live_run
|
|
2656
3001
|
? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
|
|
2657
|
-
: "none"}`,
|
|
3002
|
+
: preview.cold_start ? "unchecked (plane not yet running)" : "none"}`,
|
|
2658
3003
|
` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
|
|
2659
3004
|
];
|
|
2660
3005
|
for (const candidate of preview.plan.candidates ?? [])
|
|
@@ -2663,9 +3008,12 @@ function emitPreview(deps, options, preview) {
|
|
|
2663
3008
|
lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
|
|
2664
3009
|
}
|
|
2665
3010
|
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
|
|
3011
|
+
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}`);
|
|
3012
|
+
if (preview.readiness.remedy &&
|
|
3013
|
+
preview.readiness.remedy !== preview.refusal?.remedy &&
|
|
3014
|
+
!preview.refusal?.reason_id.startsWith("access-")) {
|
|
2668
3015
|
lines.push(` Remedy: ${preview.readiness.remedy}`);
|
|
3016
|
+
}
|
|
2669
3017
|
if (preview.refusal) {
|
|
2670
3018
|
lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
|
|
2671
3019
|
}
|