@bridge_gpt/mcp-server 0.2.62 → 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 +66 -15
- package/build/agent-launchers/claude-executor-adapter.js +145 -20
- package/build/agent-launchers/executor-adapter.js +81 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +565 -32
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +351 -0
- package/build/conductor/epic-implementer-cli.js +154 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +45 -12
- package/build/conductor/recovery-cli.js +859 -27
- package/build/conductor/recovery-operations.js +574 -5
- package/build/conductor/repair-contract.js +69 -3
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +52 -13
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +3 -2
- package/build/doctor.js +147 -21
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1537 -125
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +41 -33
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +58 -19
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +291 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +12 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +91 -4
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +61 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +1550 -55
- package/build/executor/job-types.js +9 -6
- package/build/executor/preflight.js +189 -1
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +366 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +168 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +13 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +70 -34
- package/build/executor/worker-finalization.js +168 -26
- package/build/executor/worker-guard-hook.js +75 -14
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +594 -1152
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +231 -41
- 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/merge-pull-request.js +1 -1
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +146 -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/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +208 -125
- 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 +5 -4
- package/build/worktree-core.js +6 -5
- package/docs/CONDUCTOR.md +116 -2
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +2 -1
- package/pipelines/implement-ticket.json +1 -1
package/build/drive-epic.js
CHANGED
|
@@ -53,13 +53,15 @@
|
|
|
53
53
|
* ratcheted down, and a tool would be useless in a bare-chat session anyway,
|
|
54
54
|
* where the model has no tool call to make.
|
|
55
55
|
*/
|
|
56
|
-
import { readFile } from "node:fs/promises";
|
|
57
|
-
import { dirname, join } from "node:path";
|
|
58
|
-
import { 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";
|
|
56
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
57
|
+
import path, { dirname, join } from "node:path";
|
|
58
|
+
import { CONDUCTOR_DEFAULT_BASE_URL, ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.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 { runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
62
|
+
import { policyDeclaresNotifyWebhook, effectivePolicyFacts, readSetupEpicPolicyFile, runSetupEpicWorkflow, } from "./setup-epic.js";
|
|
63
|
+
import { EPIC_KEY_PATTERN } from "./drive-epic-key.js";
|
|
64
|
+
import { discoverEpicPlan, } from "./plan-discovery.js";
|
|
63
65
|
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
64
66
|
import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
|
|
65
67
|
import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
|
|
@@ -68,11 +70,11 @@ import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
|
|
|
68
70
|
import { resolveMemberInstanceId } from "./plane/supervisor.js";
|
|
69
71
|
import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
|
|
70
72
|
import { validateBranchName } from "./base-ref.js";
|
|
73
|
+
import { createDefaultRunExecutorLaunchDeps, ensureRunExecutor as ensureRunExecutorDefault, prepareRunExecutor as prepareRunExecutorDefault, } from "./executor-launch.js";
|
|
74
|
+
import { isTerminalEpicRunStatus } from "./epic-run-status.js";
|
|
71
75
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
72
76
|
// the printed invocation interpolates it rather than repeating it.
|
|
73
77
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
74
|
-
/** Jira epic key shape. Matches the epic-key positional rule exactly. */
|
|
75
|
-
const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
76
78
|
/** The conductor paths. Derived types below come from this constant. */
|
|
77
79
|
export const DRIVE_EPIC_CONDUCTORS = ["v2"];
|
|
78
80
|
/**
|
|
@@ -112,11 +114,23 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
112
114
|
{
|
|
113
115
|
id: "reconciler_live",
|
|
114
116
|
describe: "a reconciler that is ticking and not stale",
|
|
115
|
-
|
|
117
|
+
// BAPI-1279 — launch liveness is a DEPLOYMENT fact. The `bring_up` block
|
|
118
|
+
// reads the reconciler's process heartbeat deployment-wide, so a repository
|
|
119
|
+
// that has never run an epic still reads live when the worker is healthy.
|
|
120
|
+
// The repository-scoped `reconciler` section ("is this repository's work
|
|
121
|
+
// moving?", from `epic_runs.last_tick_at`) is only the fallback for a server
|
|
122
|
+
// too old to report `bring_up`.
|
|
123
|
+
satisfied: (r) => r.bring_up
|
|
124
|
+
? r.bring_up.heartbeat_readable && r.bring_up.worker_present &&
|
|
125
|
+
r.bring_up.single_transition_authority
|
|
126
|
+
: r.reconciler.liveness_readable && !r.reconciler.stale,
|
|
116
127
|
},
|
|
117
128
|
{
|
|
118
129
|
id: "executor_live",
|
|
119
130
|
describe: "an executor provisioned and reporting ready",
|
|
131
|
+
// BAPI-1279 — diagnostic only. drive-epic starts (or, for a durable profile,
|
|
132
|
+
// verifies) the run's executor itself once the run exists.
|
|
133
|
+
advisoryOnly: true,
|
|
120
134
|
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
121
135
|
},
|
|
122
136
|
// BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
|
|
@@ -140,17 +154,22 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
140
154
|
? "This Bridge API deploy does not report a repository's unattended " +
|
|
141
155
|
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
142
156
|
"deploy, or pass --attended."
|
|
143
|
-
: "
|
|
144
|
-
"
|
|
157
|
+
: "Unattended runs are off for this repository. Turn on 'Allow unattended " +
|
|
158
|
+
"Epic Implementer runs' on the repository's Setup page in the Bridge " +
|
|
159
|
+
"admin panel, or pass --attended. If it is already on, contact Bridge support.",
|
|
145
160
|
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
146
161
|
},
|
|
147
162
|
{
|
|
148
163
|
id: "unattended_notify_default",
|
|
149
164
|
describe: "a verified notify webhook default for escalation",
|
|
150
165
|
unattendedOnly: true,
|
|
151
|
-
remedy: "Supply a --policy-file declaring notify.webhook_url, or
|
|
166
|
+
remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
|
|
167
|
+
"to the account so escalation alerts can be emailed, or pass --attended. " +
|
|
152
168
|
"notify.local_sink does not satisfy this.",
|
|
153
|
-
|
|
169
|
+
// BAPI-1274 — the account's email address is an escalation channel when no
|
|
170
|
+
// webhook is declared; the server reports its presence, never the address.
|
|
171
|
+
satisfied: (r) => r.unattended?.notify_webhook_default_declared === true ||
|
|
172
|
+
r.unattended?.escalation_email_available === true,
|
|
154
173
|
},
|
|
155
174
|
{
|
|
156
175
|
id: "repository_readiness",
|
|
@@ -190,7 +209,10 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
190
209
|
/**
|
|
191
210
|
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
192
211
|
*
|
|
193
|
-
*
|
|
212
|
+
* BAPI-1279 narrowed this to the reconciler. `executor_live` is advisory now
|
|
213
|
+
* (see `advisoryOnly`): drive-epic ensures the run's executor itself after the
|
|
214
|
+
* run is created, so its absence before then is never unmet. What remains is
|
|
215
|
+
* liveness of a process this machine starts. Everything else —
|
|
194
216
|
* `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
|
|
195
217
|
* `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
|
|
196
218
|
* stored server-side state, credentials, or repository configuration, and no
|
|
@@ -203,7 +225,7 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
203
225
|
* running plane behind and change nothing — which is the precise failure the
|
|
204
226
|
* classification exists to prevent.
|
|
205
227
|
*/
|
|
206
|
-
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"
|
|
228
|
+
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
|
|
207
229
|
/**
|
|
208
230
|
* Classify the SERVER's readiness report into the composition's three cases.
|
|
209
231
|
*
|
|
@@ -217,6 +239,24 @@ export const V2_RUNTIME_READINESS_IDS = ["reconciler_live", "executor_live"];
|
|
|
217
239
|
* already encodes that (setup, then config, then credentials).
|
|
218
240
|
*/
|
|
219
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
|
+
}
|
|
220
260
|
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended-only
|
|
221
261
|
// requirements INAPPLICABLE, not merely tolerated. They authorize unattended
|
|
222
262
|
// operation and automatic merge behavior; an attended run asks for neither, so
|
|
@@ -232,7 +272,9 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
232
272
|
// which reads the policy and refuses if it declares none. Nothing is weakened:
|
|
233
273
|
// `setup-epic` gates before the create, and the server re-checks the predicate
|
|
234
274
|
// at approval and probes the URL there.
|
|
235
|
-
const deferred = options.
|
|
275
|
+
const deferred = options.approvedStoredPolicy ||
|
|
276
|
+
options.effectivePolicy?.escalationChannel === "configured webhook" ||
|
|
277
|
+
(options.effectivePolicy === undefined && options.hasPolicyFile)
|
|
236
278
|
? V2_READINESS_REQUIREMENTS.filter((req) => req.id !== "unattended_notify_default")
|
|
237
279
|
: V2_READINESS_REQUIREMENTS;
|
|
238
280
|
// `--into-base` is the second opt-out, and it is structural rather than
|
|
@@ -243,12 +285,34 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
243
285
|
// to say about — the same reasoning `--attended` rests on, reached by a
|
|
244
286
|
// different route. See `composeDefaultSetupEpicPolicy`.
|
|
245
287
|
const optedOut = options.attended === true || options.intoBase === true;
|
|
288
|
+
// BAPI-1279 — advisory rows are reported, never unmet.
|
|
289
|
+
const gating = deferred.filter((req) => req.advisoryOnly !== true);
|
|
246
290
|
const applicable = optedOut
|
|
247
|
-
?
|
|
248
|
-
:
|
|
291
|
+
? gating.filter((req) => req.unattendedOnly !== true)
|
|
292
|
+
: gating;
|
|
249
293
|
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
250
|
-
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
|
+
}
|
|
251
314
|
return { kind: "green" };
|
|
315
|
+
}
|
|
252
316
|
const blocker = unmet.find((req) => !V2_RUNTIME_READINESS_IDS.includes(req.id));
|
|
253
317
|
if (blocker) {
|
|
254
318
|
return {
|
|
@@ -284,18 +348,34 @@ export function getDriveEpicUsage() {
|
|
|
284
348
|
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
285
349
|
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
286
350
|
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
287
|
-
"unattended run has two
|
|
288
|
-
"
|
|
289
|
-
"
|
|
351
|
+
"unattended run has two repository prerequisites (consent, which is ON by",
|
|
352
|
+
"default and switched on the Bridge admin panel's Setup page, and a verified",
|
|
353
|
+
"notify webhook default) that readiness lists with their state and that are",
|
|
354
|
+
"refused BY NAME before any run is created.",
|
|
355
|
+
"",
|
|
356
|
+
"Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
|
|
357
|
+
"created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
|
|
358
|
+
"this checkout's Git top level. It claims only that run's jobs, logs to",
|
|
359
|
+
".bridge/executors/<run>.log, and exits on its own when the run is done or",
|
|
360
|
+
"abandoned. Re-running for the same live run starts no second one. A durable",
|
|
361
|
+
"launch profile starts none: it requires a fresh --repo-wide executor instead.",
|
|
362
|
+
"Checks that need no run happen FIRST, so a refusal before the run is created",
|
|
363
|
+
"creates nothing; an executor that fails to start after it leaves the run intact",
|
|
364
|
+
"and prints the log path and the exact command to start it by hand.",
|
|
365
|
+
"",
|
|
366
|
+
"Reconciler liveness is read from the deployment's heartbeat. Against a target",
|
|
367
|
+
"that is not this machine's plane (production, say), drive-epic never touches a",
|
|
368
|
+
"local plane: a target with no live reconciler is refused by name.",
|
|
290
369
|
"",
|
|
291
370
|
"One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
|
|
292
|
-
"starts the conductor runtime for you
|
|
293
|
-
"reconciler
|
|
294
|
-
"reconciler), creates and approves the run, then starts executor lanes
|
|
295
|
-
"that run, and starts the dead-man observer last — one invocation, no
|
|
296
|
-
"`plane up` first. A report missing supervisor setup,
|
|
297
|
-
"GitHub App credentials, or an unattended prerequisite
|
|
298
|
-
"operator-owned, and drive-epic exits with that one
|
|
371
|
+
"starts the conductor runtime for you against the local plane endpoint, when",
|
|
372
|
+
"readiness is missing ONLY the reconciler. It brings up the CONTROL PLANE first",
|
|
373
|
+
"(server and reconciler), creates and approves the run, then starts executor lanes",
|
|
374
|
+
"SCOPED to that run, and starts the dead-man observer last — one invocation, no",
|
|
375
|
+
"manual `plane up` first. A report missing supervisor setup,",
|
|
376
|
+
"supervisor configuration, GitHub App credentials, or an unattended prerequisite",
|
|
377
|
+
"is NOT started: those are operator-owned, and drive-epic exits with that one",
|
|
378
|
+
"named reason instead.",
|
|
299
379
|
"",
|
|
300
380
|
"The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
|
|
301
381
|
`local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
|
|
@@ -307,18 +387,42 @@ export function getDriveEpicUsage() {
|
|
|
307
387
|
" - refusal: anything else holds the port (another listener, a manifest for a",
|
|
308
388
|
" different repository, an unreadable manifest, a plane that cannot take a",
|
|
309
389
|
" run) — one named reason, and nothing is started.",
|
|
310
|
-
"One epic per plane: a plane
|
|
311
|
-
"
|
|
312
|
-
"run
|
|
390
|
+
"One epic per plane — for its LANES: a plane's lanes serve the one run it is",
|
|
391
|
+
"bound to. A second run attaches to the same plane's reconciler and gets its own",
|
|
392
|
+
"run-scoped executor instead. A plane whose bound run cannot be read is refused",
|
|
393
|
+
"as plane-already-bound. Concurrent planes on one database are not supported.",
|
|
394
|
+
"Executor lanes and run-scoped executors are always scoped to the one run with",
|
|
395
|
+
"--epic-run-id, on every path.",
|
|
396
|
+
"",
|
|
397
|
+
"Finding the plan (BAPI-1285): without --plan-file, drive-epic searches",
|
|
398
|
+
"{docs_dir}/epic-plans/*/epic-plan.meta.json for a file naming this epic",
|
|
399
|
+
"({\"epic_key\": \"<KEY>\"}), where docs_dir is BAPI_DOCS_DIR or docs/tmp",
|
|
400
|
+
"resolved against the Git top level. Exactly one match with a sibling",
|
|
401
|
+
"epic-plan.dag.json is used as if you had passed --plan-file, and its path is",
|
|
402
|
+
"printed before anything is created. It never guesses: no match, two or more",
|
|
403
|
+
"matches, or an unusable meta file stops with the paths it found and creates",
|
|
404
|
+
"nothing. It never matches by folder name, child ticket key, or manifest. A",
|
|
405
|
+
"supplied --plan-file always wins, and discovery does not run. The meta file is",
|
|
406
|
+
"written by `emit-epic-implementer-bundle finalize`; a plan finalized before",
|
|
407
|
+
"that has none, so pass its DAG with --plan-file.",
|
|
408
|
+
"",
|
|
409
|
+
"A live run with no plan: when the epic already has a live run and no plan was",
|
|
410
|
+
"supplied or found, drive-epic does not create anything. It makes sure that run",
|
|
411
|
+
"has an executor (reusing a live one, never starting a second) and reports it. A",
|
|
412
|
+
"durable launch profile starts none and relies on its host's confirmed repo-wide",
|
|
413
|
+
"executor; a plane already bound to the run needs none. An epic with several active runs, or",
|
|
414
|
+
"a run that cannot be read, is refused by name.",
|
|
313
415
|
"",
|
|
314
416
|
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
315
|
-
"work tree
|
|
316
|
-
"you
|
|
417
|
+
"work tree inside the target repository, because the run's executor starts there.",
|
|
418
|
+
"No worktree is created for you and no replacement port is chosen for you — each",
|
|
419
|
+
"failure names the fix. Fix it and rerun.",
|
|
317
420
|
"",
|
|
318
421
|
"Options:",
|
|
319
|
-
" --plan-file <path> Plan DAG sidecar.
|
|
320
|
-
"
|
|
321
|
-
"
|
|
422
|
+
" --plan-file <path> Plan DAG sidecar. Optional: without it, drive-epic finds",
|
|
423
|
+
" the plan by epic key (see above). A supplied path always",
|
|
424
|
+
" wins, and when the v2 path is selected drive-epic runs",
|
|
425
|
+
" the bootstrap directly with it.",
|
|
322
426
|
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
323
427
|
" --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
|
|
324
428
|
" verbatim, never interpreted here.",
|
|
@@ -339,13 +443,31 @@ export function getDriveEpicUsage() {
|
|
|
339
443
|
" --launch-profile <name> Select the host-local launch profile guided setup",
|
|
340
444
|
" approved (`install conductor --launch-profile <name>`):",
|
|
341
445
|
" target, repository, mode, runtime, and policy and",
|
|
342
|
-
" credential references.
|
|
343
|
-
"
|
|
446
|
+
" credential references. Useful for a local or other",
|
|
447
|
+
" non-production server and for durable hosts.",
|
|
344
448
|
" --base-url <url> Explicit Bridge API target. With a profile it must",
|
|
345
449
|
" agree with it — an override never rebinds a profile.",
|
|
346
|
-
"
|
|
347
|
-
"
|
|
348
|
-
"
|
|
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.",
|
|
454
|
+
"Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
|
|
455
|
+
`targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
|
|
456
|
+
"stored bapi:<repo> key when one is available. A stored key approved for a",
|
|
457
|
+
"different endpoint is never sent; an unapproved key is sent only to",
|
|
458
|
+
"production.",
|
|
459
|
+
"",
|
|
460
|
+
"Preview (BAPI-1285):",
|
|
461
|
+
" --dry-run Read-only launch preview. Resolves the target, reads",
|
|
462
|
+
" readiness and the epic's live run, discovers the plan,",
|
|
463
|
+
" and runs the bootstrap's own dry-run validation. It",
|
|
464
|
+
" reports what a real run WOULD do (create, attach,",
|
|
465
|
+
" refuse, or stop) and never creates a run, cuts a",
|
|
466
|
+
" branch, starts a plane, requests a lane, or starts or",
|
|
467
|
+
" signals an executor. Exit 0 for create or attach, 1 for",
|
|
468
|
+
" refuse or stop.",
|
|
469
|
+
" --json With --dry-run only: print exactly one JSON document on",
|
|
470
|
+
" stdout and nothing else. Alone, it is a parse error.",
|
|
349
471
|
"",
|
|
350
472
|
" -h, --help Show this help",
|
|
351
473
|
].join("\n");
|
|
@@ -390,8 +512,19 @@ export function parseDriveEpicArgs(argv) {
|
|
|
390
512
|
return trimmed;
|
|
391
513
|
};
|
|
392
514
|
let attended = false;
|
|
515
|
+
// BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
|
|
516
|
+
let dryRun = false;
|
|
517
|
+
let json = false;
|
|
393
518
|
for (let i = 0; i < argv.length; i++) {
|
|
394
519
|
const arg = argv[i];
|
|
520
|
+
if (arg === "--dry-run") {
|
|
521
|
+
dryRun = true;
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (arg === "--json") {
|
|
525
|
+
json = true;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
395
528
|
if (arg === "--into-base") {
|
|
396
529
|
intoBase = true;
|
|
397
530
|
continue;
|
|
@@ -473,6 +606,15 @@ export function parseDriveEpicArgs(argv) {
|
|
|
473
606
|
}
|
|
474
607
|
positionals.push(arg);
|
|
475
608
|
}
|
|
609
|
+
// BAPI-1285 — same style as setup-epic's `--local-only` needs `--dry-run` rule,
|
|
610
|
+
// refused before any dependency is touched.
|
|
611
|
+
if (json && !dryRun) {
|
|
612
|
+
return {
|
|
613
|
+
status: "error",
|
|
614
|
+
message: "--json is only valid together with --dry-run. It selects the machine-readable " +
|
|
615
|
+
"form of the read-only launch preview, and a real launch has no JSON output.",
|
|
616
|
+
};
|
|
617
|
+
}
|
|
476
618
|
// BAPI-1009: refused HERE — before access resolution, before the readiness
|
|
477
619
|
// request, and before any delegation — with the same corrective wording
|
|
478
620
|
// setup-epic uses, so the two entry points explain one mistake one way.
|
|
@@ -530,6 +672,8 @@ export function parseDriveEpicArgs(argv) {
|
|
|
530
672
|
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
531
673
|
...(launchProfile ? { launchProfile } : {}),
|
|
532
674
|
...(baseUrl ? { baseUrl } : {}),
|
|
675
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
676
|
+
...(json ? { json: true } : {}),
|
|
533
677
|
};
|
|
534
678
|
return { status: "ok", options };
|
|
535
679
|
}
|
|
@@ -556,7 +700,7 @@ export function parseDriveEpicArgs(argv) {
|
|
|
556
700
|
* about to be created.
|
|
557
701
|
*/
|
|
558
702
|
export function selectConductor(readiness) {
|
|
559
|
-
const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
|
|
703
|
+
const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && req.advisoryOnly !== true && !req.satisfied(readiness));
|
|
560
704
|
if (missing.length === 0) {
|
|
561
705
|
return {
|
|
562
706
|
kind: "selected",
|
|
@@ -598,18 +742,24 @@ export function buildSetupEpicArgv(options) {
|
|
|
598
742
|
...(replacePolicy ? ["--replace-policy"] : []),
|
|
599
743
|
// BAPI-1260 — the SAME launch selection, so setup-epic resolves the same
|
|
600
744
|
// target and bound credential drive-epic just did. Appended only when
|
|
601
|
-
// supplied, so every pre-existing argv stays byte-identical.
|
|
745
|
+
// supplied, so every pre-existing argv stays byte-identical. A flag-less
|
|
746
|
+
// (production-default, BAPI-1272) run forwards neither: setup-epic reads
|
|
747
|
+
// the adopted BAPI_BASE_URL instead.
|
|
602
748
|
...(launchProfile ? ["--launch-profile", launchProfile] : []),
|
|
603
749
|
...(baseUrl ? ["--base-url", baseUrl] : []),
|
|
604
750
|
];
|
|
605
751
|
}
|
|
606
752
|
/**
|
|
607
|
-
* Render the
|
|
753
|
+
* Render the no-plan stop: what discovery found, how to make a plan, and how to
|
|
754
|
+
* re-run with one.
|
|
755
|
+
*
|
|
756
|
+
* Precondition: the caller has already ruled out delegation, a not-ready result,
|
|
757
|
+
* AND a live run. This runs only after plan discovery came back empty or
|
|
758
|
+
* ambiguous (BAPI-1285), so it never claims "nothing derives a plan from an epic
|
|
759
|
+
* key" — something does, and it names exactly what it found.
|
|
608
760
|
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* is only true once {@link runDriveEpicCli} has declined to delegate — so route
|
|
612
|
-
* first, render second.
|
|
761
|
+
* It offers two ways to re-run and only one of them is a runnable `drive-epic`
|
|
762
|
+
* command: `/drive-epic-run` is a session slash command, not a second conductor.
|
|
613
763
|
*/
|
|
614
764
|
export function renderConductorHandoff(selection, epicKey,
|
|
615
765
|
/**
|
|
@@ -619,7 +769,12 @@ export function renderConductorHandoff(selection, epicKey,
|
|
|
619
769
|
* shell. The plan file is the one thing that cannot be filled in yet, and it
|
|
620
770
|
* is the only placeholder left.
|
|
621
771
|
*/
|
|
622
|
-
launch = { profile: null }
|
|
772
|
+
launch = { profile: null },
|
|
773
|
+
/**
|
|
774
|
+
* BAPI-1285 — what discovery found. Absent reads as "no metadata named this
|
|
775
|
+
* epic", with the searched directory left as its placeholder.
|
|
776
|
+
*/
|
|
777
|
+
discovery) {
|
|
623
778
|
const invocation = launch.profile !== null
|
|
624
779
|
? renderEpicLaunchCommand(launch.profile, {
|
|
625
780
|
epicKey,
|
|
@@ -630,25 +785,49 @@ launch = { profile: null }) {
|
|
|
630
785
|
...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
|
|
631
786
|
}).replace(`${shellQuote("<path>")}`, "<path>")
|
|
632
787
|
: `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
|
|
788
|
+
const plansDir = discovery?.epic_plans_dir ?? "{docs_dir}/epic-plans";
|
|
789
|
+
const outcome = discovery?.status === "plan_ambiguous"
|
|
790
|
+
? [
|
|
791
|
+
`More than one plan names ${epicKey} under ${plansDir}, and drive-epic does not guess`,
|
|
792
|
+
"between them. Candidates:",
|
|
793
|
+
...discovery.candidates.map((candidate) => ` - ${candidate}`),
|
|
794
|
+
]
|
|
795
|
+
: [`No epic-plan.meta.json under ${plansDir} names ${epicKey}.`];
|
|
796
|
+
const skipped = discovery !== undefined && discovery.skipped.length > 0
|
|
797
|
+
? [
|
|
798
|
+
"",
|
|
799
|
+
"These metadata files could not be used, so they were not counted:",
|
|
800
|
+
...discovery.skipped.map((entry) => ` - ${entry.path} (${entry.reason})`),
|
|
801
|
+
]
|
|
802
|
+
: [];
|
|
633
803
|
return {
|
|
634
804
|
conductor: "v2",
|
|
635
805
|
invocation,
|
|
636
806
|
lines: [
|
|
637
|
-
`${epicKey}: ${selection.reason}, but no plan
|
|
807
|
+
`${epicKey}: ${selection.reason}, but no plan was supplied and none could be selected.`,
|
|
638
808
|
"",
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
"anywhere else. Produce one, then re-run this command with it.",
|
|
809
|
+
...outcome,
|
|
810
|
+
...skipped,
|
|
642
811
|
"",
|
|
643
|
-
|
|
812
|
+
"Discovery reads only epic-plan.meta.json ({\"epic_key\": ...}). It never matches a",
|
|
813
|
+
"folder name, a child ticket key, or a manifest. A plan finalized before that file",
|
|
814
|
+
"existed has none; find an older plan's DAG with:",
|
|
815
|
+
` ${plansDir}/*/epic-plan.dag.json`,
|
|
816
|
+
"",
|
|
817
|
+
"The engine path needs a plan DAG: the dependency edges and per-ticket",
|
|
818
|
+
"touched_files it carries do not exist anywhere else.",
|
|
819
|
+
"",
|
|
820
|
+
` 1. To make one, run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
644
821
|
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
645
822
|
" If the epic's tickets already exist, run emit-epic-implementer-bundle",
|
|
646
823
|
" finalize instead — it resolves the placeholder keys in an existing",
|
|
647
|
-
" sidecar and
|
|
824
|
+
" sidecar, attaches touched_files, and writes epic-plan.meta.json so the",
|
|
825
|
+
" plan is found next time.",
|
|
648
826
|
"",
|
|
649
|
-
` 2. ${
|
|
827
|
+
` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
|
|
828
|
+
` or from a shell: ${invocation}`,
|
|
650
829
|
"",
|
|
651
|
-
`That
|
|
830
|
+
`That shell command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
|
|
652
831
|
"something you invoke directly.",
|
|
653
832
|
],
|
|
654
833
|
};
|
|
@@ -884,8 +1063,6 @@ export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
|
|
|
884
1063
|
memberEnv.DYNO = env.DYNO;
|
|
885
1064
|
return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
|
|
886
1065
|
}
|
|
887
|
-
/** Terminal `epic_runs.status` values; every other status is a live run. */
|
|
888
|
-
const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
|
|
889
1066
|
/** The BAPI-1260 launch selection carried by parsed options. */
|
|
890
1067
|
function launchSelectionOf(options) {
|
|
891
1068
|
return {
|
|
@@ -893,18 +1070,94 @@ function launchSelectionOf(options) {
|
|
|
893
1070
|
...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
|
|
894
1071
|
};
|
|
895
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* BAPI-1285 — resolve the docs directory discovery searches, through two seams.
|
|
1075
|
+
*
|
|
1076
|
+
* `BAPI_DOCS_DIR` is read once. An absolute value is used as given and needs no
|
|
1077
|
+
* Git call. Otherwise the value — or `docs/tmp` — is resolved against the Git top
|
|
1078
|
+
* level. When Git cannot name a top level this REFUSES rather than substituting
|
|
1079
|
+
* `cwd`: silently searching the wrong directory would report "no plan" for a plan
|
|
1080
|
+
* that exists.
|
|
1081
|
+
*
|
|
1082
|
+
* `index.ts` (project root) and the bundle CLI (`cwd`) resolve the same default
|
|
1083
|
+
* slightly differently today; in the normal case all three equal the Git top
|
|
1084
|
+
* level, and neither of those is changed here.
|
|
1085
|
+
*/
|
|
1086
|
+
export async function resolveDriveEpicDocsDir(seams) {
|
|
1087
|
+
const raw = seams.env.BAPI_DOCS_DIR;
|
|
1088
|
+
const configured = raw !== undefined && raw.trim() !== "" ? raw : undefined;
|
|
1089
|
+
if (configured !== undefined && path.isAbsolute(configured)) {
|
|
1090
|
+
return { ok: true, docsDir: configured };
|
|
1091
|
+
}
|
|
1092
|
+
let top;
|
|
1093
|
+
try {
|
|
1094
|
+
top = await seams.gitTopLevel();
|
|
1095
|
+
}
|
|
1096
|
+
catch {
|
|
1097
|
+
return { ok: false };
|
|
1098
|
+
}
|
|
1099
|
+
if (top === null || top.trim() === "")
|
|
1100
|
+
return { ok: false };
|
|
1101
|
+
return { ok: true, docsDir: path.resolve(top, configured ?? path.join("docs", "tmp")) };
|
|
1102
|
+
}
|
|
1103
|
+
/** The real filesystem behind plan discovery. */
|
|
1104
|
+
function createDefaultPlanDiscoveryFs() {
|
|
1105
|
+
return {
|
|
1106
|
+
readdir: (dirPath) => readdir(dirPath, { withFileTypes: true }),
|
|
1107
|
+
readFile: (filePath) => readFile(filePath, "utf8"),
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* BAPI-1285 — map the by-epic-key run read the way `setup-epic`'s Step 0 does:
|
|
1112
|
+
* 404 is no run, 409 is a wedged epic, and every other failure is unknown.
|
|
1113
|
+
* Nothing but a status and an error kind is ever kept from a failure.
|
|
1114
|
+
*/
|
|
1115
|
+
export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
1116
|
+
try {
|
|
1117
|
+
const state = await fetchEpicRunState(access, epicKey, fetchImpl);
|
|
1118
|
+
const run = state
|
|
1119
|
+
?.epic_run;
|
|
1120
|
+
if (typeof run?.epic_run_id !== "string" ||
|
|
1121
|
+
run.epic_run_id === "" ||
|
|
1122
|
+
typeof run.status !== "string") {
|
|
1123
|
+
return { kind: "unknown", reason: "the run state response was malformed" };
|
|
1124
|
+
}
|
|
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
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
catch (err) {
|
|
1135
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
1136
|
+
if (err.status === 404)
|
|
1137
|
+
return { kind: "none" };
|
|
1138
|
+
if (err.status === 409)
|
|
1139
|
+
return { kind: "conflict" };
|
|
1140
|
+
return {
|
|
1141
|
+
kind: "unknown",
|
|
1142
|
+
reason: `kind=${err.kind}${typeof err.status === "number" ? ` status=${err.status}` : ""}`,
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
return { kind: "unknown", reason: "the run state read failed unexpectedly" };
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
896
1148
|
export function createDefaultDriveEpicDeps() {
|
|
897
1149
|
return {
|
|
898
1150
|
resolveAccess: async (repo, cwd, launch) => {
|
|
899
|
-
// BAPI-1260: the target-first launch contract.
|
|
900
|
-
// production
|
|
1151
|
+
// BAPI-1260: the target-first launch contract. BAPI-1272: with no target
|
|
1152
|
+
// selected it defaults to production, and a stored key is sent there only
|
|
1153
|
+
// when it is unbound or bound to production — never one bound elsewhere.
|
|
901
1154
|
const result = await resolveEpicLaunchBridgeApiAccess(launch ?? {}, {
|
|
902
1155
|
...(repo ? { repoName: repo } : {}),
|
|
903
1156
|
...(cwd ? { cwd } : {}),
|
|
904
1157
|
});
|
|
905
1158
|
return result.ok
|
|
906
|
-
? { ok: true, access: result.access, profile: result.profile }
|
|
907
|
-
: { 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 } : {}) };
|
|
908
1161
|
},
|
|
909
1162
|
// Non-secret, and only ever a value the resolver just agreed with the
|
|
910
1163
|
// environment on (a conflicting BAPI_BASE_URL was refused above), so the
|
|
@@ -913,7 +1166,7 @@ export function createDefaultDriveEpicDeps() {
|
|
|
913
1166
|
process.env.BAPI_BASE_URL = baseUrl;
|
|
914
1167
|
},
|
|
915
1168
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
916
|
-
runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
|
|
1169
|
+
runSetupEpic: (argv, handoff) => runSetupEpicWorkflow(argv, {}, handoff),
|
|
917
1170
|
readEligibilityArtifact: (path) => readFile(path, "utf8"),
|
|
918
1171
|
runPlanePreflight: (repoRoot) => runDefaultPlanePreflight(repoRoot),
|
|
919
1172
|
runPlane: (argv, overrides) => runPlaneCli(argv, overrides ?? {}),
|
|
@@ -955,6 +1208,39 @@ export function createDefaultDriveEpicDeps() {
|
|
|
955
1208
|
return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
|
|
956
1209
|
},
|
|
957
1210
|
readPlaneManifest: (repoRoot) => readPlaneManifest(repoRoot, createPlaneFsDeps()),
|
|
1211
|
+
resolveDocsDir: () => resolveDriveEpicDocsDir({
|
|
1212
|
+
env: process.env,
|
|
1213
|
+
gitTopLevel: async () => {
|
|
1214
|
+
const top = await createDefaultRunExecutorLaunchDeps().execFile("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd() });
|
|
1215
|
+
return top.ok ? top.stdout.trim() : null;
|
|
1216
|
+
},
|
|
1217
|
+
}),
|
|
1218
|
+
discoveryFs: createDefaultPlanDiscoveryFs(),
|
|
1219
|
+
readPlanDag: (filePath) => readFile(filePath, "utf8"),
|
|
1220
|
+
readPolicyFile: (filePath) => readFile(filePath, "utf8"),
|
|
1221
|
+
readLiveEpicRun: (access, epicKey) => readLiveEpicRunByKey(access, epicKey),
|
|
1222
|
+
previewSetupEpic: async ({ argv, baseUrl, handoff }) => {
|
|
1223
|
+
const stdout = [];
|
|
1224
|
+
const stderr = [];
|
|
1225
|
+
const result = await runSetupEpicWorkflow(argv, {
|
|
1226
|
+
// A COPY carrying the resolved target: the preview never mutates
|
|
1227
|
+
// `process.env`, so a dry run leaves the process exactly as it found it.
|
|
1228
|
+
env: { ...process.env, BAPI_BASE_URL: baseUrl },
|
|
1229
|
+
log: (message) => stdout.push(message),
|
|
1230
|
+
errorLog: (message) => stderr.push(message),
|
|
1231
|
+
}, handoff);
|
|
1232
|
+
return { exitCode: result.exitCode, stdout, stderr };
|
|
1233
|
+
},
|
|
1234
|
+
// Read-only by construction: `prepareRunExecutor` runs `git rev-parse` and
|
|
1235
|
+
// locates the package entrypoint, and starts nothing. It is reached through
|
|
1236
|
+
// its module function here, never through the `prepareRunExecutor` dep the
|
|
1237
|
+
// preview must not call.
|
|
1238
|
+
inspectRunExecutorPrecondition: async (cwd) => {
|
|
1239
|
+
const result = await prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps());
|
|
1240
|
+
return result.ok ? { ok: true } : { ok: false, message: result.message };
|
|
1241
|
+
},
|
|
1242
|
+
prepareRunExecutor: (cwd) => prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps()),
|
|
1243
|
+
ensureRunExecutor: (args) => ensureRunExecutorDefault(args, createDefaultRunExecutorLaunchDeps()),
|
|
958
1244
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
959
1245
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
960
1246
|
log: (message) => console.log(message),
|
|
@@ -982,18 +1268,74 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
982
1268
|
}
|
|
983
1269
|
const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
|
|
984
1270
|
const launch = launchSelectionOf(parsed.options);
|
|
1271
|
+
// BAPI-1285 — the read-only preview is its own path from here. It shares the
|
|
1272
|
+
// classification and refusal builders below and none of the launch code.
|
|
1273
|
+
if (parsed.options.dryRun === true) {
|
|
1274
|
+
return await runDriveEpicPreview(deps, parsed.options);
|
|
1275
|
+
}
|
|
985
1276
|
try {
|
|
1277
|
+
// Every async branch below is `return await`ed. A bare `return promise`
|
|
1278
|
+
// inside this `try` would let a rejection skip the sanitizing `catch` at the
|
|
1279
|
+
// bottom and escape with its raw message (BAPI-1279).
|
|
986
1280
|
// --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
|
|
987
1281
|
// BAPI-1260: the TARGET is decided first — from --launch-profile,
|
|
988
|
-
// --base-url, and BAPI_BASE_URL — and only
|
|
989
|
-
// that target read. A
|
|
990
|
-
// any authenticated request;
|
|
1282
|
+
// --base-url, and BAPI_BASE_URL, else production (BAPI-1272) — and only
|
|
1283
|
+
// then is a credential bound to that target read. A conflicting target or
|
|
1284
|
+
// an unusable credential refuses here, before any authenticated request;
|
|
1285
|
+
// there is no fallback to another endpoint. The adopted target becomes the
|
|
1286
|
+
// child processes' BAPI_BASE_URL.
|
|
991
1287
|
const accessResult = await deps.resolveAccess(repo, undefined, launch);
|
|
992
1288
|
if (!accessResult.ok) {
|
|
993
1289
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
|
|
994
1290
|
}
|
|
995
1291
|
const launchProfile = accessResult.profile ?? null;
|
|
996
1292
|
deps.adoptLaunchTarget?.(accessResult.access.baseUrl);
|
|
1293
|
+
// BAPI-1279 — decided ONCE, right here. A target that is not this machine's
|
|
1294
|
+
// plane endpoint never reaches a local-plane operation: no preflight, no
|
|
1295
|
+
// launch, no lane request, no manifest read, no port probe. Every branch
|
|
1296
|
+
// below reads this one value rather than re-deriving it.
|
|
1297
|
+
const planeTarget = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null;
|
|
1298
|
+
// `let`: a plan found by epic key (BAPI-1285) is folded into the options, after
|
|
1299
|
+
// which it is indistinguishable from a supplied `--plan-file`.
|
|
1300
|
+
let ctx = {
|
|
1301
|
+
deps,
|
|
1302
|
+
epicKey,
|
|
1303
|
+
options: parsed.options,
|
|
1304
|
+
access: accessResult.access,
|
|
1305
|
+
profile: launchProfile,
|
|
1306
|
+
targetSource: accessResult.targetSource ?? "default",
|
|
1307
|
+
};
|
|
1308
|
+
// BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
|
|
1309
|
+
// discovered plan must count as a supplied one for the BAPI-1153 cold-start
|
|
1310
|
+
// case below (a local plane that is not running yet is the commonest first
|
|
1311
|
+
// launch), and discovery is local, read-only, and free. What it may NOT do is
|
|
1312
|
+
// decide anything before readiness: an unresolvable docs root is only
|
|
1313
|
+
// remembered here and refused after readiness, so unknown readiness still
|
|
1314
|
+
// escalates exactly as before, and the live-run read stays after readiness.
|
|
1315
|
+
let discovered = null;
|
|
1316
|
+
let docsRootUnresolved = false;
|
|
1317
|
+
if (planFile === undefined) {
|
|
1318
|
+
const docs = await deps.resolveDocsDir();
|
|
1319
|
+
if (docs.ok) {
|
|
1320
|
+
discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
1321
|
+
if (discovered.status === "found") {
|
|
1322
|
+
ctx = { ...ctx, options: { ...ctx.options, planFile: discovered.path } };
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
docsRootUnresolved = true;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
let foundPlanAnnounced = false;
|
|
1330
|
+
/** Names the found plan once, BEFORE anything is created, and surfaces its advisory. */
|
|
1331
|
+
const announceFoundPlan = async (surfaceAdvisory = true) => {
|
|
1332
|
+
if (foundPlanAnnounced || discovered?.status !== "found")
|
|
1333
|
+
return;
|
|
1334
|
+
foundPlanAnnounced = true;
|
|
1335
|
+
deps.log(`${epicKey}: using the plan found by epic key: ${discovered.path}`);
|
|
1336
|
+
if (surfaceAdvisory)
|
|
1337
|
+
await surfaceContainmentAdvisory(deps, discovered.path);
|
|
1338
|
+
};
|
|
997
1339
|
// --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
|
|
998
1340
|
let readiness;
|
|
999
1341
|
try {
|
|
@@ -1006,7 +1348,10 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1006
1348
|
// Everything else — a non-plane base URL, an HTTP or auth or shape error, a
|
|
1007
1349
|
// timeout, any other probe result — keeps today's escalation byte for byte,
|
|
1008
1350
|
// because there unknown is still fatal (R14).
|
|
1009
|
-
if (planFile !== undefined &&
|
|
1351
|
+
if (ctx.options.planFile !== undefined &&
|
|
1352
|
+
planeTarget &&
|
|
1353
|
+
err instanceof ConductorBridgeApiError &&
|
|
1354
|
+
err.kind === "network") {
|
|
1010
1355
|
const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
|
|
1011
1356
|
// BAPI-1260 — only a SELECTED LOCAL plane is ever started here. A
|
|
1012
1357
|
// profile approved for durable execution names an authority this
|
|
@@ -1019,17 +1364,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1019
1364
|
if (endpoint !== null) {
|
|
1020
1365
|
const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
|
|
1021
1366
|
if (probe.kind === "refused") {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
options: parsed.options,
|
|
1026
|
-
access: accessResult.access,
|
|
1027
|
-
});
|
|
1367
|
+
// `coldStartPlane` surfaces the containment advisory itself.
|
|
1368
|
+
await announceFoundPlan(false);
|
|
1369
|
+
return await coldStartPlane(ctx);
|
|
1028
1370
|
}
|
|
1029
1371
|
}
|
|
1030
1372
|
}
|
|
1031
1373
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${safeDiagnosticMessage(err, "readiness request failed")}`);
|
|
1032
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
|
+
}
|
|
1033
1385
|
// --- Route. Exactly one path from here on. -------------------------------
|
|
1034
1386
|
const selection = selectConductor(readiness);
|
|
1035
1387
|
// BAPI-1022: surface any containment-hazard advisory before dispatch or
|
|
@@ -1040,6 +1392,36 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1040
1392
|
// delegated argv, or the exit code.
|
|
1041
1393
|
if (planFile)
|
|
1042
1394
|
await surfaceContainmentAdvisory(deps, planFile);
|
|
1395
|
+
// BAPI-1285 — no `--plan-file`: find the plan from the epic key, and read the
|
|
1396
|
+
// epic's live run. Readiness has already been resolved (unknown escalated
|
|
1397
|
+
// above), so nothing here can be a discovery-driven create on unknown.
|
|
1398
|
+
//
|
|
1399
|
+
// A supplied plan always wins and skips ALL of this, including the extra
|
|
1400
|
+
// live-run read: setup-epic does its own Step 0 read and owns reuse.
|
|
1401
|
+
let discoveryStop;
|
|
1402
|
+
if (planFile === undefined) {
|
|
1403
|
+
if (docsRootUnresolved || discovered === null) {
|
|
1404
|
+
return refuse(deps, docsRootRefusal(epicKey).lines);
|
|
1405
|
+
}
|
|
1406
|
+
const live = await deps.readLiveEpicRun(accessResult.access, epicKey);
|
|
1407
|
+
// Wedged or unreadable: never a create and never an attach, whatever
|
|
1408
|
+
// discovery found. Guessing here is how a duplicate run gets minted.
|
|
1409
|
+
if (live.kind === "conflict")
|
|
1410
|
+
return refuse(deps, liveRunAmbiguousRefusal(epicKey).lines);
|
|
1411
|
+
if (live.kind === "unknown")
|
|
1412
|
+
return refuse(deps, liveRunUnknownRefusal(epicKey, live.reason).lines);
|
|
1413
|
+
if (discovered.status === "found") {
|
|
1414
|
+
// The plan was folded into `ctx.options` before readiness; from here it is
|
|
1415
|
+
// indistinguishable from a supplied `--plan-file`.
|
|
1416
|
+
await announceFoundPlan();
|
|
1417
|
+
}
|
|
1418
|
+
else if (live.kind === "live") {
|
|
1419
|
+
return await attachToLiveRun(ctx, readiness, live);
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
discoveryStop = discovered;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1043
1425
|
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
1044
1426
|
// about a missing plan file, and rendering it on the path that has one
|
|
1045
1427
|
// would produce a message contradicting what is about to happen.
|
|
@@ -1049,7 +1431,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1049
1431
|
// not-ready result can be rendered, because a report that is non-green only on
|
|
1050
1432
|
// the runtime facts is not a "v2 cannot run" situation — it is a plane that
|
|
1051
1433
|
// has not been started yet.
|
|
1052
|
-
if (planFile) {
|
|
1434
|
+
if (ctx.options.planFile !== undefined) {
|
|
1053
1435
|
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended
|
|
1054
1436
|
// prerequisites inapplicable. Both inputs are FLAG checks, not policy
|
|
1055
1437
|
// reads: whatever a `--policy-file` says stays opaque here, exactly as it
|
|
@@ -1058,14 +1440,26 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1058
1440
|
const classification = classifyV2Readiness(readiness, {
|
|
1059
1441
|
attended: parsed.options.attended === true,
|
|
1060
1442
|
intoBase: parsed.options.intoBase === true,
|
|
1061
|
-
|
|
1443
|
+
effectivePolicy: ctx.handoff?.selection,
|
|
1062
1444
|
});
|
|
1063
|
-
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1445
|
+
// Green: create the run, then make sure something will claim its jobs
|
|
1446
|
+
// (BAPI-1279). Before this, green delegated to setup-epic and nothing
|
|
1447
|
+
// else, so a production launch — or any second run — created a run no
|
|
1448
|
+
// executor would ever claim. setup-epic's argv is unchanged.
|
|
1067
1449
|
if (classification.kind === "green") {
|
|
1068
|
-
|
|
1450
|
+
if (!planeTarget) {
|
|
1451
|
+
return await createRunWithRunExecutor(ctx, readiness);
|
|
1452
|
+
}
|
|
1453
|
+
// A plane target: attach to THIS checkout's plane when it is the one
|
|
1454
|
+
// listening (its lanes, or a dedicated executor for a second run).
|
|
1455
|
+
// Otherwise — no plane here, or one owned by a sibling worktree — the
|
|
1456
|
+
// run gets a run-scoped executor. The preflight runs only when a
|
|
1457
|
+
// manifest says there is a plane in this checkout to attach to.
|
|
1458
|
+
const attachable = await findAttachablePlane(ctx);
|
|
1459
|
+
if (attachable !== null) {
|
|
1460
|
+
return await attachToPlane(ctx, attachable, readiness);
|
|
1461
|
+
}
|
|
1462
|
+
return await createRunWithRunExecutor(ctx, readiness);
|
|
1069
1463
|
}
|
|
1070
1464
|
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
1071
1465
|
// cannot create stored supervisor state or resolve GitHub App credentials,
|
|
@@ -1079,8 +1473,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1079
1473
|
// The requirement's OWN remedy when it has one. Telling an operator to
|
|
1080
1474
|
// run `install-bridge conductor` for a missing consent flag sends them
|
|
1081
1475
|
// to a command that cannot set it.
|
|
1082
|
-
classification
|
|
1083
|
-
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1476
|
+
operatorBlockedRemedy(classification),
|
|
1084
1477
|
].join("\n");
|
|
1085
1478
|
// Named as a fixed reason and nothing else: no conductor is offered here,
|
|
1086
1479
|
// because handing an operator a second authority is the hazard this
|
|
@@ -1089,6 +1482,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1089
1482
|
deps.errorLog(refusal);
|
|
1090
1483
|
return 1;
|
|
1091
1484
|
}
|
|
1485
|
+
// BAPI-1279 — runtime-only against a target that is NOT this machine's
|
|
1486
|
+
// plane: starting anything here could not help, because the missing
|
|
1487
|
+
// reconciler belongs to that deployment. One named reason, and nothing
|
|
1488
|
+
// local is touched.
|
|
1489
|
+
if (!planeTarget) {
|
|
1490
|
+
return refuseRemoteRuntime(ctx, readiness);
|
|
1491
|
+
}
|
|
1092
1492
|
// Runtime-only: this invocation composes bring-up with run creation.
|
|
1093
1493
|
const repoRoot = deps.cwd();
|
|
1094
1494
|
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
@@ -1099,8 +1499,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1099
1499
|
// preflight made from its single manifest read; nothing is re-read here.
|
|
1100
1500
|
const existingPlane = preflight.observations?.existingPlane;
|
|
1101
1501
|
const attachManifest = existingPlane?.kind === "attachable" &&
|
|
1102
|
-
preflight.observations?.serverPort === "connected"
|
|
1103
|
-
planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
|
|
1502
|
+
preflight.observations?.serverPort === "connected"
|
|
1104
1503
|
? existingPlane.manifest
|
|
1105
1504
|
: null;
|
|
1106
1505
|
// Composition preconditions FIRST, and only these two. Preflight is
|
|
@@ -1128,7 +1527,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1128
1527
|
return 1;
|
|
1129
1528
|
}
|
|
1130
1529
|
if (attachManifest !== null) {
|
|
1131
|
-
return attachToPlane(
|
|
1530
|
+
return await attachToPlane(ctx, attachManifest, readiness);
|
|
1132
1531
|
}
|
|
1133
1532
|
// ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
|
|
1134
1533
|
//
|
|
@@ -1149,23 +1548,21 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1149
1548
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1150
1549
|
if (!controlPlane.ok) {
|
|
1151
1550
|
// The launch already reported why, in its own words, and it rolls its own
|
|
1152
|
-
// startup back.
|
|
1153
|
-
|
|
1154
|
-
deps.errorLog(line);
|
|
1155
|
-
return 1;
|
|
1551
|
+
// startup back. No run was created here.
|
|
1552
|
+
return reportControlPlaneLaunchFailure(ctx, controlPlane);
|
|
1156
1553
|
}
|
|
1157
1554
|
// Held so the launcher's lifetime promise is never unobserved. It settles
|
|
1158
1555
|
// when the PLANE comes down, which is long after this command returns —
|
|
1159
1556
|
// the detached runtime is its own process-group leader and outlives us by
|
|
1160
1557
|
// design, which is what "the plane is left running" means.
|
|
1161
1558
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1162
|
-
// Readiness is NOT re-read as a gate here
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
return createRunAndRequestLanes(
|
|
1559
|
+
// Readiness is NOT re-read as a gate here. A successful bring-up IS the
|
|
1560
|
+
// runtime fact: the launch already waited for this plane's own reconciler
|
|
1561
|
+
// heartbeat. (`reconciler_live` now reads the deployment heartbeat, but the
|
|
1562
|
+
// server's repository-progress diagnostics — `epic_runs.last_tick_at`,
|
|
1563
|
+
// `executor_jobs` — stay empty on a first run, and nothing here waits on
|
|
1564
|
+
// them.)
|
|
1565
|
+
return await createRunAndRequestLanes(ctx, {
|
|
1169
1566
|
origin: "launched",
|
|
1170
1567
|
repoRoot,
|
|
1171
1568
|
planeId: controlPlane.planeId,
|
|
@@ -1187,14 +1584,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1187
1584
|
deps.errorLog(refusal);
|
|
1188
1585
|
return 1;
|
|
1189
1586
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
});
|
|
1587
|
+
// BAPI-1285 — reached only after discovery came back empty or ambiguous and no
|
|
1588
|
+
// live run exists. Nothing was created, so this is a stop, not a success.
|
|
1589
|
+
const handoff = renderConductorHandoff(selection, epicKey, { profile: launchProfile, options: parsed.options }, discoveryStop);
|
|
1194
1590
|
const text = handoff.lines.join("\n");
|
|
1195
1591
|
assertSingleConductorInvocation(text);
|
|
1196
|
-
deps.
|
|
1197
|
-
return
|
|
1592
|
+
deps.errorLog(text);
|
|
1593
|
+
return 1;
|
|
1198
1594
|
}
|
|
1199
1595
|
catch (err) {
|
|
1200
1596
|
// Last resort. Sanitized, on stderr, converted to an exit code — a
|
|
@@ -1226,6 +1622,23 @@ async function surfaceContainmentAdvisory(deps, planFile) {
|
|
|
1226
1622
|
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
1227
1623
|
}
|
|
1228
1624
|
}
|
|
1625
|
+
/**
|
|
1626
|
+
* BAPI-1285 — the one remedy sentence an unknown-readiness escalation ends with.
|
|
1627
|
+
* A constant so the preview's `readiness.remedy` is the very line the real
|
|
1628
|
+
* refusal prints, not a second copy that can drift.
|
|
1629
|
+
*/
|
|
1630
|
+
const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve conductor readiness.";
|
|
1631
|
+
/** The reason id the preview reports for an unknown readiness read. */
|
|
1632
|
+
const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
|
|
1633
|
+
/**
|
|
1634
|
+
* The remedy an operator-blocked refusal names: the requirement's own, else the
|
|
1635
|
+
* general install remedy. One function so the refusal, the cold-start refusal, and
|
|
1636
|
+
* the preview cannot disagree.
|
|
1637
|
+
*/
|
|
1638
|
+
function operatorBlockedRemedy(classification) {
|
|
1639
|
+
return (classification.remedy ??
|
|
1640
|
+
"Resolve it with `install-bridge conductor`, then run drive-epic again.");
|
|
1641
|
+
}
|
|
1229
1642
|
/**
|
|
1230
1643
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
1231
1644
|
* fallback here is the dual-authority hazard, and an operator who does not know
|
|
@@ -1236,14 +1649,69 @@ function escalate(deps, epicKey, reason) {
|
|
|
1236
1649
|
`Cannot determine which conductor owns ${epicKey}: ${reason}`,
|
|
1237
1650
|
"",
|
|
1238
1651
|
"This is not a not-ready result — it is an unknown one, and starting the wrong",
|
|
1239
|
-
"conductor on an epic wedges it permanently.
|
|
1240
|
-
|
|
1652
|
+
"conductor on an epic wedges it permanently.",
|
|
1653
|
+
ESCALATE_REMEDY,
|
|
1241
1654
|
];
|
|
1242
1655
|
const text = lines.join("\n");
|
|
1243
1656
|
assertSingleConductorInvocation(text);
|
|
1244
1657
|
deps.errorLog(text);
|
|
1245
1658
|
return 1;
|
|
1246
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
|
+
}
|
|
1247
1715
|
/** Emit one refusal, guarded so it can never name a second conductor. */
|
|
1248
1716
|
function refuse(deps, lines) {
|
|
1249
1717
|
const text = lines.join("\n");
|
|
@@ -1251,6 +1719,11 @@ function refuse(deps, lines) {
|
|
|
1251
1719
|
deps.errorLog(text);
|
|
1252
1720
|
return 1;
|
|
1253
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
|
+
}
|
|
1254
1727
|
/**
|
|
1255
1728
|
* The plane endpoint, when `baseUrl` names it; otherwise `null`.
|
|
1256
1729
|
*
|
|
@@ -1339,9 +1812,7 @@ async function coldStartPlane(ctx) {
|
|
|
1339
1812
|
}
|
|
1340
1813
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1341
1814
|
if (!controlPlane.ok) {
|
|
1342
|
-
|
|
1343
|
-
deps.errorLog(line);
|
|
1344
|
-
return 1;
|
|
1815
|
+
return reportControlPlaneLaunchFailure(ctx, controlPlane);
|
|
1345
1816
|
}
|
|
1346
1817
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1347
1818
|
// Read ONCE, for route selection only — the pre-launch read had no server to
|
|
@@ -1358,16 +1829,23 @@ async function coldStartPlane(ctx) {
|
|
|
1358
1829
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1359
1830
|
]);
|
|
1360
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 };
|
|
1361
1840
|
const classification = classifyV2Readiness(readiness, {
|
|
1362
1841
|
attended: ctx.options.attended === true,
|
|
1363
1842
|
intoBase: ctx.options.intoBase === true,
|
|
1364
|
-
|
|
1843
|
+
effectivePolicy: ctx.handoff?.selection,
|
|
1365
1844
|
});
|
|
1366
1845
|
if (classification.kind === "operator_blocked") {
|
|
1367
1846
|
return refuse(deps, [
|
|
1368
1847
|
`${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
|
|
1369
|
-
classification
|
|
1370
|
-
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1848
|
+
operatorBlockedRemedy(classification),
|
|
1371
1849
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1372
1850
|
]);
|
|
1373
1851
|
}
|
|
@@ -1408,8 +1886,17 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1408
1886
|
"Nothing was started. Fix the credential and run drive-epic again.",
|
|
1409
1887
|
]);
|
|
1410
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
|
+
}
|
|
1411
1895
|
const attachCtx = { ...ctx, access: rooted.access };
|
|
1412
|
-
// R-7, decided BEFORE any create request.
|
|
1896
|
+
// R-7, decided BEFORE any create request. It binds the LANES only
|
|
1897
|
+
// (BAPI-1279): a plane whose lanes serve another run still has a reconciler
|
|
1898
|
+
// that can drive this one, and gets a dedicated executor for it.
|
|
1899
|
+
let dedicatedExecutorReason = null;
|
|
1413
1900
|
if (manifest.epicRunId !== undefined) {
|
|
1414
1901
|
let bound = null;
|
|
1415
1902
|
try {
|
|
@@ -1418,28 +1905,33 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1418
1905
|
catch {
|
|
1419
1906
|
bound = null;
|
|
1420
1907
|
}
|
|
1421
|
-
|
|
1422
|
-
? "its bound run could not be read"
|
|
1423
|
-
: TERMINAL_EPIC_RUN_STATUSES.includes(bound.status)
|
|
1424
|
-
? `its bound run is ${bound.status}`
|
|
1425
|
-
: bound.epicKey !== epicKey
|
|
1426
|
-
? `its bound run belongs to ${bound.epicKey}`
|
|
1427
|
-
: null;
|
|
1428
|
-
if (why !== null) {
|
|
1908
|
+
if (bound === null) {
|
|
1429
1909
|
return refuse(deps, [
|
|
1430
1910
|
`${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
|
|
1431
|
-
`${manifest.epicRunId}, and
|
|
1432
|
-
|
|
1433
|
-
"
|
|
1911
|
+
`${manifest.epicRunId}, and its bound run could not be read, so it is unknown whether ` +
|
|
1912
|
+
"the plane is healthy.",
|
|
1913
|
+
"Restore Bridge API access, or inspect `plane status` and that run's own status, then run",
|
|
1914
|
+
"drive-epic again.",
|
|
1434
1915
|
"",
|
|
1435
1916
|
"Nothing was started.",
|
|
1436
1917
|
]);
|
|
1437
1918
|
}
|
|
1919
|
+
if (isTerminalEpicRunStatus(bound.status)) {
|
|
1920
|
+
dedicatedExecutorReason = `its lanes' run ${manifest.epicRunId} is ${bound.status}`;
|
|
1921
|
+
}
|
|
1922
|
+
else if (bound.epicKey !== epicKey) {
|
|
1923
|
+
dedicatedExecutorReason = `its lanes serve run ${manifest.epicRunId} of ${bound.epicKey}`;
|
|
1924
|
+
}
|
|
1438
1925
|
}
|
|
1439
1926
|
const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
|
|
1440
1927
|
if (admission !== null) {
|
|
1441
1928
|
return refuse(deps, [...admission, "", "Nothing was started."]);
|
|
1442
1929
|
}
|
|
1930
|
+
if (dedicatedExecutorReason !== null) {
|
|
1931
|
+
deps.log(`${epicKey}: attaching to this repository's running plane (${dedicatedExecutorReason}); ` +
|
|
1932
|
+
"its reconciler will drive this run too, and a dedicated executor will be started for it.");
|
|
1933
|
+
return createRunWithRunExecutor(attachCtx, readiness);
|
|
1934
|
+
}
|
|
1443
1935
|
deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
|
|
1444
1936
|
return createRunAndRequestLanes(attachCtx, {
|
|
1445
1937
|
origin: "attached",
|
|
@@ -1461,7 +1953,7 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1461
1953
|
const { deps, epicKey } = ctx;
|
|
1462
1954
|
const launched = plane.origin === "launched";
|
|
1463
1955
|
// ---- PHASE TWO, part one: create the run ------------------------------
|
|
1464
|
-
const setup = await
|
|
1956
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
1465
1957
|
if (setup.exitCode !== 0) {
|
|
1466
1958
|
// The control plane is deliberately LEFT RUNNING, and NO EXECUTOR LANE
|
|
1467
1959
|
// was spawned — there is nothing to claim a job that may not exist.
|
|
@@ -1523,7 +2015,8 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1523
2015
|
`${epicKey}: the run was created and approved, but executor lanes could not be`,
|
|
1524
2016
|
`requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
|
|
1525
2017
|
laneRequest.reason === "run-conflict"
|
|
1526
|
-
? "
|
|
2018
|
+
? "This plane's lanes already serve another run. Run drive-epic again: it will attach " +
|
|
2019
|
+
"to this plane's reconciler and start a dedicated run-scoped executor for this run."
|
|
1527
2020
|
: "Inspect it with `plane status`, and wind it down with `plane down`.",
|
|
1528
2021
|
"The run itself is untouched and still committed server-side.",
|
|
1529
2022
|
].join("\n"));
|
|
@@ -1576,3 +2069,922 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1576
2069
|
].join("\n"));
|
|
1577
2070
|
return 0;
|
|
1578
2071
|
}
|
|
2072
|
+
// ---------------------------------------------------------------------------
|
|
2073
|
+
// BAPI-1279 — every launch ensures an executor; remote targets touch no plane
|
|
2074
|
+
// ---------------------------------------------------------------------------
|
|
2075
|
+
/**
|
|
2076
|
+
* The refusals BAPI-1279 adds. Like {@link DRIVE_EPIC_PLANE_REFUSALS}, these
|
|
2077
|
+
* exist nowhere else; the bring-up facts keep refusing under their own names.
|
|
2078
|
+
*/
|
|
2079
|
+
export const DRIVE_EPIC_LAUNCH_REFUSALS = [
|
|
2080
|
+
/** A remote target reports no live reconciler and no bring-up fact says why. */
|
|
2081
|
+
"remote-reconciler-not-live",
|
|
2082
|
+
/** A durable launch profile, but no fresh repo-wide executor is confirmed. */
|
|
2083
|
+
"durable-executor-not-live",
|
|
2084
|
+
/** The run-scoped executor could never be started from this checkout. */
|
|
2085
|
+
"executor-precondition",
|
|
2086
|
+
];
|
|
2087
|
+
/** Said on every remote refusal, because operators expect a local fallback. */
|
|
2088
|
+
const REMOTE_NO_PLANE_LINE = "No local plane was started, because the target is not this machine's plane.";
|
|
2089
|
+
/**
|
|
2090
|
+
* `worker-not-present` against a remote target. The shared remediation offers
|
|
2091
|
+
* "bring up a local plane", which cannot help a deployment on another machine
|
|
2092
|
+
* and would contradict {@link REMOTE_NO_PLANE_LINE}.
|
|
2093
|
+
*/
|
|
2094
|
+
function remoteWorkerNotPresentMessage(baseUrl) {
|
|
2095
|
+
return (`the Bridge API at ${baseUrl} reports no worker running its reconciler, so nothing would ` +
|
|
2096
|
+
"drive this run. Start that deployment's worker process — exactly one (for example one " +
|
|
2097
|
+
"Heroku `worker` dyno) — then run drive-epic again.");
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Is THIS checkout's plane the one listening on the plane endpoint, and able to
|
|
2101
|
+
* take a run? Returns its manifest, or `null`.
|
|
2102
|
+
*
|
|
2103
|
+
* Reads the manifest first and runs the plane preflight only when there is one:
|
|
2104
|
+
* with no manifest here there is nothing to attach to, and a green plane target
|
|
2105
|
+
* served by a sibling worktree's plane needs no preflight at all.
|
|
2106
|
+
*/
|
|
2107
|
+
async function findAttachablePlane(ctx) {
|
|
2108
|
+
const repoRoot = ctx.deps.cwd();
|
|
2109
|
+
const manifestRead = await ctx.deps.readPlaneManifest(repoRoot);
|
|
2110
|
+
if (manifestRead.kind !== "valid")
|
|
2111
|
+
return null;
|
|
2112
|
+
const preflight = await ctx.deps.runPlanePreflight(repoRoot);
|
|
2113
|
+
const existingPlane = preflight.observations?.existingPlane;
|
|
2114
|
+
return existingPlane?.kind === "attachable" && preflight.observations?.serverPort === "connected"
|
|
2115
|
+
? existingPlane.manifest
|
|
2116
|
+
: null;
|
|
2117
|
+
}
|
|
2118
|
+
/**
|
|
2119
|
+
* Runtime-only readiness against a target that is NOT this machine's plane.
|
|
2120
|
+
*
|
|
2121
|
+
* The missing reconciler belongs to that deployment, so nothing started here
|
|
2122
|
+
* could help. One named reason: the first bring-up refusal, whose message
|
|
2123
|
+
* already names its remedy, else `remote-reconciler-not-live`. No run is
|
|
2124
|
+
* created and nothing local is touched.
|
|
2125
|
+
*/
|
|
2126
|
+
function refuseRemoteRuntime(ctx, readiness) {
|
|
2127
|
+
return refuse(ctx.deps, remoteRuntimeRefusal(ctx.epicKey, ctx.access, readiness).lines);
|
|
2128
|
+
}
|
|
2129
|
+
/** The runtime-only refusal for a target that is not this machine's plane. */
|
|
2130
|
+
function remoteRuntimeRefusal(epicKey, access, readiness) {
|
|
2131
|
+
const bringUp = readiness.bring_up ?? null;
|
|
2132
|
+
const reason = firstBringUpRefusal(bringUp);
|
|
2133
|
+
const reasonId = reason ?? "remote-reconciler-not-live";
|
|
2134
|
+
const remedy = reason !== null
|
|
2135
|
+
? reason === "worker-not-present"
|
|
2136
|
+
? remoteWorkerNotPresentMessage(access.baseUrl)
|
|
2137
|
+
: BRING_UP_REFUSAL_MESSAGES[reason]
|
|
2138
|
+
: `the Bridge API at ${access.baseUrl} reports no live reconciler, so nothing would drive ` +
|
|
2139
|
+
"this run. Check that its worker process is running, then run drive-epic again.";
|
|
2140
|
+
const lines = [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`];
|
|
2141
|
+
if (bringUp === null) {
|
|
2142
|
+
lines.push("This Bridge API version cannot report deployment liveness, so a launch against it cannot " +
|
|
2143
|
+
"be confirmed. Upgrade the Bridge API deploy.");
|
|
2144
|
+
}
|
|
2145
|
+
lines.push("", "No run was created.", REMOTE_NO_PLANE_LINE);
|
|
2146
|
+
return { reasonId, remedy, lines };
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* BAPI-1285 — a durable launch profile with no confirmed fresh repo-wide
|
|
2150
|
+
* executor. `created` says what state the refusal leaves: before a create nothing
|
|
2151
|
+
* exists; on a live-run attach the run is simply untouched.
|
|
2152
|
+
*/
|
|
2153
|
+
function durableCoverageRefusal(epicKey, access, profile, coverage, outcome) {
|
|
2154
|
+
const remedy = [
|
|
2155
|
+
"Start the durable host's executor with --repo-wide",
|
|
2156
|
+
" (`install conductor --execution-mode durable --executor-id <id> --repo-wide`),",
|
|
2157
|
+
"or launch without the durable profile.",
|
|
2158
|
+
].join("\n");
|
|
2159
|
+
return {
|
|
2160
|
+
reasonId: "durable-executor-not-live",
|
|
2161
|
+
remedy,
|
|
2162
|
+
lines: [
|
|
2163
|
+
`${epicKey}: REFUSED (durable-executor-not-live) — launch profile '${profile.name}' selects ` +
|
|
2164
|
+
`durable execution, but a fresh repo-wide executor for ${access.repoName} could not be ` +
|
|
2165
|
+
"confirmed" +
|
|
2166
|
+
(coverage === undefined
|
|
2167
|
+
? " (this Bridge API version does not report executor coverage)."
|
|
2168
|
+
: "."),
|
|
2169
|
+
remedy,
|
|
2170
|
+
"",
|
|
2171
|
+
`${outcome}, and no local executor was started.`,
|
|
2172
|
+
],
|
|
2173
|
+
};
|
|
2174
|
+
}
|
|
2175
|
+
/** The pre-create refusal when no executor could ever be started from this checkout. */
|
|
2176
|
+
function executorPreconditionRefusal(epicKey, message, outcome) {
|
|
2177
|
+
return {
|
|
2178
|
+
reasonId: "executor-precondition",
|
|
2179
|
+
remedy: message,
|
|
2180
|
+
lines: [`${epicKey}: REFUSED (executor-precondition) — ${message}`, "", outcome],
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
2183
|
+
/** BAPI-1285 — `--plan-file` absent, and the checkout cannot say where plans live. */
|
|
2184
|
+
function docsRootRefusal(epicKey) {
|
|
2185
|
+
const remedy = "Run drive-epic from inside the target repository's Git checkout, set BAPI_DOCS_DIR to an " +
|
|
2186
|
+
"absolute path, or pass --plan-file.";
|
|
2187
|
+
return {
|
|
2188
|
+
reasonId: "docs-root-unresolvable",
|
|
2189
|
+
remedy,
|
|
2190
|
+
lines: [
|
|
2191
|
+
`${epicKey}: REFUSED (docs-root-unresolvable) — this directory is not inside a Git work tree ` +
|
|
2192
|
+
"and BAPI_DOCS_DIR is not an absolute path, so the directory holding epic plans could not " +
|
|
2193
|
+
"be located.",
|
|
2194
|
+
remedy,
|
|
2195
|
+
"",
|
|
2196
|
+
"Nothing was created and nothing was started.",
|
|
2197
|
+
],
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
/** BAPI-1285 — several active runs for one epic: it is wedged. */
|
|
2201
|
+
function liveRunAmbiguousRefusal(epicKey) {
|
|
2202
|
+
const remedy = "Abandon the duplicate run with `epic-implementer abandon-run --epic-run-id " +
|
|
2203
|
+
"<duplicate_epic_run_id>`, then run drive-epic again.";
|
|
2204
|
+
return {
|
|
2205
|
+
reasonId: "live-run-ambiguous",
|
|
2206
|
+
remedy,
|
|
2207
|
+
lines: [
|
|
2208
|
+
`${epicKey}: REFUSED (live-run-ambiguous) — this epic has more than one active run, so it is ` +
|
|
2209
|
+
"wedged and drive-epic cannot tell which run to drive.",
|
|
2210
|
+
remedy,
|
|
2211
|
+
"",
|
|
2212
|
+
"Nothing was created and nothing was started.",
|
|
2213
|
+
],
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
/** BAPI-1285 — the epic's run state could not be read, so nothing may be assumed. */
|
|
2217
|
+
function liveRunUnknownRefusal(epicKey, reason) {
|
|
2218
|
+
const remedy = "Restore Bridge API access, then run drive-epic again. Creating a run without knowing " +
|
|
2219
|
+
"whether one exists could mint a duplicate, which wedges the epic permanently.";
|
|
2220
|
+
return {
|
|
2221
|
+
reasonId: "live-run-unknown",
|
|
2222
|
+
remedy,
|
|
2223
|
+
lines: [
|
|
2224
|
+
`${epicKey}: REFUSED (live-run-unknown) — the epic's run state could not be read (${reason}).`,
|
|
2225
|
+
remedy,
|
|
2226
|
+
"",
|
|
2227
|
+
"Nothing was created and nothing was started.",
|
|
2228
|
+
],
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
/**
|
|
2232
|
+
* Create the run, then make sure an executor will claim its jobs — the path for
|
|
2233
|
+
* every launch that does not request a plane lane.
|
|
2234
|
+
*
|
|
2235
|
+
* - Durable launch profile: confirm a fresh REPO-WIDE executor covers the
|
|
2236
|
+
* repository BEFORE creating anything, and start nothing locally.
|
|
2237
|
+
* - Otherwise: resolve the checkout and build BEFORE creating anything, then
|
|
2238
|
+
* create the run, then start (or reuse) a run-scoped executor for it.
|
|
2239
|
+
*
|
|
2240
|
+
* The order is the whole design: every refusal that can happen before a run
|
|
2241
|
+
* exists does, so a refusal never leaves a run behind. After the run exists,
|
|
2242
|
+
* nothing here stops or retries it.
|
|
2243
|
+
*/
|
|
2244
|
+
async function createRunWithRunExecutor(ctx, readiness) {
|
|
2245
|
+
const { deps, epicKey, access, profile } = ctx;
|
|
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
|
+
}
|
|
2253
|
+
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2254
|
+
if (coverage !== true) {
|
|
2255
|
+
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
|
|
2256
|
+
}
|
|
2257
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
2258
|
+
if (setup.exitCode === 0) {
|
|
2259
|
+
deps.log([
|
|
2260
|
+
"",
|
|
2261
|
+
`${epicKey}: run ${setup.epicRunId ?? "(id not reported)"} created on ${access.baseUrl}.`,
|
|
2262
|
+
"The durable host's repo-wide executor claims its jobs; no local executor was started.",
|
|
2263
|
+
].join("\n"));
|
|
2264
|
+
}
|
|
2265
|
+
return setup.exitCode;
|
|
2266
|
+
}
|
|
2267
|
+
// PRE-CREATE: a launch that could never start an executor refuses here.
|
|
2268
|
+
const prepared = await deps.prepareRunExecutor(deps.cwd());
|
|
2269
|
+
if (!prepared.ok) {
|
|
2270
|
+
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
|
|
2271
|
+
}
|
|
2272
|
+
const setup = await runSelectedSetupEpic(ctx);
|
|
2273
|
+
if (setup.exitCode !== 0) {
|
|
2274
|
+
// setup-epic already said why. Nothing was started for a run that may not exist.
|
|
2275
|
+
deps.errorLog(`\n${epicKey}: no executor was started.`);
|
|
2276
|
+
return setup.exitCode;
|
|
2277
|
+
}
|
|
2278
|
+
if (setup.epicRunId === undefined) {
|
|
2279
|
+
// Exit zero with no run id: there is no authoritative identity to scope an
|
|
2280
|
+
// executor to, and guessing could attach it to a DIFFERENT run.
|
|
2281
|
+
return refuse(deps, [
|
|
2282
|
+
"",
|
|
2283
|
+
`${epicKey}: setup reported success but returned no epic run id, so no executor could be`,
|
|
2284
|
+
"attached to the run. NO EXECUTOR WAS STARTED. Inspect this repository's epic runs",
|
|
2285
|
+
"server-side before retrying; retrying blindly could create a second run.",
|
|
2286
|
+
]);
|
|
2287
|
+
}
|
|
2288
|
+
// POST-CREATE: start or reuse exactly one executor scoped to this run.
|
|
2289
|
+
const ensured = await deps.ensureRunExecutor({
|
|
2290
|
+
prepared: prepared.prepared,
|
|
2291
|
+
repoName: access.repoName,
|
|
2292
|
+
epicRunId: setup.epicRunId,
|
|
2293
|
+
access,
|
|
2294
|
+
profile,
|
|
2295
|
+
});
|
|
2296
|
+
return reportExecutorOutcome(ctx, ensured, setup.epicRunId, "created");
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* Print an executor outcome — started, reused, or failed — and return the exit
|
|
2300
|
+
* code. Shared by the create path and the live-run attach path (BAPI-1285) so the
|
|
2301
|
+
* two print identical text rather than two copies that drift. Only the framing
|
|
2302
|
+
* differs: whether the run was just CREATED or was already live.
|
|
2303
|
+
*
|
|
2304
|
+
* The readiness, durable, and plane gates stay with the callers; this is only the
|
|
2305
|
+
* report. A failure never stops or changes the run.
|
|
2306
|
+
*/
|
|
2307
|
+
function reportExecutorOutcome(ctx, ensured, epicRunId, framing) {
|
|
2308
|
+
const { deps, epicKey, access } = ctx;
|
|
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
|
+
}));
|
|
2315
|
+
return refuse(deps, [
|
|
2316
|
+
"",
|
|
2317
|
+
framing === "created"
|
|
2318
|
+
? `${epicKey}: Run created but executor unavailable — run ${epicRunId} on ${access.baseUrl}.`
|
|
2319
|
+
: `${epicKey}: Run ${epicRunId} executor unavailable — the run is already live on ${access.baseUrl}.`,
|
|
2320
|
+
`No executor heartbeat was confirmed (${ensured.reason}): ${ensured.message}.`,
|
|
2321
|
+
` Log: ${ensured.logPath}`,
|
|
2322
|
+
...(ensured.executorMayBeRunning
|
|
2323
|
+
? [
|
|
2324
|
+
"An executor for this run may still be running, so do not start a second one beside",
|
|
2325
|
+
"it. Check the log and the process first; only once none is running, start it by hand",
|
|
2326
|
+
"from this checkout:",
|
|
2327
|
+
]
|
|
2328
|
+
: ["Start it by hand from this checkout:"]),
|
|
2329
|
+
` ${ensured.manualCommand}`,
|
|
2330
|
+
"The run was NOT stopped. It stays server-side and recoverable, and resumes as soon as an",
|
|
2331
|
+
"executor claims its jobs.",
|
|
2332
|
+
]);
|
|
2333
|
+
}
|
|
2334
|
+
const started = ensured.outcome === "started"
|
|
2335
|
+
? `A run-scoped executor was started for it in the background (pid ${ensured.pid}).`
|
|
2336
|
+
: `A run-scoped executor for it was already running (pid ${ensured.pid}); no second one was started.`;
|
|
2337
|
+
deps.log([
|
|
2338
|
+
"",
|
|
2339
|
+
framing === "created"
|
|
2340
|
+
? `${epicKey}: run ${epicRunId} is created on ${access.baseUrl}.`
|
|
2341
|
+
: `${epicKey}: run ${epicRunId} is already live on ${access.baseUrl}; nothing was created.`,
|
|
2342
|
+
started,
|
|
2343
|
+
"It claims only this run's jobs, and exits on its own once the run is done or abandoned.",
|
|
2344
|
+
` Log: ${ensured.logPath}`,
|
|
2345
|
+
` Record: ${ensured.recordPath}`,
|
|
2346
|
+
"It runs on THIS machine: sleep is held off while it runs, but a closed lid, a logout, or a",
|
|
2347
|
+
"shutdown stops it. The run stays server-side either way; restart the executor, or use a",
|
|
2348
|
+
"durable launch profile for long runs.",
|
|
2349
|
+
].join("\n"));
|
|
2350
|
+
return 0;
|
|
2351
|
+
}
|
|
2352
|
+
/**
|
|
2353
|
+
* Is this checkout's plane serving the live run? True only when BOTH binding
|
|
2354
|
+
* facts agree: the plane manifest's `epicRunId` equals the live run's id, and the
|
|
2355
|
+
* bound run reads back live and of this epic. Anything unreadable or mismatched is
|
|
2356
|
+
* false, and the caller takes the ordinary run-scoped executor path — a plane is
|
|
2357
|
+
* never presumed to be covering a run it cannot be shown to cover.
|
|
2358
|
+
*/
|
|
2359
|
+
async function planeServesLiveRun(ctx, live) {
|
|
2360
|
+
try {
|
|
2361
|
+
const manifest = await findAttachablePlane(ctx);
|
|
2362
|
+
if (manifest === null || manifest.epicRunId !== live.epicRunId)
|
|
2363
|
+
return false;
|
|
2364
|
+
const owner = await ctx.deps.readPlaneReconcilerState({ access: ctx.access, manifest });
|
|
2365
|
+
if (owner !== "fresh")
|
|
2366
|
+
return false;
|
|
2367
|
+
const bound = await ctx.deps.readRunState(ctx.access, manifest.epicRunId);
|
|
2368
|
+
return bound.epicKey === ctx.epicKey && !isTerminalEpicRunStatus(bound.status);
|
|
2369
|
+
}
|
|
2370
|
+
catch {
|
|
2371
|
+
return false;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* BAPI-1285 — a live run and no usable plan: make sure the run has an executor.
|
|
2376
|
+
*
|
|
2377
|
+
* It creates nothing and never calls `setup-epic`, which hard-requires a plan
|
|
2378
|
+
* file. Readiness gates it exactly as it gates a create (only green proceeds),
|
|
2379
|
+
* because starting an executor for a run nothing can drive is not attaching to it.
|
|
2380
|
+
*
|
|
2381
|
+
* - durable profile: confirm a fresh repo-wide executor and start nothing local;
|
|
2382
|
+
* - this checkout's plane bound to THIS run: its lanes serve it, start nothing;
|
|
2383
|
+
* - otherwise: prepare, then ensure a run-scoped executor for the LIVE run's id.
|
|
2384
|
+
*/
|
|
2385
|
+
async function attachToLiveRun(ctx, readiness, live) {
|
|
2386
|
+
const { deps, epicKey, access, profile } = ctx;
|
|
2387
|
+
const classification = classifyV2Readiness(readiness, {
|
|
2388
|
+
attended: ctx.options.attended === true,
|
|
2389
|
+
intoBase: ctx.options.intoBase === true,
|
|
2390
|
+
approvedStoredPolicy: true,
|
|
2391
|
+
});
|
|
2392
|
+
if (classification.kind === "operator_blocked") {
|
|
2393
|
+
return refuse(deps, [
|
|
2394
|
+
`${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
|
|
2395
|
+
"",
|
|
2396
|
+
"This is not something starting the runtime can fix, so nothing was started.",
|
|
2397
|
+
operatorBlockedRemedy(classification),
|
|
2398
|
+
]);
|
|
2399
|
+
}
|
|
2400
|
+
const planeTarget = planeEndpointForBaseUrl(deps, access.baseUrl) !== null;
|
|
2401
|
+
if (classification.kind === "runtime_only") {
|
|
2402
|
+
if (!planeTarget)
|
|
2403
|
+
return refuseRemoteRuntime(ctx, readiness);
|
|
2404
|
+
return refuse(deps, localReconcilerRefusal(epicKey, readiness).lines);
|
|
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
|
+
}
|
|
2414
|
+
deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
|
|
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
|
+
}
|
|
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
|
+
}
|
|
2428
|
+
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2429
|
+
if (coverage !== true) {
|
|
2430
|
+
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was changed").lines);
|
|
2431
|
+
}
|
|
2432
|
+
deps.log([
|
|
2433
|
+
"",
|
|
2434
|
+
`${epicKey}: run ${live.epicRunId} is served by the durable host's confirmed repo-wide executor.`,
|
|
2435
|
+
"No local executor was started.",
|
|
2436
|
+
].join("\n"));
|
|
2437
|
+
return 0;
|
|
2438
|
+
}
|
|
2439
|
+
if (planeTarget && (await planeServesLiveRun(ctx, live))) {
|
|
2440
|
+
deps.log([
|
|
2441
|
+
"",
|
|
2442
|
+
`${epicKey}: this checkout's plane is bound to run ${live.epicRunId}; its executor lanes serve it.`,
|
|
2443
|
+
"No run-scoped executor was started beside them.",
|
|
2444
|
+
].join("\n"));
|
|
2445
|
+
return 0;
|
|
2446
|
+
}
|
|
2447
|
+
const prepared = await deps.prepareRunExecutor(deps.cwd());
|
|
2448
|
+
if (!prepared.ok) {
|
|
2449
|
+
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was changed.").lines);
|
|
2450
|
+
}
|
|
2451
|
+
const ensured = await deps.ensureRunExecutor({
|
|
2452
|
+
prepared: prepared.prepared,
|
|
2453
|
+
repoName: access.repoName,
|
|
2454
|
+
epicRunId: live.epicRunId,
|
|
2455
|
+
access,
|
|
2456
|
+
profile,
|
|
2457
|
+
});
|
|
2458
|
+
return reportExecutorOutcome(ctx, ensured, live.epicRunId, "attached");
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* BAPI-1285 — a live run, a local plane target, and a reconciler that is not
|
|
2462
|
+
* live. Without a plan drive-epic cannot compose the bring-up (that happens only
|
|
2463
|
+
* while creating a run), so this is one named refusal rather than an executor for
|
|
2464
|
+
* a run nothing is driving.
|
|
2465
|
+
*/
|
|
2466
|
+
function localReconcilerRefusal(epicKey, readiness) {
|
|
2467
|
+
const reason = firstBringUpRefusal(readiness.bring_up ?? null);
|
|
2468
|
+
const reasonId = reason ?? "local-reconciler-not-live";
|
|
2469
|
+
const remedy = reason !== null
|
|
2470
|
+
? BRING_UP_REFUSAL_MESSAGES[reason]
|
|
2471
|
+
: "the local conductor plane reports no live reconciler, so nothing would drive this run. " +
|
|
2472
|
+
"Start it with `plane up`, or pass --plan-file so drive-epic can bring the runtime up.";
|
|
2473
|
+
return {
|
|
2474
|
+
reasonId,
|
|
2475
|
+
remedy,
|
|
2476
|
+
lines: [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`, "", "No run was changed."],
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* Render a failed control-plane launch.
|
|
2481
|
+
*
|
|
2482
|
+
* The launch already printed its own reason. The one case this adds to is the
|
|
2483
|
+
* pre-worker guard finding another reconciler live (BAPI-1279): name it as
|
|
2484
|
+
* `second-reconciler-detected` and give the attach remedy. "Nothing was left
|
|
2485
|
+
* running" is said only after a CONFIRMED rollback.
|
|
2486
|
+
*/
|
|
2487
|
+
function reportControlPlaneLaunchFailure(ctx, controlPlane) {
|
|
2488
|
+
const { deps, epicKey } = ctx;
|
|
2489
|
+
for (const line of controlPlane.lines)
|
|
2490
|
+
deps.errorLog(line);
|
|
2491
|
+
if (controlPlane.foreignReconciler === undefined)
|
|
2492
|
+
return 1;
|
|
2493
|
+
// The live plane is on ANOTHER port — a cold start happens only when this
|
|
2494
|
+
// plane's own port is free — so the port is named by that plane, not here.
|
|
2495
|
+
return refuse(deps, [
|
|
2496
|
+
"",
|
|
2497
|
+
`${epicKey}: REFUSED (second-reconciler-detected) — ` +
|
|
2498
|
+
BRING_UP_REFUSAL_MESSAGES["second-reconciler-detected"],
|
|
2499
|
+
controlPlane.foreignReconciler.rolledBack
|
|
2500
|
+
? "The server this invocation started was rolled back; nothing was left running."
|
|
2501
|
+
: "The rollback of the server this invocation started could NOT be confirmed. Check " +
|
|
2502
|
+
"`plane status` and stop anything it still lists.",
|
|
2503
|
+
"Concurrent planes on one database are not supported. To run this epic next to the live one,",
|
|
2504
|
+
"point this launch at the plane that owns that reconciler (pass --base-url",
|
|
2505
|
+
"http://127.0.0.1:<port>, with the port that plane's `plane status` reports, and change or",
|
|
2506
|
+
"unset BAPI_BASE_URL if it names a different endpoint). drive-epic will create the run",
|
|
2507
|
+
"against that plane and start a run-scoped executor in this checkout.",
|
|
2508
|
+
"",
|
|
2509
|
+
"No run was created.",
|
|
2510
|
+
]);
|
|
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
|
+
};
|
|
2522
|
+
/** The readiness reason a preview reports for a generic not-ready engine. */
|
|
2523
|
+
const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
|
|
2524
|
+
const ENGINE_NOT_READY_REMEDY = "Resolve conductor readiness (`install-bridge conductor`, then `doctor`) and run drive-epic again.";
|
|
2525
|
+
/**
|
|
2526
|
+
* Read exactly one well-shaped setup-epic dry-run JSON document out of captured
|
|
2527
|
+
* `log` output. Anything else — none, several, or one with the wrong shape — is
|
|
2528
|
+
* `null`. Human text that is not JSON is ignored, never parsed for meaning.
|
|
2529
|
+
*/
|
|
2530
|
+
function readSetupPreviewPayload(stdout) {
|
|
2531
|
+
const documents = [];
|
|
2532
|
+
for (const message of stdout) {
|
|
2533
|
+
let value;
|
|
2534
|
+
try {
|
|
2535
|
+
value = JSON.parse(message);
|
|
2536
|
+
}
|
|
2537
|
+
catch {
|
|
2538
|
+
continue;
|
|
2539
|
+
}
|
|
2540
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
2541
|
+
documents.push(value);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
if (documents.length !== 1)
|
|
2545
|
+
return null;
|
|
2546
|
+
const doc = documents[0];
|
|
2547
|
+
if (doc.dry_run !== true || typeof doc.server_validated !== "boolean")
|
|
2548
|
+
return null;
|
|
2549
|
+
const existing = doc.existing_run_id;
|
|
2550
|
+
if (existing !== null && existing !== undefined && typeof existing !== "string")
|
|
2551
|
+
return null;
|
|
2552
|
+
const branch = doc.feature_branch;
|
|
2553
|
+
if (branch !== undefined && branch !== null && typeof branch !== "string")
|
|
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
|
+
}
|
|
2590
|
+
return {
|
|
2591
|
+
existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
|
|
2592
|
+
featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
|
|
2593
|
+
serverValidated: doc.server_validated,
|
|
2594
|
+
effectivePolicy,
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
/** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
|
|
2598
|
+
async function readPlanChildCount(deps, planPath) {
|
|
2599
|
+
try {
|
|
2600
|
+
const parsed = JSON.parse(await deps.readPlanDag(planPath));
|
|
2601
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2602
|
+
return null;
|
|
2603
|
+
const nodes = parsed.nodes;
|
|
2604
|
+
return Array.isArray(nodes) ? nodes.length : null;
|
|
2605
|
+
}
|
|
2606
|
+
catch {
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Run the read-only preview. Returns `0` for a `create` or `attach` decision and
|
|
2612
|
+
* `1` for `refuse` or `stop`. Never throws: an unexpected failure becomes one
|
|
2613
|
+
* named refusal in the same document.
|
|
2614
|
+
*/
|
|
2615
|
+
async function runDriveEpicPreview(deps, options) {
|
|
2616
|
+
const { epicKey } = options;
|
|
2617
|
+
const posture = options.attended === true || options.intoBase === true ? "attended" : "unattended";
|
|
2618
|
+
const preview = {
|
|
2619
|
+
epic_key: epicKey,
|
|
2620
|
+
target: { base_url: null, kind: null },
|
|
2621
|
+
readiness: { kind: "unknown" },
|
|
2622
|
+
live_run: null,
|
|
2623
|
+
plan: { status: "not_found" },
|
|
2624
|
+
summary: {
|
|
2625
|
+
child_count: null,
|
|
2626
|
+
branch: null,
|
|
2627
|
+
posture,
|
|
2628
|
+
auto_merge_scope: null,
|
|
2629
|
+
escalation_channel: null,
|
|
2630
|
+
server_validation: "not_run",
|
|
2631
|
+
},
|
|
2632
|
+
decision: "refuse",
|
|
2633
|
+
refusal: null,
|
|
2634
|
+
};
|
|
2635
|
+
/** A refusal carried by the readiness block (and mirrored where the ticket needs it). */
|
|
2636
|
+
const refuseWithReadiness = (kind, named, alsoTopLevel = false) => {
|
|
2637
|
+
preview.readiness = { kind, reason_id: named.reasonId, remedy: named.remedy };
|
|
2638
|
+
if (alsoTopLevel)
|
|
2639
|
+
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2640
|
+
preview.decision = "refuse";
|
|
2641
|
+
};
|
|
2642
|
+
/** A refusal that is not about readiness: only the top-level block carries it. */
|
|
2643
|
+
const refuseTopLevel = (named) => {
|
|
2644
|
+
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2645
|
+
preview.decision = "refuse";
|
|
2646
|
+
};
|
|
2647
|
+
try {
|
|
2648
|
+
await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel);
|
|
2649
|
+
}
|
|
2650
|
+
catch {
|
|
2651
|
+
// Sanitized on purpose: nothing from the thrown value reaches the document.
|
|
2652
|
+
preview.summary.server_validation =
|
|
2653
|
+
preview.summary.server_validation === "passed" ? "passed" : "not_run";
|
|
2654
|
+
refuseTopLevel({
|
|
2655
|
+
reasonId: "preview-failed",
|
|
2656
|
+
remedy: "The preview could not complete. Run it again; nothing was created or started.",
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2659
|
+
emitPreview(deps, options, preview);
|
|
2660
|
+
return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
|
|
2661
|
+
}
|
|
2662
|
+
/** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
|
|
2663
|
+
async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
|
|
2664
|
+
const { epicKey } = options;
|
|
2665
|
+
const unknownReadiness = () => refuseWithReadiness("unknown", {
|
|
2666
|
+
reasonId: READINESS_UNKNOWN_REASON_ID,
|
|
2667
|
+
remedy: ESCALATE_REMEDY,
|
|
2668
|
+
});
|
|
2669
|
+
// --- Target: resolved, never adopted. -------------------------------------
|
|
2670
|
+
const accessResult = await deps.resolveAccess(options.repo, undefined, launchSelectionOf(options));
|
|
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
|
+
}
|
|
2688
|
+
const access = accessResult.access;
|
|
2689
|
+
const profile = accessResult.profile ?? null;
|
|
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
|
+
}
|
|
2728
|
+
// --- Readiness. -----------------------------------------------------------
|
|
2729
|
+
let readiness;
|
|
2730
|
+
try {
|
|
2731
|
+
readiness = await deps.readReadiness(access);
|
|
2732
|
+
}
|
|
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
|
+
}
|
|
2762
|
+
return unknownReadiness();
|
|
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
|
+
}
|
|
2780
|
+
const classification = classifyV2Readiness(readiness, {
|
|
2781
|
+
attended: options.attended === true,
|
|
2782
|
+
intoBase: options.intoBase === true,
|
|
2783
|
+
effectivePolicy: prepared?.ok ? prepared.handoff.selection : undefined,
|
|
2784
|
+
approvedStoredPolicy: live.kind === "live" && !planPath,
|
|
2785
|
+
});
|
|
2786
|
+
preview.readiness = { kind: classification.kind };
|
|
2787
|
+
const selection = selectConductor(readiness);
|
|
2788
|
+
// --- The live run. Conflict and unknown refuse whatever the plan says. ------
|
|
2789
|
+
if (live.kind === "live") {
|
|
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
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
// --- No plan: attach to a live run, or stop. --------------------------------
|
|
2802
|
+
if (planPath === undefined) {
|
|
2803
|
+
if (live.kind !== "live") {
|
|
2804
|
+
if (selection.kind === "not_ready") {
|
|
2805
|
+
return refuseWithReadiness(classification.kind, {
|
|
2806
|
+
reasonId: ENGINE_NOT_READY_REASON_ID,
|
|
2807
|
+
remedy: ENGINE_NOT_READY_REMEDY,
|
|
2808
|
+
});
|
|
2809
|
+
}
|
|
2810
|
+
preview.decision = "stop";
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "attach");
|
|
2814
|
+
if (blocked !== null)
|
|
2815
|
+
return refuseWithReadiness(classification.kind, blocked);
|
|
2816
|
+
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, live, "No run was changed");
|
|
2817
|
+
if (facts !== null)
|
|
2818
|
+
return refuseWithReadiness(classification.kind, facts, true);
|
|
2819
|
+
preview.decision = "attach";
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
// --- A plan is available: the create path's gates, then setup's own dry run. --
|
|
2823
|
+
const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "create");
|
|
2824
|
+
if (blocked !== null)
|
|
2825
|
+
return refuseWithReadiness(classification.kind, blocked);
|
|
2826
|
+
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
|
|
2827
|
+
if (facts !== null)
|
|
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
|
+
}
|
|
2842
|
+
// Escalation channel, decided with setup-epic's own exported policy helpers. A
|
|
2843
|
+
// policy that cannot be read is one fixed refusal; its path, contents, and any
|
|
2844
|
+
// webhook URL never leave this function.
|
|
2845
|
+
if (preview.summary.posture === "unattended") {
|
|
2846
|
+
preview.summary.escalation_channel = prepared.handoff.selection.escalationChannel === "configured webhook"
|
|
2847
|
+
? "webhook"
|
|
2848
|
+
: prepared.handoff.selection.escalationChannel === "account email" ? "email" : null;
|
|
2849
|
+
}
|
|
2850
|
+
preview.summary.child_count = await readPlanChildCount(deps, planPath);
|
|
2851
|
+
const setup = await deps.previewSetupEpic({
|
|
2852
|
+
argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), ...(coldStart ? ["--local-only"] : []), "--dry-run", "--json"],
|
|
2853
|
+
baseUrl: access.baseUrl,
|
|
2854
|
+
handoff: prepared.handoff,
|
|
2855
|
+
});
|
|
2856
|
+
if (setup.exitCode !== 0) {
|
|
2857
|
+
preview.summary.server_validation = "failed";
|
|
2858
|
+
return refuseTopLevel({
|
|
2859
|
+
reasonId: "setup-preview-failed",
|
|
2860
|
+
remedy: "The plan did not pass validation, or the epic could not be prepared. Fix the plan or " +
|
|
2861
|
+
"the condition it reports, then run the preview again. Nothing was created.",
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
const payload = readSetupPreviewPayload(setup.stdout);
|
|
2865
|
+
if (payload === null) {
|
|
2866
|
+
preview.summary.server_validation = "not_run";
|
|
2867
|
+
return refuseTopLevel({
|
|
2868
|
+
reasonId: "setup-preview-malformed",
|
|
2869
|
+
remedy: "Plan validation returned no usable result, so the launch could not be predicted. Run " +
|
|
2870
|
+
"the preview again. Nothing was created.",
|
|
2871
|
+
});
|
|
2872
|
+
}
|
|
2873
|
+
preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
|
|
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
|
+
}
|
|
2887
|
+
preview.summary.auto_merge_scope =
|
|
2888
|
+
preview.summary.posture === "unattended" ? payload.featureBranch : null;
|
|
2889
|
+
// The bootstrap reusing a run IS an attach: it reports the run's id.
|
|
2890
|
+
if (payload.existingRunId !== null) {
|
|
2891
|
+
preview.live_run = {
|
|
2892
|
+
epic_run_id: payload.existingRunId,
|
|
2893
|
+
status: preview.live_run?.epic_run_id === payload.existingRunId ? preview.live_run.status : null,
|
|
2894
|
+
};
|
|
2895
|
+
preview.decision = "attach";
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
preview.decision = coldStart ? "create" : liveRunKnown ? "attach" : "create";
|
|
2899
|
+
}
|
|
2900
|
+
/**
|
|
2901
|
+
* The readiness refusal a real launch would print, as a reason id and remedy, or
|
|
2902
|
+
* `null` when readiness lets the launch proceed. Built from the SAME functions the
|
|
2903
|
+
* real refusals use, so the strings agree byte for byte.
|
|
2904
|
+
*/
|
|
2905
|
+
function previewReadinessRefusal(ctx, classification, readiness, planeTarget, path) {
|
|
2906
|
+
if (classification.kind === "operator_blocked") {
|
|
2907
|
+
return { reasonId: classification.id, remedy: operatorBlockedRemedy(classification) };
|
|
2908
|
+
}
|
|
2909
|
+
if (classification.kind === "runtime_only") {
|
|
2910
|
+
if (!planeTarget)
|
|
2911
|
+
return remoteRuntimeRefusal(ctx.epicKey, ctx.access, readiness);
|
|
2912
|
+
// A local plane with only the reconciler missing: a real launch WITH a plan
|
|
2913
|
+
// brings the runtime up. Without one (attach) it is refused by name.
|
|
2914
|
+
if (path === "attach")
|
|
2915
|
+
return localReconcilerRefusal(ctx.epicKey, readiness);
|
|
2916
|
+
}
|
|
2917
|
+
return null;
|
|
2918
|
+
}
|
|
2919
|
+
/**
|
|
2920
|
+
* The executor-side refusals a real launch would hit, decided read-only: the
|
|
2921
|
+
* durable profile's coverage check, else whether an executor could be started
|
|
2922
|
+
* from this checkout at all. Returns `null` when neither refuses, including when
|
|
2923
|
+
* this checkout's plane already serves the live run and needs no executor.
|
|
2924
|
+
*/
|
|
2925
|
+
async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
|
|
2926
|
+
const { deps, epicKey, access, profile } = ctx;
|
|
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
|
+
}
|
|
2934
|
+
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2935
|
+
return coverage === true
|
|
2936
|
+
? null
|
|
2937
|
+
: durableCoverageRefusal(epicKey, access, profile, coverage, outcome);
|
|
2938
|
+
}
|
|
2939
|
+
if (planeTarget) {
|
|
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
|
+
}
|
|
2948
|
+
if (await planeServesLiveRun(ctx, live))
|
|
2949
|
+
return null;
|
|
2950
|
+
}
|
|
2951
|
+
else if ((await findAttachablePlane(ctx).catch(() => null)) !== null) {
|
|
2952
|
+
return null;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
const inspected = await deps.inspectRunExecutorPrecondition(deps.cwd());
|
|
2956
|
+
return inspected.ok
|
|
2957
|
+
? null
|
|
2958
|
+
: executorPreconditionRefusal(epicKey, inspected.message, outcome === "No run was created" ? "No run was created." : "No run was changed.");
|
|
2959
|
+
}
|
|
2960
|
+
/** Print the preview: one JSON line with `--json`, short readable lines otherwise. */
|
|
2961
|
+
function emitPreview(deps, options, preview) {
|
|
2962
|
+
if (options.json === true) {
|
|
2963
|
+
deps.log(JSON.stringify(preview));
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
const lines = [
|
|
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."] : []),
|
|
2969
|
+
` Target: ${preview.target.base_url ?? "unresolved"}${preview.target.kind ? ` (${preview.target.kind})` : ""}`,
|
|
2970
|
+
` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
|
|
2971
|
+
` Live run: ${preview.live_run
|
|
2972
|
+
? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
|
|
2973
|
+
: preview.cold_start ? "unchecked (plane not yet running)" : "none"}`,
|
|
2974
|
+
` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
|
|
2975
|
+
];
|
|
2976
|
+
for (const candidate of preview.plan.candidates ?? [])
|
|
2977
|
+
lines.push(` candidate: ${candidate}`);
|
|
2978
|
+
for (const skipped of preview.plan.skipped ?? []) {
|
|
2979
|
+
lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
|
|
2980
|
+
}
|
|
2981
|
+
const s = preview.summary;
|
|
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-")) {
|
|
2984
|
+
lines.push(` Remedy: ${preview.readiness.remedy}`);
|
|
2985
|
+
}
|
|
2986
|
+
if (preview.refusal) {
|
|
2987
|
+
lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
|
|
2988
|
+
}
|
|
2989
|
+
deps.log(lines.join("\n"));
|
|
2990
|
+
}
|