@bridge_gpt/mcp-server 0.2.53 → 0.2.54
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 +86 -10
- package/build/agent-launchers/claude.js +3 -3
- package/build/agent-launchers/prompt.js +8 -11
- package/build/base-ref.js +33 -9
- package/build/bounded-wait.js +174 -0
- package/build/commands.generated.js +1 -1
- package/build/conductor/bridge-api-client.js +36 -8
- package/build/conductor/epic-runtime.js +133 -97
- package/build/conductor/readiness.js +85 -0
- package/build/conductor/run-branch.js +137 -0
- package/build/conductor/test-run-branch-vectors.js +165 -0
- package/build/conductor-bin.js +5 -5
- package/build/doctor.js +68 -1
- package/build/drive-epic.js +287 -51
- package/build/executor/claim-scope.js +104 -0
- package/build/executor/cli.js +14 -25
- package/build/executor/env-file-guard.js +82 -3
- package/build/executor/job-runner.js +60 -0
- package/build/index.js +128 -400
- package/build/local-artifact-storage.js +130 -0
- package/build/pipelines.generated.js +16 -9
- package/build/plane/cli.js +285 -36
- package/build/plane/manifest.js +209 -1
- package/build/plane/member-roster.js +70 -0
- package/build/plane/shutdown.js +14 -1
- package/build/plane/status.js +35 -1
- package/build/plane/supervisor.js +546 -164
- package/build/plane/types.js +25 -2
- package/build/polling-policy.js +72 -0
- package/build/readme.generated.js +1 -1
- package/build/review-generation.js +219 -0
- package/build/run-unit-tests-launcher.js +5 -0
- package/build/setup-epic.js +514 -23
- package/build/ticket-key-utils.js +4 -3
- package/build/ticket-review-artifact-gate.js +461 -0
- package/build/upgrade-cli.js +5 -26
- package/build/version.generated.js +3 -3
- package/docs/install/mcp-tool-integrations.md +23 -1
- package/package.json +1 -1
- package/pipelines/review-ticket.json +17 -4
|
@@ -39,6 +39,7 @@ import { createDefaultStartTicketsDeps, orchestrateStartTickets } from "../start
|
|
|
39
39
|
import { orchestrateReviewTickets } from "../review-tickets.js";
|
|
40
40
|
import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, } from "../start-tickets-conductor.js";
|
|
41
41
|
import { validateBranchName } from "../base-ref.js";
|
|
42
|
+
import { resolveDeclaredRunBaseBranch } from "./run-branch.js";
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
// Constants
|
|
44
45
|
// ---------------------------------------------------------------------------
|
|
@@ -1108,15 +1109,26 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
1108
1109
|
// tickets, gated by quiescence. Invert the impl-dispatch maps so ledger
|
|
1109
1110
|
// events can be attributed back to a ticket for local-first PR binding.
|
|
1110
1111
|
const dispatchedBackstopPolicy = resolveDispatchedBackstopPolicy(epicRunState.epic_run.policy_json);
|
|
1111
|
-
// BAPI-586: resolve the run's
|
|
1112
|
-
// can catch a wrong-base PR (one not targeting the run
|
|
1113
|
-
// reconciliation time — the backstop to the executor's own
|
|
1114
|
-
// guard.
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1112
|
+
// BAPI-586/BAPI-1127: resolve the run's EFFECTIVE base branch so the
|
|
1113
|
+
// done-gate pass can catch a wrong-base PR (one not targeting the run
|
|
1114
|
+
// base) at reconciliation time — the backstop to the executor's own
|
|
1115
|
+
// finalization guard.
|
|
1116
|
+
//
|
|
1117
|
+
// BAPI-1127 moved branch-name validation out of the shared declaration
|
|
1118
|
+
// resolver and into this operational wrapper, so this call site now owns
|
|
1119
|
+
// the fail-closed disposition it used to inherit. It fails CLOSED: when
|
|
1120
|
+
// the declared base is not a usable ref the pass is SKIPPED entirely
|
|
1121
|
+
// rather than run with the base comparison silently disabled. A pass that
|
|
1122
|
+
// cannot verify a PR's base cannot honestly admit one as done, and the
|
|
1123
|
+
// wrong-base PR this check exists to catch is exactly what would slip
|
|
1124
|
+
// through. The skip happens BEFORE any PR binding resolution, CI
|
|
1125
|
+
// observation, or provider call, and reports only the failed rule.
|
|
1126
|
+
const runBaseResolution = resolveEffectiveRunBaseBranch(epicRunState.epic_run.policy_json);
|
|
1127
|
+
if (!runBaseResolution.ok) {
|
|
1128
|
+
errorLog(`[epic-tick] done-gate pass skipped for ${epic_key}: ${runBaseResolution.error}`);
|
|
1129
|
+
}
|
|
1118
1130
|
const doneGateExpectedBaseBranch = runBaseResolution.ok
|
|
1119
|
-
? runBaseResolution.
|
|
1131
|
+
? runBaseResolution.effectiveBaseBranch
|
|
1120
1132
|
: undefined;
|
|
1121
1133
|
const ticketForRunId = new Map();
|
|
1122
1134
|
for (const [tk, rid] of ticketRunIdMap)
|
|
@@ -1134,63 +1146,65 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
1134
1146
|
policy: dispatchedBackstopPolicy,
|
|
1135
1147
|
resolveImplRunId: resolveTicketRunId,
|
|
1136
1148
|
});
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1149
|
+
if (runBaseResolution.ok) {
|
|
1150
|
+
await runConductorDoneGatePass(observed.ticket_statuses, {
|
|
1151
|
+
observePrCi: observePrCiSeamFn,
|
|
1152
|
+
// BAPI-586: the run base so the done-gate pass fails a wrong-base PR
|
|
1153
|
+
// before CI/review evaluation (reconciliation backstop to executor
|
|
1154
|
+
// finalization).
|
|
1155
|
+
expectedBaseBranch: doneGateExpectedBaseBranch,
|
|
1156
|
+
resolvePrBinding,
|
|
1157
|
+
// BAPI-525 Change B: local-first binding + quiescence gate for the new
|
|
1158
|
+
// dispatched/running admission (policy default OFF ⇒ no behavior change).
|
|
1159
|
+
resolveActivePrBinding,
|
|
1160
|
+
dispatchedBackstopPolicy,
|
|
1161
|
+
resolveActiveTicketQuiescence,
|
|
1162
|
+
// BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
|
|
1163
|
+
// past the head recorded on its latest blocking signal.
|
|
1164
|
+
resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
|
|
1165
|
+
resolveRunId: resolveTicketRunId,
|
|
1166
|
+
resolveWorkerId: resolveDoneGateWorkerId,
|
|
1167
|
+
// BAPI-494: convert a detected conflict into a durable, head-scoped
|
|
1168
|
+
// `merge.conflict` ledger event stamped with the ticket's dispatch run/worker
|
|
1169
|
+
// so the fold correlates it and the remediation pass redispatches. Emitted via
|
|
1170
|
+
// the shared injectable emitter, idempotent per conflict head. Folded next tick
|
|
1171
|
+
// (this pass runs after rebuildObservedState), matching the gate.met latency.
|
|
1172
|
+
emitConflictSignal: (input) => {
|
|
1173
|
+
emitConductorEventFn({
|
|
1174
|
+
source: MERGE_CONFLICT_EVENT_SOURCE,
|
|
1175
|
+
type: "merge.conflict",
|
|
1176
|
+
subject: input.ticketKey,
|
|
1177
|
+
run_id: input.runId,
|
|
1178
|
+
worker_id: input.workerId,
|
|
1179
|
+
producer: MERGE_CONFLICT_EVENT_PRODUCER,
|
|
1180
|
+
observed_via: "supervisor",
|
|
1181
|
+
time: new Date(nowFn()).toISOString(),
|
|
1182
|
+
data: {
|
|
1183
|
+
summary: `PR #${input.prNumber} for ${input.ticketKey} is not mergeable`,
|
|
1184
|
+
status: "blocked",
|
|
1185
|
+
reason: "merge.conflict",
|
|
1186
|
+
details: {
|
|
1187
|
+
epic_key,
|
|
1188
|
+
ticket_key: input.ticketKey,
|
|
1189
|
+
repo: input.repoName,
|
|
1190
|
+
pr_number: input.prNumber,
|
|
1191
|
+
head_sha: input.headSha,
|
|
1192
|
+
mergeable: input.mergeable,
|
|
1193
|
+
mergeStateStatus: input.mergeStateStatus,
|
|
1194
|
+
},
|
|
1181
1195
|
},
|
|
1182
|
-
},
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
}
|
|
1196
|
+
}, {
|
|
1197
|
+
event_type: "merge.conflict",
|
|
1198
|
+
run_id: input.runId ?? undefined,
|
|
1199
|
+
commit_sha: input.headSha,
|
|
1200
|
+
});
|
|
1201
|
+
},
|
|
1202
|
+
access,
|
|
1203
|
+
env,
|
|
1204
|
+
log,
|
|
1205
|
+
errorLog,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1194
1208
|
const maxSeqForRun = (runId) => {
|
|
1195
1209
|
let maxSeq = 0;
|
|
1196
1210
|
for (const ev of localEvents) {
|
|
@@ -1554,39 +1568,46 @@ export async function runEpicTick(options, deps = {}) {
|
|
|
1554
1568
|
}
|
|
1555
1569
|
}
|
|
1556
1570
|
/**
|
|
1557
|
-
* BAPI-586: resolve the epic run's
|
|
1558
|
-
*
|
|
1559
|
-
*
|
|
1560
|
-
*
|
|
1571
|
+
* BAPI-586/BAPI-1127: resolve the epic run's EFFECTIVE base branch — the branch
|
|
1572
|
+
* every fresh dispatch cuts from and every child PR targets.
|
|
1573
|
+
*
|
|
1574
|
+
* Two layers, deliberately kept apart:
|
|
1561
1575
|
*
|
|
1562
|
-
*
|
|
1563
|
-
*
|
|
1564
|
-
*
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1576
|
+
* 1. DECLARATION, delegated in full to the dependency-free
|
|
1577
|
+
* {@link resolveDeclaredRunBaseBranch} leaf, which is the shared cross-runtime
|
|
1578
|
+
* contract this function does not get a second opinion about. The ordered
|
|
1579
|
+
* `base_branch` → `baseBranch` scan, the skip-and-continue treatment of a
|
|
1580
|
+
* non-string or blank candidate, and the trim all live there.
|
|
1581
|
+
* 2. OPERATIONAL POLICY, which is this function's own and is NOT shared with
|
|
1582
|
+
* Python: an undeclared policy defaults to `main`, and the branch that will
|
|
1583
|
+
* actually be handed to git is validated before it is returned.
|
|
1584
|
+
*
|
|
1585
|
+
* The frozen dispositions (BAPI-1127) show up here as follows:
|
|
1586
|
+
*
|
|
1587
|
+
* - A NON-STRING candidate is absent, not fatal. `{base_branch: 42,
|
|
1588
|
+
* baseBranch: "epic/X"}` now resolves to `epic/X`; before BAPI-1127 it failed
|
|
1589
|
+
* closed, and `{base_branch: "", baseBranch: "epic/X"}` reported `main` — a
|
|
1590
|
+
* different real branch from the one the server provisioned.
|
|
1591
|
+
* - A MALFORMED declared name does NOT fall back to `main`. Silently
|
|
1592
|
+
* substituting the default for a bad declaration is how a wrong base becomes
|
|
1593
|
+
* invisible (the BAPI-586 defect), so it fails closed and dispatch refuses.
|
|
1594
|
+
* Only an ACTUAL absence of a declaration reaches the default.
|
|
1595
|
+
*
|
|
1596
|
+
* Validation happens here, once, ahead of both operational consumers (dispatch
|
|
1597
|
+
* and the done gate) rather than inside the shared resolver, so that Python and
|
|
1598
|
+
* TypeScript agree about what a policy declares while each keeps its own
|
|
1599
|
+
* judgment about what is safe to use. The failure carries only the rule that
|
|
1600
|
+
* failed — never the policy document or an unbounded branch value.
|
|
1567
1601
|
*/
|
|
1568
|
-
export function
|
|
1602
|
+
export function resolveEffectiveRunBaseBranch(policyJson) {
|
|
1569
1603
|
const DEFAULT_BASE = "main";
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
const raw = policyJson.base_branch ??
|
|
1574
|
-
policyJson.baseBranch;
|
|
1575
|
-
if (raw === undefined || raw === null) {
|
|
1576
|
-
return { ok: true, baseBranch: DEFAULT_BASE };
|
|
1577
|
-
}
|
|
1578
|
-
if (typeof raw !== "string") {
|
|
1579
|
-
return { ok: false, error: "epic run policy base_branch is present but is not a string." };
|
|
1580
|
-
}
|
|
1581
|
-
const trimmed = raw.trim();
|
|
1582
|
-
if (trimmed.length === 0) {
|
|
1583
|
-
return { ok: true, baseBranch: DEFAULT_BASE };
|
|
1584
|
-
}
|
|
1585
|
-
const validationError = validateBranchName(trimmed);
|
|
1604
|
+
const declaredBaseBranch = resolveDeclaredRunBaseBranch(policyJson);
|
|
1605
|
+
const effectiveBaseBranch = declaredBaseBranch ?? DEFAULT_BASE;
|
|
1606
|
+
const validationError = validateBranchName(effectiveBaseBranch);
|
|
1586
1607
|
if (validationError) {
|
|
1587
1608
|
return { ok: false, error: `epic run policy base_branch is invalid: ${validationError}` };
|
|
1588
1609
|
}
|
|
1589
|
-
return { ok: true,
|
|
1610
|
+
return { ok: true, declaredBaseBranch, effectiveBaseBranch };
|
|
1590
1611
|
}
|
|
1591
1612
|
/**
|
|
1592
1613
|
* Build the production EpicRuntimeDeps for use inside `runEpicTickCommand`.
|
|
@@ -1615,13 +1636,23 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1615
1636
|
// error (observe-only would already default to `main`); a malformed configured
|
|
1616
1637
|
// base is captured as an error and re-raised at dispatch time so a bad base
|
|
1617
1638
|
// never silently dispatches.
|
|
1618
|
-
|
|
1639
|
+
//
|
|
1640
|
+
// BAPI-1127: the fetch-failure fallback declares NOTHING and dispatches from
|
|
1641
|
+
// `main`, which is what "we could not read the policy" honestly means — the
|
|
1642
|
+
// two values are kept separate so a later reader cannot mistake the default
|
|
1643
|
+
// for a declaration the run never made.
|
|
1644
|
+
const UNDECLARED_MAIN = {
|
|
1645
|
+
ok: true,
|
|
1646
|
+
declaredBaseBranch: undefined,
|
|
1647
|
+
effectiveBaseBranch: "main",
|
|
1648
|
+
};
|
|
1649
|
+
let cachedRunBase = UNDECLARED_MAIN;
|
|
1619
1650
|
try {
|
|
1620
1651
|
const runState = await fetchEpicRunState(access, epicKey);
|
|
1621
|
-
cachedRunBase =
|
|
1652
|
+
cachedRunBase = resolveEffectiveRunBaseBranch(runState.epic_run.policy_json);
|
|
1622
1653
|
}
|
|
1623
1654
|
catch {
|
|
1624
|
-
cachedRunBase =
|
|
1655
|
+
cachedRunBase = UNDECLARED_MAIN;
|
|
1625
1656
|
}
|
|
1626
1657
|
// Shared closure state populated by fetchPlan and consumed by dispatchSeam.
|
|
1627
1658
|
let cachedPlanVersion = 0;
|
|
@@ -1687,12 +1718,17 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
|
|
|
1687
1718
|
if (cachedPlanVersion === 0) {
|
|
1688
1719
|
throw new Error(`dispatchSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
|
|
1689
1720
|
}
|
|
1690
|
-
// BAPI-586: fail CLOSED on a malformed configured run base rather
|
|
1691
|
-
// dispatching a worker (and opening a PR) against a bad base.
|
|
1721
|
+
// BAPI-586/BAPI-1127: fail CLOSED on a malformed configured run base rather
|
|
1722
|
+
// than dispatching a worker (and opening a PR) against a bad base. The check
|
|
1723
|
+
// is here — ahead of the dispatch claim, the spawn command, and any git or
|
|
1724
|
+
// provider work — because `resolveEffectiveRunBaseBranch` validates the ref
|
|
1725
|
+
// but this is the boundary that actually uses it. Only `effectiveBaseBranch`
|
|
1726
|
+
// (declaration, else `main`) is handed on; the declaration itself stays
|
|
1727
|
+
// separately readable on `cachedRunBase` and is never overwritten.
|
|
1692
1728
|
if (!cachedRunBase.ok) {
|
|
1693
1729
|
throw new Error(`invalid configured base branch for epic ${ek}: ${cachedRunBase.error}`);
|
|
1694
1730
|
}
|
|
1695
|
-
const runBaseBranch = cachedRunBase.
|
|
1731
|
+
const runBaseBranch = cachedRunBase.effectiveBaseBranch;
|
|
1696
1732
|
// BAPI-441: a remediation re-dispatch (attempt > 0) reuses the existing
|
|
1697
1733
|
// branch/worktree (resume mode) and claims an attempt-scoped dispatch key so
|
|
1698
1734
|
// it is not deduped against the original epic dispatch.
|
|
@@ -112,6 +112,12 @@ export const SERVER_READINESS_DESCRIPTORS = [
|
|
|
112
112
|
{ id: "executor-readiness", label: "Executor server observation" },
|
|
113
113
|
{ id: "review-workflow", label: "Review workflow protocol" },
|
|
114
114
|
{ id: "conductor-ci-workflow", label: "Conductor CI migration guard" },
|
|
115
|
+
// BAPI-1102 — the two ONE-TIME unattended prerequisites plus the aggregate the
|
|
116
|
+
// third unattended refusal reads. Noun phrases, like every row above: these
|
|
117
|
+
// render as list items under a source heading, not as sentences.
|
|
118
|
+
{ id: "unattended-consent", label: "Repository unattended consent" },
|
|
119
|
+
{ id: "unattended-notify-default", label: "Verified notify webhook default" },
|
|
120
|
+
{ id: "repository-workflow-readiness", label: "Repository workflow readiness" },
|
|
115
121
|
];
|
|
116
122
|
/** Fixed, secret-free prose per failure kind. Never a status body or header. */
|
|
117
123
|
const SERVER_FAILURE_COPY = {
|
|
@@ -153,6 +159,11 @@ const SERVER_REMEDIATIONS = {
|
|
|
153
159
|
ciWorkflowUnknown: "re-run; the default branch's conductor CI workflow could not be evaluated, which is not the same as the guard being absent.",
|
|
154
160
|
ciWorkflowMissingGuard: "add the migration-guard job to the conductor CI workflow on the default branch so migrations are checked before a merge.",
|
|
155
161
|
olderServer: "upgrade the Bridge API deploy so it reports this fact; its state is unknown here, not healthy.",
|
|
162
|
+
// BAPI-1102. Each names the ONE action that clears it, and none of them
|
|
163
|
+
// mentions a webhook URL, a destination, or any configuration value.
|
|
164
|
+
unattendedConsent: "set `unattended_conductor_allowed` for this repository (docs/claude/account-settings-operator-runbook.md), or run setup with --attended.",
|
|
165
|
+
unattendedNotifyDefault: "supply a --policy-file declaring notify.webhook_url, or run setup with --attended; an unattended run must have a working escalation channel, and notify.local_sink is not one.",
|
|
166
|
+
repositoryWorkflowReadiness: "run `install-bridge conductor` so the review and conductor-CI workflows are confirmed on the default branch, or run setup with --attended.",
|
|
156
167
|
};
|
|
157
168
|
/**
|
|
158
169
|
* Project the validated server readiness response into `server.*` checks.
|
|
@@ -261,6 +272,7 @@ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
|
|
|
261
272
|
outcomes.push(executorOutcome(readiness));
|
|
262
273
|
outcomes.push(reviewWorkflowOutcome(readiness));
|
|
263
274
|
outcomes.push(ciWorkflowOutcome(readiness));
|
|
275
|
+
outcomes.push(...unattendedOutcomes(readiness));
|
|
264
276
|
const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
|
|
265
277
|
return SERVER_READINESS_DESCRIPTORS.map(({ id, label }) => {
|
|
266
278
|
const outcome = byId.get(id);
|
|
@@ -286,6 +298,79 @@ export function mapServerReadinessToChecks(readiness, failure = "unreachable") {
|
|
|
286
298
|
});
|
|
287
299
|
});
|
|
288
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* The three BAPI-1102 unattended prerequisite rows.
|
|
303
|
+
*
|
|
304
|
+
* Returned as a group because they share one absence rule: a server older than
|
|
305
|
+
* BAPI-1102 reports no `unattended` block at all, and an unreported fact is a
|
|
306
|
+
* FAILURE with the older-server remediation — never a pass. "Could not be read"
|
|
307
|
+
* and "read and healthy" are the two answers this whole module refuses to
|
|
308
|
+
* conflate, and these are prerequisites for an UNATTENDED merge, which is the
|
|
309
|
+
* worst place to guess.
|
|
310
|
+
*
|
|
311
|
+
* Consent and workflow readiness are `warn`, not `fail`, when confirmed absent:
|
|
312
|
+
* a repository that never runs an unattended epic needs neither, so reporting
|
|
313
|
+
* them as failures would mark a perfectly healthy attended install broken. They
|
|
314
|
+
* become hard refusals only at `setup-epic`, and only for a run whose effective
|
|
315
|
+
* policy is actually unattended.
|
|
316
|
+
*/
|
|
317
|
+
function unattendedOutcomes(readiness) {
|
|
318
|
+
const u = readiness.unattended;
|
|
319
|
+
if (u === null) {
|
|
320
|
+
return [
|
|
321
|
+
"unattended-consent",
|
|
322
|
+
"unattended-notify-default",
|
|
323
|
+
"repository-workflow-readiness",
|
|
324
|
+
].map((id) => ({
|
|
325
|
+
id,
|
|
326
|
+
status: "fail",
|
|
327
|
+
detail: "this server fact was not reported",
|
|
328
|
+
remediation: SERVER_REMEDIATIONS.olderServer,
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
return [
|
|
332
|
+
u.conductor_allowed
|
|
333
|
+
? {
|
|
334
|
+
id: "unattended-consent",
|
|
335
|
+
status: "pass",
|
|
336
|
+
detail: "this repository has consented to unattended conductor runs",
|
|
337
|
+
}
|
|
338
|
+
: {
|
|
339
|
+
id: "unattended-consent",
|
|
340
|
+
status: "warn",
|
|
341
|
+
detail: "unattended_conductor_allowed is NOT set — an unattended run will be refused before it is created",
|
|
342
|
+
remediation: SERVER_REMEDIATIONS.unattendedConsent,
|
|
343
|
+
},
|
|
344
|
+
u.notify_webhook_default_declared
|
|
345
|
+
? {
|
|
346
|
+
id: "unattended-notify-default",
|
|
347
|
+
status: "pass",
|
|
348
|
+
// Presence only. The URL is never reported by the server and is never
|
|
349
|
+
// rendered here.
|
|
350
|
+
detail: u.notify_webhook_default_verified === true
|
|
351
|
+
? "a notify webhook default is declared and verified"
|
|
352
|
+
: "a notify webhook default is declared (verified at approval, not here)",
|
|
353
|
+
}
|
|
354
|
+
: {
|
|
355
|
+
id: "unattended-notify-default",
|
|
356
|
+
status: "warn",
|
|
357
|
+
detail: "no repository notify webhook default is declared — an unattended run needs an escalation channel, and no column supplies a repository default today, so the run must declare notify.webhook_url itself",
|
|
358
|
+
remediation: SERVER_REMEDIATIONS.unattendedNotifyDefault,
|
|
359
|
+
},
|
|
360
|
+
u.repository_readiness_confirmed
|
|
361
|
+
? {
|
|
362
|
+
id: "repository-workflow-readiness",
|
|
363
|
+
status: "pass",
|
|
364
|
+
detail: "the review and conductor-CI workflows are both confirmed",
|
|
365
|
+
}
|
|
366
|
+
: {
|
|
367
|
+
id: "repository-workflow-readiness",
|
|
368
|
+
status: "warn",
|
|
369
|
+
detail: "the review and conductor-CI workflows are not both confirmed — an unattended auto-merging run will be refused",
|
|
370
|
+
remediation: SERVER_REMEDIATIONS.repositoryWorkflowReadiness,
|
|
371
|
+
},
|
|
372
|
+
];
|
|
373
|
+
}
|
|
289
374
|
/** Review-policy presence and alignment, as one check with one fix each. */
|
|
290
375
|
function reviewPolicyOutcome(readiness) {
|
|
291
376
|
const s = readiness.supervisor;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical TypeScript run-branch DECLARATION resolution (BAPI-1127).
|
|
3
|
+
*
|
|
4
|
+
* The TypeScript counterpart of `api/models/run_branch.py`, and the only place
|
|
5
|
+
* in this package that answers "which base branch, if any, does this persisted
|
|
6
|
+
* run's `policy_json` DECLARE?" Before BAPI-1127 the question was answered in
|
|
7
|
+
* three places that disagreed — the defaulting resolver in `epic-runtime.ts`
|
|
8
|
+
* (a `??` scan that stopped on a present-but-blank `base_branch` and therefore
|
|
9
|
+
* never consulted the camelCase alias), `bridge-api-client.ts#readEpicRunCompletionState`
|
|
10
|
+
* (snake_case only, and it did not trim), and the Python resolver across the
|
|
11
|
+
* wire. The two runtimes feed the SAME epic run from opposite sides, so a
|
|
12
|
+
* disagreement means the server provisions one branch while the CLI cuts worker
|
|
13
|
+
* branches from another, silently.
|
|
14
|
+
*
|
|
15
|
+
* **Declaration only.** This module answers exactly one question and stops:
|
|
16
|
+
* what branch did the policy declare? It deliberately does NOT:
|
|
17
|
+
*
|
|
18
|
+
* - apply the TypeScript-only `main` default. That is a DISPATCH-layer policy
|
|
19
|
+
* owned by `epic-runtime.ts#resolveEffectiveRunBaseBranch`. A classification
|
|
20
|
+
* or observation consumer that adopted it would report `main` for a run that
|
|
21
|
+
* declared nothing, conflating "declared no branch" with "dispatches from
|
|
22
|
+
* main" — the exact confusion that kept `featureBranch` honest before.
|
|
23
|
+
* - apply branch-name validity rules. `validateBranchName` is an OPERATIONAL
|
|
24
|
+
* judgment made by whichever caller is about to hand the value to git or a
|
|
25
|
+
* provider, and it is applied there.
|
|
26
|
+
*
|
|
27
|
+
* **The frozen cross-runtime dispositions** (BAPI-1127, and the same two rules
|
|
28
|
+
* `api/models/run_branch.py` states):
|
|
29
|
+
*
|
|
30
|
+
* - A NON-STRING candidate is ABSENT, not fatal. The ordered scan skips it and
|
|
31
|
+
* continues to the next key, so the legacy row
|
|
32
|
+
* `{"base_branch": 42, "baseBranch": "epic/X"}` resolves to `epic/X` rather
|
|
33
|
+
* than failing closed on the unusable snake_case value. The strict server-side
|
|
34
|
+
* `RunPolicy` boundary already rejects a non-string `base_branch` before it can
|
|
35
|
+
* be stored, so this rule governs legacy and out-of-band rows only.
|
|
36
|
+
* - A MALFORMED or over-length declaration is still a DECLARATION. It is
|
|
37
|
+
* returned complete and trimmed, never truncated, rejected, or replaced with
|
|
38
|
+
* a default; format and length are validated at the point of operational use.
|
|
39
|
+
*
|
|
40
|
+
* **Why a leaf.** `epic-runtime.ts` already imports from `bridge-api-client.ts`,
|
|
41
|
+
* so putting the shared resolver in either one and importing it from the other
|
|
42
|
+
* would close a cycle. This module imports NOTHING — not the API client, not a
|
|
43
|
+
* command runner, not `base-ref.js`'s validator — so every consumer can depend on
|
|
44
|
+
* it freely. That is the same reasoning `api/models/run_branch.py:13-24` gives for
|
|
45
|
+
* its own placement.
|
|
46
|
+
*
|
|
47
|
+
* The cross-language contract is frozen as data in
|
|
48
|
+
* `tests/pytest/fixtures/run_branch_vectors.json` and enforced from both sides by
|
|
49
|
+
* `mcp_server/src/conductor/run-branch.test.ts` and
|
|
50
|
+
* `tests/pytest/models/test_run_branch_vectors.py`.
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* The policy keys consulted, IN ORDER. Both spellings are accepted because both
|
|
54
|
+
* have been written by real clients; `base_branch` is canonical and `baseBranch`
|
|
55
|
+
* is the legacy alias. Named once so the precedence is stated in exactly one
|
|
56
|
+
* place, mirroring `POLICY_BASE_BRANCH_KEYS` in `api/models/run_branch.py`.
|
|
57
|
+
*/
|
|
58
|
+
export const POLICY_BASE_BRANCH_KEYS = ["base_branch", "baseBranch"];
|
|
59
|
+
/**
|
|
60
|
+
* Return the trimmed string, or `undefined` for anything that is not a usable
|
|
61
|
+
* one.
|
|
62
|
+
*
|
|
63
|
+
* A blank or whitespace-only value is NOT a branch: accepting it would let a run
|
|
64
|
+
* created with `base_branch: " "` classify as a feature-branch run, which at
|
|
65
|
+
* the merge-admission boundary would hand it the ungated-auto-merge exception on
|
|
66
|
+
* the strength of whitespace. The Python twin is `run_branch._nonblank`.
|
|
67
|
+
*
|
|
68
|
+
* KNOWN GAP — the trim is proven equivalent only for ASCII whitespace
|
|
69
|
+
* (BAPI-1127 review). `String.prototype.trim()` and Python's `str.strip()` do
|
|
70
|
+
* not strip the same character class, so four inputs resolve differently and
|
|
71
|
+
* none is in the vector table:
|
|
72
|
+
*
|
|
73
|
+
* | declared value | Python `_nonblank` | this function |
|
|
74
|
+
* |---------------------|----------------------|----------------------|
|
|
75
|
+
* | `"\u001cepic/X"` | `"epic/X"` | `"\u001cepic/X"` |
|
|
76
|
+
* | `"\u0085"` | absent (blank) | `"\u0085"` |
|
|
77
|
+
* | `"\u0085epic/X"` | `"epic/X"` | `"\u0085epic/X"` |
|
|
78
|
+
* | `"\ufeffmain"` | `"\ufeffmain"` | `"main"` |
|
|
79
|
+
*
|
|
80
|
+
* `str.strip()` also strips U+001C-U+001F and U+0085; `trim()` also strips
|
|
81
|
+
* U+FEFF. Note the second row is the sharp one: U+0085 is above 0x7F, so
|
|
82
|
+
* `validateBranchName`'s control-character rule does NOT reject it either.
|
|
83
|
+
*
|
|
84
|
+
* Left as FOLLOW-UP rather than fixed here, deliberately. Picking the canonical
|
|
85
|
+
* class is a contract decision of the same kind BAPI-1127 froze for the
|
|
86
|
+
* non-string and malformed-name rows — and it was frozen in the TICKET, not by
|
|
87
|
+
* the implementer. Normalizing Python's class would additionally change
|
|
88
|
+
* feature-branch classification for a policy the `RunPolicy` boundary accepts
|
|
89
|
+
* today (`{"base_branch": "\u0085"}` classifies as no-feature-branch now), which
|
|
90
|
+
* BAPI-1127's acceptance criteria forbid. `RunPolicy` accepts any strict string,
|
|
91
|
+
* so these values are storable, but they are vanishingly unlikely in practice.
|
|
92
|
+
* Recorded alongside the `git check-ref-format` follow-up in `base-ref.ts`.
|
|
93
|
+
*/
|
|
94
|
+
function nonblank(candidate) {
|
|
95
|
+
if (typeof candidate !== "string")
|
|
96
|
+
return undefined;
|
|
97
|
+
const trimmed = candidate.trim();
|
|
98
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the branch a persisted run's `policy_json` declares, if any.
|
|
102
|
+
*
|
|
103
|
+
* Returns the first non-blank string found across {@link POLICY_BASE_BRANCH_KEYS},
|
|
104
|
+
* in that order, trimmed; or `undefined` when the policy declares none.
|
|
105
|
+
*
|
|
106
|
+
* The scan is SEQUENTIAL, not nullish-coalescing: a candidate that is not a
|
|
107
|
+
* non-blank string is skipped and the scan CONTINUES to the next key. `??` falls
|
|
108
|
+
* through only for `null`/`undefined`, which is why the pre-BAPI-1127 expression
|
|
109
|
+
* stopped at a present-but-blank `base_branch` and reported the dispatch default
|
|
110
|
+
* for a policy that plainly declared `baseBranch: "epic/X"`.
|
|
111
|
+
*
|
|
112
|
+
* A non-mapping policy (`null`, `undefined`, a primitive, an array) declares
|
|
113
|
+
* nothing. Shape is the typed boundary's job, not this resolver's — the Python
|
|
114
|
+
* twin makes the same call for the same reason.
|
|
115
|
+
*
|
|
116
|
+
* The returned value is never truncated and carries no operational length or
|
|
117
|
+
* format bound. See the module docstring for both frozen dispositions.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveDeclaredRunBaseBranch(policyJson) {
|
|
120
|
+
if (policyJson === null || typeof policyJson !== "object" || Array.isArray(policyJson)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const policy = policyJson;
|
|
124
|
+
for (const key of POLICY_BASE_BRANCH_KEYS) {
|
|
125
|
+
// OWN properties only. A Python dict has no prototype chain, so a plain
|
|
126
|
+
// `policy[key]` would not be the same read: it also sees `Object.prototype`,
|
|
127
|
+
// and a polluted prototype would make EVERY policy in the process appear to
|
|
128
|
+
// declare a branch. Real inputs come from `JSON.parse`, which never produces
|
|
129
|
+
// inherited keys, so this costs nothing and closes the gap.
|
|
130
|
+
if (!Object.prototype.hasOwnProperty.call(policy, key))
|
|
131
|
+
continue;
|
|
132
|
+
const resolved = nonblank(policy[key]);
|
|
133
|
+
if (resolved !== undefined)
|
|
134
|
+
return resolved;
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|