@bridge_gpt/mcp-server 0.2.9 → 0.2.12
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 +59 -7
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +263 -35
- package/build/conductor/cli.js +38 -17
- package/build/conductor/doctor.js +35 -2
- package/build/conductor/done-gate.js +301 -58
- package/build/conductor/epic-reconcile.js +318 -4
- package/build/conductor/epic-runtime.js +382 -18
- package/build/conductor/epic-state.js +188 -15
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +16 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +118 -19
- package/build/conductor/pr-review-producer.js +116 -0
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +105 -26
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +32 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +7 -7
- package/build/conductor-bin.js +12350 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +200 -0
- package/build/index.js +23696 -4351
- package/build/init.js +481 -0
- package/build/install-bridge.js +772 -0
- package/build/mcp-profile.js +43 -0
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/start-tickets-conductor.js +1 -0
- package/build/start-tickets.js +186 -10
- package/build/upgrade-cli.js +154 -0
- package/build/version.generated.js +1 -1
- package/package.json +7 -4
- package/pipelines/check-ci-ticket.json +2 -2
- package/pipelines/implement-ticket.json +2 -2
- package/pipelines/learn-repository.json +84 -42
- package/smoke-test/SMOKE-TEST.md +11 -17
|
@@ -1,44 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure, deterministic observed-state rebuild + ready-set computation for the
|
|
3
|
-
* Epic Supervisor (BAPI-408).
|
|
3
|
+
* Epic Supervisor (BAPI-408, BAPI-436).
|
|
4
4
|
*
|
|
5
5
|
* This module has NO I/O, NO timers, and NO LLM calls. Every time-dependent
|
|
6
6
|
* function takes an explicit `now` (epoch ms) so tests are wall-clock
|
|
7
7
|
* independent. Truth precedence: raw local ledger events override non-terminal
|
|
8
8
|
* Postgres states.
|
|
9
|
+
*
|
|
10
|
+
* Status mapping (BAPI-436 — merge-gated dependent dispatch):
|
|
11
|
+
* gate.met → ready_for_review (implementation done; awaiting merge)
|
|
12
|
+
* run.stopped → ready_for_review (worker session ended; awaiting merge)
|
|
13
|
+
* merge.succeeded → done (merged; dependents may now dispatch)
|
|
14
|
+
* ci.failed → blocked (unchanged)
|
|
9
15
|
*/
|
|
10
16
|
// ---------------------------------------------------------------------------
|
|
11
17
|
// Status sets (module-private)
|
|
12
18
|
// ---------------------------------------------------------------------------
|
|
13
19
|
const NOT_STARTED_STATUS = "planned";
|
|
14
20
|
const DONE_STATUSES = new Set(["done"]);
|
|
21
|
+
// "blocked" is intentionally non-terminal: a merge.succeeded arriving in a
|
|
22
|
+
// subsequent tick (cross-tick) can still advance a blocked ticket to done.
|
|
23
|
+
// This is the expected path when an operator merges a PR despite failed CI.
|
|
24
|
+
// Contrast with the same-tick case: ci.failed wins over merge.succeeded in
|
|
25
|
+
// the same ledger batch (first-signal-wins; see the foldedTicketKeys guard).
|
|
15
26
|
const NON_TERMINAL_STATUSES = new Set([
|
|
16
27
|
"planned",
|
|
17
28
|
"ready",
|
|
18
29
|
"dispatched",
|
|
19
30
|
"running",
|
|
20
31
|
"blocked",
|
|
32
|
+
"ready_for_review",
|
|
33
|
+
// BAPI-445: mid-flight pre-implementation spec re-review. Non-terminal so a
|
|
34
|
+
// review verdict / completion signal can still advance it on a later tick, but
|
|
35
|
+
// deliberately NOT a status computeReadySet returns for normal dispatch — a
|
|
36
|
+
// reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
|
|
37
|
+
// re-driven by reconcile's dedicated reviewing re-entry branch.
|
|
38
|
+
"reviewing",
|
|
21
39
|
]);
|
|
22
40
|
const TERMINAL_SIGNAL_TYPES = new Set([
|
|
23
41
|
"gate.met",
|
|
24
42
|
"merge.succeeded",
|
|
25
43
|
"ci.failed",
|
|
26
44
|
"run.stopped",
|
|
45
|
+
"review.changes_requested",
|
|
46
|
+
// BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
|
|
47
|
+
"spec_review.passed",
|
|
48
|
+
"spec_review.changes_requested",
|
|
27
49
|
]);
|
|
28
50
|
function isNonTerminal(status) {
|
|
29
51
|
return NON_TERMINAL_STATUSES.has(status);
|
|
30
52
|
}
|
|
31
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Map a terminal ledger signal to the next ticket status.
|
|
55
|
+
*
|
|
56
|
+
* `isReviewRun` marks signals correlated to a `:review`-suffixed dispatch
|
|
57
|
+
* (BAPI-445 Obstacle 2). The spec re-review verdicts fold explicitly:
|
|
58
|
+
* - `spec_review.passed` → `ready` (verdict: proceed to implementation)
|
|
59
|
+
* - `spec_review.changes_requested` → `blocked` (verdict: reject; reconcile escalates)
|
|
60
|
+
*
|
|
61
|
+
* FAIL-SAFE (BAPI-445): a review run's INCIDENTAL terminals (`run.stopped`,
|
|
62
|
+
* `gate.met`, …) are filtered out of folding entirely by `rebuildObservedState`
|
|
63
|
+
* BEFORE this function is called — a review session merely *ending* must never
|
|
64
|
+
* advance a `reviewing` ticket, or implementation would proceed regardless of
|
|
65
|
+
* the review's outcome (fail-open). So for a review run this is only ever called
|
|
66
|
+
* with a `spec_review.*` verdict; `isReviewRun` is retained for caller clarity.
|
|
67
|
+
* For implementation runs the BAPI-436 mapping is unchanged.
|
|
68
|
+
*/
|
|
69
|
+
export function signalToNextStatus(signalType, isReviewRun = false) {
|
|
70
|
+
void isReviewRun; // review-run incidental terminals are filtered before this call
|
|
71
|
+
if (signalType === "spec_review.passed")
|
|
72
|
+
return "ready";
|
|
73
|
+
if (signalType === "spec_review.changes_requested")
|
|
74
|
+
return "blocked";
|
|
32
75
|
if (signalType === "ci.failed")
|
|
33
76
|
return "blocked";
|
|
34
|
-
|
|
77
|
+
if (signalType === "review.changes_requested")
|
|
78
|
+
return "blocked";
|
|
79
|
+
if (signalType === "merge.succeeded")
|
|
80
|
+
return "done";
|
|
81
|
+
return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
|
|
35
82
|
}
|
|
36
83
|
// ---------------------------------------------------------------------------
|
|
37
84
|
// computeReadySet
|
|
38
85
|
// ---------------------------------------------------------------------------
|
|
39
86
|
/**
|
|
40
87
|
* Pure deterministic ready-set computation. Returns ticket keys that:
|
|
41
|
-
* 1. Have status "planned" (not yet started)
|
|
88
|
+
* 1. Have status "planned" (not yet started) or "ready" (crash-recovery —
|
|
89
|
+
* a ticket already advanced to ready on a prior tick that crashed before
|
|
90
|
+
* dispatch must not be silently dropped), AND
|
|
42
91
|
* 2. Whose full `depends_on` list is satisfied (all deps have "done" status).
|
|
43
92
|
*
|
|
44
93
|
* Never calls an LLM or performs I/O. Goal 8 invariant.
|
|
@@ -47,7 +96,7 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
47
96
|
const ready = [];
|
|
48
97
|
for (const ticket of plan.tickets) {
|
|
49
98
|
const currentStatus = ticketStatuses.get(ticket.ticket_key) ?? "planned";
|
|
50
|
-
if (currentStatus !== NOT_STARTED_STATUS)
|
|
99
|
+
if (currentStatus !== NOT_STARTED_STATUS && currentStatus !== "ready")
|
|
51
100
|
continue;
|
|
52
101
|
const allDepsResolved = ticket.depends_on.every((dep) => DONE_STATUSES.has(ticketStatuses.get(dep) ?? "planned"));
|
|
53
102
|
if (allDepsResolved)
|
|
@@ -55,6 +104,53 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
55
104
|
}
|
|
56
105
|
return ready;
|
|
57
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Default ceiling for spec re-review dispatch attempts on a stranded `reviewing`
|
|
109
|
+
* ticket (BAPI-445). Counts the initial review dispatch plus liveness-recovery
|
|
110
|
+
* re-dispatches; once reached, the reviewing recovery branch escalates instead
|
|
111
|
+
* of re-dispatching. Deliberately independent of the BAPI-441 remediation budget.
|
|
112
|
+
*/
|
|
113
|
+
export const DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS = 3;
|
|
114
|
+
/**
|
|
115
|
+
* Pure, deterministic remediation decision (no I/O, no clock). Given the current
|
|
116
|
+
* budget counters, the ledger-derived worker liveness, and the configured
|
|
117
|
+
* ceilings, decide whether to NUDGE (worker still alive), RE-DISPATCH (worker
|
|
118
|
+
* gone, budget remaining), or ESCALATE (budget exhausted).
|
|
119
|
+
*
|
|
120
|
+
* Budget exhaustion is checked FIRST so an at-ceiling ticket always escalates
|
|
121
|
+
* regardless of liveness. A counter at-or-above its ceiling exhausts the budget.
|
|
122
|
+
*/
|
|
123
|
+
export function decideRemediation(attempts, noProgressAttempts, alive, config) {
|
|
124
|
+
if (attempts >= config.max_remediation_attempts ||
|
|
125
|
+
noProgressAttempts >= config.max_remediation_no_progress_attempts) {
|
|
126
|
+
return "escalate";
|
|
127
|
+
}
|
|
128
|
+
return alive ? "nudge" : "redispatch";
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Pure liveness extraction (no I/O, explicit `nowMs`). Finds the most recent
|
|
132
|
+
* `message.delivered`/`message.acked` heartbeat event for `runId` and reports
|
|
133
|
+
* the worker alive when that heartbeat's age is within
|
|
134
|
+
* `windowSeconds`. An empty ledger (no heartbeat for the run) defaults to
|
|
135
|
+
* `{ alive: false, workerId: null }` (fail-closed: never misjudge a worker
|
|
136
|
+
* alive without evidence).
|
|
137
|
+
*/
|
|
138
|
+
export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
|
|
139
|
+
let latest = null;
|
|
140
|
+
for (const ev of events) {
|
|
141
|
+
if (ev.run_id !== runId)
|
|
142
|
+
continue;
|
|
143
|
+
if (ev.type !== "message.delivered" && ev.type !== "message.acked")
|
|
144
|
+
continue;
|
|
145
|
+
if (!latest || new Date(ev.time).getTime() > new Date(latest.time).getTime()) {
|
|
146
|
+
latest = ev;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!latest)
|
|
150
|
+
return { alive: false, workerId: null };
|
|
151
|
+
const age = nowMs - new Date(latest.time).getTime();
|
|
152
|
+
return { alive: age <= windowSeconds * 1000, workerId: latest.worker_id ?? null };
|
|
153
|
+
}
|
|
58
154
|
// ---------------------------------------------------------------------------
|
|
59
155
|
// rebuildObservedState
|
|
60
156
|
// ---------------------------------------------------------------------------
|
|
@@ -65,42 +161,117 @@ export function computeReadySet(plan, ticketStatuses) {
|
|
|
65
161
|
*/
|
|
66
162
|
export function rebuildObservedState(postgresState, events, _now) {
|
|
67
163
|
const { epic_run, ticket_statuses, dispatches } = postgresState;
|
|
68
|
-
// Build run_id → ticket_key lookup from dispatch records
|
|
164
|
+
// Build run_id → {ticket_key, isReview} lookup from dispatch records.
|
|
165
|
+
// BAPI-445 (Obstacle 2): a review run's dispatch_key carries a ":review"
|
|
166
|
+
// suffix, so the same run_id map can distinguish a pre-implementation
|
|
167
|
+
// spec-review run from the implementation run. This is correctness-critical:
|
|
168
|
+
// it lets the fold below route a review run's run.stopped to "ready" (review
|
|
169
|
+
// complete → proceed) instead of mis-folding it as implementation completion
|
|
170
|
+
// ("ready_for_review", awaiting merge), which would skip implementation.
|
|
69
171
|
const runIdToTicketKey = new Map();
|
|
70
172
|
for (const dispatch of dispatches) {
|
|
71
173
|
if (dispatch.run_id) {
|
|
72
|
-
runIdToTicketKey.set(dispatch.run_id,
|
|
174
|
+
runIdToTicketKey.set(dispatch.run_id, {
|
|
175
|
+
ticketKey: dispatch.ticket_key,
|
|
176
|
+
isReview: dispatch.dispatch_key.endsWith(":review"),
|
|
177
|
+
});
|
|
73
178
|
}
|
|
74
179
|
}
|
|
75
180
|
// Populate base maps from Postgres
|
|
76
181
|
const ticketStatusMap = new Map();
|
|
77
182
|
const ticketRowVersionMap = new Map();
|
|
183
|
+
const ticketRemediationMap = new Map();
|
|
78
184
|
for (const ts of ticket_statuses) {
|
|
79
185
|
ticketStatusMap.set(ts.ticket_key, ts.status);
|
|
80
186
|
ticketRowVersionMap.set(ts.ticket_key, ts.row_version);
|
|
187
|
+
ticketRemediationMap.set(ts.ticket_key, {
|
|
188
|
+
attempts: ts.remediation_attempts ?? 0,
|
|
189
|
+
no_progress: ts.remediation_no_progress_attempts ?? 0,
|
|
190
|
+
});
|
|
81
191
|
}
|
|
82
192
|
const unfoldedSignals = [];
|
|
83
193
|
const pendingMergeEvents = [];
|
|
84
194
|
// Track which tickets already have a folded signal (one override per ticket)
|
|
85
195
|
const foldedTicketKeys = new Set();
|
|
196
|
+
// BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
|
|
197
|
+
// tracked across the full ledger so an already-blocked ticket still carries a
|
|
198
|
+
// reason for the remediation pass to frame the nudge.
|
|
199
|
+
const ticketBlockedReasons = new Map();
|
|
86
200
|
for (const event of events) {
|
|
87
201
|
if (!TERMINAL_SIGNAL_TYPES.has(event.type))
|
|
88
202
|
continue;
|
|
89
|
-
// Map event → ticket via run_id
|
|
203
|
+
// Map event → ticket via run_id, resolving the dispatch role (review vs.
|
|
204
|
+
// implementation) so the fold can treat review-run terminals distinctly.
|
|
90
205
|
const runId = typeof event.run_id === "string" ? event.run_id : null;
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
206
|
+
const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
|
|
207
|
+
if (!mapped)
|
|
93
208
|
continue;
|
|
94
|
-
|
|
95
|
-
|
|
209
|
+
const ticketKey = mapped.ticketKey;
|
|
210
|
+
const isReview = mapped.isReview;
|
|
211
|
+
// BAPI-445 FAIL-SAFE: a review run's INCIDENTAL terminals (run.stopped,
|
|
212
|
+
// gate.met, …) must never advance a `reviewing` ticket — ONLY an explicit
|
|
213
|
+
// spec_review.* verdict does. A review session merely ending must not fold
|
|
214
|
+
// reviewing → ready and dispatch implementation regardless of whether the
|
|
215
|
+
// review requested changes (that would be fail-OPEN). When the verdict is
|
|
216
|
+
// not (yet) emitted, the ticket stays in `reviewing` and the reconcile
|
|
217
|
+
// liveness-recovery / escalation path surfaces it for operator attention —
|
|
218
|
+
// implementation never silently proceeds without a pass verdict.
|
|
219
|
+
const isSpecVerdict = event.type === "spec_review.passed" ||
|
|
220
|
+
event.type === "spec_review.changes_requested";
|
|
221
|
+
if (isReview && !isSpecVerdict)
|
|
222
|
+
continue;
|
|
223
|
+
// Record the blocking reason (latest wins; events are seq-ordered) regardless
|
|
224
|
+
// of fold state, so a ticket blocked on a prior tick still resolves a reason.
|
|
225
|
+
if (event.type === "ci.failed" || event.type === "review.changes_requested") {
|
|
226
|
+
ticketBlockedReasons.set(ticketKey, event.type);
|
|
227
|
+
}
|
|
228
|
+
else if (event.type === "spec_review.changes_requested") {
|
|
229
|
+
// BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
|
|
230
|
+
// it without spending the BAPI-441 implementation remediation budget.
|
|
231
|
+
ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
|
|
96
232
|
}
|
|
97
233
|
const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
|
|
98
234
|
if (!isNonTerminal(postgresStatus))
|
|
99
235
|
continue;
|
|
100
|
-
if
|
|
101
|
-
|
|
236
|
+
// Only queue for merge actioning if this ticket hasn't already been folded
|
|
237
|
+
// this tick. Without this guard, two gate.met events for the same ticket
|
|
238
|
+
// would both enqueue, and a ci.failed → gate.met sequence would enqueue a
|
|
239
|
+
// merge action for a ticket whose effective status is "blocked".
|
|
240
|
+
if (event.type === "gate.met" && !foldedTicketKeys.has(ticketKey)) {
|
|
241
|
+
pendingMergeEvents.push(event);
|
|
242
|
+
}
|
|
102
243
|
const signalType = event.type;
|
|
103
|
-
const nextStatus = signalToNextStatus(signalType);
|
|
244
|
+
const nextStatus = signalToNextStatus(signalType, isReview);
|
|
245
|
+
if (foldedTicketKeys.has(ticketKey)) {
|
|
246
|
+
// Allow a bounded same-tick upgrade of an already-folded ticket:
|
|
247
|
+
// 1. ready_for_review → done when merge.succeeded arrives after gate.met
|
|
248
|
+
// in the same ledger batch.
|
|
249
|
+
// 2. (BAPI-445) <any review fold> → blocked when a review run's
|
|
250
|
+
// spec_review.changes_requested arrives after its run.stopped in the
|
|
251
|
+
// same batch. A spec-review rejection must dominate plain completion
|
|
252
|
+
// so a changes-requested verdict is never lost to event ordering.
|
|
253
|
+
// First-signal-wins for everything else: if ci.failed arrived first,
|
|
254
|
+
// currentLocalStatus is "blocked" (not "ready_for_review"), so the
|
|
255
|
+
// upgrade guard below is false and merge.succeeded is intentionally
|
|
256
|
+
// dropped — a failed-CI ticket must not be silently advanced to done.
|
|
257
|
+
const currentLocalStatus = ticketStatusMap.get(ticketKey);
|
|
258
|
+
const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
|
|
259
|
+
const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
|
|
260
|
+
currentLocalStatus !== "blocked";
|
|
261
|
+
if (mergeUpgrade || specRejectUpgrade) {
|
|
262
|
+
const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
|
|
263
|
+
if (existingIdx >= 0) {
|
|
264
|
+
unfoldedSignals[existingIdx] = {
|
|
265
|
+
...unfoldedSignals[existingIdx],
|
|
266
|
+
next_status: nextStatus,
|
|
267
|
+
signal_type: signalType,
|
|
268
|
+
event,
|
|
269
|
+
};
|
|
270
|
+
ticketStatusMap.set(ticketKey, nextStatus);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
104
275
|
const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
|
|
105
276
|
unfoldedSignals.push({
|
|
106
277
|
ticket_key: ticketKey,
|
|
@@ -119,6 +290,8 @@ export function rebuildObservedState(postgresState, events, _now) {
|
|
|
119
290
|
plan_version: epic_run.current_plan_version,
|
|
120
291
|
ticket_statuses: ticketStatusMap,
|
|
121
292
|
ticket_row_versions: ticketRowVersionMap,
|
|
293
|
+
ticket_remediation_counters: ticketRemediationMap,
|
|
294
|
+
ticket_blocked_reasons: ticketBlockedReasons,
|
|
122
295
|
unfolded_terminal_signals: unfoldedSignals,
|
|
123
296
|
pending_merge_events: pendingMergeEvents,
|
|
124
297
|
};
|
|
@@ -64,6 +64,18 @@ function isSqliteBusyError(error) {
|
|
|
64
64
|
* the raw error text, stack, and any secret material are discarded.
|
|
65
65
|
*/
|
|
66
66
|
export function toConductorErrorEnvelope(error) {
|
|
67
|
+
// Optional-native-binding degradation (BAPI-451): the conductor `store.ts`
|
|
68
|
+
// throws ConductorPersistenceUnavailableError when `better-sqlite3` could not be
|
|
69
|
+
// loaded. Detected by name (not instanceof) to avoid an import cycle with
|
|
70
|
+
// store.ts. Surfaced as a retryable-shaped 503 with a fixed, secret-free message
|
|
71
|
+
// so the caller learns persistence is unavailable instead of getting an opaque 500.
|
|
72
|
+
if (error instanceof Error && error.name === "ConductorPersistenceUnavailableError") {
|
|
73
|
+
return {
|
|
74
|
+
error: "PERSISTENCE_UNAVAILABLE",
|
|
75
|
+
status: 503,
|
|
76
|
+
message: "Conductor persistence is unavailable (the optional better-sqlite3 native module is not loaded). Conductor coordination features are disabled; core tools are unaffected.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
67
79
|
if (error instanceof ConductorValidationError) {
|
|
68
80
|
// Validation messages are conductor-authored and name the offending field,
|
|
69
81
|
// never its value — but redact defensively so an adversarial/secret-bearing
|
|
@@ -25,8 +25,24 @@ export const GIT_CI_PRODUCER = "git-pr-ci-producer";
|
|
|
25
25
|
export const GIT_HOOK_PRODUCER = "git-hook";
|
|
26
26
|
/** The single v1 done-gate condition type. */
|
|
27
27
|
export const REQUIRED_CI_CHECKS_GREEN = "required_ci_checks_green";
|
|
28
|
+
/** The v2 review-state done-gate condition type. */
|
|
29
|
+
export const REVIEW_STATE = "review_state";
|
|
28
30
|
/** Default gate name surfaced in `gate.met` event data. */
|
|
29
31
|
export const DEFAULT_GATE_NAME = "done";
|
|
32
|
+
/** Event type emitted when the configured review source is satisfied. */
|
|
33
|
+
export const REVIEW_PASSED = "review.passed";
|
|
34
|
+
/** Event type emitted when a reviewer requests changes. */
|
|
35
|
+
export const REVIEW_CHANGES_REQUESTED = "review.changes_requested";
|
|
36
|
+
/**
|
|
37
|
+
* BAPI-445 pre-implementation SPEC re-review verdict event types. Emitted by the
|
|
38
|
+
* spec re-review run (``/review-ticket --auto``), correlated to that review run,
|
|
39
|
+
* and kept distinct from the PR-review {@link REVIEW_PASSED} /
|
|
40
|
+
* {@link REVIEW_CHANGES_REQUESTED} verdicts so a spec-review outcome is never
|
|
41
|
+
* folded as the implementation PR's review state.
|
|
42
|
+
*/
|
|
43
|
+
export const SPEC_REVIEW_PASSED = "spec_review.passed";
|
|
44
|
+
/** Event type emitted when a spec re-review requests changes. */
|
|
45
|
+
export const SPEC_REVIEW_CHANGES_REQUESTED = "spec_review.changes_requested";
|
|
30
46
|
/** Matches ASCII control characters (C0 range plus DEL). */
|
|
31
47
|
const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
|
|
32
48
|
/** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
|
|
@@ -79,7 +79,7 @@ export function buildWorktreeChangedEventInput(context, updates, phase) {
|
|
|
79
79
|
* dedupe by `repo + commit_sha`, and emit best-effort. Returns a
|
|
80
80
|
* success-compatible result for every failure mode so the commit is never blocked.
|
|
81
81
|
*/
|
|
82
|
-
export function runPostCommitHookProducer(deps = {}) {
|
|
82
|
+
export async function runPostCommitHookProducer(deps = {}) {
|
|
83
83
|
const getContext = deps.getContext ?? getGitWorktreeContext;
|
|
84
84
|
const readMetadata = deps.readMetadata ?? readHeadCommitMetadata;
|
|
85
85
|
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
@@ -90,7 +90,7 @@ export function runPostCommitHookProducer(deps = {}) {
|
|
|
90
90
|
return { ok: true, emitted: false, reason: "no-head-commit" };
|
|
91
91
|
}
|
|
92
92
|
const input = buildCommitCreatedEventInput(context, metadata);
|
|
93
|
-
const decision = emitIfNew(input, {
|
|
93
|
+
const decision = await emitIfNew(input, {
|
|
94
94
|
event_type: "git.commit_created",
|
|
95
95
|
repo: context.repo,
|
|
96
96
|
commit_sha: metadata.sha,
|
|
@@ -109,7 +109,7 @@ export function runPostCommitHookProducer(deps = {}) {
|
|
|
109
109
|
* success-compatible result for every failure mode so the ref update is never
|
|
110
110
|
* blocked.
|
|
111
111
|
*/
|
|
112
|
-
export function runReferenceTransactionHookProducer(args, deps = {}) {
|
|
112
|
+
export async function runReferenceTransactionHookProducer(args, deps = {}) {
|
|
113
113
|
const getContext = deps.getContext ?? getGitWorktreeContext;
|
|
114
114
|
const parseUpdates = deps.parseUpdates ?? parseReferenceTransactionUpdates;
|
|
115
115
|
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
@@ -124,7 +124,7 @@ export function runReferenceTransactionHookProducer(args, deps = {}) {
|
|
|
124
124
|
const context = getContext({ cwd: deps.cwd, env: deps.env });
|
|
125
125
|
const input = buildWorktreeChangedEventInput(context, updates, args.phase);
|
|
126
126
|
const refUpdatesHash = stableJsonHash({ phase: args.phase, updates });
|
|
127
|
-
const decision = emitIfNew(input, {
|
|
127
|
+
const decision = await emitIfNew(input, {
|
|
128
128
|
event_type: "worktree.changed",
|
|
129
129
|
repo: context.repo,
|
|
130
130
|
ref_updates_hash: refUpdatesHash,
|
|
@@ -103,9 +103,9 @@ export function makeMergeEventId(eventType, actionKey) {
|
|
|
103
103
|
const h = createHash("sha256").update(`${eventType}:${actionKey}`).digest("hex");
|
|
104
104
|
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
|
|
105
105
|
}
|
|
106
|
-
function lookupMergeEventByActionKey(eventType, actionKey, deps) {
|
|
106
|
+
async function lookupMergeEventByActionKey(eventType, actionKey, deps) {
|
|
107
107
|
const openDb = deps.openDb ?? (() => openReadonlyConductorDatabaseIfExists());
|
|
108
|
-
const db = openDb();
|
|
108
|
+
const db = await openDb();
|
|
109
109
|
if (!db)
|
|
110
110
|
return false;
|
|
111
111
|
try {
|
|
@@ -126,7 +126,7 @@ function lookupMergeEventByActionKey(eventType, actionKey, deps) {
|
|
|
126
126
|
* the action key. Only `merge.succeeded` is terminal — `merge.failed` and
|
|
127
127
|
* `merge.dry_run` are never terminal.
|
|
128
128
|
*/
|
|
129
|
-
export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
|
|
129
|
+
export async function hasTerminalMergeSucceeded(actionKey, deps = {}) {
|
|
130
130
|
return lookupMergeEventByActionKey("merge.succeeded", actionKey, deps);
|
|
131
131
|
}
|
|
132
132
|
/**
|
|
@@ -134,7 +134,7 @@ export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
|
|
|
134
134
|
* Dry-run is audit/hygiene only and is NEVER treated as terminal — a later real
|
|
135
135
|
* merge for the same PR/head/gate may still proceed if the repo flag is enabled.
|
|
136
136
|
*/
|
|
137
|
-
export function hasMergeDryRun(actionKey, deps = {}) {
|
|
137
|
+
export async function hasMergeDryRun(actionKey, deps = {}) {
|
|
138
138
|
return lookupMergeEventByActionKey("merge.dry_run", actionKey, deps);
|
|
139
139
|
}
|
|
140
140
|
/**
|
|
@@ -142,7 +142,7 @@ export function hasMergeDryRun(actionKey, deps = {}) {
|
|
|
142
142
|
* action key. Pending approval is NEVER terminal — the supervisor must re-dispatch
|
|
143
143
|
* until the backend reports approval (returning `merge.attempted` + `merge.succeeded`).
|
|
144
144
|
*/
|
|
145
|
-
export function hasMergePendingApproval(actionKey, deps = {}) {
|
|
145
|
+
export async function hasMergePendingApproval(actionKey, deps = {}) {
|
|
146
146
|
return lookupMergeEventByActionKey("merge.pending_approval", actionKey, deps);
|
|
147
147
|
}
|
|
148
148
|
/** Heuristically detect a SQLite duplicate-id / UNIQUE constraint failure. */
|
|
@@ -167,7 +167,7 @@ function isDuplicateConstraintError(error) {
|
|
|
167
167
|
* outcome fields live under `details`). A duplicate UNIQUE-constraint failure is
|
|
168
168
|
* classified as `{ emitted: false, reason: "duplicate" }` rather than thrown.
|
|
169
169
|
*/
|
|
170
|
-
export function emitMergeLedgerEventIfNew(input, deps = {}) {
|
|
170
|
+
export async function emitMergeLedgerEventIfNew(input, deps = {}) {
|
|
171
171
|
const emitEvent = deps.emitEvent ?? emitConductorEvent;
|
|
172
172
|
const eventId = makeMergeEventId(input.type, input.action_key);
|
|
173
173
|
const event = {
|
|
@@ -186,7 +186,7 @@ export function emitMergeLedgerEventIfNew(input, deps = {}) {
|
|
|
186
186
|
},
|
|
187
187
|
};
|
|
188
188
|
try {
|
|
189
|
-
const result = emitEvent(event);
|
|
189
|
+
const result = await emitEvent(event);
|
|
190
190
|
return { emitted: true, event_id: eventId, event: result.event };
|
|
191
191
|
}
|
|
192
192
|
catch (error) {
|