@bridge_gpt/mcp-server 0.2.33 → 0.2.36
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 +456 -340
- package/build/agent-capabilities/probe-context.js +8 -1
- package/build/agent-capabilities/probes.js +7 -1
- package/build/agents.generated.js +1 -1
- package/build/claude-review-workflow.js +264 -0
- package/build/cli-release.js +53 -0
- package/build/commands.generated.js +4 -4
- package/build/conductor/bridge-api-client.js +252 -3
- package/build/conductor/deny-enforcement-preflight.js +1 -0
- package/build/conductor/done-gate.js +44 -5
- package/build/conductor/epic-reconcile.js +6 -0
- package/build/conductor/install-doctor.js +462 -0
- package/build/conductor-bin.js +3 -3
- package/build/conductor-bundle-artifacts.js +30 -9
- package/build/doctor.js +234 -1
- package/build/executor/cli.js +32 -5
- package/build/executor/credentials.js +45 -11
- package/build/executor/deps.js +14 -0
- package/build/executor/env.js +23 -6
- package/build/executor/index.js +4 -0
- package/build/executor/job-runner.js +119 -9
- package/build/executor/permissions.js +12 -2
- package/build/executor/preflight.js +95 -8
- package/build/executor/prompt-spec.js +51 -0
- package/build/executor/runner.js +15 -2
- package/build/executor/service-unit.js +876 -0
- package/build/executor/test-clock.js +8 -0
- package/build/executor/types.js +0 -17
- package/build/executor/worker-command.js +62 -9
- package/build/index.js +575 -143
- package/build/init.js +153 -51
- package/build/install-bridge-conductor.js +491 -0
- package/build/install-bridge.js +884 -176
- package/build/install-reexec.js +233 -0
- package/build/mcp-host-config.js +11 -1
- package/build/mcp-install-state.js +32 -0
- package/build/mcp-provisioning.js +22 -6
- package/build/pipelines.generated.js +14 -8
- package/build/readme.generated.js +1 -1
- package/build/run-unit-tests-launcher.js +257 -0
- package/build/setup-epic.js +117 -8
- package/build/upgrade-cli.js +1 -15
- package/build/version.generated.js +1 -1
- package/docs/CONDUCTOR.md +115 -4
- package/docs/install/mcp-tool-integrations.md +29 -21
- package/package.json +9 -6
- package/pipelines/implement-ticket.json +6 -1
- package/build/conductor/supervisor-judgment-python.js +0 -141
- package/build/conductor/supervisor-judgment.js +0 -215
|
@@ -101,11 +101,38 @@ function boundedErrorPreview(text) {
|
|
|
101
101
|
? `${redacted.slice(0, CONDUCTOR_ERROR_PREVIEW_MAX)}…`
|
|
102
102
|
: redacted;
|
|
103
103
|
}
|
|
104
|
+
/** Max number of FastAPI `loc` components rendered into a diagnostic path. */
|
|
105
|
+
const CONDUCTOR_VALIDATION_LOC_MAX_PARTS = 8;
|
|
106
|
+
/**
|
|
107
|
+
* Render a FastAPI validation item (`{ loc, msg }`) as `path: message`.
|
|
108
|
+
*
|
|
109
|
+
* Returns `undefined` for any malformed item so the caller falls through to the
|
|
110
|
+
* existing generic handling rather than emitting `[object Object]`.
|
|
111
|
+
*/
|
|
112
|
+
function formatValidationDetailItem(item) {
|
|
113
|
+
if (!item || typeof item !== "object")
|
|
114
|
+
return undefined;
|
|
115
|
+
const record = item;
|
|
116
|
+
const msg = record["msg"];
|
|
117
|
+
if (typeof msg !== "string" || !msg.trim())
|
|
118
|
+
return undefined;
|
|
119
|
+
const loc = record["loc"];
|
|
120
|
+
const parts = Array.isArray(loc)
|
|
121
|
+
? loc.filter((part) => typeof part === "string" || typeof part === "number")
|
|
122
|
+
: [];
|
|
123
|
+
// Drop FastAPI's leading transport-only "body" component — the operator cares
|
|
124
|
+
// about `policy_json.job_timeout`, not where in the request it lived.
|
|
125
|
+
const scoped = parts[0] === "body" ? parts.slice(1) : parts;
|
|
126
|
+
const path = scoped.slice(0, CONDUCTOR_VALIDATION_LOC_MAX_PARTS).join(".");
|
|
127
|
+
return path ? `${path}: ${msg}` : msg;
|
|
128
|
+
}
|
|
104
129
|
/**
|
|
105
130
|
* Extract sanitized diagnostics from a parsed backend error body. Recognizes the
|
|
106
131
|
* FastAPI/backend shapes `{ detail: { error_code, message } }`,
|
|
107
|
-
* `{ detail: "..." }`,
|
|
108
|
-
*
|
|
132
|
+
* `{ detail: "..." }`, `{ detail: [{ loc, msg, ... }] }` (FastAPI's native 422
|
|
133
|
+
* request-validation shape), and `{ error_code, message }`; any other shape
|
|
134
|
+
* yields a bounded stringified preview. Never throws and never returns
|
|
135
|
+
* raw/unbounded text.
|
|
109
136
|
*/
|
|
110
137
|
export function extractSanitizedErrorDiagnostics(body) {
|
|
111
138
|
if (typeof body === "string") {
|
|
@@ -119,7 +146,14 @@ export function extractSanitizedErrorDiagnostics(body) {
|
|
|
119
146
|
const detail = record["detail"];
|
|
120
147
|
let errorCode;
|
|
121
148
|
let message;
|
|
122
|
-
if (detail
|
|
149
|
+
if (Array.isArray(detail)) {
|
|
150
|
+
// FastAPI request-validation 422. Checked BEFORE the generic object branch
|
|
151
|
+
// because arrays are objects: without this the branch below would find no
|
|
152
|
+
// error_code/message and the operator would get an empty diagnostic for the
|
|
153
|
+
// one error class that names exactly which field they got wrong.
|
|
154
|
+
message = formatValidationDetailItem(detail[0]);
|
|
155
|
+
}
|
|
156
|
+
else if (detail && typeof detail === "object") {
|
|
123
157
|
const d = detail;
|
|
124
158
|
if (typeof d["error_code"] === "string")
|
|
125
159
|
errorCode = d["error_code"];
|
|
@@ -951,6 +985,15 @@ export async function reconcileShadowMerge(access, request, fetchImpl = globalTh
|
|
|
951
985
|
* authoritative commit-watermark verdict for one ready ticket. The caller treats
|
|
952
986
|
* `covered`/`not_applicable` as dispatchable, `stale` as hold, and `failed` as a
|
|
953
987
|
* terminal shadow failure (the backend has already persisted the blocked state).
|
|
988
|
+
*
|
|
989
|
+
* BAPI-678: `stale` now covers two backend situations, and deliberately does not
|
|
990
|
+
* distinguish them on the wire — ordinary unproven freshness, and a `failed`
|
|
991
|
+
* shadow lifecycle DOWNGRADED by the backend's `policy_json.shadow.warn_only`
|
|
992
|
+
* posture. Both mean exactly the same thing to this caller (hold, do not claim a
|
|
993
|
+
* dispatch key), which is why no new verdict was introduced. The backend also
|
|
994
|
+
* bounds the warn-only hold with its own stale deadline and parks the ticket if
|
|
995
|
+
* it expires, so a hold here is not unbounded even though nothing on this side
|
|
996
|
+
* tracks its age. See {@link ShadowFreshnessVerdict}.
|
|
954
997
|
*/
|
|
955
998
|
export async function fetchShadowDispatchFreshness(access, request, fetchImpl = globalThis.fetch) {
|
|
956
999
|
requireNonEmptyString(request.epicKey);
|
|
@@ -985,6 +1028,38 @@ export async function storeEpicPlan(access, request, fetchImpl = globalThis.fetc
|
|
|
985
1028
|
});
|
|
986
1029
|
return fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
987
1030
|
}
|
|
1031
|
+
/**
|
|
1032
|
+
* PATCH one approved plan node's `ticket_spec` without re-storing or re-approving
|
|
1033
|
+
* the plan (BAPI-722).
|
|
1034
|
+
*
|
|
1035
|
+
* This is how a ticket specification too large for Jira's 32,767-character
|
|
1036
|
+
* description cap reaches the reviewer intact: it is stored on the approved plan
|
|
1037
|
+
* node, which the reconciler already consults FIRST when resolving a spec-review
|
|
1038
|
+
* prompt. Nothing else about the run changes — not the plan version, not the
|
|
1039
|
+
* approval pointers, and not a single `epic_ticket_status` row — which is what
|
|
1040
|
+
* makes it safe against a live epic.
|
|
1041
|
+
*
|
|
1042
|
+
* `expectedPlanHash` is the optimistic-concurrency token; a stale token on a real
|
|
1043
|
+
* content change returns HTTP 409. The API key travels ONLY in the `X-API-Key`
|
|
1044
|
+
* header, and neither the spec text nor any credential is ever incorporated into a
|
|
1045
|
+
* thrown error message. Errors surface as a sanitized
|
|
1046
|
+
* {@link ConductorBridgeApiError}.
|
|
1047
|
+
*/
|
|
1048
|
+
export async function updateApprovedPlanNodeTicketSpec(access, request, fetchImpl = globalThis.fetch) {
|
|
1049
|
+
requireNonEmptyString(request.epicKey);
|
|
1050
|
+
requireNonEmptyString(request.ticketKey);
|
|
1051
|
+
requireNonEmptyString(request.expectedPlanHash);
|
|
1052
|
+
requireNonEmptyString(request.ticketSpec);
|
|
1053
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${epicRunApiPath(request.epicKey)}/plan/nodes/` +
|
|
1054
|
+
`${encodeURIComponent(request.ticketKey)}/ticket-spec`);
|
|
1055
|
+
const body = JSON.stringify({
|
|
1056
|
+
repo_name: access.repoName,
|
|
1057
|
+
expected_plan_hash: request.expectedPlanHash,
|
|
1058
|
+
ticket_spec: request.ticketSpec,
|
|
1059
|
+
});
|
|
1060
|
+
const parsed = await fetchConductorJsonPatchWithTimeout(url, conductorPostHeaders(access), body, CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1061
|
+
return parsed;
|
|
1062
|
+
}
|
|
988
1063
|
/**
|
|
989
1064
|
* Atomically approve a plan version. Bumps `epic_runs.approved_plan_hash` to
|
|
990
1065
|
* the named version's `plan_hash` in a single CAS write — never a
|
|
@@ -1169,3 +1244,177 @@ export async function transitionJiraStatus(access, ticketNumber, targetStatus =
|
|
|
1169
1244
|
throw err;
|
|
1170
1245
|
}
|
|
1171
1246
|
}
|
|
1247
|
+
const READINESS_SOURCES = new Set(["epic", "project_default", "none"]);
|
|
1248
|
+
const ACTIONS_LEVELS = new Set(["write", "read", "none", "unknown"]);
|
|
1249
|
+
function readinessShapeError() {
|
|
1250
|
+
// Deliberately carries NO field name, value, or payload excerpt: a malformed
|
|
1251
|
+
// body is untrusted input and its contents must not reach output.
|
|
1252
|
+
return new ConductorBridgeApiError("invalid-input", undefined, {
|
|
1253
|
+
errorCode: "READINESS_SHAPE_INVALID",
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
function requireObject(value) {
|
|
1257
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
1258
|
+
throw readinessShapeError();
|
|
1259
|
+
}
|
|
1260
|
+
return value;
|
|
1261
|
+
}
|
|
1262
|
+
function requireBool(o, key) {
|
|
1263
|
+
const v = o[key];
|
|
1264
|
+
if (typeof v !== "boolean")
|
|
1265
|
+
throw readinessShapeError();
|
|
1266
|
+
return v;
|
|
1267
|
+
}
|
|
1268
|
+
function requireInt(o, key) {
|
|
1269
|
+
const v = o[key];
|
|
1270
|
+
if (typeof v !== "number" || !Number.isInteger(v))
|
|
1271
|
+
throw readinessShapeError();
|
|
1272
|
+
return v;
|
|
1273
|
+
}
|
|
1274
|
+
function requireNullableInt(o, key) {
|
|
1275
|
+
const v = o[key];
|
|
1276
|
+
if (v === null || v === undefined)
|
|
1277
|
+
return null;
|
|
1278
|
+
if (typeof v !== "number" || !Number.isInteger(v))
|
|
1279
|
+
throw readinessShapeError();
|
|
1280
|
+
return v;
|
|
1281
|
+
}
|
|
1282
|
+
function requireNullableString(o, key) {
|
|
1283
|
+
const v = o[key];
|
|
1284
|
+
if (v === null || v === undefined)
|
|
1285
|
+
return null;
|
|
1286
|
+
if (typeof v !== "string")
|
|
1287
|
+
throw readinessShapeError();
|
|
1288
|
+
return v;
|
|
1289
|
+
}
|
|
1290
|
+
function requireNullableBool(o, key) {
|
|
1291
|
+
const v = o[key];
|
|
1292
|
+
if (v === null || v === undefined)
|
|
1293
|
+
return null;
|
|
1294
|
+
if (typeof v !== "boolean")
|
|
1295
|
+
throw readinessShapeError();
|
|
1296
|
+
return v;
|
|
1297
|
+
}
|
|
1298
|
+
function requireEnum(o, key, allowed) {
|
|
1299
|
+
const v = o[key];
|
|
1300
|
+
if (typeof v !== "string" || !allowed.has(v))
|
|
1301
|
+
throw readinessShapeError();
|
|
1302
|
+
return v;
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Validate a raw readiness body into the typed response, FAIL-CLOSED.
|
|
1306
|
+
*
|
|
1307
|
+
* Every field the composed doctor makes a safety decision on is checked for
|
|
1308
|
+
* presence and exact type. This is not defensive nicety: a 200 whose body is
|
|
1309
|
+
* missing `required_checks_empty`, or carries it as the string `"false"`, would
|
|
1310
|
+
* otherwise be cast straight into an apparently-healthy report and the operator
|
|
1311
|
+
* would be told a trivially-passing gate was fine.
|
|
1312
|
+
*/
|
|
1313
|
+
export function parseConductorReadinessResponse(body) {
|
|
1314
|
+
const root = requireObject(body);
|
|
1315
|
+
const repoName = root.repo_name;
|
|
1316
|
+
if (typeof repoName !== "string" || repoName.length === 0)
|
|
1317
|
+
throw readinessShapeError();
|
|
1318
|
+
const sup = requireObject(root.supervisor);
|
|
1319
|
+
const gh = requireObject(root.github);
|
|
1320
|
+
const rec = requireObject(root.reconciler);
|
|
1321
|
+
const exec = requireObject(root.executor);
|
|
1322
|
+
const thr = requireObject(root.thresholds);
|
|
1323
|
+
return {
|
|
1324
|
+
repo_name: repoName,
|
|
1325
|
+
supervisor: {
|
|
1326
|
+
setup_present: requireBool(sup, "setup_present"),
|
|
1327
|
+
setup_source: requireEnum(sup, "setup_source", READINESS_SOURCES),
|
|
1328
|
+
setup_created_at: requireNullableString(sup, "setup_created_at"),
|
|
1329
|
+
setup_updated_at: requireNullableString(sup, "setup_updated_at"),
|
|
1330
|
+
config_present: requireBool(sup, "config_present"),
|
|
1331
|
+
config_source: requireEnum(sup, "config_source", READINESS_SOURCES),
|
|
1332
|
+
config_created_at: requireNullableString(sup, "config_created_at"),
|
|
1333
|
+
config_updated_at: requireNullableString(sup, "config_updated_at"),
|
|
1334
|
+
required_checks_count: requireInt(sup, "required_checks_count"),
|
|
1335
|
+
required_checks_empty: requireBool(sup, "required_checks_empty"),
|
|
1336
|
+
auto_merge_enabled: requireBool(sup, "auto_merge_enabled"),
|
|
1337
|
+
merge_approval_required_set: requireBool(sup, "merge_approval_required_set"),
|
|
1338
|
+
},
|
|
1339
|
+
github: {
|
|
1340
|
+
credentials_readable: requireBool(gh, "credentials_readable"),
|
|
1341
|
+
owner_resolved: requireBool(gh, "owner_resolved"),
|
|
1342
|
+
repo_id_resolved: requireBool(gh, "repo_id_resolved"),
|
|
1343
|
+
installation_id_resolved: requireBool(gh, "installation_id_resolved"),
|
|
1344
|
+
credentials_complete: requireBool(gh, "credentials_complete"),
|
|
1345
|
+
actions_probe_succeeded: requireBool(gh, "actions_probe_succeeded"),
|
|
1346
|
+
actions_permission_present: requireBool(gh, "actions_permission_present"),
|
|
1347
|
+
actions_permission_level: requireEnum(gh, "actions_permission_level", ACTIONS_LEVELS),
|
|
1348
|
+
actions_write: requireBool(gh, "actions_write"),
|
|
1349
|
+
},
|
|
1350
|
+
reconciler: {
|
|
1351
|
+
liveness_readable: requireBool(rec, "liveness_readable"),
|
|
1352
|
+
last_tick_at: requireNullableString(rec, "last_tick_at"),
|
|
1353
|
+
last_tick_age_seconds: requireNullableInt(rec, "last_tick_age_seconds"),
|
|
1354
|
+
stale: requireBool(rec, "stale"),
|
|
1355
|
+
active_run_count: requireInt(rec, "active_run_count"),
|
|
1356
|
+
expired_lease_count: requireInt(rec, "expired_lease_count"),
|
|
1357
|
+
},
|
|
1358
|
+
executor: {
|
|
1359
|
+
liveness_readable: requireBool(exec, "liveness_readable"),
|
|
1360
|
+
last_seen_at: requireNullableString(exec, "last_seen_at"),
|
|
1361
|
+
last_seen_age_seconds: requireNullableInt(exec, "last_seen_age_seconds"),
|
|
1362
|
+
ready: requireNullableBool(exec, "ready"),
|
|
1363
|
+
},
|
|
1364
|
+
thresholds: {
|
|
1365
|
+
reconciler_stale_after_seconds: requireInt(thr, "reconciler_stale_after_seconds"),
|
|
1366
|
+
executor_stale_after_seconds: requireInt(thr, "executor_stale_after_seconds"),
|
|
1367
|
+
},
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* GET `/jira/epic-runs/conductor-readiness?repo_name=<repo>`.
|
|
1372
|
+
*
|
|
1373
|
+
* The API key travels ONLY in `X-API-Key`; `repo_name` is URL-encoded into the
|
|
1374
|
+
* query string because the endpoint scopes on it. Fails closed on transport,
|
|
1375
|
+
* auth, and shape errors alike.
|
|
1376
|
+
*/
|
|
1377
|
+
export async function fetchConductorReadiness(access, fetchImpl = globalThis.fetch) {
|
|
1378
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/conductor-readiness`, {
|
|
1379
|
+
repo_name: access.repoName,
|
|
1380
|
+
});
|
|
1381
|
+
const body = await fetchConductorJsonWithTimeout(url, conductorGetHeaders(access), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1382
|
+
return parseConductorReadinessResponse(body);
|
|
1383
|
+
}
|
|
1384
|
+
/** Validate a raw bootstrap body, fail-closed (see {@link parseConductorReadinessResponse}). */
|
|
1385
|
+
export function parseConductorSupervisorBootstrapResponse(body) {
|
|
1386
|
+
const o = requireObject(body);
|
|
1387
|
+
const repoName = o.repo_name;
|
|
1388
|
+
if (typeof repoName !== "string" || repoName.length === 0)
|
|
1389
|
+
throw readinessShapeError();
|
|
1390
|
+
const names = o.audited_field_names;
|
|
1391
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
|
|
1392
|
+
throw readinessShapeError();
|
|
1393
|
+
}
|
|
1394
|
+
return {
|
|
1395
|
+
repo_name: repoName,
|
|
1396
|
+
setup_written: requireBool(o, "setup_written"),
|
|
1397
|
+
config_written: requireBool(o, "config_written"),
|
|
1398
|
+
setup_source: requireEnum(o, "setup_source", READINESS_SOURCES),
|
|
1399
|
+
config_source: requireEnum(o, "config_source", READINESS_SOURCES),
|
|
1400
|
+
setup_created_at: requireNullableString(o, "setup_created_at"),
|
|
1401
|
+
setup_updated_at: requireNullableString(o, "setup_updated_at"),
|
|
1402
|
+
config_created_at: requireNullableString(o, "config_created_at"),
|
|
1403
|
+
config_updated_at: requireNullableString(o, "config_updated_at"),
|
|
1404
|
+
required_checks_count: requireInt(o, "required_checks_count"),
|
|
1405
|
+
audited_field_names: names,
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* POST `/jira/epic-runs/supervisor-bootstrap?repo_name=<repo>`.
|
|
1410
|
+
*
|
|
1411
|
+
* The ONLY write this client performs. Deliberately carries no `review_policy`
|
|
1412
|
+
* field: repository-default review policy is owned by BAPI-694.
|
|
1413
|
+
*/
|
|
1414
|
+
export async function bootstrapConductorSupervisorDefaults(access, request, fetchImpl) {
|
|
1415
|
+
const url = buildConductorJiraUrl(access.baseUrl, `${EPIC_RUNS_API_PREFIX}/supervisor-bootstrap`, {
|
|
1416
|
+
repo_name: access.repoName,
|
|
1417
|
+
});
|
|
1418
|
+
const body = await fetchConductorJsonPostWithTimeout(url, conductorPostHeaders(access), JSON.stringify(request), CONDUCTOR_FETCH_TIMEOUT_MS, fetchImpl);
|
|
1419
|
+
return parseConductorSupervisorBootstrapResponse(body);
|
|
1420
|
+
}
|
|
@@ -47,6 +47,7 @@ export async function runDenyEnforcementPreflight(opts = {}) {
|
|
|
47
47
|
const { result, layer } = await runDenyEnforcementCheck(ctx, {
|
|
48
48
|
model: opts.model,
|
|
49
49
|
timeoutMs: opts.timeoutMs,
|
|
50
|
+
permissionPosture: opts.permissionPosture,
|
|
50
51
|
});
|
|
51
52
|
if (result.status === "pass" && layer === "settings-deny") {
|
|
52
53
|
return {
|
|
@@ -83,15 +83,49 @@ function parseCiChecksCondition(entry) {
|
|
|
83
83
|
}
|
|
84
84
|
return { type: REQUIRED_CI_CHECKS_GREEN, required_checks: normalized };
|
|
85
85
|
}
|
|
86
|
-
/** Valid review source values. */
|
|
87
|
-
const VALID_REVIEW_SOURCES = new Set(["
|
|
86
|
+
/** Valid — i.e. CANONICAL — review source values (BAPI-689). */
|
|
87
|
+
const VALID_REVIEW_SOURCES = new Set(["verdict_protocol", "native_review_decision", "min_approvals", "combination"]);
|
|
88
|
+
/**
|
|
89
|
+
* Historical review-source spellings accepted on INPUT only.
|
|
90
|
+
*
|
|
91
|
+
* BAPI-689 unified three vocabularies onto one, but `done_gate_config` is
|
|
92
|
+
* free-form operator-authored JSONB that this module only ever reads — there is
|
|
93
|
+
* no migration that rewrites it. So already-stored configs keep naming the
|
|
94
|
+
* sticky-verdict source `sticky_verdict` (this module's own former spelling) or
|
|
95
|
+
* `claude_review_sticky` / `github_review_decision` (the former supervisor
|
|
96
|
+
* spellings), and they must stay readable.
|
|
97
|
+
*
|
|
98
|
+
* Mapping is applied at the parser seam and the CANONICAL value is what the
|
|
99
|
+
* parsed condition carries, so no alias survives into the config hash, the gate
|
|
100
|
+
* evaluator, or any serialized output.
|
|
101
|
+
*/
|
|
102
|
+
const REVIEW_SOURCE_ALIASES = Object.freeze({
|
|
103
|
+
sticky_verdict: "verdict_protocol",
|
|
104
|
+
claude_review_sticky: "verdict_protocol",
|
|
105
|
+
github_review_decision: "native_review_decision",
|
|
106
|
+
});
|
|
107
|
+
/**
|
|
108
|
+
* Canonicalize a raw stored review-source value.
|
|
109
|
+
*
|
|
110
|
+
* Returns the canonical spelling for a known alias, the value unchanged when it
|
|
111
|
+
* is already canonical, and the value unchanged when it is neither — so an
|
|
112
|
+
* unsupported spelling still reaches (and fails) `VALID_REVIEW_SOURCES` rather
|
|
113
|
+
* than being coerced into a default. Exact-match only: no case folding and no
|
|
114
|
+
* trimming, matching the strictness of every other field in this parser.
|
|
115
|
+
*/
|
|
116
|
+
function normalizeReviewSource(source) {
|
|
117
|
+
return REVIEW_SOURCE_ALIASES[source] ?? source;
|
|
118
|
+
}
|
|
88
119
|
/**
|
|
89
120
|
* Parse and validate a single `review_state` condition entry.
|
|
90
121
|
* Returns the normalized condition or `null` on any validation failure.
|
|
91
122
|
*/
|
|
92
123
|
function parseReviewStateCondition(entry) {
|
|
93
|
-
const
|
|
94
|
-
if (typeof
|
|
124
|
+
const rawSource = entry.source;
|
|
125
|
+
if (typeof rawSource !== "string")
|
|
126
|
+
return null;
|
|
127
|
+
const source = normalizeReviewSource(rawSource);
|
|
128
|
+
if (!VALID_REVIEW_SOURCES.has(source))
|
|
95
129
|
return null;
|
|
96
130
|
const condition = { type: REVIEW_STATE, source: source };
|
|
97
131
|
if (entry.require_sticky_verdict !== undefined) {
|
|
@@ -425,7 +459,12 @@ export function evaluateReviewCondition(condition, snapshot) {
|
|
|
425
459
|
return { passed: false, changesRequested: false, reason: "review snapshot unavailable" };
|
|
426
460
|
}
|
|
427
461
|
const source = condition.source;
|
|
428
|
-
|
|
462
|
+
// BAPI-689: the condition's `source` is always CANONICAL here — every path to a
|
|
463
|
+
// `ReviewStateCondition` goes through `parseReviewStateCondition`, which maps
|
|
464
|
+
// the historical spellings before validating. `snapshot.sticky_verdict` is a
|
|
465
|
+
// different thing entirely (the parsed value OF the sticky comment) and keeps
|
|
466
|
+
// its name.
|
|
467
|
+
if (source === "verdict_protocol") {
|
|
429
468
|
if (snapshot.sticky_verdict === "approved")
|
|
430
469
|
return { passed: true, changesRequested: false, reason: "sticky verdict approved" };
|
|
431
470
|
if (snapshot.sticky_verdict === "changes_requested")
|
|
@@ -171,6 +171,12 @@ export async function reconcileEpic(access, observed, plan, deps, supervisorConf
|
|
|
171
171
|
deps.log(`[epic-reconcile] shadow-freshness error for ${ticketKey}: ${safeDiagnosticMessage(err, "freshness error")}; holding`);
|
|
172
172
|
}
|
|
173
173
|
if (verdict === "stale") {
|
|
174
|
+
// BAPI-678: this covers ordinary unproven freshness AND the backend's
|
|
175
|
+
// warn-only downgrade of a `failed` shadow lifecycle. Identical handling
|
|
176
|
+
// is intentional — either way the watermark is unproven, so hold before
|
|
177
|
+
// claiming a dispatch key. The backend clocks the hold and parks the
|
|
178
|
+
// ticket if its own stale deadline expires, so "hold" is bounded there
|
|
179
|
+
// rather than repeating forever here.
|
|
174
180
|
deps.log(`[epic-reconcile] holding ${ticketKey}: shadow watermark stale`);
|
|
175
181
|
continue;
|
|
176
182
|
}
|