@bridge_gpt/mcp-server 0.2.62 → 0.2.65
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 +47 -8
- package/build/agent-launchers/claude-executor-adapter.js +134 -3
- package/build/agent-launchers/executor-adapter.js +54 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/commands.generated.js +6 -5
- package/build/conductor/bridge-api-client.js +335 -22
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +324 -0
- package/build/conductor/epic-implementer-cli.js +142 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +22 -11
- package/build/conductor/recovery-cli.js +354 -13
- package/build/conductor/recovery-operations.js +251 -5
- package/build/conductor/repair-contract.js +8 -0
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +50 -12
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +77 -11
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1211 -116
- 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 +35 -29
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-profile.js +14 -5
- 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 +286 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +6 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +76 -4
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1294 -26
- package/build/executor/job-types.js +10 -2
- package/build/executor/preflight.js +99 -0
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +262 -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 +145 -0
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +12 -0
- package/build/executor/worker-command.js +49 -13
- package/build/executor/worker-finalization.js +88 -11
- package/build/executor/worker-guard-hook.js +29 -4
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +537 -943
- package/build/install-bridge-conductor.js +231 -41
- package/build/merge-pull-request.js +1 -1
- package/build/pipelines.generated.js +8 -7
- 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/supervisor.js +142 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +28 -17
- package/build/version.generated.js +3 -3
- package/build/worker-guard-hook-bin.js +2 -2
- package/docs/CONDUCTOR.md +116 -2
- package/package.json +2 -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";
|
|
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
59
|
import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, renderEpicLaunchCommand, 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, 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,22 @@ 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 !== null && r.bring_up !== undefined
|
|
124
|
+
? r.bring_up.heartbeat_readable && r.bring_up.worker_present
|
|
125
|
+
: r.reconciler.liveness_readable && !r.reconciler.stale,
|
|
116
126
|
},
|
|
117
127
|
{
|
|
118
128
|
id: "executor_live",
|
|
119
129
|
describe: "an executor provisioned and reporting ready",
|
|
130
|
+
// BAPI-1279 — diagnostic only. drive-epic starts (or, for a durable profile,
|
|
131
|
+
// verifies) the run's executor itself once the run exists.
|
|
132
|
+
advisoryOnly: true,
|
|
120
133
|
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
121
134
|
},
|
|
122
135
|
// BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
|
|
@@ -140,17 +153,22 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
140
153
|
? "This Bridge API deploy does not report a repository's unattended " +
|
|
141
154
|
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
142
155
|
"deploy, or pass --attended."
|
|
143
|
-
: "
|
|
144
|
-
"
|
|
156
|
+
: "Unattended runs are off for this repository. Turn on 'Allow unattended " +
|
|
157
|
+
"Epic Implementer runs' on the repository's Setup page in the Bridge " +
|
|
158
|
+
"admin panel, or pass --attended. If it is already on, contact Bridge support.",
|
|
145
159
|
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
146
160
|
},
|
|
147
161
|
{
|
|
148
162
|
id: "unattended_notify_default",
|
|
149
163
|
describe: "a verified notify webhook default for escalation",
|
|
150
164
|
unattendedOnly: true,
|
|
151
|
-
remedy: "Supply a --policy-file declaring notify.webhook_url, or
|
|
165
|
+
remedy: "Supply a --policy-file declaring notify.webhook_url, or add an email address " +
|
|
166
|
+
"to the account so escalation alerts can be emailed, or pass --attended. " +
|
|
152
167
|
"notify.local_sink does not satisfy this.",
|
|
153
|
-
|
|
168
|
+
// BAPI-1274 — the account's email address is an escalation channel when no
|
|
169
|
+
// webhook is declared; the server reports its presence, never the address.
|
|
170
|
+
satisfied: (r) => r.unattended?.notify_webhook_default_declared === true ||
|
|
171
|
+
r.unattended?.escalation_email_available === true,
|
|
154
172
|
},
|
|
155
173
|
{
|
|
156
174
|
id: "repository_readiness",
|
|
@@ -190,7 +208,10 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
190
208
|
/**
|
|
191
209
|
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
192
210
|
*
|
|
193
|
-
*
|
|
211
|
+
* BAPI-1279 narrowed this to the reconciler. `executor_live` is advisory now
|
|
212
|
+
* (see `advisoryOnly`): drive-epic ensures the run's executor itself after the
|
|
213
|
+
* run is created, so its absence before then is never unmet. What remains is
|
|
214
|
+
* liveness of a process this machine starts. Everything else —
|
|
194
215
|
* `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
|
|
195
216
|
* `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
|
|
196
217
|
* stored server-side state, credentials, or repository configuration, and no
|
|
@@ -203,7 +224,7 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
203
224
|
* running plane behind and change nothing — which is the precise failure the
|
|
204
225
|
* classification exists to prevent.
|
|
205
226
|
*/
|
|
206
|
-
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"
|
|
227
|
+
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
|
|
207
228
|
/**
|
|
208
229
|
* Classify the SERVER's readiness report into the composition's three cases.
|
|
209
230
|
*
|
|
@@ -243,9 +264,11 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
243
264
|
// to say about — the same reasoning `--attended` rests on, reached by a
|
|
244
265
|
// different route. See `composeDefaultSetupEpicPolicy`.
|
|
245
266
|
const optedOut = options.attended === true || options.intoBase === true;
|
|
267
|
+
// BAPI-1279 — advisory rows are reported, never unmet.
|
|
268
|
+
const gating = deferred.filter((req) => req.advisoryOnly !== true);
|
|
246
269
|
const applicable = optedOut
|
|
247
|
-
?
|
|
248
|
-
:
|
|
270
|
+
? gating.filter((req) => req.unattendedOnly !== true)
|
|
271
|
+
: gating;
|
|
249
272
|
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
250
273
|
if (unmet.length === 0)
|
|
251
274
|
return { kind: "green" };
|
|
@@ -284,18 +307,34 @@ export function getDriveEpicUsage() {
|
|
|
284
307
|
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
285
308
|
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
286
309
|
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
287
|
-
"unattended run has two
|
|
288
|
-
"
|
|
289
|
-
"
|
|
310
|
+
"unattended run has two repository prerequisites (consent, which is ON by",
|
|
311
|
+
"default and switched on the Bridge admin panel's Setup page, and a verified",
|
|
312
|
+
"notify webhook default) that readiness lists with their state and that are",
|
|
313
|
+
"refused BY NAME before any run is created.",
|
|
314
|
+
"",
|
|
315
|
+
"Every run gets an executor (BAPI-1279): with --plan-file, once the run is",
|
|
316
|
+
"created drive-epic starts a RUN-SCOPED EXECUTOR for it in the background, from",
|
|
317
|
+
"this checkout's Git top level. It claims only that run's jobs, logs to",
|
|
318
|
+
".bridge/executors/<run>.log, and exits on its own when the run is done or",
|
|
319
|
+
"abandoned. Re-running for the same live run starts no second one. A durable",
|
|
320
|
+
"launch profile starts none: it requires a fresh --repo-wide executor instead.",
|
|
321
|
+
"Checks that need no run happen FIRST, so a refusal before the run is created",
|
|
322
|
+
"creates nothing; an executor that fails to start after it leaves the run intact",
|
|
323
|
+
"and prints the log path and the exact command to start it by hand.",
|
|
324
|
+
"",
|
|
325
|
+
"Reconciler liveness is read from the deployment's heartbeat. Against a target",
|
|
326
|
+
"that is not this machine's plane (production, say), drive-epic never touches a",
|
|
327
|
+
"local plane: a target with no live reconciler is refused by name.",
|
|
290
328
|
"",
|
|
291
329
|
"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
|
|
330
|
+
"starts the conductor runtime for you against the local plane endpoint, when",
|
|
331
|
+
"readiness is missing ONLY the reconciler. It brings up the CONTROL PLANE first",
|
|
332
|
+
"(server and reconciler), creates and approves the run, then starts executor lanes",
|
|
333
|
+
"SCOPED to that run, and starts the dead-man observer last — one invocation, no",
|
|
334
|
+
"manual `plane up` first. A report missing supervisor setup,",
|
|
335
|
+
"supervisor configuration, GitHub App credentials, or an unattended prerequisite",
|
|
336
|
+
"is NOT started: those are operator-owned, and drive-epic exits with that one",
|
|
337
|
+
"named reason instead.",
|
|
299
338
|
"",
|
|
300
339
|
"The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
|
|
301
340
|
`local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
|
|
@@ -307,18 +346,42 @@ export function getDriveEpicUsage() {
|
|
|
307
346
|
" - refusal: anything else holds the port (another listener, a manifest for a",
|
|
308
347
|
" different repository, an unreadable manifest, a plane that cannot take a",
|
|
309
348
|
" run) — one named reason, and nothing is started.",
|
|
310
|
-
"One epic per plane: a plane
|
|
311
|
-
"
|
|
312
|
-
"run
|
|
349
|
+
"One epic per plane — for its LANES: a plane's lanes serve the one run it is",
|
|
350
|
+
"bound to. A second run attaches to the same plane's reconciler and gets its own",
|
|
351
|
+
"run-scoped executor instead. A plane whose bound run cannot be read is refused",
|
|
352
|
+
"as plane-already-bound. Concurrent planes on one database are not supported.",
|
|
353
|
+
"Executor lanes and run-scoped executors are always scoped to the one run with",
|
|
354
|
+
"--epic-run-id, on every path.",
|
|
355
|
+
"",
|
|
356
|
+
"Finding the plan (BAPI-1285): without --plan-file, drive-epic searches",
|
|
357
|
+
"{docs_dir}/epic-plans/*/epic-plan.meta.json for a file naming this epic",
|
|
358
|
+
"({\"epic_key\": \"<KEY>\"}), where docs_dir is BAPI_DOCS_DIR or docs/tmp",
|
|
359
|
+
"resolved against the Git top level. Exactly one match with a sibling",
|
|
360
|
+
"epic-plan.dag.json is used as if you had passed --plan-file, and its path is",
|
|
361
|
+
"printed before anything is created. It never guesses: no match, two or more",
|
|
362
|
+
"matches, or an unusable meta file stops with the paths it found and creates",
|
|
363
|
+
"nothing. It never matches by folder name, child ticket key, or manifest. A",
|
|
364
|
+
"supplied --plan-file always wins, and discovery does not run. The meta file is",
|
|
365
|
+
"written by `emit-epic-implementer-bundle finalize`; a plan finalized before",
|
|
366
|
+
"that has none, so pass its DAG with --plan-file.",
|
|
367
|
+
"",
|
|
368
|
+
"A live run with no plan: when the epic already has a live run and no plan was",
|
|
369
|
+
"supplied or found, drive-epic does not create anything. It makes sure that run",
|
|
370
|
+
"has an executor (reusing a live one, never starting a second) and reports it. A",
|
|
371
|
+
"durable launch profile starts none and relies on its host's confirmed repo-wide",
|
|
372
|
+
"executor; a plane already bound to the run needs none. An epic with several active runs, or",
|
|
373
|
+
"a run that cannot be read, is refused by name.",
|
|
313
374
|
"",
|
|
314
375
|
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
315
|
-
"work tree
|
|
316
|
-
"you
|
|
376
|
+
"work tree inside the target repository, because the run's executor starts there.",
|
|
377
|
+
"No worktree is created for you and no replacement port is chosen for you — each",
|
|
378
|
+
"failure names the fix. Fix it and rerun.",
|
|
317
379
|
"",
|
|
318
380
|
"Options:",
|
|
319
|
-
" --plan-file <path> Plan DAG sidecar.
|
|
320
|
-
"
|
|
321
|
-
"
|
|
381
|
+
" --plan-file <path> Plan DAG sidecar. Optional: without it, drive-epic finds",
|
|
382
|
+
" the plan by epic key (see above). A supplied path always",
|
|
383
|
+
" wins, and when the v2 path is selected drive-epic runs",
|
|
384
|
+
" the bootstrap directly with it.",
|
|
322
385
|
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
323
386
|
" --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
|
|
324
387
|
" verbatim, never interpreted here.",
|
|
@@ -339,13 +402,27 @@ export function getDriveEpicUsage() {
|
|
|
339
402
|
" --launch-profile <name> Select the host-local launch profile guided setup",
|
|
340
403
|
" approved (`install conductor --launch-profile <name>`):",
|
|
341
404
|
" target, repository, mode, runtime, and policy and",
|
|
342
|
-
" credential references.
|
|
343
|
-
"
|
|
405
|
+
" credential references. Useful for a local or other",
|
|
406
|
+
" non-production server and for durable hosts.",
|
|
344
407
|
" --base-url <url> Explicit Bridge API target. With a profile it must",
|
|
345
408
|
" agree with it — an override never rebinds a profile.",
|
|
346
|
-
"Without either, BAPI_BASE_URL is used
|
|
347
|
-
|
|
348
|
-
"
|
|
409
|
+
"Without either, BAPI_BASE_URL is used. With none of the three, drive-epic",
|
|
410
|
+
`targets production (${CONDUCTOR_DEFAULT_BASE_URL}) and uses the`,
|
|
411
|
+
"stored bapi:<repo> key when one is available. A stored key approved for a",
|
|
412
|
+
"different endpoint is never sent; an unapproved key is sent only to",
|
|
413
|
+
"production.",
|
|
414
|
+
"",
|
|
415
|
+
"Preview (BAPI-1285):",
|
|
416
|
+
" --dry-run Read-only launch preview. Resolves the target, reads",
|
|
417
|
+
" readiness and the epic's live run, discovers the plan,",
|
|
418
|
+
" and runs the bootstrap's own dry-run validation. It",
|
|
419
|
+
" reports what a real run WOULD do (create, attach,",
|
|
420
|
+
" refuse, or stop) and never creates a run, cuts a",
|
|
421
|
+
" branch, starts a plane, requests a lane, or starts or",
|
|
422
|
+
" signals an executor. Exit 0 for create or attach, 1 for",
|
|
423
|
+
" refuse or stop.",
|
|
424
|
+
" --json With --dry-run only: print exactly one JSON document on",
|
|
425
|
+
" stdout and nothing else. Alone, it is a parse error.",
|
|
349
426
|
"",
|
|
350
427
|
" -h, --help Show this help",
|
|
351
428
|
].join("\n");
|
|
@@ -390,8 +467,19 @@ export function parseDriveEpicArgs(argv) {
|
|
|
390
467
|
return trimmed;
|
|
391
468
|
};
|
|
392
469
|
let attended = false;
|
|
470
|
+
// BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
|
|
471
|
+
let dryRun = false;
|
|
472
|
+
let json = false;
|
|
393
473
|
for (let i = 0; i < argv.length; i++) {
|
|
394
474
|
const arg = argv[i];
|
|
475
|
+
if (arg === "--dry-run") {
|
|
476
|
+
dryRun = true;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
if (arg === "--json") {
|
|
480
|
+
json = true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
395
483
|
if (arg === "--into-base") {
|
|
396
484
|
intoBase = true;
|
|
397
485
|
continue;
|
|
@@ -473,6 +561,15 @@ export function parseDriveEpicArgs(argv) {
|
|
|
473
561
|
}
|
|
474
562
|
positionals.push(arg);
|
|
475
563
|
}
|
|
564
|
+
// BAPI-1285 — same style as setup-epic's `--local-only` needs `--dry-run` rule,
|
|
565
|
+
// refused before any dependency is touched.
|
|
566
|
+
if (json && !dryRun) {
|
|
567
|
+
return {
|
|
568
|
+
status: "error",
|
|
569
|
+
message: "--json is only valid together with --dry-run. It selects the machine-readable " +
|
|
570
|
+
"form of the read-only launch preview, and a real launch has no JSON output.",
|
|
571
|
+
};
|
|
572
|
+
}
|
|
476
573
|
// BAPI-1009: refused HERE — before access resolution, before the readiness
|
|
477
574
|
// request, and before any delegation — with the same corrective wording
|
|
478
575
|
// setup-epic uses, so the two entry points explain one mistake one way.
|
|
@@ -530,6 +627,8 @@ export function parseDriveEpicArgs(argv) {
|
|
|
530
627
|
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
531
628
|
...(launchProfile ? { launchProfile } : {}),
|
|
532
629
|
...(baseUrl ? { baseUrl } : {}),
|
|
630
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
631
|
+
...(json ? { json: true } : {}),
|
|
533
632
|
};
|
|
534
633
|
return { status: "ok", options };
|
|
535
634
|
}
|
|
@@ -556,7 +655,7 @@ export function parseDriveEpicArgs(argv) {
|
|
|
556
655
|
* about to be created.
|
|
557
656
|
*/
|
|
558
657
|
export function selectConductor(readiness) {
|
|
559
|
-
const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && !req.satisfied(readiness));
|
|
658
|
+
const missing = V2_READINESS_REQUIREMENTS.filter((req) => req.unattendedOnly !== true && req.advisoryOnly !== true && !req.satisfied(readiness));
|
|
560
659
|
if (missing.length === 0) {
|
|
561
660
|
return {
|
|
562
661
|
kind: "selected",
|
|
@@ -598,18 +697,24 @@ export function buildSetupEpicArgv(options) {
|
|
|
598
697
|
...(replacePolicy ? ["--replace-policy"] : []),
|
|
599
698
|
// BAPI-1260 — the SAME launch selection, so setup-epic resolves the same
|
|
600
699
|
// target and bound credential drive-epic just did. Appended only when
|
|
601
|
-
// supplied, so every pre-existing argv stays byte-identical.
|
|
700
|
+
// supplied, so every pre-existing argv stays byte-identical. A flag-less
|
|
701
|
+
// (production-default, BAPI-1272) run forwards neither: setup-epic reads
|
|
702
|
+
// the adopted BAPI_BASE_URL instead.
|
|
602
703
|
...(launchProfile ? ["--launch-profile", launchProfile] : []),
|
|
603
704
|
...(baseUrl ? ["--base-url", baseUrl] : []),
|
|
604
705
|
];
|
|
605
706
|
}
|
|
606
707
|
/**
|
|
607
|
-
* Render the
|
|
708
|
+
* Render the no-plan stop: what discovery found, how to make a plan, and how to
|
|
709
|
+
* re-run with one.
|
|
608
710
|
*
|
|
609
|
-
* Precondition: the caller has already ruled out delegation
|
|
610
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
711
|
+
* Precondition: the caller has already ruled out delegation, a not-ready result,
|
|
712
|
+
* AND a live run. This runs only after plan discovery came back empty or
|
|
713
|
+
* ambiguous (BAPI-1285), so it never claims "nothing derives a plan from an epic
|
|
714
|
+
* key" — something does, and it names exactly what it found.
|
|
715
|
+
*
|
|
716
|
+
* It offers two ways to re-run and only one of them is a runnable `drive-epic`
|
|
717
|
+
* command: `/drive-epic-run` is a session slash command, not a second conductor.
|
|
613
718
|
*/
|
|
614
719
|
export function renderConductorHandoff(selection, epicKey,
|
|
615
720
|
/**
|
|
@@ -619,7 +724,12 @@ export function renderConductorHandoff(selection, epicKey,
|
|
|
619
724
|
* shell. The plan file is the one thing that cannot be filled in yet, and it
|
|
620
725
|
* is the only placeholder left.
|
|
621
726
|
*/
|
|
622
|
-
launch = { profile: null }
|
|
727
|
+
launch = { profile: null },
|
|
728
|
+
/**
|
|
729
|
+
* BAPI-1285 — what discovery found. Absent reads as "no metadata named this
|
|
730
|
+
* epic", with the searched directory left as its placeholder.
|
|
731
|
+
*/
|
|
732
|
+
discovery) {
|
|
623
733
|
const invocation = launch.profile !== null
|
|
624
734
|
? renderEpicLaunchCommand(launch.profile, {
|
|
625
735
|
epicKey,
|
|
@@ -630,25 +740,49 @@ launch = { profile: null }) {
|
|
|
630
740
|
...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
|
|
631
741
|
}).replace(`${shellQuote("<path>")}`, "<path>")
|
|
632
742
|
: `npx -y ${MCP_PACKAGE_NAME} drive-epic ${epicKey} --plan-file <path>`;
|
|
743
|
+
const plansDir = discovery?.epic_plans_dir ?? "{docs_dir}/epic-plans";
|
|
744
|
+
const outcome = discovery?.status === "plan_ambiguous"
|
|
745
|
+
? [
|
|
746
|
+
`More than one plan names ${epicKey} under ${plansDir}, and drive-epic does not guess`,
|
|
747
|
+
"between them. Candidates:",
|
|
748
|
+
...discovery.candidates.map((candidate) => ` - ${candidate}`),
|
|
749
|
+
]
|
|
750
|
+
: [`No epic-plan.meta.json under ${plansDir} names ${epicKey}.`];
|
|
751
|
+
const skipped = discovery !== undefined && discovery.skipped.length > 0
|
|
752
|
+
? [
|
|
753
|
+
"",
|
|
754
|
+
"These metadata files could not be used, so they were not counted:",
|
|
755
|
+
...discovery.skipped.map((entry) => ` - ${entry.path} (${entry.reason})`),
|
|
756
|
+
]
|
|
757
|
+
: [];
|
|
633
758
|
return {
|
|
634
759
|
conductor: "v2",
|
|
635
760
|
invocation,
|
|
636
761
|
lines: [
|
|
637
|
-
`${epicKey}: ${selection.reason}, but no plan
|
|
762
|
+
`${epicKey}: ${selection.reason}, but no plan was supplied and none could be selected.`,
|
|
763
|
+
"",
|
|
764
|
+
...outcome,
|
|
765
|
+
...skipped,
|
|
766
|
+
"",
|
|
767
|
+
"Discovery reads only epic-plan.meta.json ({\"epic_key\": ...}). It never matches a",
|
|
768
|
+
"folder name, a child ticket key, or a manifest. A plan finalized before that file",
|
|
769
|
+
"existed has none; find an older plan's DAG with:",
|
|
770
|
+
` ${plansDir}/*/epic-plan.dag.json`,
|
|
638
771
|
"",
|
|
639
|
-
"The engine path needs a plan DAG
|
|
640
|
-
"
|
|
641
|
-
"anywhere else. Produce one, then re-run this command with it.",
|
|
772
|
+
"The engine path needs a plan DAG: the dependency edges and per-ticket",
|
|
773
|
+
"touched_files it carries do not exist anywhere else.",
|
|
642
774
|
"",
|
|
643
|
-
` 1.
|
|
775
|
+
` 1. To make one, run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
644
776
|
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
645
777
|
" If the epic's tickets already exist, run emit-epic-implementer-bundle",
|
|
646
778
|
" finalize instead — it resolves the placeholder keys in an existing",
|
|
647
|
-
" sidecar and
|
|
779
|
+
" sidecar, attaches touched_files, and writes epic-plan.meta.json so the",
|
|
780
|
+
" plan is found next time.",
|
|
648
781
|
"",
|
|
649
|
-
` 2. ${
|
|
782
|
+
` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
|
|
783
|
+
` or from a shell: ${invocation}`,
|
|
650
784
|
"",
|
|
651
|
-
`That
|
|
785
|
+
`That shell command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
|
|
652
786
|
"something you invoke directly.",
|
|
653
787
|
],
|
|
654
788
|
};
|
|
@@ -884,8 +1018,6 @@ export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
|
|
|
884
1018
|
memberEnv.DYNO = env.DYNO;
|
|
885
1019
|
return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
|
|
886
1020
|
}
|
|
887
|
-
/** Terminal `epic_runs.status` values; every other status is a live run. */
|
|
888
|
-
const TERMINAL_EPIC_RUN_STATUSES = ["abandoned", "done"];
|
|
889
1021
|
/** The BAPI-1260 launch selection carried by parsed options. */
|
|
890
1022
|
function launchSelectionOf(options) {
|
|
891
1023
|
return {
|
|
@@ -893,11 +1025,80 @@ function launchSelectionOf(options) {
|
|
|
893
1025
|
...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
|
|
894
1026
|
};
|
|
895
1027
|
}
|
|
1028
|
+
/**
|
|
1029
|
+
* BAPI-1285 — resolve the docs directory discovery searches, through two seams.
|
|
1030
|
+
*
|
|
1031
|
+
* `BAPI_DOCS_DIR` is read once. An absolute value is used as given and needs no
|
|
1032
|
+
* Git call. Otherwise the value — or `docs/tmp` — is resolved against the Git top
|
|
1033
|
+
* level. When Git cannot name a top level this REFUSES rather than substituting
|
|
1034
|
+
* `cwd`: silently searching the wrong directory would report "no plan" for a plan
|
|
1035
|
+
* that exists.
|
|
1036
|
+
*
|
|
1037
|
+
* `index.ts` (project root) and the bundle CLI (`cwd`) resolve the same default
|
|
1038
|
+
* slightly differently today; in the normal case all three equal the Git top
|
|
1039
|
+
* level, and neither of those is changed here.
|
|
1040
|
+
*/
|
|
1041
|
+
export async function resolveDriveEpicDocsDir(seams) {
|
|
1042
|
+
const raw = seams.env.BAPI_DOCS_DIR;
|
|
1043
|
+
const configured = raw !== undefined && raw.trim() !== "" ? raw : undefined;
|
|
1044
|
+
if (configured !== undefined && path.isAbsolute(configured)) {
|
|
1045
|
+
return { ok: true, docsDir: configured };
|
|
1046
|
+
}
|
|
1047
|
+
let top;
|
|
1048
|
+
try {
|
|
1049
|
+
top = await seams.gitTopLevel();
|
|
1050
|
+
}
|
|
1051
|
+
catch {
|
|
1052
|
+
return { ok: false };
|
|
1053
|
+
}
|
|
1054
|
+
if (top === null || top.trim() === "")
|
|
1055
|
+
return { ok: false };
|
|
1056
|
+
return { ok: true, docsDir: path.resolve(top, configured ?? path.join("docs", "tmp")) };
|
|
1057
|
+
}
|
|
1058
|
+
/** The real filesystem behind plan discovery. */
|
|
1059
|
+
function createDefaultPlanDiscoveryFs() {
|
|
1060
|
+
return {
|
|
1061
|
+
readdir: (dirPath) => readdir(dirPath, { withFileTypes: true }),
|
|
1062
|
+
readFile: (filePath) => readFile(filePath, "utf8"),
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* BAPI-1285 — map the by-epic-key run read the way `setup-epic`'s Step 0 does:
|
|
1067
|
+
* 404 is no run, 409 is a wedged epic, and every other failure is unknown.
|
|
1068
|
+
* Nothing but a status and an error kind is ever kept from a failure.
|
|
1069
|
+
*/
|
|
1070
|
+
export async function readLiveEpicRunByKey(access, epicKey, fetchImpl = globalThis.fetch) {
|
|
1071
|
+
try {
|
|
1072
|
+
const state = await fetchEpicRunState(access, epicKey, fetchImpl);
|
|
1073
|
+
const run = state
|
|
1074
|
+
?.epic_run;
|
|
1075
|
+
if (typeof run?.epic_run_id !== "string" ||
|
|
1076
|
+
run.epic_run_id === "" ||
|
|
1077
|
+
typeof run.status !== "string") {
|
|
1078
|
+
return { kind: "unknown", reason: "the run state response was malformed" };
|
|
1079
|
+
}
|
|
1080
|
+
return { kind: "live", epicRunId: run.epic_run_id, status: run.status };
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
if (err instanceof ConductorBridgeApiError) {
|
|
1084
|
+
if (err.status === 404)
|
|
1085
|
+
return { kind: "none" };
|
|
1086
|
+
if (err.status === 409)
|
|
1087
|
+
return { kind: "conflict" };
|
|
1088
|
+
return {
|
|
1089
|
+
kind: "unknown",
|
|
1090
|
+
reason: `kind=${err.kind}${typeof err.status === "number" ? ` status=${err.status}` : ""}`,
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
return { kind: "unknown", reason: "the run state read failed unexpectedly" };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
896
1096
|
export function createDefaultDriveEpicDeps() {
|
|
897
1097
|
return {
|
|
898
1098
|
resolveAccess: async (repo, cwd, launch) => {
|
|
899
|
-
// BAPI-1260: the target-first launch contract.
|
|
900
|
-
// production
|
|
1099
|
+
// BAPI-1260: the target-first launch contract. BAPI-1272: with no target
|
|
1100
|
+
// selected it defaults to production, and a stored key is sent there only
|
|
1101
|
+
// when it is unbound or bound to production — never one bound elsewhere.
|
|
901
1102
|
const result = await resolveEpicLaunchBridgeApiAccess(launch ?? {}, {
|
|
902
1103
|
...(repo ? { repoName: repo } : {}),
|
|
903
1104
|
...(cwd ? { cwd } : {}),
|
|
@@ -955,6 +1156,39 @@ export function createDefaultDriveEpicDeps() {
|
|
|
955
1156
|
return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
|
|
956
1157
|
},
|
|
957
1158
|
readPlaneManifest: (repoRoot) => readPlaneManifest(repoRoot, createPlaneFsDeps()),
|
|
1159
|
+
resolveDocsDir: () => resolveDriveEpicDocsDir({
|
|
1160
|
+
env: process.env,
|
|
1161
|
+
gitTopLevel: async () => {
|
|
1162
|
+
const top = await createDefaultRunExecutorLaunchDeps().execFile("git", ["rev-parse", "--show-toplevel"], { cwd: process.cwd() });
|
|
1163
|
+
return top.ok ? top.stdout.trim() : null;
|
|
1164
|
+
},
|
|
1165
|
+
}),
|
|
1166
|
+
discoveryFs: createDefaultPlanDiscoveryFs(),
|
|
1167
|
+
readPlanDag: (filePath) => readFile(filePath, "utf8"),
|
|
1168
|
+
readPolicyFile: (filePath) => readFile(filePath, "utf8"),
|
|
1169
|
+
readLiveEpicRun: (access, epicKey) => readLiveEpicRunByKey(access, epicKey),
|
|
1170
|
+
previewSetupEpic: async ({ argv, baseUrl }) => {
|
|
1171
|
+
const stdout = [];
|
|
1172
|
+
const stderr = [];
|
|
1173
|
+
const result = await runSetupEpicWorkflow(argv, {
|
|
1174
|
+
// A COPY carrying the resolved target: the preview never mutates
|
|
1175
|
+
// `process.env`, so a dry run leaves the process exactly as it found it.
|
|
1176
|
+
env: { ...process.env, BAPI_BASE_URL: baseUrl },
|
|
1177
|
+
log: (message) => stdout.push(message),
|
|
1178
|
+
errorLog: (message) => stderr.push(message),
|
|
1179
|
+
});
|
|
1180
|
+
return { exitCode: result.exitCode, stdout, stderr };
|
|
1181
|
+
},
|
|
1182
|
+
// Read-only by construction: `prepareRunExecutor` runs `git rev-parse` and
|
|
1183
|
+
// locates the package entrypoint, and starts nothing. It is reached through
|
|
1184
|
+
// its module function here, never through the `prepareRunExecutor` dep the
|
|
1185
|
+
// preview must not call.
|
|
1186
|
+
inspectRunExecutorPrecondition: async (cwd) => {
|
|
1187
|
+
const result = await prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps());
|
|
1188
|
+
return result.ok ? { ok: true } : { ok: false, message: result.message };
|
|
1189
|
+
},
|
|
1190
|
+
prepareRunExecutor: (cwd) => prepareRunExecutorDefault(cwd, createDefaultRunExecutorLaunchDeps()),
|
|
1191
|
+
ensureRunExecutor: (args) => ensureRunExecutorDefault(args, createDefaultRunExecutorLaunchDeps()),
|
|
958
1192
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
959
1193
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
960
1194
|
log: (message) => console.log(message),
|
|
@@ -982,18 +1216,73 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
982
1216
|
}
|
|
983
1217
|
const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
|
|
984
1218
|
const launch = launchSelectionOf(parsed.options);
|
|
1219
|
+
// BAPI-1285 — the read-only preview is its own path from here. It shares the
|
|
1220
|
+
// classification and refusal builders below and none of the launch code.
|
|
1221
|
+
if (parsed.options.dryRun === true) {
|
|
1222
|
+
return await runDriveEpicPreview(deps, parsed.options);
|
|
1223
|
+
}
|
|
985
1224
|
try {
|
|
1225
|
+
// Every async branch below is `return await`ed. A bare `return promise`
|
|
1226
|
+
// inside this `try` would let a rejection skip the sanitizing `catch` at the
|
|
1227
|
+
// bottom and escape with its raw message (BAPI-1279).
|
|
986
1228
|
// --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
|
|
987
1229
|
// 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;
|
|
1230
|
+
// --base-url, and BAPI_BASE_URL, else production (BAPI-1272) — and only
|
|
1231
|
+
// then is a credential bound to that target read. A conflicting target or
|
|
1232
|
+
// an unusable credential refuses here, before any authenticated request;
|
|
1233
|
+
// there is no fallback to another endpoint. The adopted target becomes the
|
|
1234
|
+
// child processes' BAPI_BASE_URL.
|
|
991
1235
|
const accessResult = await deps.resolveAccess(repo, undefined, launch);
|
|
992
1236
|
if (!accessResult.ok) {
|
|
993
1237
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
|
|
994
1238
|
}
|
|
995
1239
|
const launchProfile = accessResult.profile ?? null;
|
|
996
1240
|
deps.adoptLaunchTarget?.(accessResult.access.baseUrl);
|
|
1241
|
+
// BAPI-1279 — decided ONCE, right here. A target that is not this machine's
|
|
1242
|
+
// plane endpoint never reaches a local-plane operation: no preflight, no
|
|
1243
|
+
// launch, no lane request, no manifest read, no port probe. Every branch
|
|
1244
|
+
// below reads this one value rather than re-deriving it.
|
|
1245
|
+
const planeTarget = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null;
|
|
1246
|
+
// `let`: a plan found by epic key (BAPI-1285) is folded into the options, after
|
|
1247
|
+
// which it is indistinguishable from a supplied `--plan-file`.
|
|
1248
|
+
let ctx = {
|
|
1249
|
+
deps,
|
|
1250
|
+
epicKey,
|
|
1251
|
+
options: parsed.options,
|
|
1252
|
+
access: accessResult.access,
|
|
1253
|
+
profile: launchProfile,
|
|
1254
|
+
};
|
|
1255
|
+
// BAPI-1285 — with no `--plan-file`, look for the plan BEFORE readiness. A
|
|
1256
|
+
// discovered plan must count as a supplied one for the BAPI-1153 cold-start
|
|
1257
|
+
// case below (a local plane that is not running yet is the commonest first
|
|
1258
|
+
// launch), and discovery is local, read-only, and free. What it may NOT do is
|
|
1259
|
+
// decide anything before readiness: an unresolvable docs root is only
|
|
1260
|
+
// remembered here and refused after readiness, so unknown readiness still
|
|
1261
|
+
// escalates exactly as before, and the live-run read stays after readiness.
|
|
1262
|
+
let discovered = null;
|
|
1263
|
+
let docsRootUnresolved = false;
|
|
1264
|
+
if (planFile === undefined) {
|
|
1265
|
+
const docs = await deps.resolveDocsDir();
|
|
1266
|
+
if (docs.ok) {
|
|
1267
|
+
discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
1268
|
+
if (discovered.status === "found") {
|
|
1269
|
+
ctx = { ...ctx, options: { ...ctx.options, planFile: discovered.path } };
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
else {
|
|
1273
|
+
docsRootUnresolved = true;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
let foundPlanAnnounced = false;
|
|
1277
|
+
/** Names the found plan once, BEFORE anything is created, and surfaces its advisory. */
|
|
1278
|
+
const announceFoundPlan = async (surfaceAdvisory = true) => {
|
|
1279
|
+
if (foundPlanAnnounced || discovered?.status !== "found")
|
|
1280
|
+
return;
|
|
1281
|
+
foundPlanAnnounced = true;
|
|
1282
|
+
deps.log(`${epicKey}: using the plan found by epic key: ${discovered.path}`);
|
|
1283
|
+
if (surfaceAdvisory)
|
|
1284
|
+
await surfaceContainmentAdvisory(deps, discovered.path);
|
|
1285
|
+
};
|
|
997
1286
|
// --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
|
|
998
1287
|
let readiness;
|
|
999
1288
|
try {
|
|
@@ -1006,7 +1295,10 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1006
1295
|
// Everything else — a non-plane base URL, an HTTP or auth or shape error, a
|
|
1007
1296
|
// timeout, any other probe result — keeps today's escalation byte for byte,
|
|
1008
1297
|
// because there unknown is still fatal (R14).
|
|
1009
|
-
if (planFile !== undefined &&
|
|
1298
|
+
if (ctx.options.planFile !== undefined &&
|
|
1299
|
+
planeTarget &&
|
|
1300
|
+
err instanceof ConductorBridgeApiError &&
|
|
1301
|
+
err.kind === "network") {
|
|
1010
1302
|
const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
|
|
1011
1303
|
// BAPI-1260 — only a SELECTED LOCAL plane is ever started here. A
|
|
1012
1304
|
// profile approved for durable execution names an authority this
|
|
@@ -1019,12 +1311,9 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1019
1311
|
if (endpoint !== null) {
|
|
1020
1312
|
const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
|
|
1021
1313
|
if (probe.kind === "refused") {
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
options: parsed.options,
|
|
1026
|
-
access: accessResult.access,
|
|
1027
|
-
});
|
|
1314
|
+
// `coldStartPlane` surfaces the containment advisory itself.
|
|
1315
|
+
await announceFoundPlan(false);
|
|
1316
|
+
return await coldStartPlane(ctx);
|
|
1028
1317
|
}
|
|
1029
1318
|
}
|
|
1030
1319
|
}
|
|
@@ -1040,6 +1329,36 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1040
1329
|
// delegated argv, or the exit code.
|
|
1041
1330
|
if (planFile)
|
|
1042
1331
|
await surfaceContainmentAdvisory(deps, planFile);
|
|
1332
|
+
// BAPI-1285 — no `--plan-file`: find the plan from the epic key, and read the
|
|
1333
|
+
// epic's live run. Readiness has already been resolved (unknown escalated
|
|
1334
|
+
// above), so nothing here can be a discovery-driven create on unknown.
|
|
1335
|
+
//
|
|
1336
|
+
// A supplied plan always wins and skips ALL of this, including the extra
|
|
1337
|
+
// live-run read: setup-epic does its own Step 0 read and owns reuse.
|
|
1338
|
+
let discoveryStop;
|
|
1339
|
+
if (planFile === undefined) {
|
|
1340
|
+
if (docsRootUnresolved || discovered === null) {
|
|
1341
|
+
return refuse(deps, docsRootRefusal(epicKey).lines);
|
|
1342
|
+
}
|
|
1343
|
+
const live = await deps.readLiveEpicRun(accessResult.access, epicKey);
|
|
1344
|
+
// Wedged or unreadable: never a create and never an attach, whatever
|
|
1345
|
+
// discovery found. Guessing here is how a duplicate run gets minted.
|
|
1346
|
+
if (live.kind === "conflict")
|
|
1347
|
+
return refuse(deps, liveRunAmbiguousRefusal(epicKey).lines);
|
|
1348
|
+
if (live.kind === "unknown")
|
|
1349
|
+
return refuse(deps, liveRunUnknownRefusal(epicKey, live.reason).lines);
|
|
1350
|
+
if (discovered.status === "found") {
|
|
1351
|
+
// The plan was folded into `ctx.options` before readiness; from here it is
|
|
1352
|
+
// indistinguishable from a supplied `--plan-file`.
|
|
1353
|
+
await announceFoundPlan();
|
|
1354
|
+
}
|
|
1355
|
+
else if (live.kind === "live") {
|
|
1356
|
+
return await attachToLiveRun(ctx, readiness, live);
|
|
1357
|
+
}
|
|
1358
|
+
else {
|
|
1359
|
+
discoveryStop = discovered;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1043
1362
|
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
1044
1363
|
// about a missing plan file, and rendering it on the path that has one
|
|
1045
1364
|
// would produce a message contradicting what is about to happen.
|
|
@@ -1049,7 +1368,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1049
1368
|
// not-ready result can be rendered, because a report that is non-green only on
|
|
1050
1369
|
// the runtime facts is not a "v2 cannot run" situation — it is a plane that
|
|
1051
1370
|
// has not been started yet.
|
|
1052
|
-
if (planFile) {
|
|
1371
|
+
if (ctx.options.planFile !== undefined) {
|
|
1053
1372
|
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended
|
|
1054
1373
|
// prerequisites inapplicable. Both inputs are FLAG checks, not policy
|
|
1055
1374
|
// reads: whatever a `--policy-file` says stays opaque here, exactly as it
|
|
@@ -1060,12 +1379,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1060
1379
|
intoBase: parsed.options.intoBase === true,
|
|
1061
1380
|
hasPolicyFile: parsed.options.policyFile !== undefined,
|
|
1062
1381
|
});
|
|
1063
|
-
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1382
|
+
// Green: create the run, then make sure something will claim its jobs
|
|
1383
|
+
// (BAPI-1279). Before this, green delegated to setup-epic and nothing
|
|
1384
|
+
// else, so a production launch — or any second run — created a run no
|
|
1385
|
+
// executor would ever claim. setup-epic's argv is unchanged.
|
|
1067
1386
|
if (classification.kind === "green") {
|
|
1068
|
-
|
|
1387
|
+
if (!planeTarget) {
|
|
1388
|
+
return await createRunWithRunExecutor(ctx, readiness);
|
|
1389
|
+
}
|
|
1390
|
+
// A plane target: attach to THIS checkout's plane when it is the one
|
|
1391
|
+
// listening (its lanes, or a dedicated executor for a second run).
|
|
1392
|
+
// Otherwise — no plane here, or one owned by a sibling worktree — the
|
|
1393
|
+
// run gets a run-scoped executor. The preflight runs only when a
|
|
1394
|
+
// manifest says there is a plane in this checkout to attach to.
|
|
1395
|
+
const attachable = await findAttachablePlane(ctx);
|
|
1396
|
+
if (attachable !== null) {
|
|
1397
|
+
return await attachToPlane(ctx, attachable, readiness);
|
|
1398
|
+
}
|
|
1399
|
+
return await createRunWithRunExecutor(ctx, readiness);
|
|
1069
1400
|
}
|
|
1070
1401
|
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
1071
1402
|
// cannot create stored supervisor state or resolve GitHub App credentials,
|
|
@@ -1079,8 +1410,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1079
1410
|
// The requirement's OWN remedy when it has one. Telling an operator to
|
|
1080
1411
|
// run `install-bridge conductor` for a missing consent flag sends them
|
|
1081
1412
|
// to a command that cannot set it.
|
|
1082
|
-
classification
|
|
1083
|
-
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1413
|
+
operatorBlockedRemedy(classification),
|
|
1084
1414
|
].join("\n");
|
|
1085
1415
|
// Named as a fixed reason and nothing else: no conductor is offered here,
|
|
1086
1416
|
// because handing an operator a second authority is the hazard this
|
|
@@ -1089,6 +1419,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1089
1419
|
deps.errorLog(refusal);
|
|
1090
1420
|
return 1;
|
|
1091
1421
|
}
|
|
1422
|
+
// BAPI-1279 — runtime-only against a target that is NOT this machine's
|
|
1423
|
+
// plane: starting anything here could not help, because the missing
|
|
1424
|
+
// reconciler belongs to that deployment. One named reason, and nothing
|
|
1425
|
+
// local is touched.
|
|
1426
|
+
if (!planeTarget) {
|
|
1427
|
+
return refuseRemoteRuntime(ctx, readiness);
|
|
1428
|
+
}
|
|
1092
1429
|
// Runtime-only: this invocation composes bring-up with run creation.
|
|
1093
1430
|
const repoRoot = deps.cwd();
|
|
1094
1431
|
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
@@ -1099,8 +1436,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1099
1436
|
// preflight made from its single manifest read; nothing is re-read here.
|
|
1100
1437
|
const existingPlane = preflight.observations?.existingPlane;
|
|
1101
1438
|
const attachManifest = existingPlane?.kind === "attachable" &&
|
|
1102
|
-
preflight.observations?.serverPort === "connected"
|
|
1103
|
-
planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
|
|
1439
|
+
preflight.observations?.serverPort === "connected"
|
|
1104
1440
|
? existingPlane.manifest
|
|
1105
1441
|
: null;
|
|
1106
1442
|
// Composition preconditions FIRST, and only these two. Preflight is
|
|
@@ -1128,7 +1464,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1128
1464
|
return 1;
|
|
1129
1465
|
}
|
|
1130
1466
|
if (attachManifest !== null) {
|
|
1131
|
-
return attachToPlane(
|
|
1467
|
+
return await attachToPlane(ctx, attachManifest, readiness);
|
|
1132
1468
|
}
|
|
1133
1469
|
// ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
|
|
1134
1470
|
//
|
|
@@ -1149,23 +1485,21 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1149
1485
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1150
1486
|
if (!controlPlane.ok) {
|
|
1151
1487
|
// 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;
|
|
1488
|
+
// startup back. No run was created here.
|
|
1489
|
+
return reportControlPlaneLaunchFailure(ctx, controlPlane);
|
|
1156
1490
|
}
|
|
1157
1491
|
// Held so the launcher's lifetime promise is never unobserved. It settles
|
|
1158
1492
|
// when the PLANE comes down, which is long after this command returns —
|
|
1159
1493
|
// the detached runtime is its own process-group leader and outlives us by
|
|
1160
1494
|
// design, which is what "the plane is left running" means.
|
|
1161
1495
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1162
|
-
// Readiness is NOT re-read as a gate here
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
return createRunAndRequestLanes(
|
|
1496
|
+
// Readiness is NOT re-read as a gate here. A successful bring-up IS the
|
|
1497
|
+
// runtime fact: the launch already waited for this plane's own reconciler
|
|
1498
|
+
// heartbeat. (`reconciler_live` now reads the deployment heartbeat, but the
|
|
1499
|
+
// server's repository-progress diagnostics — `epic_runs.last_tick_at`,
|
|
1500
|
+
// `executor_jobs` — stay empty on a first run, and nothing here waits on
|
|
1501
|
+
// them.)
|
|
1502
|
+
return await createRunAndRequestLanes(ctx, {
|
|
1169
1503
|
origin: "launched",
|
|
1170
1504
|
repoRoot,
|
|
1171
1505
|
planeId: controlPlane.planeId,
|
|
@@ -1187,14 +1521,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1187
1521
|
deps.errorLog(refusal);
|
|
1188
1522
|
return 1;
|
|
1189
1523
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
});
|
|
1524
|
+
// BAPI-1285 — reached only after discovery came back empty or ambiguous and no
|
|
1525
|
+
// live run exists. Nothing was created, so this is a stop, not a success.
|
|
1526
|
+
const handoff = renderConductorHandoff(selection, epicKey, { profile: launchProfile, options: parsed.options }, discoveryStop);
|
|
1194
1527
|
const text = handoff.lines.join("\n");
|
|
1195
1528
|
assertSingleConductorInvocation(text);
|
|
1196
|
-
deps.
|
|
1197
|
-
return
|
|
1529
|
+
deps.errorLog(text);
|
|
1530
|
+
return 1;
|
|
1198
1531
|
}
|
|
1199
1532
|
catch (err) {
|
|
1200
1533
|
// Last resort. Sanitized, on stderr, converted to an exit code — a
|
|
@@ -1226,6 +1559,23 @@ async function surfaceContainmentAdvisory(deps, planFile) {
|
|
|
1226
1559
|
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
1227
1560
|
}
|
|
1228
1561
|
}
|
|
1562
|
+
/**
|
|
1563
|
+
* BAPI-1285 — the one remedy sentence an unknown-readiness escalation ends with.
|
|
1564
|
+
* A constant so the preview's `readiness.remedy` is the very line the real
|
|
1565
|
+
* refusal prints, not a second copy that can drift.
|
|
1566
|
+
*/
|
|
1567
|
+
const ESCALATE_REMEDY = "Restore Bridge API access and run drive-epic again, or ask an operator to resolve conductor readiness.";
|
|
1568
|
+
/** The reason id the preview reports for an unknown readiness read. */
|
|
1569
|
+
const READINESS_UNKNOWN_REASON_ID = "readiness-unknown";
|
|
1570
|
+
/**
|
|
1571
|
+
* The remedy an operator-blocked refusal names: the requirement's own, else the
|
|
1572
|
+
* general install remedy. One function so the refusal, the cold-start refusal, and
|
|
1573
|
+
* the preview cannot disagree.
|
|
1574
|
+
*/
|
|
1575
|
+
function operatorBlockedRemedy(classification) {
|
|
1576
|
+
return (classification.remedy ??
|
|
1577
|
+
"Resolve it with `install-bridge conductor`, then run drive-epic again.");
|
|
1578
|
+
}
|
|
1229
1579
|
/**
|
|
1230
1580
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
1231
1581
|
* fallback here is the dual-authority hazard, and an operator who does not know
|
|
@@ -1236,8 +1586,8 @@ function escalate(deps, epicKey, reason) {
|
|
|
1236
1586
|
`Cannot determine which conductor owns ${epicKey}: ${reason}`,
|
|
1237
1587
|
"",
|
|
1238
1588
|
"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
|
-
|
|
1589
|
+
"conductor on an epic wedges it permanently.",
|
|
1590
|
+
ESCALATE_REMEDY,
|
|
1241
1591
|
];
|
|
1242
1592
|
const text = lines.join("\n");
|
|
1243
1593
|
assertSingleConductorInvocation(text);
|
|
@@ -1339,9 +1689,7 @@ async function coldStartPlane(ctx) {
|
|
|
1339
1689
|
}
|
|
1340
1690
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1341
1691
|
if (!controlPlane.ok) {
|
|
1342
|
-
|
|
1343
|
-
deps.errorLog(line);
|
|
1344
|
-
return 1;
|
|
1692
|
+
return reportControlPlaneLaunchFailure(ctx, controlPlane);
|
|
1345
1693
|
}
|
|
1346
1694
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1347
1695
|
// Read ONCE, for route selection only — the pre-launch read had no server to
|
|
@@ -1366,8 +1714,7 @@ async function coldStartPlane(ctx) {
|
|
|
1366
1714
|
if (classification.kind === "operator_blocked") {
|
|
1367
1715
|
return refuse(deps, [
|
|
1368
1716
|
`${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
|
|
1369
|
-
classification
|
|
1370
|
-
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1717
|
+
operatorBlockedRemedy(classification),
|
|
1371
1718
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1372
1719
|
]);
|
|
1373
1720
|
}
|
|
@@ -1409,7 +1756,10 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1409
1756
|
]);
|
|
1410
1757
|
}
|
|
1411
1758
|
const attachCtx = { ...ctx, access: rooted.access };
|
|
1412
|
-
// R-7, decided BEFORE any create request.
|
|
1759
|
+
// R-7, decided BEFORE any create request. It binds the LANES only
|
|
1760
|
+
// (BAPI-1279): a plane whose lanes serve another run still has a reconciler
|
|
1761
|
+
// that can drive this one, and gets a dedicated executor for it.
|
|
1762
|
+
let dedicatedExecutorReason = null;
|
|
1413
1763
|
if (manifest.epicRunId !== undefined) {
|
|
1414
1764
|
let bound = null;
|
|
1415
1765
|
try {
|
|
@@ -1418,28 +1768,33 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1418
1768
|
catch {
|
|
1419
1769
|
bound = null;
|
|
1420
1770
|
}
|
|
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) {
|
|
1771
|
+
if (bound === null) {
|
|
1429
1772
|
return refuse(deps, [
|
|
1430
1773
|
`${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
|
|
1431
|
-
`${manifest.epicRunId}, and
|
|
1432
|
-
|
|
1433
|
-
"
|
|
1774
|
+
`${manifest.epicRunId}, and its bound run could not be read, so it is unknown whether ` +
|
|
1775
|
+
"the plane is healthy.",
|
|
1776
|
+
"Restore Bridge API access, or inspect `plane status` and that run's own status, then run",
|
|
1777
|
+
"drive-epic again.",
|
|
1434
1778
|
"",
|
|
1435
1779
|
"Nothing was started.",
|
|
1436
1780
|
]);
|
|
1437
1781
|
}
|
|
1782
|
+
if (isTerminalEpicRunStatus(bound.status)) {
|
|
1783
|
+
dedicatedExecutorReason = `its lanes' run ${manifest.epicRunId} is ${bound.status}`;
|
|
1784
|
+
}
|
|
1785
|
+
else if (bound.epicKey !== epicKey) {
|
|
1786
|
+
dedicatedExecutorReason = `its lanes serve run ${manifest.epicRunId} of ${bound.epicKey}`;
|
|
1787
|
+
}
|
|
1438
1788
|
}
|
|
1439
1789
|
const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
|
|
1440
1790
|
if (admission !== null) {
|
|
1441
1791
|
return refuse(deps, [...admission, "", "Nothing was started."]);
|
|
1442
1792
|
}
|
|
1793
|
+
if (dedicatedExecutorReason !== null) {
|
|
1794
|
+
deps.log(`${epicKey}: attaching to this repository's running plane (${dedicatedExecutorReason}); ` +
|
|
1795
|
+
"its reconciler will drive this run too, and a dedicated executor will be started for it.");
|
|
1796
|
+
return createRunWithRunExecutor(attachCtx, readiness);
|
|
1797
|
+
}
|
|
1443
1798
|
deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
|
|
1444
1799
|
return createRunAndRequestLanes(attachCtx, {
|
|
1445
1800
|
origin: "attached",
|
|
@@ -1523,7 +1878,8 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1523
1878
|
`${epicKey}: the run was created and approved, but executor lanes could not be`,
|
|
1524
1879
|
`requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
|
|
1525
1880
|
laneRequest.reason === "run-conflict"
|
|
1526
|
-
? "
|
|
1881
|
+
? "This plane's lanes already serve another run. Run drive-epic again: it will attach " +
|
|
1882
|
+
"to this plane's reconciler and start a dedicated run-scoped executor for this run."
|
|
1527
1883
|
: "Inspect it with `plane status`, and wind it down with `plane down`.",
|
|
1528
1884
|
"The run itself is untouched and still committed server-side.",
|
|
1529
1885
|
].join("\n"));
|
|
@@ -1576,3 +1932,742 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1576
1932
|
].join("\n"));
|
|
1577
1933
|
return 0;
|
|
1578
1934
|
}
|
|
1935
|
+
// ---------------------------------------------------------------------------
|
|
1936
|
+
// BAPI-1279 — every launch ensures an executor; remote targets touch no plane
|
|
1937
|
+
// ---------------------------------------------------------------------------
|
|
1938
|
+
/**
|
|
1939
|
+
* The refusals BAPI-1279 adds. Like {@link DRIVE_EPIC_PLANE_REFUSALS}, these
|
|
1940
|
+
* exist nowhere else; the bring-up facts keep refusing under their own names.
|
|
1941
|
+
*/
|
|
1942
|
+
export const DRIVE_EPIC_LAUNCH_REFUSALS = [
|
|
1943
|
+
/** A remote target reports no live reconciler and no bring-up fact says why. */
|
|
1944
|
+
"remote-reconciler-not-live",
|
|
1945
|
+
/** A durable launch profile, but no fresh repo-wide executor is confirmed. */
|
|
1946
|
+
"durable-executor-not-live",
|
|
1947
|
+
/** The run-scoped executor could never be started from this checkout. */
|
|
1948
|
+
"executor-precondition",
|
|
1949
|
+
];
|
|
1950
|
+
/** Said on every remote refusal, because operators expect a local fallback. */
|
|
1951
|
+
const REMOTE_NO_PLANE_LINE = "No local plane was started, because the target is not this machine's plane.";
|
|
1952
|
+
/**
|
|
1953
|
+
* `worker-not-present` against a remote target. The shared remediation offers
|
|
1954
|
+
* "bring up a local plane", which cannot help a deployment on another machine
|
|
1955
|
+
* and would contradict {@link REMOTE_NO_PLANE_LINE}.
|
|
1956
|
+
*/
|
|
1957
|
+
function remoteWorkerNotPresentMessage(baseUrl) {
|
|
1958
|
+
return (`the Bridge API at ${baseUrl} reports no worker running its reconciler, so nothing would ` +
|
|
1959
|
+
"drive this run. Start that deployment's worker process — exactly one (for example one " +
|
|
1960
|
+
"Heroku `worker` dyno) — then run drive-epic again.");
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* Is THIS checkout's plane the one listening on the plane endpoint, and able to
|
|
1964
|
+
* take a run? Returns its manifest, or `null`.
|
|
1965
|
+
*
|
|
1966
|
+
* Reads the manifest first and runs the plane preflight only when there is one:
|
|
1967
|
+
* with no manifest here there is nothing to attach to, and a green plane target
|
|
1968
|
+
* served by a sibling worktree's plane needs no preflight at all.
|
|
1969
|
+
*/
|
|
1970
|
+
async function findAttachablePlane(ctx) {
|
|
1971
|
+
const repoRoot = ctx.deps.cwd();
|
|
1972
|
+
const manifestRead = await ctx.deps.readPlaneManifest(repoRoot);
|
|
1973
|
+
if (manifestRead.kind !== "valid")
|
|
1974
|
+
return null;
|
|
1975
|
+
const preflight = await ctx.deps.runPlanePreflight(repoRoot);
|
|
1976
|
+
const existingPlane = preflight.observations?.existingPlane;
|
|
1977
|
+
return existingPlane?.kind === "attachable" && preflight.observations?.serverPort === "connected"
|
|
1978
|
+
? existingPlane.manifest
|
|
1979
|
+
: null;
|
|
1980
|
+
}
|
|
1981
|
+
/**
|
|
1982
|
+
* Runtime-only readiness against a target that is NOT this machine's plane.
|
|
1983
|
+
*
|
|
1984
|
+
* The missing reconciler belongs to that deployment, so nothing started here
|
|
1985
|
+
* could help. One named reason: the first bring-up refusal, whose message
|
|
1986
|
+
* already names its remedy, else `remote-reconciler-not-live`. No run is
|
|
1987
|
+
* created and nothing local is touched.
|
|
1988
|
+
*/
|
|
1989
|
+
function refuseRemoteRuntime(ctx, readiness) {
|
|
1990
|
+
return refuse(ctx.deps, remoteRuntimeRefusal(ctx.epicKey, ctx.access, readiness).lines);
|
|
1991
|
+
}
|
|
1992
|
+
/** The runtime-only refusal for a target that is not this machine's plane. */
|
|
1993
|
+
function remoteRuntimeRefusal(epicKey, access, readiness) {
|
|
1994
|
+
const bringUp = readiness.bring_up ?? null;
|
|
1995
|
+
const reason = firstBringUpRefusal(bringUp);
|
|
1996
|
+
const reasonId = reason ?? "remote-reconciler-not-live";
|
|
1997
|
+
const remedy = reason !== null
|
|
1998
|
+
? reason === "worker-not-present"
|
|
1999
|
+
? remoteWorkerNotPresentMessage(access.baseUrl)
|
|
2000
|
+
: BRING_UP_REFUSAL_MESSAGES[reason]
|
|
2001
|
+
: `the Bridge API at ${access.baseUrl} reports no live reconciler, so nothing would drive ` +
|
|
2002
|
+
"this run. Check that its worker process is running, then run drive-epic again.";
|
|
2003
|
+
const lines = [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`];
|
|
2004
|
+
if (bringUp === null) {
|
|
2005
|
+
lines.push("This Bridge API version cannot report deployment liveness, so a launch against it cannot " +
|
|
2006
|
+
"be confirmed. Upgrade the Bridge API deploy.");
|
|
2007
|
+
}
|
|
2008
|
+
lines.push("", "No run was created.", REMOTE_NO_PLANE_LINE);
|
|
2009
|
+
return { reasonId, remedy, lines };
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* BAPI-1285 — a durable launch profile with no confirmed fresh repo-wide
|
|
2013
|
+
* executor. `created` says what state the refusal leaves: before a create nothing
|
|
2014
|
+
* exists; on a live-run attach the run is simply untouched.
|
|
2015
|
+
*/
|
|
2016
|
+
function durableCoverageRefusal(epicKey, access, profile, coverage, outcome) {
|
|
2017
|
+
const remedy = [
|
|
2018
|
+
"Start the durable host's executor with --repo-wide",
|
|
2019
|
+
" (`install conductor --execution-mode durable --executor-id <id> --repo-wide`),",
|
|
2020
|
+
"or launch without the durable profile.",
|
|
2021
|
+
].join("\n");
|
|
2022
|
+
return {
|
|
2023
|
+
reasonId: "durable-executor-not-live",
|
|
2024
|
+
remedy,
|
|
2025
|
+
lines: [
|
|
2026
|
+
`${epicKey}: REFUSED (durable-executor-not-live) — launch profile '${profile.name}' selects ` +
|
|
2027
|
+
`durable execution, but a fresh repo-wide executor for ${access.repoName} could not be ` +
|
|
2028
|
+
"confirmed" +
|
|
2029
|
+
(coverage === undefined
|
|
2030
|
+
? " (this Bridge API version does not report executor coverage)."
|
|
2031
|
+
: "."),
|
|
2032
|
+
remedy,
|
|
2033
|
+
"",
|
|
2034
|
+
`${outcome}, and no local executor was started.`,
|
|
2035
|
+
],
|
|
2036
|
+
};
|
|
2037
|
+
}
|
|
2038
|
+
/** The pre-create refusal when no executor could ever be started from this checkout. */
|
|
2039
|
+
function executorPreconditionRefusal(epicKey, message, outcome) {
|
|
2040
|
+
return {
|
|
2041
|
+
reasonId: "executor-precondition",
|
|
2042
|
+
remedy: message,
|
|
2043
|
+
lines: [`${epicKey}: REFUSED (executor-precondition) — ${message}`, "", outcome],
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
/** BAPI-1285 — `--plan-file` absent, and the checkout cannot say where plans live. */
|
|
2047
|
+
function docsRootRefusal(epicKey) {
|
|
2048
|
+
const remedy = "Run drive-epic from inside the target repository's Git checkout, set BAPI_DOCS_DIR to an " +
|
|
2049
|
+
"absolute path, or pass --plan-file.";
|
|
2050
|
+
return {
|
|
2051
|
+
reasonId: "docs-root-unresolvable",
|
|
2052
|
+
remedy,
|
|
2053
|
+
lines: [
|
|
2054
|
+
`${epicKey}: REFUSED (docs-root-unresolvable) — this directory is not inside a Git work tree ` +
|
|
2055
|
+
"and BAPI_DOCS_DIR is not an absolute path, so the directory holding epic plans could not " +
|
|
2056
|
+
"be located.",
|
|
2057
|
+
remedy,
|
|
2058
|
+
"",
|
|
2059
|
+
"Nothing was created and nothing was started.",
|
|
2060
|
+
],
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
/** BAPI-1285 — several active runs for one epic: it is wedged. */
|
|
2064
|
+
function liveRunAmbiguousRefusal(epicKey) {
|
|
2065
|
+
const remedy = "Abandon the duplicate run with `epic-implementer abandon-run --epic-run-id " +
|
|
2066
|
+
"<duplicate_epic_run_id>`, then run drive-epic again.";
|
|
2067
|
+
return {
|
|
2068
|
+
reasonId: "live-run-ambiguous",
|
|
2069
|
+
remedy,
|
|
2070
|
+
lines: [
|
|
2071
|
+
`${epicKey}: REFUSED (live-run-ambiguous) — this epic has more than one active run, so it is ` +
|
|
2072
|
+
"wedged and drive-epic cannot tell which run to drive.",
|
|
2073
|
+
remedy,
|
|
2074
|
+
"",
|
|
2075
|
+
"Nothing was created and nothing was started.",
|
|
2076
|
+
],
|
|
2077
|
+
};
|
|
2078
|
+
}
|
|
2079
|
+
/** BAPI-1285 — the epic's run state could not be read, so nothing may be assumed. */
|
|
2080
|
+
function liveRunUnknownRefusal(epicKey, reason) {
|
|
2081
|
+
const remedy = "Restore Bridge API access, then run drive-epic again. Creating a run without knowing " +
|
|
2082
|
+
"whether one exists could mint a duplicate, which wedges the epic permanently.";
|
|
2083
|
+
return {
|
|
2084
|
+
reasonId: "live-run-unknown",
|
|
2085
|
+
remedy,
|
|
2086
|
+
lines: [
|
|
2087
|
+
`${epicKey}: REFUSED (live-run-unknown) — the epic's run state could not be read (${reason}).`,
|
|
2088
|
+
remedy,
|
|
2089
|
+
"",
|
|
2090
|
+
"Nothing was created and nothing was started.",
|
|
2091
|
+
],
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Create the run, then make sure an executor will claim its jobs — the path for
|
|
2096
|
+
* every launch that does not request a plane lane.
|
|
2097
|
+
*
|
|
2098
|
+
* - Durable launch profile: confirm a fresh REPO-WIDE executor covers the
|
|
2099
|
+
* repository BEFORE creating anything, and start nothing locally.
|
|
2100
|
+
* - Otherwise: resolve the checkout and build BEFORE creating anything, then
|
|
2101
|
+
* create the run, then start (or reuse) a run-scoped executor for it.
|
|
2102
|
+
*
|
|
2103
|
+
* The order is the whole design: every refusal that can happen before a run
|
|
2104
|
+
* exists does, so a refusal never leaves a run behind. After the run exists,
|
|
2105
|
+
* nothing here stops or retries it.
|
|
2106
|
+
*/
|
|
2107
|
+
async function createRunWithRunExecutor(ctx, readiness) {
|
|
2108
|
+
const { deps, epicKey, access, profile } = ctx;
|
|
2109
|
+
if (profile !== null && profile.executionMode === "durable") {
|
|
2110
|
+
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2111
|
+
if (coverage !== true) {
|
|
2112
|
+
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was created").lines);
|
|
2113
|
+
}
|
|
2114
|
+
const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
|
|
2115
|
+
if (setup.exitCode === 0) {
|
|
2116
|
+
deps.log([
|
|
2117
|
+
"",
|
|
2118
|
+
`${epicKey}: run ${setup.epicRunId ?? "(id not reported)"} created on ${access.baseUrl}.`,
|
|
2119
|
+
"The durable host's repo-wide executor claims its jobs; no local executor was started.",
|
|
2120
|
+
].join("\n"));
|
|
2121
|
+
}
|
|
2122
|
+
return setup.exitCode;
|
|
2123
|
+
}
|
|
2124
|
+
// PRE-CREATE: a launch that could never start an executor refuses here.
|
|
2125
|
+
const prepared = await deps.prepareRunExecutor(deps.cwd());
|
|
2126
|
+
if (!prepared.ok) {
|
|
2127
|
+
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was created.").lines);
|
|
2128
|
+
}
|
|
2129
|
+
const setup = await deps.runSetupEpic(buildSetupEpicArgv(ctx.options));
|
|
2130
|
+
if (setup.exitCode !== 0) {
|
|
2131
|
+
// setup-epic already said why. Nothing was started for a run that may not exist.
|
|
2132
|
+
deps.errorLog(`\n${epicKey}: no executor was started.`);
|
|
2133
|
+
return setup.exitCode;
|
|
2134
|
+
}
|
|
2135
|
+
if (setup.epicRunId === undefined) {
|
|
2136
|
+
// Exit zero with no run id: there is no authoritative identity to scope an
|
|
2137
|
+
// executor to, and guessing could attach it to a DIFFERENT run.
|
|
2138
|
+
return refuse(deps, [
|
|
2139
|
+
"",
|
|
2140
|
+
`${epicKey}: setup reported success but returned no epic run id, so no executor could be`,
|
|
2141
|
+
"attached to the run. NO EXECUTOR WAS STARTED. Inspect this repository's epic runs",
|
|
2142
|
+
"server-side before retrying; retrying blindly could create a second run.",
|
|
2143
|
+
]);
|
|
2144
|
+
}
|
|
2145
|
+
// POST-CREATE: start or reuse exactly one executor scoped to this run.
|
|
2146
|
+
const ensured = await deps.ensureRunExecutor({
|
|
2147
|
+
prepared: prepared.prepared,
|
|
2148
|
+
repoName: access.repoName,
|
|
2149
|
+
epicRunId: setup.epicRunId,
|
|
2150
|
+
access,
|
|
2151
|
+
profile,
|
|
2152
|
+
});
|
|
2153
|
+
return reportExecutorOutcome(ctx, ensured, setup.epicRunId, "created");
|
|
2154
|
+
}
|
|
2155
|
+
/**
|
|
2156
|
+
* Print an executor outcome — started, reused, or failed — and return the exit
|
|
2157
|
+
* code. Shared by the create path and the live-run attach path (BAPI-1285) so the
|
|
2158
|
+
* two print identical text rather than two copies that drift. Only the framing
|
|
2159
|
+
* differs: whether the run was just CREATED or was already live.
|
|
2160
|
+
*
|
|
2161
|
+
* The readiness, durable, and plane gates stay with the callers; this is only the
|
|
2162
|
+
* report. A failure never stops or changes the run.
|
|
2163
|
+
*/
|
|
2164
|
+
function reportExecutorOutcome(ctx, ensured, epicRunId, framing) {
|
|
2165
|
+
const { deps, epicKey, access } = ctx;
|
|
2166
|
+
if (!ensured.ok) {
|
|
2167
|
+
return refuse(deps, [
|
|
2168
|
+
"",
|
|
2169
|
+
framing === "created"
|
|
2170
|
+
? `${epicKey}: Run created but executor unavailable — run ${epicRunId} on ${access.baseUrl}.`
|
|
2171
|
+
: `${epicKey}: Run ${epicRunId} executor unavailable — the run is already live on ${access.baseUrl}.`,
|
|
2172
|
+
`No executor heartbeat was confirmed (${ensured.reason}): ${ensured.message}.`,
|
|
2173
|
+
` Log: ${ensured.logPath}`,
|
|
2174
|
+
...(ensured.executorMayBeRunning
|
|
2175
|
+
? [
|
|
2176
|
+
"An executor for this run may still be running, so do not start a second one beside",
|
|
2177
|
+
"it. Check the log and the process first; only once none is running, start it by hand",
|
|
2178
|
+
"from this checkout:",
|
|
2179
|
+
]
|
|
2180
|
+
: ["Start it by hand from this checkout:"]),
|
|
2181
|
+
` ${ensured.manualCommand}`,
|
|
2182
|
+
"The run was NOT stopped. It stays server-side and recoverable, and resumes as soon as an",
|
|
2183
|
+
"executor claims its jobs.",
|
|
2184
|
+
]);
|
|
2185
|
+
}
|
|
2186
|
+
const started = ensured.outcome === "started"
|
|
2187
|
+
? `A run-scoped executor was started for it in the background (pid ${ensured.pid}).`
|
|
2188
|
+
: `A run-scoped executor for it was already running (pid ${ensured.pid}); no second one was started.`;
|
|
2189
|
+
deps.log([
|
|
2190
|
+
"",
|
|
2191
|
+
framing === "created"
|
|
2192
|
+
? `${epicKey}: run ${epicRunId} is created on ${access.baseUrl}.`
|
|
2193
|
+
: `${epicKey}: run ${epicRunId} is already live on ${access.baseUrl}; nothing was created.`,
|
|
2194
|
+
started,
|
|
2195
|
+
"It claims only this run's jobs, and exits on its own once the run is done or abandoned.",
|
|
2196
|
+
` Log: ${ensured.logPath}`,
|
|
2197
|
+
` Record: ${ensured.recordPath}`,
|
|
2198
|
+
"It runs on THIS machine: sleep is held off while it runs, but a closed lid, a logout, or a",
|
|
2199
|
+
"shutdown stops it. The run stays server-side either way; restart the executor, or use a",
|
|
2200
|
+
"durable launch profile for long runs.",
|
|
2201
|
+
].join("\n"));
|
|
2202
|
+
return 0;
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Is this checkout's plane serving the live run? True only when BOTH binding
|
|
2206
|
+
* facts agree: the plane manifest's `epicRunId` equals the live run's id, and the
|
|
2207
|
+
* bound run reads back live and of this epic. Anything unreadable or mismatched is
|
|
2208
|
+
* false, and the caller takes the ordinary run-scoped executor path — a plane is
|
|
2209
|
+
* never presumed to be covering a run it cannot be shown to cover.
|
|
2210
|
+
*/
|
|
2211
|
+
async function planeServesLiveRun(ctx, live) {
|
|
2212
|
+
try {
|
|
2213
|
+
const manifest = await findAttachablePlane(ctx);
|
|
2214
|
+
if (manifest === null || manifest.epicRunId !== live.epicRunId)
|
|
2215
|
+
return false;
|
|
2216
|
+
const bound = await ctx.deps.readRunState(ctx.access, manifest.epicRunId);
|
|
2217
|
+
return bound.epicKey === ctx.epicKey && !isTerminalEpicRunStatus(bound.status);
|
|
2218
|
+
}
|
|
2219
|
+
catch {
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
/**
|
|
2224
|
+
* BAPI-1285 — a live run and no usable plan: make sure the run has an executor.
|
|
2225
|
+
*
|
|
2226
|
+
* It creates nothing and never calls `setup-epic`, which hard-requires a plan
|
|
2227
|
+
* file. Readiness gates it exactly as it gates a create (only green proceeds),
|
|
2228
|
+
* because starting an executor for a run nothing can drive is not attaching to it.
|
|
2229
|
+
*
|
|
2230
|
+
* - durable profile: confirm a fresh repo-wide executor and start nothing local;
|
|
2231
|
+
* - this checkout's plane bound to THIS run: its lanes serve it, start nothing;
|
|
2232
|
+
* - otherwise: prepare, then ensure a run-scoped executor for the LIVE run's id.
|
|
2233
|
+
*/
|
|
2234
|
+
async function attachToLiveRun(ctx, readiness, live) {
|
|
2235
|
+
const { deps, epicKey, access, profile } = ctx;
|
|
2236
|
+
const classification = classifyV2Readiness(readiness, {
|
|
2237
|
+
attended: ctx.options.attended === true,
|
|
2238
|
+
intoBase: ctx.options.intoBase === true,
|
|
2239
|
+
hasPolicyFile: ctx.options.policyFile !== undefined,
|
|
2240
|
+
});
|
|
2241
|
+
if (classification.kind === "operator_blocked") {
|
|
2242
|
+
return refuse(deps, [
|
|
2243
|
+
`${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
|
|
2244
|
+
"",
|
|
2245
|
+
"This is not something starting the runtime can fix, so nothing was started.",
|
|
2246
|
+
operatorBlockedRemedy(classification),
|
|
2247
|
+
]);
|
|
2248
|
+
}
|
|
2249
|
+
const planeTarget = planeEndpointForBaseUrl(deps, access.baseUrl) !== null;
|
|
2250
|
+
if (classification.kind === "runtime_only") {
|
|
2251
|
+
if (!planeTarget)
|
|
2252
|
+
return refuseRemoteRuntime(ctx, readiness);
|
|
2253
|
+
return refuse(deps, localReconcilerRefusal(epicKey, readiness).lines);
|
|
2254
|
+
}
|
|
2255
|
+
deps.log(`${epicKey}: run ${live.epicRunId} is already live (${live.status}) and no plan was supplied or ` +
|
|
2256
|
+
"found, so nothing will be created; making sure it has an executor.");
|
|
2257
|
+
if (profile !== null && profile.executionMode === "durable") {
|
|
2258
|
+
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2259
|
+
if (coverage !== true) {
|
|
2260
|
+
return refuse(deps, durableCoverageRefusal(epicKey, access, profile, coverage, "No run was changed").lines);
|
|
2261
|
+
}
|
|
2262
|
+
deps.log([
|
|
2263
|
+
"",
|
|
2264
|
+
`${epicKey}: run ${live.epicRunId} is served by the durable host's confirmed repo-wide executor.`,
|
|
2265
|
+
"No local executor was started.",
|
|
2266
|
+
].join("\n"));
|
|
2267
|
+
return 0;
|
|
2268
|
+
}
|
|
2269
|
+
if (planeTarget && (await planeServesLiveRun(ctx, live))) {
|
|
2270
|
+
deps.log([
|
|
2271
|
+
"",
|
|
2272
|
+
`${epicKey}: this checkout's plane is bound to run ${live.epicRunId}; its executor lanes serve it.`,
|
|
2273
|
+
"No run-scoped executor was started beside them.",
|
|
2274
|
+
].join("\n"));
|
|
2275
|
+
return 0;
|
|
2276
|
+
}
|
|
2277
|
+
const prepared = await deps.prepareRunExecutor(deps.cwd());
|
|
2278
|
+
if (!prepared.ok) {
|
|
2279
|
+
return refuse(deps, executorPreconditionRefusal(epicKey, prepared.message, "No run was changed.").lines);
|
|
2280
|
+
}
|
|
2281
|
+
const ensured = await deps.ensureRunExecutor({
|
|
2282
|
+
prepared: prepared.prepared,
|
|
2283
|
+
repoName: access.repoName,
|
|
2284
|
+
epicRunId: live.epicRunId,
|
|
2285
|
+
access,
|
|
2286
|
+
profile,
|
|
2287
|
+
});
|
|
2288
|
+
return reportExecutorOutcome(ctx, ensured, live.epicRunId, "attached");
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* BAPI-1285 — a live run, a local plane target, and a reconciler that is not
|
|
2292
|
+
* live. Without a plan drive-epic cannot compose the bring-up (that happens only
|
|
2293
|
+
* while creating a run), so this is one named refusal rather than an executor for
|
|
2294
|
+
* a run nothing is driving.
|
|
2295
|
+
*/
|
|
2296
|
+
function localReconcilerRefusal(epicKey, readiness) {
|
|
2297
|
+
const reason = firstBringUpRefusal(readiness.bring_up ?? null);
|
|
2298
|
+
const reasonId = reason ?? "local-reconciler-not-live";
|
|
2299
|
+
const remedy = reason !== null
|
|
2300
|
+
? BRING_UP_REFUSAL_MESSAGES[reason]
|
|
2301
|
+
: "the local conductor plane reports no live reconciler, so nothing would drive this run. " +
|
|
2302
|
+
"Start it with `plane up`, or pass --plan-file so drive-epic can bring the runtime up.";
|
|
2303
|
+
return {
|
|
2304
|
+
reasonId,
|
|
2305
|
+
remedy,
|
|
2306
|
+
lines: [`${epicKey}: REFUSED (${reasonId}) — ${remedy}`, "", "No run was changed."],
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
/**
|
|
2310
|
+
* Render a failed control-plane launch.
|
|
2311
|
+
*
|
|
2312
|
+
* The launch already printed its own reason. The one case this adds to is the
|
|
2313
|
+
* pre-worker guard finding another reconciler live (BAPI-1279): name it as
|
|
2314
|
+
* `second-reconciler-detected` and give the attach remedy. "Nothing was left
|
|
2315
|
+
* running" is said only after a CONFIRMED rollback.
|
|
2316
|
+
*/
|
|
2317
|
+
function reportControlPlaneLaunchFailure(ctx, controlPlane) {
|
|
2318
|
+
const { deps, epicKey } = ctx;
|
|
2319
|
+
for (const line of controlPlane.lines)
|
|
2320
|
+
deps.errorLog(line);
|
|
2321
|
+
if (controlPlane.foreignReconciler === undefined)
|
|
2322
|
+
return 1;
|
|
2323
|
+
// The live plane is on ANOTHER port — a cold start happens only when this
|
|
2324
|
+
// plane's own port is free — so the port is named by that plane, not here.
|
|
2325
|
+
return refuse(deps, [
|
|
2326
|
+
"",
|
|
2327
|
+
`${epicKey}: REFUSED (second-reconciler-detected) — ` +
|
|
2328
|
+
BRING_UP_REFUSAL_MESSAGES["second-reconciler-detected"],
|
|
2329
|
+
controlPlane.foreignReconciler.rolledBack
|
|
2330
|
+
? "The server this invocation started was rolled back; nothing was left running."
|
|
2331
|
+
: "The rollback of the server this invocation started could NOT be confirmed. Check " +
|
|
2332
|
+
"`plane status` and stop anything it still lists.",
|
|
2333
|
+
"Concurrent planes on one database are not supported. To run this epic next to the live one,",
|
|
2334
|
+
"point this launch at the plane that owns that reconciler (pass --base-url",
|
|
2335
|
+
"http://127.0.0.1:<port>, with the port that plane's `plane status` reports, and change or",
|
|
2336
|
+
"unset BAPI_BASE_URL if it names a different endpoint). drive-epic will create the run",
|
|
2337
|
+
"against that plane and start a run-scoped executor in this checkout.",
|
|
2338
|
+
"",
|
|
2339
|
+
"No run was created.",
|
|
2340
|
+
]);
|
|
2341
|
+
}
|
|
2342
|
+
/** The readiness reason a preview reports for a generic not-ready engine. */
|
|
2343
|
+
const ENGINE_NOT_READY_REASON_ID = "engine-not-ready";
|
|
2344
|
+
const ENGINE_NOT_READY_REMEDY = "Resolve conductor readiness (`install-bridge conductor`, then `doctor`) and run drive-epic again.";
|
|
2345
|
+
/**
|
|
2346
|
+
* Read exactly one well-shaped setup-epic dry-run JSON document out of captured
|
|
2347
|
+
* `log` output. Anything else — none, several, or one with the wrong shape — is
|
|
2348
|
+
* `null`. Human text that is not JSON is ignored, never parsed for meaning.
|
|
2349
|
+
*/
|
|
2350
|
+
function readSetupPreviewPayload(stdout) {
|
|
2351
|
+
const documents = [];
|
|
2352
|
+
for (const message of stdout) {
|
|
2353
|
+
let value;
|
|
2354
|
+
try {
|
|
2355
|
+
value = JSON.parse(message);
|
|
2356
|
+
}
|
|
2357
|
+
catch {
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
2361
|
+
documents.push(value);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
if (documents.length !== 1)
|
|
2365
|
+
return null;
|
|
2366
|
+
const doc = documents[0];
|
|
2367
|
+
if (doc.dry_run !== true || typeof doc.server_validated !== "boolean")
|
|
2368
|
+
return null;
|
|
2369
|
+
const existing = doc.existing_run_id;
|
|
2370
|
+
if (existing !== null && existing !== undefined && typeof existing !== "string")
|
|
2371
|
+
return null;
|
|
2372
|
+
const branch = doc.feature_branch;
|
|
2373
|
+
if (branch !== undefined && typeof branch !== "string")
|
|
2374
|
+
return null;
|
|
2375
|
+
return {
|
|
2376
|
+
existingRunId: typeof existing === "string" && existing !== "" ? existing : null,
|
|
2377
|
+
featureBranch: typeof branch === "string" && branch !== "" ? branch : null,
|
|
2378
|
+
serverValidated: doc.server_validated,
|
|
2379
|
+
};
|
|
2380
|
+
}
|
|
2381
|
+
/** The plan DAG's node count, or `null` unless it parses to `{ nodes: [...] }`. */
|
|
2382
|
+
async function readPlanChildCount(deps, planPath) {
|
|
2383
|
+
try {
|
|
2384
|
+
const parsed = JSON.parse(await deps.readPlanDag(planPath));
|
|
2385
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
2386
|
+
return null;
|
|
2387
|
+
const nodes = parsed.nodes;
|
|
2388
|
+
return Array.isArray(nodes) ? nodes.length : null;
|
|
2389
|
+
}
|
|
2390
|
+
catch {
|
|
2391
|
+
return null;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
/**
|
|
2395
|
+
* Run the read-only preview. Returns `0` for a `create` or `attach` decision and
|
|
2396
|
+
* `1` for `refuse` or `stop`. Never throws: an unexpected failure becomes one
|
|
2397
|
+
* named refusal in the same document.
|
|
2398
|
+
*/
|
|
2399
|
+
async function runDriveEpicPreview(deps, options) {
|
|
2400
|
+
const { epicKey } = options;
|
|
2401
|
+
const posture = options.attended === true || options.intoBase === true ? "attended" : "unattended";
|
|
2402
|
+
const preview = {
|
|
2403
|
+
epic_key: epicKey,
|
|
2404
|
+
target: { base_url: null, kind: null },
|
|
2405
|
+
readiness: { kind: "unknown" },
|
|
2406
|
+
live_run: null,
|
|
2407
|
+
plan: { status: "not_found" },
|
|
2408
|
+
summary: {
|
|
2409
|
+
child_count: null,
|
|
2410
|
+
branch: null,
|
|
2411
|
+
posture,
|
|
2412
|
+
auto_merge_scope: null,
|
|
2413
|
+
escalation_channel: null,
|
|
2414
|
+
server_validation: "not_run",
|
|
2415
|
+
},
|
|
2416
|
+
decision: "refuse",
|
|
2417
|
+
refusal: null,
|
|
2418
|
+
};
|
|
2419
|
+
/** A refusal carried by the readiness block (and mirrored where the ticket needs it). */
|
|
2420
|
+
const refuseWithReadiness = (kind, named, alsoTopLevel = false) => {
|
|
2421
|
+
preview.readiness = { kind, reason_id: named.reasonId, remedy: named.remedy };
|
|
2422
|
+
if (alsoTopLevel)
|
|
2423
|
+
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2424
|
+
preview.decision = "refuse";
|
|
2425
|
+
};
|
|
2426
|
+
/** A refusal that is not about readiness: only the top-level block carries it. */
|
|
2427
|
+
const refuseTopLevel = (named) => {
|
|
2428
|
+
preview.refusal = { reason_id: named.reasonId, remedy: named.remedy };
|
|
2429
|
+
preview.decision = "refuse";
|
|
2430
|
+
};
|
|
2431
|
+
try {
|
|
2432
|
+
await previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel);
|
|
2433
|
+
}
|
|
2434
|
+
catch {
|
|
2435
|
+
// Sanitized on purpose: nothing from the thrown value reaches the document.
|
|
2436
|
+
preview.summary.server_validation =
|
|
2437
|
+
preview.summary.server_validation === "passed" ? "passed" : "not_run";
|
|
2438
|
+
refuseTopLevel({
|
|
2439
|
+
reasonId: "preview-failed",
|
|
2440
|
+
remedy: "The preview could not complete. Run it again; nothing was created or started.",
|
|
2441
|
+
});
|
|
2442
|
+
}
|
|
2443
|
+
emitPreview(deps, options, preview);
|
|
2444
|
+
return preview.decision === "create" || preview.decision === "attach" ? 0 : 1;
|
|
2445
|
+
}
|
|
2446
|
+
/** Fill in `preview` by walking the same decisions a real launch makes, read-only. */
|
|
2447
|
+
async function previewDecision(deps, options, preview, refuseWithReadiness, refuseTopLevel) {
|
|
2448
|
+
const { epicKey } = options;
|
|
2449
|
+
const unknownReadiness = () => refuseWithReadiness("unknown", {
|
|
2450
|
+
reasonId: READINESS_UNKNOWN_REASON_ID,
|
|
2451
|
+
remedy: ESCALATE_REMEDY,
|
|
2452
|
+
});
|
|
2453
|
+
// --- Target: resolved, never adopted. -------------------------------------
|
|
2454
|
+
const accessResult = await deps.resolveAccess(options.repo, undefined, launchSelectionOf(options));
|
|
2455
|
+
if (!accessResult.ok)
|
|
2456
|
+
return unknownReadiness();
|
|
2457
|
+
const access = accessResult.access;
|
|
2458
|
+
const profile = accessResult.profile ?? null;
|
|
2459
|
+
const planeTarget = planeEndpointForBaseUrl(deps, access.baseUrl) !== null;
|
|
2460
|
+
preview.target = { base_url: access.baseUrl, kind: planeTarget ? "local_plane" : "remote" };
|
|
2461
|
+
const ctx = { deps, epicKey, options, access, profile };
|
|
2462
|
+
// --- Readiness. -----------------------------------------------------------
|
|
2463
|
+
let readiness;
|
|
2464
|
+
try {
|
|
2465
|
+
readiness = await deps.readReadiness(access);
|
|
2466
|
+
}
|
|
2467
|
+
catch {
|
|
2468
|
+
return unknownReadiness();
|
|
2469
|
+
}
|
|
2470
|
+
const classification = classifyV2Readiness(readiness, {
|
|
2471
|
+
attended: options.attended === true,
|
|
2472
|
+
intoBase: options.intoBase === true,
|
|
2473
|
+
hasPolicyFile: options.policyFile !== undefined,
|
|
2474
|
+
});
|
|
2475
|
+
preview.readiness = { kind: classification.kind };
|
|
2476
|
+
const selection = selectConductor(readiness);
|
|
2477
|
+
// --- The plan: supplied, or discovered. -----------------------------------
|
|
2478
|
+
let planPath = options.planFile;
|
|
2479
|
+
if (planPath !== undefined) {
|
|
2480
|
+
preview.plan = { status: "supplied", path: planPath };
|
|
2481
|
+
}
|
|
2482
|
+
else {
|
|
2483
|
+
const docs = await deps.resolveDocsDir();
|
|
2484
|
+
if (!docs.ok)
|
|
2485
|
+
return refuseTopLevel(docsRootRefusal(epicKey));
|
|
2486
|
+
const discovered = await discoverEpicPlan(epicKey, docs.docsDir, deps.discoveryFs);
|
|
2487
|
+
preview.plan = {
|
|
2488
|
+
status: discovered.status === "found"
|
|
2489
|
+
? "found"
|
|
2490
|
+
: discovered.status === "plan_ambiguous"
|
|
2491
|
+
? "ambiguous"
|
|
2492
|
+
: "not_found",
|
|
2493
|
+
...(discovered.status === "found" ? { path: discovered.path } : {}),
|
|
2494
|
+
...(discovered.status === "plan_ambiguous" ? { candidates: [...discovered.candidates] } : {}),
|
|
2495
|
+
...(discovered.skipped.length > 0
|
|
2496
|
+
? { skipped: discovered.skipped.map((entry) => ({ path: entry.path, reason: entry.reason })) }
|
|
2497
|
+
: {}),
|
|
2498
|
+
epic_plans_dir: discovered.epic_plans_dir,
|
|
2499
|
+
};
|
|
2500
|
+
if (discovered.status === "found")
|
|
2501
|
+
planPath = discovered.path;
|
|
2502
|
+
}
|
|
2503
|
+
// --- The live run. Conflict and unknown refuse whatever the plan says. ------
|
|
2504
|
+
const live = await deps.readLiveEpicRun(access, epicKey);
|
|
2505
|
+
if (live.kind === "conflict")
|
|
2506
|
+
return refuseTopLevel(liveRunAmbiguousRefusal(epicKey));
|
|
2507
|
+
if (live.kind === "unknown")
|
|
2508
|
+
return refuseTopLevel(liveRunUnknownRefusal(epicKey, live.reason));
|
|
2509
|
+
if (live.kind === "live") {
|
|
2510
|
+
preview.live_run = { epic_run_id: live.epicRunId, status: live.status };
|
|
2511
|
+
}
|
|
2512
|
+
// --- No plan: attach to a live run, or stop. --------------------------------
|
|
2513
|
+
if (planPath === undefined) {
|
|
2514
|
+
if (live.kind !== "live") {
|
|
2515
|
+
if (selection.kind === "not_ready") {
|
|
2516
|
+
return refuseWithReadiness(classification.kind, {
|
|
2517
|
+
reasonId: ENGINE_NOT_READY_REASON_ID,
|
|
2518
|
+
remedy: ENGINE_NOT_READY_REMEDY,
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
preview.decision = "stop";
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "attach");
|
|
2525
|
+
if (blocked !== null)
|
|
2526
|
+
return refuseWithReadiness(classification.kind, blocked);
|
|
2527
|
+
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, live, "No run was changed");
|
|
2528
|
+
if (facts !== null)
|
|
2529
|
+
return refuseWithReadiness(classification.kind, facts, true);
|
|
2530
|
+
preview.decision = "attach";
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
// --- A plan is available: the create path's gates, then setup's own dry run. --
|
|
2534
|
+
const blocked = previewReadinessRefusal(ctx, classification, readiness, planeTarget, "create");
|
|
2535
|
+
if (blocked !== null)
|
|
2536
|
+
return refuseWithReadiness(classification.kind, blocked);
|
|
2537
|
+
const facts = await previewExecutorFacts(ctx, readiness, planeTarget, null, "No run was created");
|
|
2538
|
+
if (facts !== null)
|
|
2539
|
+
return refuseWithReadiness(classification.kind, facts, true);
|
|
2540
|
+
// Escalation channel, decided with setup-epic's own exported policy helpers. A
|
|
2541
|
+
// policy that cannot be read is one fixed refusal; its path, contents, and any
|
|
2542
|
+
// webhook URL never leave this function.
|
|
2543
|
+
if (preview.summary.posture === "unattended") {
|
|
2544
|
+
if (options.policyFile !== undefined) {
|
|
2545
|
+
const policy = await readSetupEpicPolicyFile(options.policyFile, deps.readPolicyFile);
|
|
2546
|
+
if (!policy.ok) {
|
|
2547
|
+
return refuseTopLevel({
|
|
2548
|
+
reasonId: "policy-file-unreadable",
|
|
2549
|
+
remedy: "The --policy-file could not be read as a JSON object. Fix it or pass a different " +
|
|
2550
|
+
"file, then run the preview again.",
|
|
2551
|
+
});
|
|
2552
|
+
}
|
|
2553
|
+
preview.summary.escalation_channel = policyDeclaresNotifyWebhook(policy.policy)
|
|
2554
|
+
? "webhook"
|
|
2555
|
+
: "email";
|
|
2556
|
+
}
|
|
2557
|
+
else {
|
|
2558
|
+
preview.summary.escalation_channel = "email";
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
preview.summary.child_count = await readPlanChildCount(deps, planPath);
|
|
2562
|
+
const setup = await deps.previewSetupEpic({
|
|
2563
|
+
argv: [...buildSetupEpicArgv({ ...options, planFile: planPath }), "--dry-run", "--json"],
|
|
2564
|
+
baseUrl: access.baseUrl,
|
|
2565
|
+
});
|
|
2566
|
+
if (setup.exitCode !== 0) {
|
|
2567
|
+
preview.summary.server_validation = "failed";
|
|
2568
|
+
return refuseTopLevel({
|
|
2569
|
+
reasonId: "setup-preview-failed",
|
|
2570
|
+
remedy: "The plan did not pass validation, or the epic could not be prepared. Fix the plan or " +
|
|
2571
|
+
"the condition it reports, then run the preview again. Nothing was created.",
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
const payload = readSetupPreviewPayload(setup.stdout);
|
|
2575
|
+
if (payload === null) {
|
|
2576
|
+
preview.summary.server_validation = "not_run";
|
|
2577
|
+
return refuseTopLevel({
|
|
2578
|
+
reasonId: "setup-preview-malformed",
|
|
2579
|
+
remedy: "Plan validation returned no usable result, so the launch could not be predicted. Run " +
|
|
2580
|
+
"the preview again. Nothing was created.",
|
|
2581
|
+
});
|
|
2582
|
+
}
|
|
2583
|
+
preview.summary.server_validation = payload.serverValidated ? "passed" : "partial";
|
|
2584
|
+
preview.summary.branch = payload.featureBranch;
|
|
2585
|
+
preview.summary.auto_merge_scope =
|
|
2586
|
+
preview.summary.posture === "unattended" ? payload.featureBranch : null;
|
|
2587
|
+
// The bootstrap reusing a run IS an attach: it reports the run's id.
|
|
2588
|
+
if (payload.existingRunId !== null) {
|
|
2589
|
+
preview.live_run = {
|
|
2590
|
+
epic_run_id: payload.existingRunId,
|
|
2591
|
+
status: preview.live_run?.epic_run_id === payload.existingRunId ? preview.live_run.status : null,
|
|
2592
|
+
};
|
|
2593
|
+
preview.decision = "attach";
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
preview.decision = live.kind === "live" ? "attach" : "create";
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* The readiness refusal a real launch would print, as a reason id and remedy, or
|
|
2600
|
+
* `null` when readiness lets the launch proceed. Built from the SAME functions the
|
|
2601
|
+
* real refusals use, so the strings agree byte for byte.
|
|
2602
|
+
*/
|
|
2603
|
+
function previewReadinessRefusal(ctx, classification, readiness, planeTarget, path) {
|
|
2604
|
+
if (classification.kind === "operator_blocked") {
|
|
2605
|
+
return { reasonId: classification.id, remedy: operatorBlockedRemedy(classification) };
|
|
2606
|
+
}
|
|
2607
|
+
if (classification.kind === "runtime_only") {
|
|
2608
|
+
if (!planeTarget)
|
|
2609
|
+
return remoteRuntimeRefusal(ctx.epicKey, ctx.access, readiness);
|
|
2610
|
+
// A local plane with only the reconciler missing: a real launch WITH a plan
|
|
2611
|
+
// brings the runtime up. Without one (attach) it is refused by name.
|
|
2612
|
+
if (path === "attach")
|
|
2613
|
+
return localReconcilerRefusal(ctx.epicKey, readiness);
|
|
2614
|
+
}
|
|
2615
|
+
return null;
|
|
2616
|
+
}
|
|
2617
|
+
/**
|
|
2618
|
+
* The executor-side refusals a real launch would hit, decided read-only: the
|
|
2619
|
+
* durable profile's coverage check, else whether an executor could be started
|
|
2620
|
+
* from this checkout at all. Returns `null` when neither refuses, including when
|
|
2621
|
+
* this checkout's plane already serves the live run and needs no executor.
|
|
2622
|
+
*/
|
|
2623
|
+
async function previewExecutorFacts(ctx, readiness, planeTarget, live, outcome) {
|
|
2624
|
+
const { deps, epicKey, access, profile } = ctx;
|
|
2625
|
+
if (profile !== null && profile.executionMode === "durable") {
|
|
2626
|
+
const coverage = readiness.executor.repo_wide_executor_live;
|
|
2627
|
+
return coverage === true
|
|
2628
|
+
? null
|
|
2629
|
+
: durableCoverageRefusal(epicKey, access, profile, coverage, outcome);
|
|
2630
|
+
}
|
|
2631
|
+
if (planeTarget) {
|
|
2632
|
+
if (live !== null) {
|
|
2633
|
+
if (await planeServesLiveRun(ctx, live))
|
|
2634
|
+
return null;
|
|
2635
|
+
}
|
|
2636
|
+
else if ((await findAttachablePlane(ctx).catch(() => null)) !== null) {
|
|
2637
|
+
return null;
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
const inspected = await deps.inspectRunExecutorPrecondition(deps.cwd());
|
|
2641
|
+
return inspected.ok
|
|
2642
|
+
? null
|
|
2643
|
+
: executorPreconditionRefusal(epicKey, inspected.message, outcome === "No run was created" ? "No run was created." : "No run was changed.");
|
|
2644
|
+
}
|
|
2645
|
+
/** Print the preview: one JSON line with `--json`, short readable lines otherwise. */
|
|
2646
|
+
function emitPreview(deps, options, preview) {
|
|
2647
|
+
if (options.json === true) {
|
|
2648
|
+
deps.log(JSON.stringify(preview));
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const lines = [
|
|
2652
|
+
`${preview.epic_key}: launch preview (read-only; nothing was created or started)`,
|
|
2653
|
+
` Target: ${preview.target.base_url ?? "unresolved"}${preview.target.kind ? ` (${preview.target.kind})` : ""}`,
|
|
2654
|
+
` Readiness: ${preview.readiness.kind}${preview.readiness.reason_id ? ` (${preview.readiness.reason_id})` : ""}`,
|
|
2655
|
+
` Live run: ${preview.live_run
|
|
2656
|
+
? `${preview.live_run.epic_run_id}${preview.live_run.status ? ` (${preview.live_run.status})` : ""}`
|
|
2657
|
+
: "none"}`,
|
|
2658
|
+
` Plan: ${preview.plan.status}${preview.plan.path ? ` — ${preview.plan.path}` : ""}`,
|
|
2659
|
+
];
|
|
2660
|
+
for (const candidate of preview.plan.candidates ?? [])
|
|
2661
|
+
lines.push(` candidate: ${candidate}`);
|
|
2662
|
+
for (const skipped of preview.plan.skipped ?? []) {
|
|
2663
|
+
lines.push(` skipped: ${skipped.path} (${skipped.reason})`);
|
|
2664
|
+
}
|
|
2665
|
+
const s = preview.summary;
|
|
2666
|
+
lines.push(` Posture: ${s.posture ?? "n/a"}`, ` Children: ${s.child_count ?? "n/a"}`, ` Branch: ${s.branch ?? "n/a"}`, ` Auto-merge: ${s.auto_merge_scope ?? "n/a"}`, ` Escalation: ${s.escalation_channel ?? "n/a"}`, ` Validation: ${s.server_validation ?? "n/a"}`, ` Decision: ${preview.decision}`);
|
|
2667
|
+
if (preview.readiness.remedy)
|
|
2668
|
+
lines.push(` Remedy: ${preview.readiness.remedy}`);
|
|
2669
|
+
if (preview.refusal) {
|
|
2670
|
+
lines.push(` Refusal: ${preview.refusal.reason_id} — ${preview.refusal.remedy}`);
|
|
2671
|
+
}
|
|
2672
|
+
deps.log(lines.join("\n"));
|
|
2673
|
+
}
|