@bridge_gpt/mcp-server 0.2.60 → 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 +58 -11
- 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 +8 -7
- package/build/conductor/bridge-api-client.js +678 -14
- 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/merge-execution-contract.js +30 -5
- package/build/conductor/merge-execution-eligibility.js +10 -9
- package/build/conductor/readiness-sections.js +104 -20
- package/build/conductor/readiness.js +129 -25
- 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 +194 -0
- package/build/docs.generated.js +2 -1
- package/build/doctor.js +161 -7
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1318 -111
- 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-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +464 -0
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +48 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +286 -0
- package/build/executor/credentials.js +19 -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 +117 -1
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-errors.js +48 -0
- package/build/executor/job-runner.js +1386 -35
- package/build/executor/job-types.js +10 -2
- package/build/executor/observation.js +114 -7
- 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 +246 -1
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +55 -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 +772 -954
- package/build/install-bridge-conductor.js +622 -40
- package/build/install-bridge.js +92 -16
- package/build/merge-pull-request.js +1 -1
- package/build/merge-workflow-config.js +54 -0
- package/build/merge-workflow-drift.js +35 -4
- package/build/merge-workflow.js +386 -2
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/supervisor.js +148 -0
- package/build/readme.generated.js +1 -1
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +130 -48
- 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,11 +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,
|
|
56
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
57
|
+
import path, { dirname, join } from "node:path";
|
|
58
|
+
import { CONDUCTOR_DEFAULT_BASE_URL, ConductorBridgeApiError, resolveEpicLaunchBridgeApiAccess, fetchConductorReadiness, fetchEpicRunState, safeDiagnosticMessage, } from "./conductor/bridge-api-client.js";
|
|
59
|
+
import { EPIC_LAUNCH_PROFILE_NAME_PATTERN, renderEpicLaunchCommand, shellQuote, } from "./epic-launch-profile.js";
|
|
60
|
+
import { validateHttpBaseUrl, describeBaseUrlRejection } from "./base-url.js";
|
|
59
61
|
import { BRING_UP_REFUSAL_MESSAGES, BRING_UP_RUNBOOK, firstBringUpRefusal, } from "./conductor/bring-up-facts.js";
|
|
60
|
-
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";
|
|
61
65
|
import { formatPlaneDiagnostic, launchPlaneControlPlane, requestPlaneScopedLanes, runDefaultPlanePreflight, runPlaneCli, } from "./plane/cli.js";
|
|
62
66
|
import { PLANE_PORT_PROBE_TIMEOUT_MS, describePlanePortOccupant, } from "./plane/preflight.js";
|
|
63
67
|
import { createPlaneFsDeps, probeAutomationHealth, probeTcpPort, resolveHostname, } from "./plane/defaults.js";
|
|
@@ -66,11 +70,11 @@ import { resolvePlaneServerEndpoint, } from "./plane/member-roster.js";
|
|
|
66
70
|
import { resolveMemberInstanceId } from "./plane/supervisor.js";
|
|
67
71
|
import { PLANE_SERVER_PORT_ENV_VAR, } from "./plane/types.js";
|
|
68
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";
|
|
69
75
|
// BAPI-806: mcp-identity.ts is the SOLE source of the package-name literal, so
|
|
70
76
|
// the printed invocation interpolates it rather than repeating it.
|
|
71
77
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
72
|
-
/** Jira epic key shape. Matches the epic-key positional rule exactly. */
|
|
73
|
-
const EPIC_KEY_PATTERN = /^[A-Z]+-[0-9]+$/;
|
|
74
78
|
/** The conductor paths. Derived types below come from this constant. */
|
|
75
79
|
export const DRIVE_EPIC_CONDUCTORS = ["v2"];
|
|
76
80
|
/**
|
|
@@ -110,11 +114,22 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
110
114
|
{
|
|
111
115
|
id: "reconciler_live",
|
|
112
116
|
describe: "a reconciler that is ticking and not stale",
|
|
113
|
-
|
|
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,
|
|
114
126
|
},
|
|
115
127
|
{
|
|
116
128
|
id: "executor_live",
|
|
117
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,
|
|
118
133
|
satisfied: (r) => r.executor.liveness_readable && r.executor.ready === true,
|
|
119
134
|
},
|
|
120
135
|
// BAPI-1102 — the three unattended prerequisites, in the order `setup-epic`
|
|
@@ -138,17 +153,22 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
138
153
|
? "This Bridge API deploy does not report a repository's unattended " +
|
|
139
154
|
"prerequisites at all, so none of them could be confirmed. Upgrade the " +
|
|
140
155
|
"deploy, or pass --attended."
|
|
141
|
-
: "
|
|
142
|
-
"
|
|
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.",
|
|
143
159
|
satisfied: (r) => r.unattended?.conductor_allowed === true,
|
|
144
160
|
},
|
|
145
161
|
{
|
|
146
162
|
id: "unattended_notify_default",
|
|
147
163
|
describe: "a verified notify webhook default for escalation",
|
|
148
164
|
unattendedOnly: true,
|
|
149
|
-
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. " +
|
|
150
167
|
"notify.local_sink does not satisfy this.",
|
|
151
|
-
|
|
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,
|
|
152
172
|
},
|
|
153
173
|
{
|
|
154
174
|
id: "repository_readiness",
|
|
@@ -188,7 +208,10 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
188
208
|
/**
|
|
189
209
|
* The readiness facts a `plane up` can actually repair (BAPI-1054).
|
|
190
210
|
*
|
|
191
|
-
*
|
|
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 —
|
|
192
215
|
* `supervisor_setup`, `supervisor_config`, `github_credentials`, and (BAPI-1102)
|
|
193
216
|
* `unattended_consent`, `unattended_notify_default`, `repository_readiness` — is
|
|
194
217
|
* stored server-side state, credentials, or repository configuration, and no
|
|
@@ -201,7 +224,7 @@ export const V2_READINESS_REQUIREMENTS = [
|
|
|
201
224
|
* running plane behind and change nothing — which is the precise failure the
|
|
202
225
|
* classification exists to prevent.
|
|
203
226
|
*/
|
|
204
|
-
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"
|
|
227
|
+
export const V2_RUNTIME_READINESS_IDS = ["reconciler_live"];
|
|
205
228
|
/**
|
|
206
229
|
* Classify the SERVER's readiness report into the composition's three cases.
|
|
207
230
|
*
|
|
@@ -241,9 +264,11 @@ export function classifyV2Readiness(readiness, options = {}) {
|
|
|
241
264
|
// to say about — the same reasoning `--attended` rests on, reached by a
|
|
242
265
|
// different route. See `composeDefaultSetupEpicPolicy`.
|
|
243
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);
|
|
244
269
|
const applicable = optedOut
|
|
245
|
-
?
|
|
246
|
-
:
|
|
270
|
+
? gating.filter((req) => req.unattendedOnly !== true)
|
|
271
|
+
: gating;
|
|
247
272
|
const unmet = applicable.filter((req) => !req.satisfied(readiness));
|
|
248
273
|
if (unmet.length === 0)
|
|
249
274
|
return { kind: "green" };
|
|
@@ -282,18 +307,34 @@ export function getDriveEpicUsage() {
|
|
|
282
307
|
"UNATTENDED by default — it authorizes automatic merge into the epic branch, and",
|
|
283
308
|
"the server stamps its CI gate at first approval. The INTEGRATION PR into the",
|
|
284
309
|
"repository base branch stays human-gated. Pass --attended to opt out. An",
|
|
285
|
-
"unattended run has two
|
|
286
|
-
"
|
|
287
|
-
"
|
|
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.",
|
|
288
328
|
"",
|
|
289
329
|
"One command (BAPI-1054, reordered by BAPI-1102): with --plan-file, drive-epic",
|
|
290
|
-
"starts the conductor runtime for you
|
|
291
|
-
"reconciler
|
|
292
|
-
"reconciler), creates and approves the run, then starts executor lanes
|
|
293
|
-
"that run, and starts the dead-man observer last — one invocation, no
|
|
294
|
-
"`plane up` first. A report missing supervisor setup,
|
|
295
|
-
"GitHub App credentials, or an unattended prerequisite
|
|
296
|
-
"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.",
|
|
297
338
|
"",
|
|
298
339
|
"The plane port (BAPI-1153): with --plan-file and BAPI_BASE_URL naming the",
|
|
299
340
|
`local plane endpoint (127.0.0.1, port ${PLANE_SERVER_PORT_ENV_VAR} or the default),`,
|
|
@@ -305,18 +346,42 @@ export function getDriveEpicUsage() {
|
|
|
305
346
|
" - refusal: anything else holds the port (another listener, a manifest for a",
|
|
306
347
|
" different repository, an unreadable manifest, a plane that cannot take a",
|
|
307
348
|
" run) — one named reason, and nothing is started.",
|
|
308
|
-
"One epic per plane: a plane
|
|
309
|
-
"
|
|
310
|
-
"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.",
|
|
311
374
|
"",
|
|
312
375
|
"Preconditions are validated, never repaired: the current directory must be a Git",
|
|
313
|
-
"work tree
|
|
314
|
-
"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.",
|
|
315
379
|
"",
|
|
316
380
|
"Options:",
|
|
317
|
-
" --plan-file <path> Plan DAG sidecar.
|
|
318
|
-
"
|
|
319
|
-
"
|
|
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.",
|
|
320
385
|
" --repo <name> Repo name (default: BAPI_REPO_NAME or .bridge/config)",
|
|
321
386
|
" --feature-branch <name> Override the derived epic/<KEY> name. Forwarded",
|
|
322
387
|
" verbatim, never interpreted here.",
|
|
@@ -333,6 +398,32 @@ export function getDriveEpicUsage() {
|
|
|
333
398
|
" --review-policy <src> PER-RUN review policy source.",
|
|
334
399
|
" --replace-policy Authorize replacing a LIVE run's stored policy.",
|
|
335
400
|
"",
|
|
401
|
+
"Launch target (BAPI-1260) — decided BEFORE any authenticated request:",
|
|
402
|
+
" --launch-profile <name> Select the host-local launch profile guided setup",
|
|
403
|
+
" approved (`install conductor --launch-profile <name>`):",
|
|
404
|
+
" target, repository, mode, runtime, and policy and",
|
|
405
|
+
" credential references. Useful for a local or other",
|
|
406
|
+
" non-production server and for durable hosts.",
|
|
407
|
+
" --base-url <url> Explicit Bridge API target. With a profile it must",
|
|
408
|
+
" agree with it — an override never rebinds a profile.",
|
|
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.",
|
|
426
|
+
"",
|
|
336
427
|
" -h, --help Show this help",
|
|
337
428
|
].join("\n");
|
|
338
429
|
}
|
|
@@ -359,6 +450,9 @@ export function parseDriveEpicArgs(argv) {
|
|
|
359
450
|
let policyFile;
|
|
360
451
|
let reviewPolicy;
|
|
361
452
|
let replacePolicy = false;
|
|
453
|
+
// BAPI-1260 launch selection.
|
|
454
|
+
let launchProfile;
|
|
455
|
+
let baseUrl;
|
|
362
456
|
// BAPI-1009: validated with the SHARED validator setup-epic uses, so a name
|
|
363
457
|
// drive-epic accepts is exactly a name setup-epic accepts. A second rule here
|
|
364
458
|
// would let a name pass the router and fail the delegate.
|
|
@@ -373,8 +467,19 @@ export function parseDriveEpicArgs(argv) {
|
|
|
373
467
|
return trimmed;
|
|
374
468
|
};
|
|
375
469
|
let attended = false;
|
|
470
|
+
// BAPI-1285 preview flags. Valueless; `--json` is meaningless without `--dry-run`.
|
|
471
|
+
let dryRun = false;
|
|
472
|
+
let json = false;
|
|
376
473
|
for (let i = 0; i < argv.length; i++) {
|
|
377
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
|
+
}
|
|
378
483
|
if (arg === "--into-base") {
|
|
379
484
|
intoBase = true;
|
|
380
485
|
continue;
|
|
@@ -394,7 +499,9 @@ export function parseDriveEpicArgs(argv) {
|
|
|
394
499
|
arg === "--repo" ||
|
|
395
500
|
arg === "--feature-branch" ||
|
|
396
501
|
arg === "--policy-file" ||
|
|
397
|
-
arg === "--review-policy"
|
|
502
|
+
arg === "--review-policy" ||
|
|
503
|
+
arg === "--launch-profile" ||
|
|
504
|
+
arg === "--base-url") {
|
|
398
505
|
const value = argv[i + 1];
|
|
399
506
|
if (value === undefined || value.startsWith("-")) {
|
|
400
507
|
return { status: "error", message: `${arg} requires a value.` };
|
|
@@ -407,6 +514,10 @@ export function parseDriveEpicArgs(argv) {
|
|
|
407
514
|
policyFile = value;
|
|
408
515
|
else if (arg === "--review-policy")
|
|
409
516
|
reviewPolicy = value;
|
|
517
|
+
else if (arg === "--launch-profile")
|
|
518
|
+
launchProfile = value;
|
|
519
|
+
else if (arg === "--base-url")
|
|
520
|
+
baseUrl = value;
|
|
410
521
|
else if (takeFeatureBranch(value) === null) {
|
|
411
522
|
return {
|
|
412
523
|
status: "error",
|
|
@@ -416,7 +527,7 @@ export function parseDriveEpicArgs(argv) {
|
|
|
416
527
|
i++;
|
|
417
528
|
continue;
|
|
418
529
|
}
|
|
419
|
-
const eq = arg.match(/^(--plan-file|--repo|--feature-branch|--policy-file|--review-policy)=(.*)$/);
|
|
530
|
+
const eq = arg.match(/^(--plan-file|--repo|--feature-branch|--policy-file|--review-policy|--launch-profile|--base-url)=(.*)$/);
|
|
420
531
|
if (eq) {
|
|
421
532
|
const value = eq[2];
|
|
422
533
|
if (eq[1] === "--feature-branch") {
|
|
@@ -437,6 +548,10 @@ export function parseDriveEpicArgs(argv) {
|
|
|
437
548
|
policyFile = value;
|
|
438
549
|
else if (eq[1] === "--review-policy")
|
|
439
550
|
reviewPolicy = value;
|
|
551
|
+
else if (eq[1] === "--launch-profile")
|
|
552
|
+
launchProfile = value;
|
|
553
|
+
else if (eq[1] === "--base-url")
|
|
554
|
+
baseUrl = value;
|
|
440
555
|
else
|
|
441
556
|
repo = value;
|
|
442
557
|
continue;
|
|
@@ -446,6 +561,15 @@ export function parseDriveEpicArgs(argv) {
|
|
|
446
561
|
}
|
|
447
562
|
positionals.push(arg);
|
|
448
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
|
+
}
|
|
449
573
|
// BAPI-1009: refused HERE — before access resolution, before the readiness
|
|
450
574
|
// request, and before any delegation — with the same corrective wording
|
|
451
575
|
// setup-epic uses, so the two entry points explain one mistake one way.
|
|
@@ -458,6 +582,22 @@ export function parseDriveEpicArgs(argv) {
|
|
|
458
582
|
"may be selected — remove whichever one you did not mean.",
|
|
459
583
|
};
|
|
460
584
|
}
|
|
585
|
+
// BAPI-1260 — the launch selection is validated for SHAPE here, before any
|
|
586
|
+
// side effect. Whether it agrees with the profile and the environment is
|
|
587
|
+
// decided by the target resolver, still before any authenticated request.
|
|
588
|
+
if (launchProfile !== undefined && !EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(launchProfile)) {
|
|
589
|
+
return {
|
|
590
|
+
status: "error",
|
|
591
|
+
message: "--launch-profile must match [a-z0-9][a-z0-9._-]{0,62}.",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (baseUrl !== undefined) {
|
|
595
|
+
const validated = validateHttpBaseUrl(baseUrl);
|
|
596
|
+
if (!validated.ok) {
|
|
597
|
+
return { status: "error", message: describeBaseUrlRejection("--base-url", validated.reason) };
|
|
598
|
+
}
|
|
599
|
+
baseUrl = validated.baseUrl;
|
|
600
|
+
}
|
|
461
601
|
if (positionals.length === 0) {
|
|
462
602
|
return { status: "error", message: "Missing required epic key." };
|
|
463
603
|
}
|
|
@@ -485,6 +625,10 @@ export function parseDriveEpicArgs(argv) {
|
|
|
485
625
|
...(policyFile ? { policyFile } : {}),
|
|
486
626
|
...(reviewPolicy ? { reviewPolicy } : {}),
|
|
487
627
|
...(replacePolicy ? { replacePolicy: true } : {}),
|
|
628
|
+
...(launchProfile ? { launchProfile } : {}),
|
|
629
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
630
|
+
...(dryRun ? { dryRun: true } : {}),
|
|
631
|
+
...(json ? { json: true } : {}),
|
|
488
632
|
};
|
|
489
633
|
return { status: "ok", options };
|
|
490
634
|
}
|
|
@@ -511,7 +655,7 @@ export function parseDriveEpicArgs(argv) {
|
|
|
511
655
|
* about to be created.
|
|
512
656
|
*/
|
|
513
657
|
export function selectConductor(readiness) {
|
|
514
|
-
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));
|
|
515
659
|
if (missing.length === 0) {
|
|
516
660
|
return {
|
|
517
661
|
kind: "selected",
|
|
@@ -537,7 +681,7 @@ export function selectConductor(readiness) {
|
|
|
537
681
|
* no quoting to get wrong.
|
|
538
682
|
*/
|
|
539
683
|
export function buildSetupEpicArgv(options) {
|
|
540
|
-
const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, } = options;
|
|
684
|
+
const { epicKey, planFile, repo, featureBranch, intoBase, attended, policyFile, reviewPolicy, replacePolicy, launchProfile, baseUrl, } = options;
|
|
541
685
|
return [
|
|
542
686
|
"--epic-key",
|
|
543
687
|
epicKey,
|
|
@@ -551,37 +695,94 @@ export function buildSetupEpicArgv(options) {
|
|
|
551
695
|
...(policyFile ? ["--policy-file", policyFile] : []),
|
|
552
696
|
...(reviewPolicy ? ["--review-policy", reviewPolicy] : []),
|
|
553
697
|
...(replacePolicy ? ["--replace-policy"] : []),
|
|
698
|
+
// BAPI-1260 — the SAME launch selection, so setup-epic resolves the same
|
|
699
|
+
// target and bound credential drive-epic just did. Appended only when
|
|
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.
|
|
703
|
+
...(launchProfile ? ["--launch-profile", launchProfile] : []),
|
|
704
|
+
...(baseUrl ? ["--base-url", baseUrl] : []),
|
|
554
705
|
];
|
|
555
706
|
}
|
|
556
707
|
/**
|
|
557
|
-
* Render the
|
|
708
|
+
* Render the no-plan stop: what discovery found, how to make a plan, and how to
|
|
709
|
+
* re-run with one.
|
|
558
710
|
*
|
|
559
|
-
* Precondition: the caller has already ruled out delegation
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
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.
|
|
718
|
+
*/
|
|
719
|
+
export function renderConductorHandoff(selection, epicKey,
|
|
720
|
+
/**
|
|
721
|
+
* BAPI-1260 — the selected launch profile and the options this invocation
|
|
722
|
+
* carried. With a profile, the re-run command selects it and carries every
|
|
723
|
+
* non-secret flag it needs, shell-quoted, so it runs unchanged in a fresh
|
|
724
|
+
* shell. The plan file is the one thing that cannot be filled in yet, and it
|
|
725
|
+
* is the only placeholder left.
|
|
726
|
+
*/
|
|
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.
|
|
563
731
|
*/
|
|
564
|
-
|
|
565
|
-
const invocation =
|
|
732
|
+
discovery) {
|
|
733
|
+
const invocation = launch.profile !== null
|
|
734
|
+
? renderEpicLaunchCommand(launch.profile, {
|
|
735
|
+
epicKey,
|
|
736
|
+
planFile: "<path>",
|
|
737
|
+
...(launch.options?.policyFile ? { policyFile: launch.options.policyFile } : {}),
|
|
738
|
+
...(launch.options?.attended ? { attended: true } : {}),
|
|
739
|
+
...(launch.options?.intoBase ? { intoBase: true } : {}),
|
|
740
|
+
...(launch.options?.featureBranch ? { featureBranch: launch.options.featureBranch } : {}),
|
|
741
|
+
}).replace(`${shellQuote("<path>")}`, "<path>")
|
|
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
|
+
: [];
|
|
566
758
|
return {
|
|
567
759
|
conductor: "v2",
|
|
568
760
|
invocation,
|
|
569
761
|
lines: [
|
|
570
|
-
`${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`,
|
|
571
771
|
"",
|
|
572
|
-
"The engine path needs a plan DAG
|
|
573
|
-
"
|
|
574
|
-
"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.",
|
|
575
774
|
"",
|
|
576
|
-
` 1.
|
|
775
|
+
` 1. To make one, run /plan-epic ${epicKey} in an interactive session. It writes the`,
|
|
577
776
|
" sidecar to {docs_dir}/epic-plans/{epic_slug}/epic-plan.dag.json.",
|
|
578
777
|
" If the epic's tickets already exist, run emit-epic-implementer-bundle",
|
|
579
778
|
" finalize instead — it resolves the placeholder keys in an existing",
|
|
580
|
-
" sidecar and
|
|
779
|
+
" sidecar, attaches touched_files, and writes epic-plan.meta.json so the",
|
|
780
|
+
" plan is found next time.",
|
|
581
781
|
"",
|
|
582
|
-
` 2. ${
|
|
782
|
+
` 2. Re-run with the plan, in a session: /drive-epic-run ${epicKey} <plan path>`,
|
|
783
|
+
` or from a shell: ${invocation}`,
|
|
583
784
|
"",
|
|
584
|
-
`That
|
|
785
|
+
`That shell command bootstraps the run for you; ${SETUP_EPIC_SUBCOMMAND} is not`,
|
|
585
786
|
"something you invoke directly.",
|
|
586
787
|
],
|
|
587
788
|
};
|
|
@@ -608,6 +809,16 @@ export function assertSingleConductorInvocation(text) {
|
|
|
608
809
|
throw new Error(`drive-epic emitted more than one conductor invocation (${named.join(", ")}). ` +
|
|
609
810
|
"Exactly one path may ever be presented.");
|
|
610
811
|
}
|
|
812
|
+
// BAPI-1260 — the entry point itself, too. A rendered handoff now carries a
|
|
813
|
+
// complete, runnable `drive-epic <KEY> ...` command; two DIFFERENT runnable
|
|
814
|
+
// commands (say, one per profile or target) would hand the reader a choice of
|
|
815
|
+
// authority just as surely as naming two conductors. The same command
|
|
816
|
+
// repeated is still one invocation.
|
|
817
|
+
const runnable = new Set((text.match(/\bdrive-epic\s+[A-Z][A-Z0-9]*-[0-9]+[^\n]*/g) ?? []).map((line) => line.trim()));
|
|
818
|
+
if (runnable.size > 1) {
|
|
819
|
+
throw new Error(`drive-epic emitted ${runnable.size} different runnable launch commands. ` +
|
|
820
|
+
"Exactly one path may ever be presented.");
|
|
821
|
+
}
|
|
611
822
|
}
|
|
612
823
|
/**
|
|
613
824
|
* Conservative bound on any single advisory field. Exists so malformed or
|
|
@@ -807,16 +1018,100 @@ export function resolvePlaneReconcilerInstanceId(manifest, env, hostname) {
|
|
|
807
1018
|
memberEnv.DYNO = env.DYNO;
|
|
808
1019
|
return resolveMemberInstanceId(memberEnv, worker.pid, hostname);
|
|
809
1020
|
}
|
|
810
|
-
/**
|
|
811
|
-
|
|
1021
|
+
/** The BAPI-1260 launch selection carried by parsed options. */
|
|
1022
|
+
function launchSelectionOf(options) {
|
|
1023
|
+
return {
|
|
1024
|
+
...(options.launchProfile ? { profileName: options.launchProfile } : {}),
|
|
1025
|
+
...(options.baseUrl ? { baseUrl: options.baseUrl } : {}),
|
|
1026
|
+
};
|
|
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
|
+
}
|
|
812
1096
|
export function createDefaultDriveEpicDeps() {
|
|
813
1097
|
return {
|
|
814
|
-
resolveAccess: async (repo, cwd) => {
|
|
815
|
-
|
|
1098
|
+
resolveAccess: async (repo, cwd, launch) => {
|
|
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.
|
|
1102
|
+
const result = await resolveEpicLaunchBridgeApiAccess(launch ?? {}, {
|
|
816
1103
|
...(repo ? { repoName: repo } : {}),
|
|
817
1104
|
...(cwd ? { cwd } : {}),
|
|
818
1105
|
});
|
|
819
|
-
return result.ok
|
|
1106
|
+
return result.ok
|
|
1107
|
+
? { ok: true, access: result.access, profile: result.profile }
|
|
1108
|
+
: { ok: false, error: result.error };
|
|
1109
|
+
},
|
|
1110
|
+
// Non-secret, and only ever a value the resolver just agreed with the
|
|
1111
|
+
// environment on (a conflicting BAPI_BASE_URL was refused above), so the
|
|
1112
|
+
// processes this command starts see the same target it resolved.
|
|
1113
|
+
adoptLaunchTarget: (baseUrl) => {
|
|
1114
|
+
process.env.BAPI_BASE_URL = baseUrl;
|
|
820
1115
|
},
|
|
821
1116
|
readReadiness: (access) => fetchConductorReadiness(access, globalThis.fetch),
|
|
822
1117
|
runSetupEpic: (argv) => runSetupEpicWorkflow(argv),
|
|
@@ -861,6 +1156,39 @@ export function createDefaultDriveEpicDeps() {
|
|
|
861
1156
|
return observation.result.kind === "state" ? observation.result.state : observation.result.kind;
|
|
862
1157
|
},
|
|
863
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()),
|
|
864
1192
|
// stdout is safe here: `drive-epic` is dispatched BEFORE MCP server
|
|
865
1193
|
// construction, so nothing has claimed stdout for the protocol transport.
|
|
866
1194
|
log: (message) => console.log(message),
|
|
@@ -887,12 +1215,74 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
887
1215
|
return 1;
|
|
888
1216
|
}
|
|
889
1217
|
const { epicKey, planFile, repo, featureBranch, intoBase } = parsed.options;
|
|
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
|
+
}
|
|
890
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).
|
|
891
1228
|
// --- Resolve access. A missing credential is UNKNOWN, not not-ready. -----
|
|
892
|
-
|
|
1229
|
+
// BAPI-1260: the TARGET is decided first — from --launch-profile,
|
|
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.
|
|
1235
|
+
const accessResult = await deps.resolveAccess(repo, undefined, launch);
|
|
893
1236
|
if (!accessResult.ok) {
|
|
894
1237
|
return escalate(deps, epicKey, `conductor readiness could not be read: ${accessResult.error}`);
|
|
895
1238
|
}
|
|
1239
|
+
const launchProfile = accessResult.profile ?? null;
|
|
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
|
+
};
|
|
896
1286
|
// --- Read readiness. Any failure here is UNKNOWN — with ONE exception. ---
|
|
897
1287
|
let readiness;
|
|
898
1288
|
try {
|
|
@@ -905,17 +1295,25 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
905
1295
|
// Everything else — a non-plane base URL, an HTTP or auth or shape error, a
|
|
906
1296
|
// timeout, any other probe result — keeps today's escalation byte for byte,
|
|
907
1297
|
// because there unknown is still fatal (R14).
|
|
908
|
-
if (planFile !== undefined &&
|
|
1298
|
+
if (ctx.options.planFile !== undefined &&
|
|
1299
|
+
planeTarget &&
|
|
1300
|
+
err instanceof ConductorBridgeApiError &&
|
|
1301
|
+
err.kind === "network") {
|
|
909
1302
|
const endpoint = planeEndpointForBaseUrl(deps, accessResult.access.baseUrl);
|
|
1303
|
+
// BAPI-1260 — only a SELECTED LOCAL plane is ever started here. A
|
|
1304
|
+
// profile approved for durable execution names an authority this
|
|
1305
|
+
// machine does not own, so a refused port is that authority being
|
|
1306
|
+
// down — never permission to launch a local replacement.
|
|
1307
|
+
if (endpoint !== null && launchProfile !== null && launchProfile.executionMode !== "local") {
|
|
1308
|
+
return escalate(deps, epicKey, `launch profile '${launchProfile.name}' selects durable execution, and its control plane is ` +
|
|
1309
|
+
"not answering; no local replacement was started");
|
|
1310
|
+
}
|
|
910
1311
|
if (endpoint !== null) {
|
|
911
1312
|
const probe = await deps.probePlanePort(endpoint.host, endpoint.port);
|
|
912
1313
|
if (probe.kind === "refused") {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
options: parsed.options,
|
|
917
|
-
access: accessResult.access,
|
|
918
|
-
});
|
|
1314
|
+
// `coldStartPlane` surfaces the containment advisory itself.
|
|
1315
|
+
await announceFoundPlan(false);
|
|
1316
|
+
return await coldStartPlane(ctx);
|
|
919
1317
|
}
|
|
920
1318
|
}
|
|
921
1319
|
}
|
|
@@ -931,6 +1329,36 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
931
1329
|
// delegated argv, or the exit code.
|
|
932
1330
|
if (planFile)
|
|
933
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
|
+
}
|
|
934
1362
|
// Decide delegation BEFORE rendering. The v2 branch's text is specifically
|
|
935
1363
|
// about a missing plan file, and rendering it on the path that has one
|
|
936
1364
|
// would produce a message contradicting what is about to happen.
|
|
@@ -940,7 +1368,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
940
1368
|
// not-ready result can be rendered, because a report that is non-green only on
|
|
941
1369
|
// the runtime facts is not a "v2 cannot run" situation — it is a plane that
|
|
942
1370
|
// has not been started yet.
|
|
943
|
-
if (planFile) {
|
|
1371
|
+
if (ctx.options.planFile !== undefined) {
|
|
944
1372
|
// BAPI-1102 — the explicit `--attended` opt-out makes the unattended
|
|
945
1373
|
// prerequisites inapplicable. Both inputs are FLAG checks, not policy
|
|
946
1374
|
// reads: whatever a `--policy-file` says stays opaque here, exactly as it
|
|
@@ -951,12 +1379,24 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
951
1379
|
intoBase: parsed.options.intoBase === true,
|
|
952
1380
|
hasPolicyFile: parsed.options.policyFile !== undefined,
|
|
953
1381
|
});
|
|
954
|
-
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
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.
|
|
958
1386
|
if (classification.kind === "green") {
|
|
959
|
-
|
|
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);
|
|
960
1400
|
}
|
|
961
1401
|
// Operator-blocked: ONE named reason, and nothing is started. `plane up`
|
|
962
1402
|
// cannot create stored supervisor state or resolve GitHub App credentials,
|
|
@@ -970,8 +1410,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
970
1410
|
// The requirement's OWN remedy when it has one. Telling an operator to
|
|
971
1411
|
// run `install-bridge conductor` for a missing consent flag sends them
|
|
972
1412
|
// to a command that cannot set it.
|
|
973
|
-
classification
|
|
974
|
-
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1413
|
+
operatorBlockedRemedy(classification),
|
|
975
1414
|
].join("\n");
|
|
976
1415
|
// Named as a fixed reason and nothing else: no conductor is offered here,
|
|
977
1416
|
// because handing an operator a second authority is the hazard this
|
|
@@ -980,6 +1419,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
980
1419
|
deps.errorLog(refusal);
|
|
981
1420
|
return 1;
|
|
982
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
|
+
}
|
|
983
1429
|
// Runtime-only: this invocation composes bring-up with run creation.
|
|
984
1430
|
const repoRoot = deps.cwd();
|
|
985
1431
|
const preflight = await deps.runPlanePreflight(repoRoot);
|
|
@@ -990,8 +1436,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
990
1436
|
// preflight made from its single manifest read; nothing is re-read here.
|
|
991
1437
|
const existingPlane = preflight.observations?.existingPlane;
|
|
992
1438
|
const attachManifest = existingPlane?.kind === "attachable" &&
|
|
993
|
-
preflight.observations?.serverPort === "connected"
|
|
994
|
-
planeEndpointForBaseUrl(deps, accessResult.access.baseUrl) !== null
|
|
1439
|
+
preflight.observations?.serverPort === "connected"
|
|
995
1440
|
? existingPlane.manifest
|
|
996
1441
|
: null;
|
|
997
1442
|
// Composition preconditions FIRST, and only these two. Preflight is
|
|
@@ -1019,7 +1464,7 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1019
1464
|
return 1;
|
|
1020
1465
|
}
|
|
1021
1466
|
if (attachManifest !== null) {
|
|
1022
|
-
return attachToPlane(
|
|
1467
|
+
return await attachToPlane(ctx, attachManifest, readiness);
|
|
1023
1468
|
}
|
|
1024
1469
|
// ---- PHASE ONE: the control plane, and NOTHING that claims jobs --------
|
|
1025
1470
|
//
|
|
@@ -1040,23 +1485,21 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1040
1485
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1041
1486
|
if (!controlPlane.ok) {
|
|
1042
1487
|
// The launch already reported why, in its own words, and it rolls its own
|
|
1043
|
-
// startup back.
|
|
1044
|
-
|
|
1045
|
-
deps.errorLog(line);
|
|
1046
|
-
return 1;
|
|
1488
|
+
// startup back. No run was created here.
|
|
1489
|
+
return reportControlPlaneLaunchFailure(ctx, controlPlane);
|
|
1047
1490
|
}
|
|
1048
1491
|
// Held so the launcher's lifetime promise is never unobserved. It settles
|
|
1049
1492
|
// when the PLANE comes down, which is long after this command returns —
|
|
1050
1493
|
// the detached runtime is its own process-group leader and outlives us by
|
|
1051
1494
|
// design, which is what "the plane is left running" means.
|
|
1052
1495
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1053
|
-
// Readiness is NOT re-read as a gate here
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
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, {
|
|
1060
1503
|
origin: "launched",
|
|
1061
1504
|
repoRoot,
|
|
1062
1505
|
planeId: controlPlane.planeId,
|
|
@@ -1078,11 +1521,13 @@ export async function runDriveEpicCli(argv, overrides = {}) {
|
|
|
1078
1521
|
deps.errorLog(refusal);
|
|
1079
1522
|
return 1;
|
|
1080
1523
|
}
|
|
1081
|
-
|
|
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);
|
|
1082
1527
|
const text = handoff.lines.join("\n");
|
|
1083
1528
|
assertSingleConductorInvocation(text);
|
|
1084
|
-
deps.
|
|
1085
|
-
return
|
|
1529
|
+
deps.errorLog(text);
|
|
1530
|
+
return 1;
|
|
1086
1531
|
}
|
|
1087
1532
|
catch (err) {
|
|
1088
1533
|
// Last resort. Sanitized, on stderr, converted to an exit code — a
|
|
@@ -1114,6 +1559,23 @@ async function surfaceContainmentAdvisory(deps, planFile) {
|
|
|
1114
1559
|
// Fail open — a containment advisory is never allowed to block dispatch.
|
|
1115
1560
|
}
|
|
1116
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
|
+
}
|
|
1117
1579
|
/**
|
|
1118
1580
|
* The escalation branch. Prints NO conductor invocation at all — naming a
|
|
1119
1581
|
* fallback here is the dual-authority hazard, and an operator who does not know
|
|
@@ -1124,8 +1586,8 @@ function escalate(deps, epicKey, reason) {
|
|
|
1124
1586
|
`Cannot determine which conductor owns ${epicKey}: ${reason}`,
|
|
1125
1587
|
"",
|
|
1126
1588
|
"This is not a not-ready result — it is an unknown one, and starting the wrong",
|
|
1127
|
-
"conductor on an epic wedges it permanently.
|
|
1128
|
-
|
|
1589
|
+
"conductor on an epic wedges it permanently.",
|
|
1590
|
+
ESCALATE_REMEDY,
|
|
1129
1591
|
];
|
|
1130
1592
|
const text = lines.join("\n");
|
|
1131
1593
|
assertSingleConductorInvocation(text);
|
|
@@ -1227,9 +1689,7 @@ async function coldStartPlane(ctx) {
|
|
|
1227
1689
|
}
|
|
1228
1690
|
const controlPlane = await deps.launchControlPlane({ preflight, executors: 1 });
|
|
1229
1691
|
if (!controlPlane.ok) {
|
|
1230
|
-
|
|
1231
|
-
deps.errorLog(line);
|
|
1232
|
-
return 1;
|
|
1692
|
+
return reportControlPlaneLaunchFailure(ctx, controlPlane);
|
|
1233
1693
|
}
|
|
1234
1694
|
void controlPlane.lifetime.catch(() => undefined);
|
|
1235
1695
|
// Read ONCE, for route selection only — the pre-launch read had no server to
|
|
@@ -1254,8 +1714,7 @@ async function coldStartPlane(ctx) {
|
|
|
1254
1714
|
if (classification.kind === "operator_blocked") {
|
|
1255
1715
|
return refuse(deps, [
|
|
1256
1716
|
`${epicKey}: the engine path still needs ${classification.describe} (${classification.id}).`,
|
|
1257
|
-
classification
|
|
1258
|
-
"Resolve it with `install-bridge conductor`, then run drive-epic again.",
|
|
1717
|
+
operatorBlockedRemedy(classification),
|
|
1259
1718
|
...LAUNCHED_PLANE_LEFT_RUNNING,
|
|
1260
1719
|
]);
|
|
1261
1720
|
}
|
|
@@ -1287,7 +1746,7 @@ async function coldStartPlane(ctx) {
|
|
|
1287
1746
|
*/
|
|
1288
1747
|
async function attachToPlane(ctx, manifest, readiness) {
|
|
1289
1748
|
const { deps, epicKey } = ctx;
|
|
1290
|
-
const rooted = await deps.resolveAccess(ctx.options.repo, manifest.repoRoot);
|
|
1749
|
+
const rooted = await deps.resolveAccess(ctx.options.repo, manifest.repoRoot, launchSelectionOf(ctx.options));
|
|
1291
1750
|
if (!rooted.ok) {
|
|
1292
1751
|
return refuse(deps, [
|
|
1293
1752
|
`${epicKey}: cannot attach to the running plane — Bridge API credentials could not be ` +
|
|
@@ -1297,7 +1756,10 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1297
1756
|
]);
|
|
1298
1757
|
}
|
|
1299
1758
|
const attachCtx = { ...ctx, access: rooted.access };
|
|
1300
|
-
// 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;
|
|
1301
1763
|
if (manifest.epicRunId !== undefined) {
|
|
1302
1764
|
let bound = null;
|
|
1303
1765
|
try {
|
|
@@ -1306,28 +1768,33 @@ async function attachToPlane(ctx, manifest, readiness) {
|
|
|
1306
1768
|
catch {
|
|
1307
1769
|
bound = null;
|
|
1308
1770
|
}
|
|
1309
|
-
|
|
1310
|
-
? "its bound run could not be read"
|
|
1311
|
-
: TERMINAL_EPIC_RUN_STATUSES.includes(bound.status)
|
|
1312
|
-
? `its bound run is ${bound.status}`
|
|
1313
|
-
: bound.epicKey !== epicKey
|
|
1314
|
-
? `its bound run belongs to ${bound.epicKey}`
|
|
1315
|
-
: null;
|
|
1316
|
-
if (why !== null) {
|
|
1771
|
+
if (bound === null) {
|
|
1317
1772
|
return refuse(deps, [
|
|
1318
1773
|
`${epicKey}: REFUSED (plane-already-bound) — this repository's plane is bound to run ` +
|
|
1319
|
-
`${manifest.epicRunId}, and
|
|
1320
|
-
|
|
1321
|
-
"
|
|
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.",
|
|
1322
1778
|
"",
|
|
1323
1779
|
"Nothing was started.",
|
|
1324
1780
|
]);
|
|
1325
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
|
+
}
|
|
1326
1788
|
}
|
|
1327
1789
|
const admission = await checkPlaneAdmission(attachCtx, readiness, manifest);
|
|
1328
1790
|
if (admission !== null) {
|
|
1329
1791
|
return refuse(deps, [...admission, "", "Nothing was started."]);
|
|
1330
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
|
+
}
|
|
1331
1798
|
deps.log(`${epicKey}: attaching to this repository's running plane — no second server or reconciler will start.`);
|
|
1332
1799
|
return createRunAndRequestLanes(attachCtx, {
|
|
1333
1800
|
origin: "attached",
|
|
@@ -1411,7 +1878,8 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1411
1878
|
`${epicKey}: the run was created and approved, but executor lanes could not be`,
|
|
1412
1879
|
`requested from the live plane (${laneRequest.reason}): ${laneRequest.message}.`,
|
|
1413
1880
|
laneRequest.reason === "run-conflict"
|
|
1414
|
-
? "
|
|
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."
|
|
1415
1883
|
: "Inspect it with `plane status`, and wind it down with `plane down`.",
|
|
1416
1884
|
"The run itself is untouched and still committed server-side.",
|
|
1417
1885
|
].join("\n"));
|
|
@@ -1464,3 +1932,742 @@ async function createRunAndRequestLanes(ctx, plane) {
|
|
|
1464
1932
|
].join("\n"));
|
|
1465
1933
|
return 0;
|
|
1466
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
|
+
}
|