@bridge_gpt/mcp-server 0.2.59 → 0.2.62
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 +181 -51
- package/build/agent-guidance.generated.js +1 -1
- package/build/agent-notes.js +7 -1
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +406 -4
- package/build/conductor/doctor.js +255 -53
- package/build/conductor/epic-runtime.js +11 -2
- package/build/conductor/install-doctor.js +24 -2
- package/build/conductor/local-merge.js +319 -26
- package/build/conductor/merge-execution-contract.js +86 -0
- package/build/conductor/merge-execution-eligibility.js +130 -0
- package/build/conductor/readiness-sections.js +256 -16
- package/build/conductor/readiness.js +203 -29
- package/build/conductor/recovery-operations.js +8 -2
- package/build/connect-bitbucket-api.js +6 -1
- package/build/connect-bitbucket.js +398 -88
- package/build/credential-store.js +172 -0
- package/build/docs.generated.js +2 -2
- package/build/doctor.js +277 -15
- package/build/drive-epic.js +147 -12
- package/build/epic-implementer-bin.js +15 -15
- package/build/epic-implementer-claude-hook-bin.js +1 -1
- package/build/epic-implementer-env.js +43 -14
- package/build/epic-integration-pr.js +61 -15
- package/build/epic-launch-profile.js +455 -0
- package/build/executor/ci-rerun-job.js +269 -0
- package/build/executor/cli.js +40 -0
- package/build/executor/credentials.js +28 -1
- package/build/executor/env.js +60 -0
- package/build/executor/http-client.js +45 -1
- package/build/executor/index.js +21 -4
- package/build/executor/integration-job.js +759 -0
- package/build/executor/job-runner.js +218 -9
- package/build/executor/job-types.js +12 -2
- package/build/executor/merge-job.js +11 -2
- package/build/executor/observation.js +114 -7
- package/build/executor/runner.js +101 -1
- package/build/executor/service-lifecycle.js +136 -0
- package/build/executor/service-unit.js +427 -9
- package/build/executor/types.js +48 -0
- package/build/index.js +479 -240
- package/build/install-bridge-conductor.js +599 -14
- package/build/install-bridge.js +448 -46
- package/build/mcp-install-state.js +42 -4
- package/build/merge-pull-request.js +103 -10
- package/build/merge-workflow-codeowners.js +224 -0
- package/build/merge-workflow-config.js +187 -0
- package/build/merge-workflow-drift.js +97 -0
- package/build/merge-workflow-install.js +638 -0
- package/build/merge-workflow.js +956 -0
- package/build/pipelines.generated.js +3 -3
- package/build/plane/cli.js +24 -2
- package/build/plane/preflight.js +68 -0
- package/build/plane/supervisor.js +6 -0
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +263 -38
- package/build/version.generated.js +3 -3
- package/package.json +3 -3
- package/build/conduct-epic/bridge-client.js +0 -773
- package/build/conduct-epic/checkpoint-store.js +0 -542
- package/build/conduct-epic/cli.js +0 -3145
- package/build/conduct-epic/cut-protocol.js +0 -392
- package/build/conduct-epic/lock.js +0 -302
- package/build/conduct-epic/pr-state.js +0 -286
- package/build/conduct-epic/spawn.js +0 -113
- package/build/conductor/cli.js +0 -1278
- package/build/conductor-bundle-cli.js +0 -256
- package/build/direct-ticket-tools.js +0 -70
- package/build/executor/reconcile-overlap-governance.js +0 -129
- package/build/implement-epic/bridge-client.js +0 -773
- package/build/implement-epic/checkpoint-store.js +0 -542
- package/build/implement-epic/cli.js +0 -3158
- package/build/implement-epic/cut-protocol.js +0 -392
- package/build/implement-epic/lock.js +0 -302
- package/build/implement-epic/pr-state.js +0 -286
- package/build/implement-epic/spawn.js +0 -113
- package/build/plan-epic-conductor-eligibility.js +0 -213
|
@@ -54,6 +54,32 @@
|
|
|
54
54
|
* Neither can produce `complete`: they provision LOCAL capability, and the
|
|
55
55
|
* terminal outcome still turns only on the live server-observed executor claim.
|
|
56
56
|
*
|
|
57
|
+
* DURABLE-HOST MODE (BAPI-1137). The executor phase below is the supported
|
|
58
|
+
* bring-up for a *durable host*: an execution home that is not an interactive
|
|
59
|
+
* shell session, so a sleeping or closed operator laptop no longer stalls a run.
|
|
60
|
+
* The client command spelling is unchanged — it is still `install conductor` —
|
|
61
|
+
* and so is every primitive it drives; what this slice does is name the
|
|
62
|
+
* composition and make its boundaries explicit. Durable-host mode IS that
|
|
63
|
+
* composition, and nothing more:
|
|
64
|
+
*
|
|
65
|
+
* planning (pure)
|
|
66
|
+
* → separately consented unit writing
|
|
67
|
+
* → separately consented start
|
|
68
|
+
* → read-only local inspection (service state, session/linger durability)
|
|
69
|
+
* → bounded server-observed readiness
|
|
70
|
+
*
|
|
71
|
+
* `executor install-service` remains the standalone, lifecycle-free generator
|
|
72
|
+
* for operators who deliberately manage lifecycle themselves.
|
|
73
|
+
*
|
|
74
|
+
* HOST OWNERSHIP is an operational selection, not server policy. A durable host
|
|
75
|
+
* may be operator-operated or Bridge-operated, chosen per repository, and both
|
|
76
|
+
* choices run IDENTICAL generation and lifecycle logic: `BAPI_BASE_URL` is the
|
|
77
|
+
* only ownership/location input, while platform paths, unit format, and `PATH`
|
|
78
|
+
* are machine-specific details rather than ownership signals. Nothing about that
|
|
79
|
+
* choice reaches `RunPolicy` or any other server policy payload. Bridge-operated
|
|
80
|
+
* mode is also NON-ALLOCATING: this command can configure a host that has
|
|
81
|
+
* already been assigned, and it never provisions a fleet or allocates a machine.
|
|
82
|
+
*
|
|
57
83
|
* The executor phase (BAPI-779) is opt-in via `--executor-id` and runs behind
|
|
58
84
|
* TWO independent consents, per property 3: writing the unit is not consent to
|
|
59
85
|
* start a persistent daemon. Its shape is:
|
|
@@ -70,6 +96,13 @@
|
|
|
70
96
|
* non-fatal: the run prints the exact platform-specific start command and
|
|
71
97
|
* finishes at `complete-with-manual-steps`.
|
|
72
98
|
*
|
|
99
|
+
* MERGE AUTHORITY IS UNTOUCHED (BAPI-1137). Provisioning a durable host gives
|
|
100
|
+
* the executor no merge-decision route and no direct GitHub write operation: the
|
|
101
|
+
* reconciler remains the sole transition authority and the actuator writes only
|
|
102
|
+
* evidence. Readiness output above must never be worded so as to imply
|
|
103
|
+
* otherwise — `executor_ready` means an executor is claiming work, not that this
|
|
104
|
+
* host can merge anything.
|
|
105
|
+
*
|
|
73
106
|
* The command does NOT persist a repository-level review policy: that is
|
|
74
107
|
* BAPI-694's. The policy selected here is per-run and is reported as such.
|
|
75
108
|
*/
|
|
@@ -77,10 +110,17 @@ import { bootstrapConductorSupervisorDefaults, ConductorBridgeApiError, fetchCon
|
|
|
77
110
|
import { CONDUCTOR_OPERATOR_RUNBOOK_POINTER, EXECUTOR_PROVISIONING_GUIDANCE, conductorInstallDoctorExitCode, conductorInstallDoctorHasFatal, formatConductorInstallDoctorReport, } from "./conductor/install-doctor.js";
|
|
78
111
|
import { describeNativeLedgerAvailability, } from "./conductor/doctor.js";
|
|
79
112
|
import { CONDUCTOR_BIN_UNRESOLVED_ERROR, MANAGED_HOOK_NAMES, } from "./conductor/git-hooks.js";
|
|
113
|
+
import os from "node:os";
|
|
114
|
+
import path from "node:path";
|
|
115
|
+
import { readFile as readFileAsync, stat as statAsync } from "node:fs/promises";
|
|
116
|
+
import { bindEpicLaunchCredential, readStoredBinding, } from "./credential-store.js";
|
|
117
|
+
import { EPIC_LAUNCH_EXECUTION_MODES, EPIC_LAUNCH_PROFILE_NAME_PATTERN, EPIC_LAUNCH_PROFILE_VERSION, createNodeEpicLaunchStoreDeps, readEpicLaunchProfile, renderEpicLaunchCommand, resolveEpicLaunchTarget, writeEpicLaunchProfile, } from "./epic-launch-profile.js";
|
|
118
|
+
import { DEFAULT_BAPI_BASE_URL } from "./executor/credentials.js";
|
|
80
119
|
import { MCP_PACKAGE_NAME } from "./mcp-identity.js";
|
|
81
120
|
import { CLAUDE_REVIEW_WORKFLOW_RELPATH, renderClaudeReviewWorkflow, } from "./claude-review-workflow.js";
|
|
82
121
|
import { formatExecutorInstallPreflight, } from "./executor/install-preflight.js";
|
|
83
122
|
import { buildExecutorServiceStartPlan, formatExecutorServiceStartFailure, } from "./executor/service-lifecycle.js";
|
|
123
|
+
import { executorEpicRunIdRefusal, isValidExecutorEpicRunId, } from "./executor/claim-scope.js";
|
|
84
124
|
import { planExecutorServiceInstall, } from "./executor/service-unit.js";
|
|
85
125
|
// ---------------------------------------------------------------------------
|
|
86
126
|
// Options + parsing
|
|
@@ -117,10 +157,28 @@ export function getInstallBridgeConductorUsage() {
|
|
|
117
157
|
" --executor-id <id> Stable executor id. Supplying it OPTS IN to the",
|
|
118
158
|
" consented executor-provisioning phase below; the",
|
|
119
159
|
" phase is skipped entirely without it.",
|
|
160
|
+
" --epic-run-id <id> Claim scope for the generated unit (repeatable).",
|
|
161
|
+
" --repo-wide Claim-scope opt-out: claim from every authorized",
|
|
162
|
+
" repo, unscoped by run. EXACTLY ONE of --epic-run-id",
|
|
163
|
+
" or --repo-wide is required with --executor-id — the",
|
|
164
|
+
" executor refuses to start without one, so a unit",
|
|
165
|
+
" generated without it would fail on every boot.",
|
|
120
166
|
" --allowed-bot <slug> claude-review workflow: allowed bot login",
|
|
121
167
|
" --secret-name <NAME> claude-review workflow: OAuth token secret NAME",
|
|
122
168
|
" --branches a,b claude-review workflow: PR branch triggers",
|
|
123
169
|
" --skip-workflow Never offer the claude-review workflow",
|
|
170
|
+
" --launch-profile <name> (BAPI-1260) Opt in to the reusable epic launch: after",
|
|
171
|
+
" every mandatory prerequisite is verified, persist a",
|
|
172
|
+
" host-local profile and print the ONE drive-epic",
|
|
173
|
+
" command that selects it (runs in a fresh shell).",
|
|
174
|
+
" --base-url <url> Explicit Bridge API target for --launch-profile.",
|
|
175
|
+
" Required there (or BAPI_BASE_URL): nothing defaults",
|
|
176
|
+
" to production; select it deliberately.",
|
|
177
|
+
" --execution-mode <mode> local | durable (default: durable with",
|
|
178
|
+
" --executor-id, otherwise local).",
|
|
179
|
+
" --policy-file <path> Run policy the profile references; an unattended",
|
|
180
|
+
" run needs notify.webhook_url reachable from the",
|
|
181
|
+
" API server (notify.local_sink is not enough).",
|
|
124
182
|
" --dry-run Diagnose and preview only. Performs NO writes:",
|
|
125
183
|
" no POST, no file write, no host MCP config write,",
|
|
126
184
|
" no Git hook write, no install-state or capability",
|
|
@@ -152,8 +210,17 @@ export function getInstallBridgeConductorUsage() {
|
|
|
152
210
|
"hook consent; a degraded ledger reports the module, NODE_MODULE_VERSION, and",
|
|
153
211
|
"failure kind, and never triggers an install, rebuild, or migration.",
|
|
154
212
|
"",
|
|
155
|
-
"
|
|
156
|
-
"
|
|
213
|
+
"Durable-host mode: this command is the supported bring-up for a persistent,",
|
|
214
|
+
"laptop-independent executor. It composes planning, separately consented unit",
|
|
215
|
+
"writing, separately consented start, read-only local inspection, and bounded",
|
|
216
|
+
"server-observed readiness. `executor install-service` still generates a unit and",
|
|
217
|
+
"nothing else, and remains available for operators who manage lifecycle themselves.",
|
|
218
|
+
"",
|
|
219
|
+
"Host ownership (operator-operated or Bridge-operated) is an operational selection,",
|
|
220
|
+
"not server policy: both run identical generation and lifecycle logic, and",
|
|
221
|
+
"BAPI_BASE_URL is the only ownership/location input. Bridge-operated mode allocates",
|
|
222
|
+
"no host — it configures one already assigned. A repository with no assigned durable",
|
|
223
|
+
"host is NOT ready; escalate that to Bridge operations.",
|
|
157
224
|
"",
|
|
158
225
|
"With --executor-id, this command does the whole leg. A preflight checks the platform,",
|
|
159
226
|
"`wt` / `git` / `claude` on PATH, and an explicit base URL, then reports whether",
|
|
@@ -200,10 +267,16 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
200
267
|
let epicKey;
|
|
201
268
|
let planFile;
|
|
202
269
|
let executorId;
|
|
270
|
+
const epicRunIds = [];
|
|
271
|
+
let repoWide = false;
|
|
203
272
|
let allowedBot;
|
|
204
273
|
let secretName;
|
|
205
274
|
let branches;
|
|
206
275
|
const requiredChecks = [];
|
|
276
|
+
let launchProfile;
|
|
277
|
+
let baseUrl;
|
|
278
|
+
let executionMode;
|
|
279
|
+
let policyFile;
|
|
207
280
|
const takeValue = (arg, inline, next) => {
|
|
208
281
|
if (inline !== undefined) {
|
|
209
282
|
if (inline.trim().length === 0)
|
|
@@ -231,6 +304,11 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
231
304
|
return { status: "error", message: "--skip-workflow takes no value" };
|
|
232
305
|
skipWorkflow = true;
|
|
233
306
|
break;
|
|
307
|
+
case "--repo-wide":
|
|
308
|
+
if (inline !== undefined)
|
|
309
|
+
return { status: "error", message: "--repo-wide takes no value" };
|
|
310
|
+
repoWide = true;
|
|
311
|
+
break;
|
|
234
312
|
case "--repo":
|
|
235
313
|
case "--required-check":
|
|
236
314
|
case "--required-checks":
|
|
@@ -238,9 +316,14 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
238
316
|
case "--epic-key":
|
|
239
317
|
case "--plan-file":
|
|
240
318
|
case "--executor-id":
|
|
319
|
+
case "--epic-run-id":
|
|
241
320
|
case "--allowed-bot":
|
|
242
321
|
case "--secret-name":
|
|
243
|
-
case "--branches":
|
|
322
|
+
case "--branches":
|
|
323
|
+
case "--launch-profile":
|
|
324
|
+
case "--base-url":
|
|
325
|
+
case "--execution-mode":
|
|
326
|
+
case "--policy-file": {
|
|
244
327
|
const taken = takeValue(flag, inline, argv[i + 1]);
|
|
245
328
|
if (!taken.ok)
|
|
246
329
|
return { status: "error", message: taken.error };
|
|
@@ -259,10 +342,34 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
259
342
|
planFile = value;
|
|
260
343
|
else if (flag === "--executor-id")
|
|
261
344
|
executorId = value;
|
|
345
|
+
else if (flag === "--epic-run-id")
|
|
346
|
+
epicRunIds.push(value);
|
|
262
347
|
else if (flag === "--allowed-bot")
|
|
263
348
|
allowedBot = value;
|
|
264
349
|
else if (flag === "--secret-name")
|
|
265
350
|
secretName = value;
|
|
351
|
+
else if (flag === "--launch-profile") {
|
|
352
|
+
if (!EPIC_LAUNCH_PROFILE_NAME_PATTERN.test(value)) {
|
|
353
|
+
return {
|
|
354
|
+
status: "error",
|
|
355
|
+
message: "--launch-profile must match [a-z0-9][a-z0-9._-]{0,62}.",
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
launchProfile = value;
|
|
359
|
+
}
|
|
360
|
+
else if (flag === "--base-url")
|
|
361
|
+
baseUrl = value;
|
|
362
|
+
else if (flag === "--policy-file")
|
|
363
|
+
policyFile = value;
|
|
364
|
+
else if (flag === "--execution-mode") {
|
|
365
|
+
if (!EPIC_LAUNCH_EXECUTION_MODES.includes(value)) {
|
|
366
|
+
return {
|
|
367
|
+
status: "error",
|
|
368
|
+
message: `Invalid --execution-mode '${value}'. Expected one of: ${EPIC_LAUNCH_EXECUTION_MODES.join(", ")}`,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
executionMode = value;
|
|
372
|
+
}
|
|
266
373
|
else if (flag === "--branches") {
|
|
267
374
|
const list = splitList(value);
|
|
268
375
|
if (list.length === 0)
|
|
@@ -304,6 +411,61 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
304
411
|
"so the bootstrap refuses to write one.",
|
|
305
412
|
};
|
|
306
413
|
}
|
|
414
|
+
// BAPI-1137 — the claim scope is validated HERE, at parse time, for the same
|
|
415
|
+
// reason the required-check list is: a unit that cannot start is worse than a
|
|
416
|
+
// refused invocation, and discovering it costs a reboot to notice.
|
|
417
|
+
if (executorId !== undefined) {
|
|
418
|
+
if (epicRunIds.length > 0 && repoWide) {
|
|
419
|
+
return {
|
|
420
|
+
status: "error",
|
|
421
|
+
message: "--epic-run-id and --repo-wide are mutually exclusive: a generated unit carries exactly " +
|
|
422
|
+
"one claim scope.",
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
if (epicRunIds.length === 0 && !repoWide) {
|
|
426
|
+
return {
|
|
427
|
+
status: "error",
|
|
428
|
+
message: "--executor-id requires an explicit claim scope: pass --epic-run-id <id> (repeatable) or " +
|
|
429
|
+
"--repo-wide. The executor refuses to start without one, so a unit generated without it " +
|
|
430
|
+
"would install cleanly and then fail on every boot.",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const seenRunIds = new Set();
|
|
434
|
+
for (const runId of epicRunIds) {
|
|
435
|
+
// The SAME shape check `executor install-service` applies, reused rather
|
|
436
|
+
// than restated: a value that reaches the rendered argument vector is
|
|
437
|
+
// validated at the point of entry, so a malformed id is one refusal here
|
|
438
|
+
// instead of a unit that installs and then fails on every boot.
|
|
439
|
+
if (!isValidExecutorEpicRunId(runId)) {
|
|
440
|
+
return { status: "error", message: executorEpicRunIdRefusal(runId) };
|
|
441
|
+
}
|
|
442
|
+
if (seenRunIds.has(runId)) {
|
|
443
|
+
return { status: "error", message: `Duplicate --epic-run-id: ${runId}` };
|
|
444
|
+
}
|
|
445
|
+
seenRunIds.add(runId);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
else if (epicRunIds.length > 0 || repoWide) {
|
|
449
|
+
return {
|
|
450
|
+
status: "error",
|
|
451
|
+
message: "--epic-run-id / --repo-wide configure the generated executor unit, so they require " +
|
|
452
|
+
"--executor-id.",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
// BAPI-1260 — the launch-profile inputs only mean something together.
|
|
456
|
+
if (launchProfile === undefined && (baseUrl !== undefined || executionMode !== undefined)) {
|
|
457
|
+
return {
|
|
458
|
+
status: "error",
|
|
459
|
+
message: "--base-url and --execution-mode configure an epic launch profile, so they require --launch-profile.",
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (executionMode === "durable" && executorId === undefined) {
|
|
463
|
+
return {
|
|
464
|
+
status: "error",
|
|
465
|
+
message: "--execution-mode durable requires --executor-id: a durable launch is served by a provisioned " +
|
|
466
|
+
"executor service, and this command proves its server-observed handshake before approving it.",
|
|
467
|
+
};
|
|
468
|
+
}
|
|
307
469
|
return {
|
|
308
470
|
status: "ok",
|
|
309
471
|
options: {
|
|
@@ -319,9 +481,36 @@ export function parseInstallBridgeConductorArgs(argv) {
|
|
|
319
481
|
branches,
|
|
320
482
|
skipWorkflow,
|
|
321
483
|
executorId,
|
|
484
|
+
...(epicRunIds.length > 0 ? { epicRunIds } : {}),
|
|
485
|
+
...(repoWide ? { repoWide: true } : {}),
|
|
486
|
+
...(launchProfile ? { launchProfile } : {}),
|
|
487
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
488
|
+
...(launchProfile
|
|
489
|
+
? { executionMode: executionMode ?? (executorId !== undefined ? "durable" : "local") }
|
|
490
|
+
: {}),
|
|
491
|
+
...(policyFile ? { policyFile } : {}),
|
|
322
492
|
},
|
|
323
493
|
};
|
|
324
494
|
}
|
|
495
|
+
/** Real host-local implementations of the launch-profile seams. */
|
|
496
|
+
export function createDefaultLaunchProfileDeps(env) {
|
|
497
|
+
const store = createNodeEpicLaunchStoreDeps(env);
|
|
498
|
+
return {
|
|
499
|
+
hostname: () => os.hostname(),
|
|
500
|
+
nowIso: () => new Date().toISOString(),
|
|
501
|
+
readProfile: (name) => readEpicLaunchProfile(name, store),
|
|
502
|
+
writeProfile: (profile) => writeEpicLaunchProfile(profile, store),
|
|
503
|
+
readCredentialBinding: (repoName) => readStoredBinding(`bapi:${repoName}`, {
|
|
504
|
+
env,
|
|
505
|
+
homedir: store.homedir,
|
|
506
|
+
platform: store.platform,
|
|
507
|
+
readFile: store.readFile,
|
|
508
|
+
stat: (p) => statAsync(p),
|
|
509
|
+
}),
|
|
510
|
+
bindCredential: (repoName, baseUrl) => bindEpicLaunchCredential(repoName, baseUrl, store),
|
|
511
|
+
readPolicyFile: (absolutePath) => readFileAsync(absolutePath, "utf-8"),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
325
514
|
// ---------------------------------------------------------------------------
|
|
326
515
|
// Bounded readiness observation (BAPI-779)
|
|
327
516
|
// ---------------------------------------------------------------------------
|
|
@@ -435,7 +624,12 @@ function buildExecutorProvisioningPlans(executor, executorId, baseUrl,
|
|
|
435
624
|
* access rather than from a flag, so the unit can only ever serve the
|
|
436
625
|
* repository this install actually authenticated for.
|
|
437
626
|
*/
|
|
438
|
-
repos
|
|
627
|
+
repos,
|
|
628
|
+
/**
|
|
629
|
+
* The claim scope the unit starts with (BAPI-1137). Exactly one side is set,
|
|
630
|
+
* validated at parse time — a unit without a scope fails on every boot.
|
|
631
|
+
*/
|
|
632
|
+
claimScope) {
|
|
439
633
|
if (executor.platform !== "darwin" && executor.platform !== "linux") {
|
|
440
634
|
return {
|
|
441
635
|
ok: false,
|
|
@@ -455,6 +649,13 @@ repos) {
|
|
|
455
649
|
baseUrl,
|
|
456
650
|
homeDir: executor.homeDir,
|
|
457
651
|
envPath: executor.envPath,
|
|
652
|
+
// Forwarded so the supervised process starts with the scope the
|
|
653
|
+
// operator chose. Without it the executor refuses to start (R54) and the
|
|
654
|
+
// service manager restarts it forever.
|
|
655
|
+
...(claimScope.epicRunIds !== undefined && claimScope.epicRunIds.length > 0
|
|
656
|
+
? { epicRunIds: claimScope.epicRunIds }
|
|
657
|
+
: {}),
|
|
658
|
+
...(claimScope.repoWide === true ? { repoWide: true } : {}),
|
|
458
659
|
}, platform);
|
|
459
660
|
}
|
|
460
661
|
catch (err) {
|
|
@@ -523,23 +724,102 @@ async function runExecutorProvisioningPhase(deps, executor, plans, access) {
|
|
|
523
724
|
return null;
|
|
524
725
|
}
|
|
525
726
|
deps.log(" the service manager accepted the unit.");
|
|
727
|
+
// ---- Read-only local inspection (BAPI-1137) ---------------------------
|
|
728
|
+
// Reported as its own fact, deliberately BEFORE and separately from the server
|
|
729
|
+
// observation below. "The manager accepted the unit" and "the manager is
|
|
730
|
+
// running it" and "Bridge has seen it claim" are three different statements,
|
|
731
|
+
// and an operator who reads one as another is exactly how a dead durable host
|
|
732
|
+
// gets called healthy.
|
|
733
|
+
let localState = "unknown";
|
|
734
|
+
if (executor.inspectServiceState) {
|
|
735
|
+
try {
|
|
736
|
+
const inspection = await executor.inspectServiceState(plans.install);
|
|
737
|
+
localState = inspection.state;
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
// A probe that throws leaves the state unknown. Nothing here is allowed to
|
|
741
|
+
// upgrade "I could not ask" into a state.
|
|
742
|
+
localState = "unknown";
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
deps.log(` local service state (read-only): ${localState}`);
|
|
746
|
+
deps.log(" that is a LOCAL fact only — it says nothing about whether Bridge has observed a claim.");
|
|
747
|
+
if (executor.inspectDurability) {
|
|
748
|
+
let durability = null;
|
|
749
|
+
try {
|
|
750
|
+
durability = await executor.inspectDurability(plans.install);
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
durability = null;
|
|
754
|
+
}
|
|
755
|
+
if (durability === null || durability.prerequisite === "unverifiable") {
|
|
756
|
+
deps.log(" durable session: unverifiable — this run could not establish whether the executor " +
|
|
757
|
+
"survives logout.");
|
|
758
|
+
}
|
|
759
|
+
else if (durability.prerequisite === "configured") {
|
|
760
|
+
deps.log(" durable session: configured — the executor survives logout on this host.");
|
|
761
|
+
}
|
|
762
|
+
else if (durability.prerequisite === "session-bound") {
|
|
763
|
+
// The probe answered, and what it answered is "a login session exists" —
|
|
764
|
+
// not "this survives logout". Reported as the qualified fact it is, with
|
|
765
|
+
// the session prerequisite the operator has to keep true.
|
|
766
|
+
deps.log(" durable session: session-bound — a login session exists, but the executor is NOT " +
|
|
767
|
+
"logout-durable on this host.");
|
|
768
|
+
if (durability.remediation) {
|
|
769
|
+
deps.log(` manual action: ${durability.remediation}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
else if (durability.prerequisite === "not-configured") {
|
|
773
|
+
// NOT durably configured, said plainly, with the command the OPERATOR runs.
|
|
774
|
+
// Bridge never runs it: enabling lingering changes how the host behaves
|
|
775
|
+
// after logout, and that is the operator's decision on their own machine.
|
|
776
|
+
deps.log(" durable session: NOT durably configured — the executor stops when you log out.");
|
|
777
|
+
if (durability.remediation) {
|
|
778
|
+
deps.log(` manual action: ${durability.remediation}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
// `not-applicable`. Unreachable in practice — this phase only runs on
|
|
783
|
+
// darwin and linux, both of which have a prerequisite — but stated rather
|
|
784
|
+
// than left as silence, because a fact that renders nothing is
|
|
785
|
+
// indistinguishable from a fact that was never collected.
|
|
786
|
+
deps.log(" durable session: not applicable on this platform.");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
526
789
|
// ---- Bounded observation ----------------------------------------------
|
|
527
790
|
// Only reached after a SUCCESSFUL start: polling for an executor that was
|
|
528
791
|
// never started would burn the whole budget to learn what we already know.
|
|
529
792
|
deps.log(` watching the server for an executor claim (up to ${Math.round(EXECUTOR_OBSERVATION_BUDGET_MS / 1000)}s)…`);
|
|
530
793
|
const observation = await observeExecutorReadiness(access, deps.fetchJson, executor);
|
|
531
794
|
if (observation.status === "observed") {
|
|
532
|
-
deps.log(
|
|
795
|
+
deps.log(` the server observed an executor claim for this repository (requested executor id: ${plans.install.executorId}).`);
|
|
796
|
+
// BAPI-1137, honestly scoped: the readiness contract this client can read is
|
|
797
|
+
// REPOSITORY-scoped — `GET .../conductor-readiness?repo_name=` returns
|
|
798
|
+
// `executor.{liveness_readable,last_seen_at,last_seen_age_seconds,ready}`
|
|
799
|
+
// and carries no executor id, because the server derives liveness from the
|
|
800
|
+
// repository's `executor_jobs` rows. So this observation proves an executor
|
|
801
|
+
// is claiming for this repository; it cannot prove it is THIS executor id.
|
|
802
|
+
// The honest move is to say so rather than to invent a request or response
|
|
803
|
+
// field the endpoint does not implement — see the durable-host runbook's
|
|
804
|
+
// prerequisite note for the server-side contract this needs.
|
|
805
|
+
deps.log(" note: the readiness contract is repository-scoped and carries no executor id, so this");
|
|
806
|
+
deps.log(" confirms an executor is claiming for this repository — not specifically the one just started.");
|
|
807
|
+
deps.log(" if another executor already serves this repository, verify the requested id yourself before");
|
|
808
|
+
deps.log(" treating this host as the one doing the work.");
|
|
533
809
|
return observation.readiness;
|
|
534
810
|
}
|
|
535
811
|
// The honest degradation: name what WAS achieved and what was not, separately.
|
|
536
|
-
deps.log(
|
|
537
|
-
deps.log(
|
|
538
|
-
|
|
812
|
+
deps.log(` started locally, not observed (executor id: ${plans.install.executorId}).`);
|
|
813
|
+
deps.log(` local service state (read-only): ${localState}`);
|
|
814
|
+
deps.log(" the unit was written and the service manager started it, but the server did not observe " +
|
|
815
|
+
`an executor claim within the budget (${observation.attempts} read(s), ` +
|
|
816
|
+
`${observation.readFailures} unreadable).`);
|
|
539
817
|
deps.log(observation.lastReady === false
|
|
540
818
|
? " the server last reported this repository's executor as stale/not ready."
|
|
541
819
|
: " the server has not yet seen any executor claim for this repository.");
|
|
542
820
|
deps.log(" executor_ready is NOT claimed — only a live server observation can produce it.");
|
|
821
|
+
deps.log(" escalate to the host's operating party (operator-operated) or to Bridge operations " +
|
|
822
|
+
"(Bridge-operated) with the repository name and the executor id above.");
|
|
543
823
|
return null;
|
|
544
824
|
}
|
|
545
825
|
// ---------------------------------------------------------------------------
|
|
@@ -755,6 +1035,265 @@ async function runConductorLocalObservabilityPhase(deps, local) {
|
|
|
755
1035
|
/**
|
|
756
1036
|
* Run the nested conductor installer. Returns the process exit code.
|
|
757
1037
|
*/
|
|
1038
|
+
// ---------------------------------------------------------------------------
|
|
1039
|
+
// Epic launch profile phase (BAPI-1260)
|
|
1040
|
+
// ---------------------------------------------------------------------------
|
|
1041
|
+
/** The packaged CLI's own npm spec, used when no absolute build path resolves. */
|
|
1042
|
+
const LAUNCH_NPM_CHANNEL_SPEC = `${MCP_PACKAGE_NAME}@latest`;
|
|
1043
|
+
/**
|
|
1044
|
+
* The stable invocation a profile records. A durable profile requires the
|
|
1045
|
+
* absolute packaged path (the same resolver the service unit uses, which already
|
|
1046
|
+
* refuses npx caches); a local one may fall back to the published package on
|
|
1047
|
+
* a named channel. Never a transient `_npx` cache path.
|
|
1048
|
+
*/
|
|
1049
|
+
function resolveLaunchRuntime(executor, mode) {
|
|
1050
|
+
const invocation = executor?.resolveInvocation();
|
|
1051
|
+
if (invocation?.ok) {
|
|
1052
|
+
const entryPath = invocation.invocation.prefixArgs[0];
|
|
1053
|
+
if (typeof entryPath === "string" && entryPath.length > 0) {
|
|
1054
|
+
return {
|
|
1055
|
+
ok: true,
|
|
1056
|
+
runtime: { kind: "packaged", executable: invocation.invocation.executable, entryPath },
|
|
1057
|
+
};
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
if (mode === "durable") {
|
|
1061
|
+
return {
|
|
1062
|
+
ok: false,
|
|
1063
|
+
error: invocation && !invocation.ok
|
|
1064
|
+
? invocation.error
|
|
1065
|
+
: "no absolute packaged CLI path could be resolved for a durable launch.",
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
return { ok: true, runtime: { kind: "npm-channel", spec: LAUNCH_NPM_CHANNEL_SPEC } };
|
|
1069
|
+
}
|
|
1070
|
+
/** Loopback / link-local / private hostnames the API server cannot reach from elsewhere. */
|
|
1071
|
+
function isHostLocalHostname(hostname) {
|
|
1072
|
+
const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
1073
|
+
return (h === "localhost" ||
|
|
1074
|
+
h.endsWith(".localhost") ||
|
|
1075
|
+
h === "::1" ||
|
|
1076
|
+
/^127\./.test(h) ||
|
|
1077
|
+
/^0\.0\.0\.0$/.test(h) ||
|
|
1078
|
+
/^169\.254\./.test(h));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Assess the policy's notification destination from the API SERVER's network
|
|
1082
|
+
* perspective. Presence and reachability SHAPE only — the authoritative
|
|
1083
|
+
* delivery check is the server's own `verify_notify_webhook` at approval, and a
|
|
1084
|
+
* failure there holds activation. A launcher-local listener can never pass
|
|
1085
|
+
* here for a remote target: the server cannot reach this machine's loopback.
|
|
1086
|
+
*/
|
|
1087
|
+
function assessNotification(policy, attended, targetBaseUrl) {
|
|
1088
|
+
if (attended) {
|
|
1089
|
+
return { status: "not-applicable", detail: "the referenced policy is attended; no escalation webhook is required." };
|
|
1090
|
+
}
|
|
1091
|
+
const notify = policy && typeof policy.notify === "object" && policy.notify !== null
|
|
1092
|
+
? policy.notify
|
|
1093
|
+
: null;
|
|
1094
|
+
const url = typeof notify?.webhook_url === "string" ? notify.webhook_url.trim() : "";
|
|
1095
|
+
if (url === "") {
|
|
1096
|
+
return {
|
|
1097
|
+
status: "missing",
|
|
1098
|
+
detail: "an unattended launch needs notify.webhook_url in its --policy-file" +
|
|
1099
|
+
(notify && "local_sink" in notify ? " (notify.local_sink is not an escalation channel)." : "."),
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
let webhook;
|
|
1103
|
+
let target;
|
|
1104
|
+
try {
|
|
1105
|
+
webhook = new URL(url);
|
|
1106
|
+
target = new URL(targetBaseUrl);
|
|
1107
|
+
}
|
|
1108
|
+
catch {
|
|
1109
|
+
return { status: "missing", detail: "notify.webhook_url is not an absolute URL." };
|
|
1110
|
+
}
|
|
1111
|
+
if (webhook.protocol !== "https:" && webhook.protocol !== "http:") {
|
|
1112
|
+
return { status: "missing", detail: "notify.webhook_url must be an http(s) URL." };
|
|
1113
|
+
}
|
|
1114
|
+
if (isHostLocalHostname(webhook.hostname) && !isHostLocalHostname(target.hostname)) {
|
|
1115
|
+
return {
|
|
1116
|
+
status: "missing",
|
|
1117
|
+
detail: "notify.webhook_url points at this machine's loopback, which the API server at the selected " +
|
|
1118
|
+
"target cannot reach. Use an API-reachable, durable notification service — a terminal-held " +
|
|
1119
|
+
"local listener cannot establish server reachability.",
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
return {
|
|
1123
|
+
status: "ok",
|
|
1124
|
+
detail: "notify.webhook_url is declared and addressable from the selected API server; the server " +
|
|
1125
|
+
"delivers a verification to it at approval, and a failure there holds activation.",
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Verify every mandatory launch prerequisite at the selected target, then —
|
|
1130
|
+
* and only then — persist the profile and print the one command that selects
|
|
1131
|
+
* it. Any unproved prerequisite yields `incomplete-setup` with the reason, and
|
|
1132
|
+
* no profile is written, so a profile's existence means setup was sufficient.
|
|
1133
|
+
*
|
|
1134
|
+
* Consent is READ from the server (`unattended.conductor_allowed`), never
|
|
1135
|
+
* written: ordinary configuration writes cannot self-grant unattended
|
|
1136
|
+
* authority, and nothing in this phase tries to.
|
|
1137
|
+
*/
|
|
1138
|
+
async function runLaunchProfilePhase(deps, options, inputs) {
|
|
1139
|
+
const name = options.launchProfile;
|
|
1140
|
+
const mode = options.executionMode ?? "local";
|
|
1141
|
+
const lp = deps.launchProfiles ?? createDefaultLaunchProfileDeps(deps.env);
|
|
1142
|
+
const repoName = inputs.access.repoName;
|
|
1143
|
+
const gaps = [];
|
|
1144
|
+
deps.log("");
|
|
1145
|
+
deps.log(`Epic launch profile '${name}' — verification…`);
|
|
1146
|
+
deps.log(` target: ${inputs.baseUrl}`);
|
|
1147
|
+
deps.log(` repository: ${repoName}`);
|
|
1148
|
+
deps.log(` mode: ${mode}`);
|
|
1149
|
+
// (1) Access at THIS target — only a server answer proves the key works there.
|
|
1150
|
+
if (inputs.readiness === null) {
|
|
1151
|
+
gaps.push("the selected target's conductor readiness could not be read with this repository's credential.");
|
|
1152
|
+
}
|
|
1153
|
+
// (2) The policy reference and what it implies.
|
|
1154
|
+
let policy = null;
|
|
1155
|
+
let policyPath = null;
|
|
1156
|
+
if (options.policyFile !== undefined) {
|
|
1157
|
+
policyPath = path.resolve(deps.cwd, options.policyFile);
|
|
1158
|
+
try {
|
|
1159
|
+
const parsed = JSON.parse(await lp.readPolicyFile(policyPath));
|
|
1160
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
1161
|
+
policy = parsed;
|
|
1162
|
+
}
|
|
1163
|
+
else {
|
|
1164
|
+
gaps.push("the --policy-file does not hold a JSON object.");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
catch {
|
|
1168
|
+
gaps.push("the --policy-file could not be read as JSON.");
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const attended = policy?.posture === "attended";
|
|
1172
|
+
// (3) Consent — the server's administrative authority, read not written.
|
|
1173
|
+
const consent = inputs.readiness?.unattended?.conductor_allowed;
|
|
1174
|
+
if (attended) {
|
|
1175
|
+
deps.log(" consent: not applicable (attended policy)");
|
|
1176
|
+
}
|
|
1177
|
+
else if (consent === true) {
|
|
1178
|
+
deps.log(" consent: confirmed by the server (unattended_epic_implementer_allowed)");
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
deps.log(` consent: ${consent === false ? "NOT granted" : "could not be verified"}`);
|
|
1182
|
+
gaps.push(consent === false
|
|
1183
|
+
? "unattended consent is not granted for this repository; an administrator sets it " +
|
|
1184
|
+
"(docs/claude/account-settings-operator-runbook.md) — this command never writes it."
|
|
1185
|
+
: "unattended consent could not be verified at the selected target (older server or unreadable report).");
|
|
1186
|
+
}
|
|
1187
|
+
// (4) Notification, from the server's network perspective.
|
|
1188
|
+
const notification = assessNotification(policy, attended, inputs.baseUrl);
|
|
1189
|
+
deps.log(` notify: ${notification.status} — ${notification.detail}`);
|
|
1190
|
+
if (notification.status === "missing")
|
|
1191
|
+
gaps.push(notification.detail);
|
|
1192
|
+
// (5) The actor that consumes the work.
|
|
1193
|
+
if (mode === "durable") {
|
|
1194
|
+
deps.log(` actor: ${inputs.actorObserved ? "server-observed executor handshake" : "NOT observed"}`);
|
|
1195
|
+
if (!inputs.actorObserved) {
|
|
1196
|
+
gaps.push("no executor was observed by the server for this repository; a written unit, a started " +
|
|
1197
|
+
"service, or a local process is not proof. Provision it with --executor-id and let the " +
|
|
1198
|
+
"bounded observation complete.");
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
else {
|
|
1202
|
+
deps.log(" actor: local — drive-epic starts this repository's plane and scoped lanes itself");
|
|
1203
|
+
}
|
|
1204
|
+
// (6) A stable runtime invocation.
|
|
1205
|
+
if (!inputs.runtime.ok) {
|
|
1206
|
+
gaps.push(`the packaged CLI invocation is not stable: ${inputs.runtime.error}`);
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
deps.log(` runtime: ${inputs.runtime.runtime.kind === "packaged" ? "packaged absolute path" : inputs.runtime.runtime.spec}`);
|
|
1210
|
+
}
|
|
1211
|
+
// (7) The existing profile: a changed binding is re-approved explicitly.
|
|
1212
|
+
const existing = await lp.readProfile(name);
|
|
1213
|
+
if (existing.ok) {
|
|
1214
|
+
const prior = existing.profile;
|
|
1215
|
+
const changed = prior.baseUrl !== inputs.baseUrl || prior.repoName !== repoName || prior.executionMode !== mode;
|
|
1216
|
+
if (changed) {
|
|
1217
|
+
deps.log("");
|
|
1218
|
+
deps.log(` profile '${name}' exists with a DIFFERENT binding:`);
|
|
1219
|
+
deps.log(` current: ${prior.baseUrl} repo=${prior.repoName} mode=${prior.executionMode}`);
|
|
1220
|
+
deps.log(` proposed: ${inputs.baseUrl} repo=${repoName} mode=${mode}`);
|
|
1221
|
+
const rebind = await confirm(deps, `Re-bind profile '${name}' to the proposed target? [y/N]: `);
|
|
1222
|
+
if (!rebind)
|
|
1223
|
+
gaps.push(`re-binding profile '${name}' was declined; it was left unchanged.`);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
else if (existing.kind !== "profile-missing") {
|
|
1227
|
+
// Unreadable or from a newer/older schema: reported, and replaced only
|
|
1228
|
+
// through the same consented write below.
|
|
1229
|
+
deps.log(` existing profile: ${existing.message}`);
|
|
1230
|
+
}
|
|
1231
|
+
// (8) The credential binding — the explicit one-time upgrade path.
|
|
1232
|
+
if (gaps.length === 0) {
|
|
1233
|
+
const bound = await lp.readCredentialBinding(repoName);
|
|
1234
|
+
if (bound === inputs.baseUrl) {
|
|
1235
|
+
deps.log(` credential: bapi:${repoName} is already approved for this target`);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
deps.log(bound === null
|
|
1239
|
+
? ` credential: bapi:${repoName} is not yet approved for any endpoint (legacy repo-only binding)`
|
|
1240
|
+
: ` credential: bapi:${repoName} is approved for a DIFFERENT endpoint`);
|
|
1241
|
+
const approve = await confirm(deps, `Approve the stored credential bapi:${repoName} for ${inputs.baseUrl} only? [y/N]: `);
|
|
1242
|
+
if (!approve) {
|
|
1243
|
+
gaps.push("the credential binding was not approved, so a launch could not use the stored key.");
|
|
1244
|
+
}
|
|
1245
|
+
else {
|
|
1246
|
+
const result = await lp.bindCredential(repoName, inputs.baseUrl);
|
|
1247
|
+
if (!result.ok)
|
|
1248
|
+
gaps.push(`the credential binding could not be written: ${result.error}`);
|
|
1249
|
+
else
|
|
1250
|
+
deps.log(` credential: ${result.action} — approved for this target only`);
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
if (gaps.length > 0 || !inputs.runtime.ok) {
|
|
1255
|
+
deps.log("");
|
|
1256
|
+
deps.log(` incomplete — profile '${name}' was NOT written:`);
|
|
1257
|
+
for (const gap of gaps)
|
|
1258
|
+
deps.log(` → ${gap}`);
|
|
1259
|
+
deps.log(` re-run: npx -y ${MCP_PACKAGE_NAME} install conductor --launch-profile ${name} ` +
|
|
1260
|
+
`--base-url ${inputs.baseUrl} --required-check <name> ...`);
|
|
1261
|
+
return { complete: false };
|
|
1262
|
+
}
|
|
1263
|
+
const write = await confirm(deps, `Write epic launch profile '${name}' (references only, no secrets)? [y/N]: `);
|
|
1264
|
+
if (!write) {
|
|
1265
|
+
deps.log(" declined — no profile was written.");
|
|
1266
|
+
return { complete: false };
|
|
1267
|
+
}
|
|
1268
|
+
const profile = {
|
|
1269
|
+
version: EPIC_LAUNCH_PROFILE_VERSION,
|
|
1270
|
+
name,
|
|
1271
|
+
baseUrl: inputs.baseUrl,
|
|
1272
|
+
repoName,
|
|
1273
|
+
executionMode: mode,
|
|
1274
|
+
host: lp.hostname(),
|
|
1275
|
+
runtime: inputs.runtime.runtime,
|
|
1276
|
+
policyRef: policyPath !== null ? { kind: "file", path: policyPath } : null,
|
|
1277
|
+
credentialRef: { store: "bridge-credentials", target: `bapi:${repoName}` },
|
|
1278
|
+
approvedAt: lp.nowIso(),
|
|
1279
|
+
};
|
|
1280
|
+
const written = await lp.writeProfile(profile);
|
|
1281
|
+
if (!written.ok) {
|
|
1282
|
+
deps.errorLog(` profile write failed: ${written.message}`);
|
|
1283
|
+
return { complete: false };
|
|
1284
|
+
}
|
|
1285
|
+
deps.log(` ${written.action} ${written.path}`);
|
|
1286
|
+
deps.log("");
|
|
1287
|
+
deps.log("Launch command (fresh shell; no exports needed):");
|
|
1288
|
+
deps.log(` ${renderEpicLaunchCommand(profile, {
|
|
1289
|
+
epicKey: options.epicKey ?? "<EPIC>",
|
|
1290
|
+
planFile: options.planFile ? path.resolve(deps.cwd, options.planFile) : "<path>",
|
|
1291
|
+
})
|
|
1292
|
+
// Placeholders are for the operator to replace, not literal arguments.
|
|
1293
|
+
.replace("'<EPIC>'", "<EPIC>")
|
|
1294
|
+
.replace("'<path>'", "<path>")}`);
|
|
1295
|
+
return { complete: true };
|
|
1296
|
+
}
|
|
758
1297
|
export async function runInstallBridgeConductorCli(argv, deps) {
|
|
759
1298
|
const parsed = parseInstallBridgeConductorArgs(argv);
|
|
760
1299
|
if (parsed.status === "help") {
|
|
@@ -769,12 +1308,31 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
769
1308
|
}
|
|
770
1309
|
const options = parsed.options;
|
|
771
1310
|
// ---- Base URL: normalized ONCE, confirmed BEFORE any network call ------
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1311
|
+
// BAPI-1260: an epic launch profile has NO production default. Its target is
|
|
1312
|
+
// --base-url or BAPI_BASE_URL (they must agree), decided here before any
|
|
1313
|
+
// request; the hosted default below applies to the legacy flow only.
|
|
1314
|
+
let baseUrl;
|
|
1315
|
+
if (options.launchProfile !== undefined) {
|
|
1316
|
+
const target = resolveEpicLaunchTarget({
|
|
1317
|
+
flagBaseUrl: options.baseUrl,
|
|
1318
|
+
profile: null,
|
|
1319
|
+
env: deps.env,
|
|
1320
|
+
});
|
|
1321
|
+
if (!target.ok) {
|
|
1322
|
+
deps.errorLog(target.message);
|
|
1323
|
+
deps.errorLog("Nothing was sent anywhere and nothing was written.");
|
|
1324
|
+
return 1;
|
|
1325
|
+
}
|
|
1326
|
+
baseUrl = target.baseUrl;
|
|
1327
|
+
}
|
|
1328
|
+
else {
|
|
1329
|
+
const baseUrlResult = deps.resolveBaseUrl(deps.env);
|
|
1330
|
+
if (!baseUrlResult.ok) {
|
|
1331
|
+
deps.errorLog(baseUrlResult.error);
|
|
1332
|
+
return 1;
|
|
1333
|
+
}
|
|
1334
|
+
baseUrl = baseUrlResult.value;
|
|
776
1335
|
}
|
|
777
|
-
const baseUrl = baseUrlResult.value;
|
|
778
1336
|
// ---- Consent #1: the resolved base URL ---------------------------------
|
|
779
1337
|
// This consent gates the FIRST network request of any kind — including the
|
|
780
1338
|
// read-only doctor readiness GET (which carries this repository's API key)
|
|
@@ -782,6 +1340,10 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
782
1340
|
// BAPI_BASE_URL is unset, so the operator must see and confirm the target
|
|
783
1341
|
// before the key is sent anywhere. No TTY means no consent means abort.
|
|
784
1342
|
deps.log(`Bridge API base URL: ${baseUrl}`);
|
|
1343
|
+
if (options.launchProfile !== undefined) {
|
|
1344
|
+
deps.log(` explicit target for epic launch profile '${options.launchProfile}'` +
|
|
1345
|
+
(baseUrl === DEFAULT_BAPI_BASE_URL ? " — this is the PRODUCTION Bridge API, selected deliberately." : "."));
|
|
1346
|
+
}
|
|
785
1347
|
const baseUrlOk = await confirm(deps, "Bootstrap against this URL? [y/N]: ");
|
|
786
1348
|
if (!baseUrlOk) {
|
|
787
1349
|
deps.log("Aborted — no network call (readiness GET or bootstrap POST) was attempted.");
|
|
@@ -847,7 +1409,7 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
847
1409
|
// could not be resolved the doctor above has already produced a fatal
|
|
848
1410
|
// section, so this fallback only matters for the narrow window where a
|
|
849
1411
|
// caller injected a non-fatal doctor.
|
|
850
|
-
const planned = buildExecutorProvisioningPlans(executor, options.executorId, baseUrl, access ? [access.repoName] : []);
|
|
1412
|
+
const planned = buildExecutorProvisioningPlans(executor, options.executorId, baseUrl, access ? [access.repoName] : [], { epicRunIds: options.epicRunIds, repoWide: options.repoWide });
|
|
851
1413
|
if (!planned.ok) {
|
|
852
1414
|
deps.errorLog("");
|
|
853
1415
|
deps.errorLog(`Executor provisioning refused — ${planned.error}`);
|
|
@@ -908,6 +1470,11 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
908
1470
|
else if (executor) {
|
|
909
1471
|
deps.log(" Executor provisioning: skipped (no --executor-id supplied)");
|
|
910
1472
|
}
|
|
1473
|
+
if (options.launchProfile !== undefined) {
|
|
1474
|
+
deps.log(` Epic launch profile '${options.launchProfile}': would verify target access, credential ` +
|
|
1475
|
+
"binding, consent, notification, and actor, and persist the profile only if all hold " +
|
|
1476
|
+
"(each write behind its own consent) — nothing was read from or written to the profile store.");
|
|
1477
|
+
}
|
|
911
1478
|
renderCapabilityMatrix(deps, firstReport.readiness);
|
|
912
1479
|
deps.log("");
|
|
913
1480
|
deps.log("complete-with-manual-steps (dry run)");
|
|
@@ -1127,6 +1694,24 @@ export async function runInstallBridgeConductorCli(argv, deps) {
|
|
|
1127
1694
|
for (const action of manualActions)
|
|
1128
1695
|
deps.log(` → ${action}`);
|
|
1129
1696
|
}
|
|
1697
|
+
// ---- BAPI-1260: the reusable epic launch profile ------------------------
|
|
1698
|
+
// Last, because it can only be approved on facts every earlier phase
|
|
1699
|
+
// produced: server-observed readiness at THIS target, the executor handshake
|
|
1700
|
+
// for a durable host, and the operator's consents.
|
|
1701
|
+
if (options.launchProfile !== undefined) {
|
|
1702
|
+
const launch = await runLaunchProfilePhase(deps, options, {
|
|
1703
|
+
access,
|
|
1704
|
+
baseUrl,
|
|
1705
|
+
readiness: postReadiness,
|
|
1706
|
+
actorObserved: observedReadiness !== null || postReadiness?.executor.ready === true,
|
|
1707
|
+
runtime: resolveLaunchRuntime(executor, options.executionMode ?? "local"),
|
|
1708
|
+
});
|
|
1709
|
+
deps.log("");
|
|
1710
|
+
if (!launch.complete) {
|
|
1711
|
+
deps.log("incomplete-setup");
|
|
1712
|
+
return 1;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1130
1715
|
deps.log("");
|
|
1131
1716
|
// The executor leg can only be true when the server observed a recent claim,
|
|
1132
1717
|
// so a bootstrap alone can never print `complete`. The two local phases above
|