@bridge_gpt/mcp-server 0.2.49 → 0.2.51
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 +25 -8
- package/build/base-ref.js +28 -3
- package/build/claude-review-workflow-drift-probe.js +130 -0
- package/build/claude-review-workflow-drift.js +173 -0
- package/build/claude-review-workflow.js +81 -16
- package/build/commands.generated.js +5 -5
- package/build/conduct-epic/bridge-client.js +115 -1
- package/build/conduct-epic/cli.js +351 -33
- package/build/conduct-epic/cut-protocol.js +51 -0
- package/build/conductor/done-gate.js +25 -3
- package/build/conductor/install-doctor.js +65 -5
- package/build/conductor/latest-check-selector.js +170 -0
- package/build/conductor/local-merge.js +8 -6
- package/build/conductor-bin.js +1 -1
- package/build/{brainstorm-files.js → council-files.js} +15 -15
- package/build/decision-page-schema.js +1 -1
- package/build/docs.generated.js +1 -1
- package/build/doctor.js +352 -4
- package/build/epic-integration-pr.js +280 -0
- package/build/executor/job-runner.js +7 -1
- package/build/executor/merge-job.js +46 -1
- package/build/executor/worktree.js +46 -1
- package/build/index.js +153 -65
- package/build/init.js +9 -2
- package/build/install-bridge.js +60 -2
- package/build/install-reexec.js +47 -9
- package/build/pipelines.generated.js +8 -2
- package/build/plan-epic-conductor-eligibility.js +183 -0
- package/build/plane/cli.js +12 -2
- package/build/plane/manifest.js +25 -1
- package/build/plane/member-roster.js +61 -7
- package/build/plane/preflight.js +24 -9
- package/build/plane/supervisor.js +77 -5
- package/build/plane/types.js +23 -3
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +2 -1
- package/build/setup-epic.js +32 -0
- package/build/sfcc/reads-custom-object-def.js +10 -13
- package/build/sfcc/reads-site-preference.js +5 -5
- package/build/sfcc/reads-system-object.js +4 -4
- package/build/sfcc/writes-custom-object-def.js +7 -7
- package/build/sfcc/writes-site-preference.js +4 -3
- package/build/sfcc/writes-system-object.js +7 -6
- package/build/stale-worktree-doctor.js +120 -0
- package/build/start-tickets-prereqs.js +70 -0
- package/build/start-tickets.js +91 -3
- package/build/version.generated.js +3 -2
- package/package.json +6 -3
- package/pipelines/plan-epic.json +5 -0
- package/build/chain-orchestrator.js +0 -1457
- package/build/chain-utils.js +0 -68
- package/build/command-catalog.js +0 -376
- package/build/schedule-run.js +0 -1300
- package/build/schedule-store.js +0 -172
- package/build/scheduled-prompt.js +0 -115
- package/build/scheduler-backends/at-fallback.js +0 -139
- package/build/scheduler-backends/escaping.js +0 -143
- package/build/scheduler-backends/index.js +0 -72
- package/build/scheduler-backends/launchd.js +0 -225
- package/build/scheduler-backends/systemd-user.js +0 -250
- package/build/scheduler-backends/task-scheduler.js +0 -214
- package/build/scheduler-backends/types.js +0 -23
|
@@ -6,12 +6,17 @@
|
|
|
6
6
|
* right base, that a checkpoint write is atomic, that two sessions cannot edit
|
|
7
7
|
* one checkpoint, that a `MERGED` PR is visible, that `auto_merge_enabled` is on
|
|
8
8
|
* before the first ticket is dispatched — are exactly the kind that fail
|
|
9
|
-
* silently when they live in an instruction file. So they live here, behind
|
|
9
|
+
* silently when they live in an instruction file. So they live here, behind the
|
|
10
10
|
* verbs the loop calls and this file's tests pin:
|
|
11
11
|
*
|
|
12
12
|
* init | status | checkpoint set | finish | spawn
|
|
13
|
+
* recover | retire | reclaim | catch-up (index-scope lifecycle)
|
|
14
|
+
* scopes (repository-scoped inventory)
|
|
13
15
|
*
|
|
14
|
-
*
|
|
16
|
+
* `scopes` (BAPI-963) is the one verb that takes no `<EPIC>` key: it answers
|
|
17
|
+
* "what does this repository own?", which is not a question about any one epic.
|
|
18
|
+
*
|
|
19
|
+
* Design rules that hold across all of them:
|
|
15
20
|
*
|
|
16
21
|
* - **Nothing creates or mutates an `epic_run`.** This CLI drives a LOCAL loop.
|
|
17
22
|
* The one epic-run call it makes (`getEpicRunState`) is a read whose only
|
|
@@ -34,12 +39,13 @@ import os from "node:os";
|
|
|
34
39
|
import path from "node:path";
|
|
35
40
|
import { validateBranchName } from "../base-ref.js";
|
|
36
41
|
import { parseDoneGateConfig } from "../conductor/done-gate.js";
|
|
42
|
+
import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, } from "../epic-integration-pr.js";
|
|
37
43
|
import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
|
|
38
44
|
import { runGhCommand } from "../conductor/pr-discovery.js";
|
|
39
45
|
import { getDefaultSpawnTerminalTabForPlatform, detectTerminal, createDefaultStartTicketsDeps, } from "../start-tickets.js";
|
|
40
46
|
import { resolveWorktrunkBinary } from "../start-tickets-prereqs.js";
|
|
41
47
|
import { resolveRequiredStartTicketsRepoName } from "../start-tickets-repo.js";
|
|
42
|
-
import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, catchUpIndexScope, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
|
|
48
|
+
import { bootstrapIndexScope, getConfigFieldBaseBranch, getConductorReadiness, getIndexScopeLifecycle, catchUpIndexScope, getIndexScopeStatus, getEffectiveSupervisorConfig, getEffectiveSupervisorSetup, getEpicRunState, getParseDispatcherHealth, getParseStatus, getPrReviewStatus, heartbeatIndexScope, pollCiChecks, putSupervisorConfigDefaults, reclaimIndexScope, recoverIndexScope, resolveCiChecks, retireIndexScope, } from "./bridge-client.js";
|
|
43
49
|
import { appendTicketJournal, createInitialConductEpicCheckpoint, readConductEpicCheckpoint, resolveConductEpicCheckpointPath, resolveConductEpicLockPath, writeConductEpicCheckpointAtomic, CONDUCT_EPIC_REVIEW_VERDICTLESS_CEILING, CONDUCT_EPIC_TICKET_STATUSES, } from "./checkpoint-store.js";
|
|
44
50
|
import { acquireConductEpicLock, inspectConductEpicLock, isConductEpicLockOwnerAlive, } from "./lock.js";
|
|
45
51
|
import { discoverConductEpicPrState, discoverTicketWorktree, parseGitWorktreePorcelain, } from "./pr-state.js";
|
|
@@ -51,7 +57,7 @@ import { INDEX_SCOPE_CONFIGURATION_ERROR, validateOptionalIndexScope, } from "..
|
|
|
51
57
|
// local-git helpers live in ONE shared module that `setup-epic` drives too. This
|
|
52
58
|
// file remains the pilot's owner of the preflight and of how a cut outcome is
|
|
53
59
|
// reported; the cut itself is performed by the shared module.
|
|
54
|
-
import { createExecFileRunCommand, firstOutputLine as firstLine, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, } from "./cut-protocol.js";
|
|
60
|
+
import { createExecFileRunCommand, describeScopeBootstrapWindow, firstOutputLine as firstLine, formatScopeBootstrapHeartbeat, lsRemoteSha, normalizeCommitSha, performExactIndexScopeCut, runGit, SCOPE_BOOTSTRAP_MAX_POLLS, SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_UNREADABLE_STATE, } from "./cut-protocol.js";
|
|
55
61
|
// Re-exported so existing importers of the pilot's normalizer keep compiling.
|
|
56
62
|
export { normalizeCommitSha };
|
|
57
63
|
/** Epic and ticket keys accepted by every verb. */
|
|
@@ -67,6 +73,7 @@ export const CONDUCT_EPIC_VERBS = [
|
|
|
67
73
|
"retire",
|
|
68
74
|
"reclaim",
|
|
69
75
|
"catch-up",
|
|
76
|
+
"scopes",
|
|
70
77
|
];
|
|
71
78
|
/** Per-ticket fields `checkpoint set` may assign. */
|
|
72
79
|
const TICKET_FIELDS = [
|
|
@@ -91,9 +98,17 @@ export function createDefaultConductEpicDeps() {
|
|
|
91
98
|
const runCommand = createExecFileRunCommand();
|
|
92
99
|
const spawner = getDefaultSpawnTerminalTabForPlatform(process.platform);
|
|
93
100
|
const startTicketsDeps = createDefaultStartTicketsDeps();
|
|
101
|
+
// Strict enabled-value check (matches `MCP_INTEGRATION === "1"` in
|
|
102
|
+
// `integration/harness.ts`): only the exact value "1" activates the
|
|
103
|
+
// fixture, so no broadly truthy ambient environment value can enable it
|
|
104
|
+
// by accident.
|
|
105
|
+
const publishedIdentityFixture = process.env[CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV] === "1"
|
|
106
|
+
? CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE
|
|
107
|
+
: undefined;
|
|
94
108
|
return {
|
|
95
109
|
runCommand,
|
|
96
110
|
runGh: (args, options) => runGhCommand(args, options ?? {}),
|
|
111
|
+
epicIntegrationGh: createProductionEpicIntegrationGhRunner(),
|
|
97
112
|
spawnTab: (shellCommand, context) => spawner(startTicketsDeps, detectTerminal(undefined, process.env), shellCommand, context),
|
|
98
113
|
fetchImpl: globalThis.fetch,
|
|
99
114
|
fs: {
|
|
@@ -130,6 +145,7 @@ export function createDefaultConductEpicDeps() {
|
|
|
130
145
|
errorLog: (m) => console.error(m),
|
|
131
146
|
resolveAccess: resolveConductorBridgeApiAccess,
|
|
132
147
|
resolveLatestPublishedVersion: () => fetchLatestVersion({ fetch: globalThis.fetch }),
|
|
148
|
+
publishedIdentityFixture,
|
|
133
149
|
resolveRepoName: resolveRequiredStartTicketsRepoName,
|
|
134
150
|
};
|
|
135
151
|
}
|
|
@@ -158,8 +174,14 @@ export function getConductEpicUsage() {
|
|
|
158
174
|
" CI, review, parse, deadline, and lock state. --json is required. A missing",
|
|
159
175
|
" checkpoint exits 0 with checkpoint_exists:false. A failed probe leaves its",
|
|
160
176
|
" sub-object null and is listed in probe_errors; it never fails the command.",
|
|
161
|
-
"
|
|
162
|
-
"
|
|
177
|
+
" status warns about STRANDED scopes only; run `conduct-epic scopes` below",
|
|
178
|
+
" for the full repository-wide inventory.",
|
|
179
|
+
"",
|
|
180
|
+
" scopes [--json]",
|
|
181
|
+
" Repository-scoped, and the ONE verb that takes no <EPIC> key. Lists EVERY",
|
|
182
|
+
" index scope this repository owns — live, expired, reclaiming, and",
|
|
183
|
+
" reclaimed alike — so a crashed epic is visible without SQL. Read-only:",
|
|
184
|
+
" it writes nothing and takes no lease.",
|
|
163
185
|
"",
|
|
164
186
|
" checkpoint set <EPIC> --ticket <KEY> [--field <name> <value>]... [--journal <line>]",
|
|
165
187
|
" [--checkpoint-path <p>]",
|
|
@@ -240,6 +262,10 @@ const VERB_FLAGS = {
|
|
|
240
262
|
// the verb. Catch-up replays commits the SERVER reads through the unchanged
|
|
241
263
|
// AC-10 gate; it cannot make that gate say yes.
|
|
242
264
|
"catch-up": ["--scope", "--checkpoint-path", "--json"],
|
|
265
|
+
// BAPI-963. `--json` ONLY: this verb reads a repository-wide listing, so there
|
|
266
|
+
// is no checkpoint to point at and no scope to single out. The absence of
|
|
267
|
+
// --force/--override/--skip-guard here is the same deliberate absence as above.
|
|
268
|
+
scopes: ["--json"],
|
|
243
269
|
};
|
|
244
270
|
/**
|
|
245
271
|
* Parse and fully validate argv BEFORE any I/O.
|
|
@@ -272,7 +298,8 @@ export function parseConductEpicArgs(argv) {
|
|
|
272
298
|
argv[0] === "recover" ||
|
|
273
299
|
argv[0] === "retire" ||
|
|
274
300
|
argv[0] === "reclaim" ||
|
|
275
|
-
argv[0] === "catch-up"
|
|
301
|
+
argv[0] === "catch-up" ||
|
|
302
|
+
argv[0] === "scopes") {
|
|
276
303
|
verb = argv[0];
|
|
277
304
|
rest = argv.slice(1);
|
|
278
305
|
}
|
|
@@ -340,12 +367,23 @@ export function parseConductEpicArgs(argv) {
|
|
|
340
367
|
}
|
|
341
368
|
}
|
|
342
369
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
370
|
+
// BAPI-963: `scopes` is repository-scoped and takes NO positional. Rejecting a
|
|
371
|
+
// supplied key rather than ignoring it keeps the promise `--help` makes exact —
|
|
372
|
+
// an operator who types `conduct-epic scopes BAPI-1` learns the verb's shape
|
|
373
|
+
// instead of silently getting a listing that ignored their argument.
|
|
374
|
+
if (verb === "scopes") {
|
|
375
|
+
if (epicKey !== undefined) {
|
|
376
|
+
return parseError(`'scopes' takes no <EPIC> key; it lists every scope this repository owns.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
if (epicKey === undefined)
|
|
381
|
+
return parseError(`'${verbLabel(verb)}' requires an <EPIC> key.`);
|
|
382
|
+
if (!CONDUCT_EPIC_KEY_PATTERN.test(epicKey)) {
|
|
383
|
+
return parseError(`Invalid epic key '${epicKey}'. Expected the form PROJ-123.`);
|
|
384
|
+
}
|
|
385
|
+
options.epicKey = epicKey;
|
|
347
386
|
}
|
|
348
|
-
options.epicKey = epicKey;
|
|
349
387
|
const missing = requiredFlagError(options);
|
|
350
388
|
if (missing !== null)
|
|
351
389
|
return parseError(missing);
|
|
@@ -487,6 +525,29 @@ function emitFailure(deps, json, reasons, payload = {}) {
|
|
|
487
525
|
function epicBranchFor(epicKey) {
|
|
488
526
|
return `epic/${epicKey}`;
|
|
489
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Ensure the draft epic-integration PR (BAPI-951), non-fatally. Never throws;
|
|
530
|
+
* returns the formatted, sanctioned-fields-only outcome for the caller's own
|
|
531
|
+
* advisory/announcement line.
|
|
532
|
+
*/
|
|
533
|
+
async function ensureEpicIntegrationPrAdvisory(deps, input) {
|
|
534
|
+
const gh = deps.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
|
|
535
|
+
try {
|
|
536
|
+
const outcome = await ensureEpicIntegrationPullRequest({
|
|
537
|
+
epicKey: input.epicKey,
|
|
538
|
+
epicBranch: input.epicBranch,
|
|
539
|
+
baseBranch: input.baseBranch,
|
|
540
|
+
command: input.command,
|
|
541
|
+
gh,
|
|
542
|
+
cwd: deps.cwd,
|
|
543
|
+
requestReady: input.requestReady,
|
|
544
|
+
});
|
|
545
|
+
return formatEpicIntegrationPullRequestOutcome(outcome);
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return formatEpicIntegrationPullRequestOutcome({ kind: "unavailable", reason: "probe_inconclusive" });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
490
551
|
/** Resolve the checkpoint path from `--checkpoint-path` or the default. */
|
|
491
552
|
function resolveCheckpointPath(deps, repoName, epicKey, override) {
|
|
492
553
|
if (override !== undefined)
|
|
@@ -603,6 +664,36 @@ export const PUBLISHED_IDENTITY_TIMEOUT_MS = 120_000;
|
|
|
603
664
|
const PUBLISHED_IDENTITY_PATTERN = /^commit: ([0-9a-f]{12})(-dirty)?$/;
|
|
604
665
|
/** The sentinel a build with no git metadata reports. */
|
|
605
666
|
const PUBLISHED_IDENTITY_UNKNOWN = "unknown";
|
|
667
|
+
/**
|
|
668
|
+
* Opt-out for the published-identity read, for spawned integration children
|
|
669
|
+
* ONLY (BAPI-944).
|
|
670
|
+
*
|
|
671
|
+
* The required `mcp-integration` CI lane spawns real `conduct-epic init`
|
|
672
|
+
* children, and `readPublishedBuildIdentity` otherwise resolves the npm
|
|
673
|
+
* `latest` dist-tag and launches it through `npx` — a live registry
|
|
674
|
+
* dependency inside a lane that is supposed to be hermetic. Naming follows
|
|
675
|
+
* `INSTALL_REEXEC_SENTINEL` (`install-reexec.ts`); the strict-enabled-value
|
|
676
|
+
* convention follows `MCP_INTEGRATION` (`integration/harness.ts`). Kept
|
|
677
|
+
* distinct from `MCP_INTEGRATION` deliberately: that flag gates which test
|
|
678
|
+
* files run at all, this one gates a single dependency inside them, and
|
|
679
|
+
* conflating the two would make it impossible to run the integration suite
|
|
680
|
+
* against a real registry when that is exactly what is under test.
|
|
681
|
+
*/
|
|
682
|
+
export const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE_ENV = "BAPI_CONDUCTOR_PUBLISHED_IDENTITY_FIXTURE";
|
|
683
|
+
/**
|
|
684
|
+
* The static clean identity returned when the fixture seam is enabled.
|
|
685
|
+
*
|
|
686
|
+
* Non-secret and independent of the current checkout by construction — its
|
|
687
|
+
* commit is not expected to be present in any local history, so it exercises
|
|
688
|
+
* the SAME "commit unavailable locally" advisory path a real cold checkout
|
|
689
|
+
* would hit, rather than a synthesized pass.
|
|
690
|
+
*/
|
|
691
|
+
const CONDUCT_EPIC_PUBLISHED_IDENTITY_FIXTURE = {
|
|
692
|
+
kind: "known",
|
|
693
|
+
version: "0.0.0-fixture",
|
|
694
|
+
commit: "deadbeefcafe",
|
|
695
|
+
dirty: false,
|
|
696
|
+
};
|
|
606
697
|
/** Human wording for each unavailable category, for the fail-open advisory. */
|
|
607
698
|
export function describePublishedIdentityReason(reason) {
|
|
608
699
|
switch (reason) {
|
|
@@ -630,6 +721,13 @@ export function describePublishedIdentityReason(reason) {
|
|
|
630
721
|
* may block a run.
|
|
631
722
|
*/
|
|
632
723
|
export async function readPublishedBuildIdentity(deps) {
|
|
724
|
+
// BAPI-944: the fixture short-circuits BOTH the registry resolver and the
|
|
725
|
+
// `npx` probe below — checked before either is touched, so a spawned
|
|
726
|
+
// integration child with the sentinel set never reaches the network or the
|
|
727
|
+
// package manager.
|
|
728
|
+
if (deps.publishedIdentityFixture !== undefined) {
|
|
729
|
+
return deps.publishedIdentityFixture;
|
|
730
|
+
}
|
|
633
731
|
const resolveVersion = deps.resolveLatestPublishedVersion ?? (() => fetchLatestVersion({ fetch: deps.fetchImpl }));
|
|
634
732
|
let version;
|
|
635
733
|
try {
|
|
@@ -752,7 +850,11 @@ export async function evaluatePublishGate(deps, expectedCommitSha) {
|
|
|
752
850
|
failures: [
|
|
753
851
|
`The published ${MCP_PACKAGE_NAME}@${identity.version} was built from ${identity.commit}, ` +
|
|
754
852
|
`which does not contain ${expected} — the canonical indexed commit this epic is cut at. ` +
|
|
755
|
-
"Publish a build containing that commit before initializing."
|
|
853
|
+
"Publish a build containing that commit before initializing. Since BAPI-945 the " +
|
|
854
|
+
"publisher is CI, not a laptop: from a clean, current `main` checkout run " +
|
|
855
|
+
"`cd mcp_server && npm version patch`, then `git push origin main` and " +
|
|
856
|
+
'`git push origin "mcp-server/v<version>"` — that tag push triggers the ' +
|
|
857
|
+
"clean-room release workflow. There is deliberately no --force or --override here.",
|
|
756
858
|
],
|
|
757
859
|
advisories: [],
|
|
758
860
|
};
|
|
@@ -1054,16 +1156,35 @@ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
|
|
|
1054
1156
|
return { ok: false, failures: [`The index scope could not be seeded: ${scheduled.error}`] };
|
|
1055
1157
|
}
|
|
1056
1158
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1159
|
+
// BAPI-963. The seed printed NOTHING for ~30 minutes during the BAPI-943 pilot:
|
|
1160
|
+
// no output, no checkpoint, and a frozen `epic_shadow_index.updated_at`, so a
|
|
1161
|
+
// healthy seed and a wedge looked identical from outside. Because the seed
|
|
1162
|
+
// copies the canonical index wholesale rather than re-parsing, no
|
|
1163
|
+
// `repository_parse_runs` row appears either, removing the last progress
|
|
1164
|
+
// signal. The notice and the per-poll heartbeat below are the fix.
|
|
1165
|
+
//
|
|
1166
|
+
// The MECHANISM (elapsed computation, the line shape, the window description)
|
|
1167
|
+
// lives at the shared `cut-protocol.ts` seam that `setup-epic` also drives, so
|
|
1168
|
+
// v2 can adopt the same progress observation; the RENDERING stays here, on the
|
|
1169
|
+
// pilot's own stderr advisory channel, which is what keeps stdout exactly one
|
|
1170
|
+
// JSON object under `--json`.
|
|
1171
|
+
const startedAtMs = deps.now().getTime();
|
|
1172
|
+
deps.errorLog(describeScopeBootstrapWindow(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS, SCOPE_BOOTSTRAP_MAX_POLLS));
|
|
1057
1173
|
let lastState = "unknown";
|
|
1058
1174
|
for (let poll = 0; poll < SCOPE_BOOTSTRAP_MAX_POLLS; poll += 1) {
|
|
1059
1175
|
await sleep(SCOPE_BOOTSTRAP_POLL_INTERVAL_MS);
|
|
1060
1176
|
const status = await getIndexScopeStatus(access, scopeId, deps.fetchImpl);
|
|
1177
|
+
const elapsedMs = deps.now().getTime() - startedAtMs;
|
|
1061
1178
|
if (!status.ok) {
|
|
1062
1179
|
// A transient read failure is not a verdict: keep polling and let the
|
|
1063
|
-
// bound below be the thing that gives up.
|
|
1180
|
+
// bound below be the thing that gives up. The heartbeat reports the fixed
|
|
1181
|
+
// `unreadable` label rather than the raw error, which stays out of a line
|
|
1182
|
+
// that repeats every interval.
|
|
1183
|
+
deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, SCOPE_BOOTSTRAP_UNREADABLE_STATE));
|
|
1064
1184
|
lastState = `unreadable (${status.error})`;
|
|
1065
1185
|
continue;
|
|
1066
1186
|
}
|
|
1187
|
+
deps.errorLog(formatScopeBootstrapHeartbeat(elapsedMs, status.value.lifecycle_state));
|
|
1067
1188
|
lastState = status.value.lifecycle_state;
|
|
1068
1189
|
if (status.value.lifecycle_state === "ready") {
|
|
1069
1190
|
if (status.value.indexed_commit_sha !== null &&
|
|
@@ -1105,7 +1226,7 @@ async function driveIndexScopeBootstrap(deps, access, scopeId, options) {
|
|
|
1105
1226
|
return {
|
|
1106
1227
|
ok: false,
|
|
1107
1228
|
failures: [
|
|
1108
|
-
`
|
|
1229
|
+
`TIMED OUT: index scope ${scopeId} did not become ready within the bootstrap window ` +
|
|
1109
1230
|
`(last observed state: ${lastState}). Re-run init to resume verification.`,
|
|
1110
1231
|
],
|
|
1111
1232
|
};
|
|
@@ -1191,7 +1312,12 @@ export async function runConductEpicInit(deps, options) {
|
|
|
1191
1312
|
tickets: options.tickets,
|
|
1192
1313
|
checkpoint_path: checkpointPath,
|
|
1193
1314
|
announcements,
|
|
1194
|
-
}, [
|
|
1315
|
+
}, [
|
|
1316
|
+
"Planned (dry run — nothing was pushed, cut, seeded, repointed, or written):",
|
|
1317
|
+
...describePlan(),
|
|
1318
|
+
`would open a draft pull request ${epicBranch} → ${preflight.baseBranch} for ` +
|
|
1319
|
+
"conductor-ci / gate (BAPI-951; nothing opened in dry-run)",
|
|
1320
|
+
]);
|
|
1195
1321
|
}
|
|
1196
1322
|
// The FIRST durable mutation of the whole verb, deliberately placed here: every
|
|
1197
1323
|
// preflight check has passed, and nothing has been pushed, repointed, written,
|
|
@@ -1242,6 +1368,20 @@ export async function runConductEpicInit(deps, options) {
|
|
|
1242
1368
|
});
|
|
1243
1369
|
}
|
|
1244
1370
|
announcements.push(`announced: index scope ${cut.scope_id} is ready at ${cut.cut_commit_sha}.`);
|
|
1371
|
+
// --- Draft epic-integration PR (BAPI-951) --------------------------------
|
|
1372
|
+
// Only when THIS invocation actually created the branch (`cutOutcome.branchCreated`)
|
|
1373
|
+
// — never on a reused/already-recorded cut, which `init` refuses to reach at all
|
|
1374
|
+
// (an existing checkpoint returns early above). Placed AFTER scope readiness and
|
|
1375
|
+
// BEFORE the checkpoint write, which stays the final mutation.
|
|
1376
|
+
if (cutOutcome.branchCreated) {
|
|
1377
|
+
const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
|
|
1378
|
+
epicKey: options.epicKey,
|
|
1379
|
+
epicBranch,
|
|
1380
|
+
baseBranch: preflight.baseBranch,
|
|
1381
|
+
command: "conduct-epic init",
|
|
1382
|
+
});
|
|
1383
|
+
announcements.push(`integration pr: ${JSON.stringify(formatted)}`);
|
|
1384
|
+
}
|
|
1245
1385
|
// BAPI-847: `init` used to repoint the REPOSITORY's `base_branch` at the epic
|
|
1246
1386
|
// branch here, which is the defect this epic exists to remove — a
|
|
1247
1387
|
// repository-wide mutation that every unrelated run then resolved through. The
|
|
@@ -1303,12 +1443,19 @@ export async function runConductEpicInit(deps, options) {
|
|
|
1303
1443
|
* unavailable — each leaves its sub-object `null`, adds a `probe_errors` entry,
|
|
1304
1444
|
* and the command still exits 0 with a complete object. The loop must be able
|
|
1305
1445
|
* to see its own checkpoint during a GitHub outage.
|
|
1306
|
-
* - **The write allowlist is exactly
|
|
1307
|
-
* `last_seen_head`, `last_state_change_at`,
|
|
1308
|
-
* `ticket.branch
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1446
|
+
* - **The write allowlist is exactly five fields** (`ci_last_poll`,
|
|
1447
|
+
* `last_seen_head`, `last_state_change_at`, a newly discovered
|
|
1448
|
+
* `ticket.branch`, and — since BAPI-963 — an observed `ticket.pr_number`)
|
|
1449
|
+
* plus the normal `updated_at`, alongside the scope's fencing epoch. Everything
|
|
1450
|
+
* else is observational. In particular the checkpoint stores no "expected
|
|
1451
|
+
* head": merge identity always comes from a fresh `pr.head_sha`, and a stored
|
|
1311
1452
|
* expectation would be a second source of truth that goes stale.
|
|
1453
|
+
*
|
|
1454
|
+
* `ticket.pr_number` is a SELF-HEAL, not a new source of truth. `status` already
|
|
1455
|
+
* derives the PR from GitHub independently, so the checkpoint field was pure
|
|
1456
|
+
* caller obligation — and when the pilot watcher died during BAPI-943, BAPI-946's
|
|
1457
|
+
* checkpoint still read `pr_number: null` long after PR #1144 had merged, exactly
|
|
1458
|
+
* when the driver had lost track and most needed the durable record.
|
|
1312
1459
|
*/
|
|
1313
1460
|
export async function runConductEpicStatus(deps, options) {
|
|
1314
1461
|
const accessProbe = await resolveAccess(deps);
|
|
@@ -1572,6 +1719,31 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1572
1719
|
};
|
|
1573
1720
|
}
|
|
1574
1721
|
}
|
|
1722
|
+
// --- BAPI-963: what a `pending` scope is actually waiting on ----------------
|
|
1723
|
+
//
|
|
1724
|
+
// Read ONLY when the scope is `pending`, because that is the single state where
|
|
1725
|
+
// "who is going to move this?" is the operator's question. A fresh, blocked, or
|
|
1726
|
+
// failed scope is not waiting on a dispatcher, and issuing the request anyway
|
|
1727
|
+
// would add a round trip to every ordinary tick.
|
|
1728
|
+
//
|
|
1729
|
+
// Fail-open like every other status probe: a failed read records a bounded
|
|
1730
|
+
// probe error and reports `unavailable`, which renders the pre-existing
|
|
1731
|
+
// headline unchanged. Absence is claimed only on positive evidence.
|
|
1732
|
+
let dispatcher = null;
|
|
1733
|
+
if (access !== null && scope !== null && scope.freshness_status === "pending") {
|
|
1734
|
+
const health = await getParseDispatcherHealth(access, deps.fetchImpl);
|
|
1735
|
+
if (health.ok) {
|
|
1736
|
+
dispatcher = health.value;
|
|
1737
|
+
}
|
|
1738
|
+
else {
|
|
1739
|
+
probeErrors.push({ probe: "parse_dispatcher", reason: health.error });
|
|
1740
|
+
dispatcher = {
|
|
1741
|
+
observation: "unavailable",
|
|
1742
|
+
heartbeatState: null,
|
|
1743
|
+
respondingSchedulerRunning: false,
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1575
1747
|
// --- BAPI-846: the repository's index scopes, and this epic's heartbeat ---
|
|
1576
1748
|
//
|
|
1577
1749
|
// TWO distinct jobs, both belonging here rather than in a daemon:
|
|
@@ -1635,13 +1807,17 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1635
1807
|
? false
|
|
1636
1808
|
: null,
|
|
1637
1809
|
};
|
|
1638
|
-
// --- the
|
|
1639
|
-
// BAPI-846 added the
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
// observed or nothing.
|
|
1810
|
+
// --- the permitted writes ------------------------------------------------
|
|
1811
|
+
// BAPI-846 added the scope's fencing epoch, refreshed from the server's
|
|
1812
|
+
// authoritative answer; BAPI-963 added the observed `ticket.pr_number`. Every
|
|
1813
|
+
// one of them rides in the SAME atomic write rather than in a second one, so a
|
|
1814
|
+
// tick either records everything it observed or nothing.
|
|
1643
1815
|
let lastSeenHead = ticket?.last_seen_head ?? null;
|
|
1644
1816
|
let lastStateChangeAt = ticket?.last_state_change_at ?? null;
|
|
1817
|
+
// BAPI-963: what the response PROJECTS for the in-flight ticket. It advances to
|
|
1818
|
+
// the mutated clone only after the atomic write proves durable, so the payload
|
|
1819
|
+
// cannot report a self-healed `pr_number` that never reached disk.
|
|
1820
|
+
let projectedTicket = ticket;
|
|
1645
1821
|
const leaseEpochChanged = nextLeaseEpoch !== checkpoint.index_scope_lease_epoch;
|
|
1646
1822
|
if (ticket !== null) {
|
|
1647
1823
|
const next = { ...checkpoint, tickets: [...checkpoint.tickets] };
|
|
@@ -1659,6 +1835,15 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1659
1835
|
lastStateChangeAt = updatedTicket.last_state_change_at;
|
|
1660
1836
|
dirty = true;
|
|
1661
1837
|
}
|
|
1838
|
+
// BAPI-963: self-heal the PR number from the PR this tick already observed.
|
|
1839
|
+
// `pr.number` is the validated positive-integer-or-null the PR-state parser
|
|
1840
|
+
// produced, so nothing unvalidated reaches the checkpoint. No PR observed
|
|
1841
|
+
// means no write at all — a null observation must never erase a number a
|
|
1842
|
+
// previous tick durably recorded.
|
|
1843
|
+
if (pr !== null && pr.number !== null && pr.number !== updatedTicket.pr_number) {
|
|
1844
|
+
updatedTicket.pr_number = pr.number;
|
|
1845
|
+
dirty = true;
|
|
1846
|
+
}
|
|
1662
1847
|
next.tickets[index] = updatedTicket;
|
|
1663
1848
|
if (ci?.ci_last_poll) {
|
|
1664
1849
|
next.ci_last_poll = ci.ci_last_poll;
|
|
@@ -1673,8 +1858,15 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1673
1858
|
const written = await writeConductEpicCheckpointAtomic(checkpointPath, next, deps.fs, {
|
|
1674
1859
|
skipChmod: deps.platform === "win32",
|
|
1675
1860
|
});
|
|
1676
|
-
if (!written.ok)
|
|
1861
|
+
if (!written.ok) {
|
|
1677
1862
|
probeErrors.push({ probe: "checkpoint_write", reason: written.error });
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
// Project what was actually PERSISTED. A failed write leaves the response
|
|
1866
|
+
// reporting the original stored value, so the payload never claims a
|
|
1867
|
+
// durable record that is not on disk.
|
|
1868
|
+
projectedTicket = updatedTicket;
|
|
1869
|
+
}
|
|
1678
1870
|
}
|
|
1679
1871
|
}
|
|
1680
1872
|
const payload = {
|
|
@@ -1684,7 +1876,9 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1684
1876
|
checkpoint_path: checkpointPath,
|
|
1685
1877
|
checkpoint_exists: true,
|
|
1686
1878
|
all_done: allDone,
|
|
1687
|
-
ticket:
|
|
1879
|
+
ticket: projectedTicket === null
|
|
1880
|
+
? null
|
|
1881
|
+
: projectConductEpicTicketFacts(projectedTicket, discoveredBranch),
|
|
1688
1882
|
worktree_path: worktreePath,
|
|
1689
1883
|
worktree_exists: worktreeExists,
|
|
1690
1884
|
branch_head: branchHead,
|
|
@@ -1703,6 +1897,9 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1703
1897
|
elapsed_since_spawn_seconds: elapsedSeconds(ticket?.spawned_at ?? null, now),
|
|
1704
1898
|
},
|
|
1705
1899
|
scope,
|
|
1900
|
+
// BAPI-963: `observed` | `absent` | `unavailable`, or null when the question
|
|
1901
|
+
// did not arise (no scope, no access, or a scope that is not `pending`).
|
|
1902
|
+
parse_dispatcher: dispatcher === null ? null : dispatcher.observation,
|
|
1706
1903
|
scope_lease_epoch: nextLeaseEpoch,
|
|
1707
1904
|
retention_seconds: retentionSeconds,
|
|
1708
1905
|
scopes,
|
|
@@ -1711,7 +1908,7 @@ export async function runConductEpicStatus(deps, options) {
|
|
|
1711
1908
|
probe_errors: probeErrors,
|
|
1712
1909
|
};
|
|
1713
1910
|
return emitSuccess(deps, options.json, payload, [
|
|
1714
|
-
...renderScopeFreshnessLines(scope),
|
|
1911
|
+
...renderScopeFreshnessLines(scope, dispatcher),
|
|
1715
1912
|
...renderStrandedScopeLines(scopes, declaredScopeId),
|
|
1716
1913
|
]);
|
|
1717
1914
|
}
|
|
@@ -1770,12 +1967,20 @@ function renderStrandedScopeLines(scopes, ownScopeId) {
|
|
|
1770
1967
|
}
|
|
1771
1968
|
return lines;
|
|
1772
1969
|
}
|
|
1773
|
-
function renderScopeFreshnessLines(scope) {
|
|
1970
|
+
function renderScopeFreshnessLines(scope, dispatcher = null) {
|
|
1774
1971
|
if (scope === null)
|
|
1775
1972
|
return [];
|
|
1973
|
+
// BAPI-963: `pending` gets a sharper headline when nothing is observed sweeping
|
|
1974
|
+
// the parse queue — "Waiting for index refresh" implies autonomous progress
|
|
1975
|
+
// that, with no dispatcher, is never going to happen. Only a POSITIVE `absent`
|
|
1976
|
+
// observation changes the wording; `observed` and `unavailable` both keep the
|
|
1977
|
+
// original line, because an unavailable read is not evidence of absence.
|
|
1978
|
+
const dispatcherAbsent = dispatcher !== null && dispatcher.observation === "absent";
|
|
1776
1979
|
const headline = {
|
|
1777
1980
|
fresh: "Index is fresh for this epic.",
|
|
1778
|
-
pending:
|
|
1981
|
+
pending: dispatcherAbsent
|
|
1982
|
+
? "Waiting for live parse dispatcher."
|
|
1983
|
+
: "Waiting for index refresh.",
|
|
1779
1984
|
blocked: "Index refresh is BLOCKED — this advance will not be indexed.",
|
|
1780
1985
|
failed: "Index generation FAILED for this scope.",
|
|
1781
1986
|
unavailable: "Index freshness is unavailable — treat as not fresh.",
|
|
@@ -1799,6 +2004,9 @@ function renderScopeFreshnessLines(scope) {
|
|
|
1799
2004
|
const actionLine = action[scope.freshness_status];
|
|
1800
2005
|
if (actionLine !== undefined)
|
|
1801
2006
|
lines.push(actionLine);
|
|
2007
|
+
if (scope.freshness_status === "pending" && dispatcherAbsent) {
|
|
2008
|
+
lines.push(" No parse dispatcher was observed. The scheduled refresh cannot begin until", " the `worker:` dyno is sweeping the parse queue.");
|
|
2009
|
+
}
|
|
1802
2010
|
lines.push(` lifecycle: ${scope.lifecycle_state ?? "unknown"}`, ` Required commit: ${scope.required_commit_sha ?? "none"}`, ` Indexed commit: ${scope.indexed_commit_sha ?? "none"}`);
|
|
1803
2011
|
if (scope.freshness_status === "unobserved_advance") {
|
|
1804
2012
|
lines.push(" Both watermarks agree — at a commit the branch has left behind, which is", " why this is not `fresh`. Run `conduct-epic catch-up <EPIC>` to repair it.");
|
|
@@ -2427,6 +2635,18 @@ export async function runConductEpicFinish(deps, options) {
|
|
|
2427
2635
|
deps.errorLog(`The index scope was not retired: ${scopeRetirementError}. ` +
|
|
2428
2636
|
`Retry with \`conduct-epic retire ${checkpoint.epic_key}\`.`);
|
|
2429
2637
|
}
|
|
2638
|
+
// --- Draft epic-integration PR (BAPI-951) --------------------------------
|
|
2639
|
+
// Wind-down: ensure the PR exists AND request it be marked ready for human
|
|
2640
|
+
// review. Fail-open, alongside scope retirement — a failure here never rolls
|
|
2641
|
+
// back retirement or fails `finish`, which has already released the lock.
|
|
2642
|
+
const integrationPrAdvisory = await ensureEpicIntegrationPrAdvisory(deps, {
|
|
2643
|
+
epicKey: checkpoint.epic_key,
|
|
2644
|
+
epicBranch: checkpoint.epic_branch,
|
|
2645
|
+
baseBranch: checkpoint.base_branch_original,
|
|
2646
|
+
command: "conduct-epic finish",
|
|
2647
|
+
requestReady: true,
|
|
2648
|
+
});
|
|
2649
|
+
deps.errorLog(`Integration PR: ${JSON.stringify(integrationPrAdvisory)}`);
|
|
2430
2650
|
const summary = {
|
|
2431
2651
|
ok: true,
|
|
2432
2652
|
epic_key: checkpoint.epic_key,
|
|
@@ -2593,7 +2813,35 @@ export async function runConductEpicCatchUp(deps, options) {
|
|
|
2593
2813
|
required_commit_sha: value.required_commit_sha,
|
|
2594
2814
|
parse_scheduled: value.parse_scheduled,
|
|
2595
2815
|
};
|
|
2596
|
-
|
|
2816
|
+
// BAPI-963. Catch-up says "a re-parse is scheduled", but NOTHING in the pilot
|
|
2817
|
+
// dispatches it: the queue is swept by the APScheduler job in `worker.py`'s job
|
|
2818
|
+
// set, and the web dyno runs with `DISABLE_SCHEDULER=true`. During the BAPI-943
|
|
2819
|
+
// pilot the first catch-up worked only because an unrelated v2 run's server
|
|
2820
|
+
// happened to be sweeping; had that process stayed dead, the scope would have
|
|
2821
|
+
// sat at `pending` forever with no diagnostic. Read the dispatcher only when a
|
|
2822
|
+
// parse was actually scheduled, so an ordinary catch-up adds no request.
|
|
2823
|
+
let dispatcher = null;
|
|
2824
|
+
if (value.ok && value.parse_scheduled) {
|
|
2825
|
+
const health = await getParseDispatcherHealth(accessProbe.access, deps.fetchImpl);
|
|
2826
|
+
// Advisory: a failed read leaves the successful catch-up successful and simply
|
|
2827
|
+
// says nothing, rather than claiming a dispatcher is missing.
|
|
2828
|
+
if (health.ok)
|
|
2829
|
+
dispatcher = health.value;
|
|
2830
|
+
}
|
|
2831
|
+
const lines = renderCatchUpLines(value, target.scopeId, dispatcher);
|
|
2832
|
+
// --- Draft epic-integration PR (BAPI-951) --------------------------------
|
|
2833
|
+
// Retry point for deferred provisioning: only after a SUCCESSFUL reconciliation
|
|
2834
|
+
// (`value.ok`), and only when the checkpoint carries the epic/base branches this
|
|
2835
|
+
// needs (absent for an explicit `--scope` target with no local checkpoint).
|
|
2836
|
+
if (value.ok && target.checkpoint !== null) {
|
|
2837
|
+
const formatted = await ensureEpicIntegrationPrAdvisory(deps, {
|
|
2838
|
+
epicKey: options.epicKey,
|
|
2839
|
+
epicBranch: target.checkpoint.epic_branch,
|
|
2840
|
+
baseBranch: target.checkpoint.base_branch_original,
|
|
2841
|
+
command: "conduct-epic catch-up",
|
|
2842
|
+
});
|
|
2843
|
+
lines.push(`integration pr: ${JSON.stringify(formatted)}`);
|
|
2844
|
+
}
|
|
2597
2845
|
return value.ok
|
|
2598
2846
|
? emitSuccess(deps, options.json, payload, lines)
|
|
2599
2847
|
: emitFailure(deps, options.json, lines, payload);
|
|
@@ -2607,8 +2855,17 @@ export async function runConductEpicCatchUp(deps, options) {
|
|
|
2607
2855
|
* block needs the BRANCH fixed, a history-limit refusal needs a fresh scope, and
|
|
2608
2856
|
* an `unavailable` needs the provider to come back.
|
|
2609
2857
|
*/
|
|
2610
|
-
function renderCatchUpLines(value, scopeId) {
|
|
2858
|
+
function renderCatchUpLines(value, scopeId, dispatcher = null) {
|
|
2611
2859
|
const pin = ` Required commit: ${value.required_commit_sha ?? "unknown"}`;
|
|
2860
|
+
// BAPI-963: appended only on POSITIVE evidence that nothing is sweeping. An
|
|
2861
|
+
// `unavailable` observation says nothing, because an advisory that cries wolf
|
|
2862
|
+
// on a network blip is one an operator learns to skip.
|
|
2863
|
+
const waiting = dispatcher !== null && dispatcher.observation === "absent"
|
|
2864
|
+
? [
|
|
2865
|
+
" WAITING ON: a live parse dispatcher. No dispatcher was observed, so the",
|
|
2866
|
+
" scheduled re-parse cannot begin until the `worker:` dyno is sweeping the parse queue.",
|
|
2867
|
+
]
|
|
2868
|
+
: [];
|
|
2612
2869
|
switch (value.outcome) {
|
|
2613
2870
|
case "repaired":
|
|
2614
2871
|
return [
|
|
@@ -2618,6 +2875,7 @@ function renderCatchUpLines(value, scopeId) {
|
|
|
2618
2875
|
value.parse_scheduled
|
|
2619
2876
|
? " A re-parse is scheduled. Poll `conduct-epic status` until freshness reads `fresh`."
|
|
2620
2877
|
: " No re-parse was scheduled; poll `conduct-epic status` for the scope's own state.",
|
|
2878
|
+
...waiting,
|
|
2621
2879
|
];
|
|
2622
2880
|
case "already_current":
|
|
2623
2881
|
return [
|
|
@@ -2631,6 +2889,7 @@ function renderCatchUpLines(value, scopeId) {
|
|
|
2631
2889
|
? " A re-parse is scheduled. Poll `conduct-epic status` until freshness reads `fresh`."
|
|
2632
2890
|
: ` No re-parse was scheduled (${value.reason ?? "the scope is not schedulable right now"}).`,
|
|
2633
2891
|
pin,
|
|
2892
|
+
...waiting,
|
|
2634
2893
|
];
|
|
2635
2894
|
case "race_lost":
|
|
2636
2895
|
return [
|
|
@@ -2780,6 +3039,63 @@ export async function runConductEpicReclaim(deps, options) {
|
|
|
2780
3039
|
" `reclaimed`.",
|
|
2781
3040
|
]);
|
|
2782
3041
|
}
|
|
3042
|
+
/**
|
|
3043
|
+
* `conduct-epic scopes` — the repository-wide index-scope inventory (BAPI-963).
|
|
3044
|
+
*
|
|
3045
|
+
* `--help` promised this listing under `status` and no invocation delivered it:
|
|
3046
|
+
* `conduct-epic scopes` answered `Unknown verb` and `status <EPIC> --scopes`
|
|
3047
|
+
* answered `Unknown flag`, so an operator asking exactly the question the help
|
|
3048
|
+
* advertised was sent to SQL. The capability already existed — `status` pushes a
|
|
3049
|
+
* stranded-scope warning to stderr — only its documented entry point did not.
|
|
3050
|
+
*
|
|
3051
|
+
* Repository-scoped by construction: it resolves access, reads the lifecycle
|
|
3052
|
+
* listing ONCE, and takes no epic key, no checkpoint, and no lease.
|
|
3053
|
+
*/
|
|
3054
|
+
export async function runConductEpicScopes(deps, options) {
|
|
3055
|
+
const accessProbe = await resolveAccess(deps);
|
|
3056
|
+
if (!accessProbe.ok)
|
|
3057
|
+
return emitFailure(deps, options.json, [accessProbe.error]);
|
|
3058
|
+
const listing = await getIndexScopeLifecycle(accessProbe.access, deps.fetchImpl);
|
|
3059
|
+
if (!listing.ok) {
|
|
3060
|
+
return emitFailure(deps, options.json, [
|
|
3061
|
+
`The index-scope inventory could not be read: ${listing.error}`,
|
|
3062
|
+
]);
|
|
3063
|
+
}
|
|
3064
|
+
return emitSuccess(deps, options.json, {
|
|
3065
|
+
ok: true,
|
|
3066
|
+
repo_name: accessProbe.access.repoName,
|
|
3067
|
+
retention_seconds: listing.value.retention_seconds,
|
|
3068
|
+
// The COMPLETE listing, unfiltered. `status` narrows deliberately; this verb
|
|
3069
|
+
// exists because that narrowing is the wrong answer to "what exists?".
|
|
3070
|
+
scopes: listing.value.scopes,
|
|
3071
|
+
}, renderIndexScopeInventoryLines(listing.value.scopes, accessProbe.access.repoName));
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Render the FULL scope inventory for `conduct-epic scopes` (BAPI-963).
|
|
3075
|
+
*
|
|
3076
|
+
* Deliberately unlike {@link renderStrandedScopeLines}, which stays narrow: that
|
|
3077
|
+
* one prints only actionable wreckage on a status tick an operator reads every
|
|
3078
|
+
* few minutes, and widening it would turn a warning into wallpaper. This one
|
|
3079
|
+
* answers a question the operator asked on purpose, so it hides nothing —
|
|
3080
|
+
* reclaimed tombstones included, since "it is already gone" is an answer.
|
|
3081
|
+
*/
|
|
3082
|
+
function renderIndexScopeInventoryLines(scopes, repoName) {
|
|
3083
|
+
if (scopes.length === 0) {
|
|
3084
|
+
return [`${repoName} owns no index scopes.`];
|
|
3085
|
+
}
|
|
3086
|
+
const lines = [`${repoName} owns ${scopes.length} index scope(s):`];
|
|
3087
|
+
for (const scope of scopes) {
|
|
3088
|
+
lines.push(` ${scope.scope_id} ${scope.lifecycle_state ?? "unknown"} ` +
|
|
3089
|
+
`branch=${scope.feature_branch ?? "unknown"} ` +
|
|
3090
|
+
`lease=${scope.lease_valid ? "live" : "expired"} ` +
|
|
3091
|
+
`retention=${scope.retention_elapsed ? "elapsed" : (scope.retention_deadline ?? "none")} ` +
|
|
3092
|
+
`recoverable=${scope.recoverable ? "yes" : "no"}`);
|
|
3093
|
+
if (scope.blockers.length > 0) {
|
|
3094
|
+
lines.push(` blocked by: ${scope.blockers.join(", ")}`);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
return lines;
|
|
3098
|
+
}
|
|
2783
3099
|
// ---------------------------------------------------------------------------
|
|
2784
3100
|
// Entry point
|
|
2785
3101
|
// ---------------------------------------------------------------------------
|
|
@@ -2805,6 +3121,8 @@ export async function runConductEpicCli(argv, overrides = {}) {
|
|
|
2805
3121
|
}
|
|
2806
3122
|
const options = parsed.options;
|
|
2807
3123
|
switch (options.verb) {
|
|
3124
|
+
case "scopes":
|
|
3125
|
+
return runConductEpicScopes(deps, options);
|
|
2808
3126
|
case "init":
|
|
2809
3127
|
return runConductEpicInit(deps, options);
|
|
2810
3128
|
case "status":
|