@bridge_gpt/mcp-server 0.2.10 → 0.2.13
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 +66 -4
- package/build/commands.generated.js +6 -6
- package/build/conductor/bridge-api-client.js +2 -1
- package/build/conductor/cli.js +16 -16
- package/build/conductor/doctor.js +79 -2
- package/build/conductor/epic-reconcile.js +213 -16
- package/build/conductor/epic-runtime.js +215 -60
- package/build/conductor/epic-state.js +105 -16
- package/build/conductor/errors.js +12 -0
- package/build/conductor/git-ci-types.js +10 -0
- package/build/conductor/git-producer.js +4 -4
- package/build/conductor/local-merge.js +212 -0
- package/build/conductor/merge-ledger.js +7 -7
- package/build/conductor/pr-ci-producer.js +18 -8
- package/build/conductor/pr-review-producer.js +2 -2
- package/build/conductor/producer-ledger.js +5 -5
- package/build/conductor/spec-review-producer.js +88 -0
- package/build/conductor/store.js +100 -25
- package/build/conductor/supervisor-ledger.js +2 -2
- package/build/conductor/supervisor-merge.js +5 -5
- package/build/conductor/supervisor-message-relay.js +1 -1
- package/build/conductor/supervisor-runtime.js +10 -10
- package/build/conductor/taxonomy.js +9 -0
- package/build/conductor/tools.js +5 -5
- package/build/conductor-bin.js +12689 -19
- package/build/conductor-claude-hook-bin.js +167 -17
- package/build/decision-page-schema.js +26 -0
- package/build/doctor.js +203 -0
- package/build/index.js +25084 -3632
- package/build/init.js +57 -0
- package/build/install-bridge.js +80 -0
- package/build/mcp-profile.js +33 -30
- package/build/pipelines.generated.js +70 -48
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +151 -0
- package/build/sfcc/config.js +39 -0
- package/build/sfcc/credentials.js +136 -0
- package/build/sfcc/ocapi-shape.js +77 -0
- package/build/sfcc/output.js +39 -0
- package/build/sfcc/permissions.js +136 -0
- package/build/sfcc/reads-custom-object-def.js +119 -0
- package/build/sfcc/reads-site-preference.js +158 -0
- package/build/sfcc/reads-system-object.js +162 -0
- package/build/sfcc/register.js +73 -0
- package/build/sfcc/setup-status.js +114 -0
- package/build/sfcc/tool-wrapper.js +70 -0
- package/build/start-tickets-conductor.js +9 -1
- package/build/start-tickets.js +47 -4
- 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
|
@@ -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,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side (local) conductor merge executor (F4).
|
|
3
|
+
*
|
|
4
|
+
* Performs the GitHub merge in the epic-tick process using the agent's OWN
|
|
5
|
+
* granted `gh` credentials, instead of the hosted Bridge API merge route (which
|
|
6
|
+
* would require the backend to hold global GitHub write scope — the reason a
|
|
7
|
+
* smoke-test merge failed with `provider_unauthorized`). Opt-in only: the runtime
|
|
8
|
+
* selects this executor when `policy_json.local_merge.enabled === true`.
|
|
9
|
+
*
|
|
10
|
+
* It mirrors the backend merge guard's decision order — approval precondition →
|
|
11
|
+
* re-read PR head (drift / not-open guard) → revalidate required CI for the EXACT
|
|
12
|
+
* head SHA → provider merge — and returns the SAME `merge.*` ledger events the
|
|
13
|
+
* backend route returns, so the rest of {@link processGateMetMerge} is unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Subprocess safety: argument arrays (never a shell string), a validated numeric
|
|
16
|
+
* PR number, a merge method from a fixed allowlist, `GH_PROMPT_DISABLED` to avoid
|
|
17
|
+
* interactive hangs, and no token/secret ever logged. Default OFF everywhere.
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "child_process";
|
|
20
|
+
import { pollCiChecksForCommit, } from "./bridge-api-client.js";
|
|
21
|
+
const MERGE_METHODS = new Set(["squash", "merge", "rebase"]);
|
|
22
|
+
/**
|
|
23
|
+
* Hard wall-clock cap on every `gh` subprocess. The epic-tick runs in a single
|
|
24
|
+
* stateless process with no separate scheduler thread — a `gh` call that hangs
|
|
25
|
+
* (network stall, an auth prompt that slips past GH_PROMPT_DISABLED) would block
|
|
26
|
+
* the Node event loop indefinitely, freezing the whole tick. Killing at 60s
|
|
27
|
+
* yields a `timedOut` result the executor maps to a distinct `*_timeout` reason.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
|
30
|
+
/** Coerce an untrusted method value to a safe allowlisted method (default squash). */
|
|
31
|
+
export function resolveLocalMergeMethod(value) {
|
|
32
|
+
return typeof value === "string" && MERGE_METHODS.has(value)
|
|
33
|
+
? value
|
|
34
|
+
: "squash";
|
|
35
|
+
}
|
|
36
|
+
function defaultRunCommand(cmd, args, env) {
|
|
37
|
+
const result = spawnSync(cmd, args, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
env: { ...process.env, ...env },
|
|
40
|
+
timeout: DEFAULT_COMMAND_TIMEOUT_MS,
|
|
41
|
+
});
|
|
42
|
+
// On timeout spawnSync kills the child (status: null, signal: "SIGTERM") and
|
|
43
|
+
// sets `error.code === "ETIMEDOUT"`. Surface that as a first-class flag.
|
|
44
|
+
const timedOut = result.error?.code === "ETIMEDOUT" ||
|
|
45
|
+
result.signal === "SIGTERM";
|
|
46
|
+
return {
|
|
47
|
+
status: result.status,
|
|
48
|
+
stdout: result.stdout ?? "",
|
|
49
|
+
stderr: result.stderr ?? "",
|
|
50
|
+
timedOut,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function buildResponse(request, status, reason, terminal, ledgerEvents) {
|
|
54
|
+
return {
|
|
55
|
+
action_key: request.action_key,
|
|
56
|
+
repo_name: request.repo_name,
|
|
57
|
+
pr_number: request.pr_number,
|
|
58
|
+
expected_head_sha: request.expected_head_sha,
|
|
59
|
+
status,
|
|
60
|
+
reason,
|
|
61
|
+
terminal,
|
|
62
|
+
ledger_events: ledgerEvents,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decide whether every required CI check is green for the polled head SHA. When
|
|
67
|
+
* the gate lists no required checks, fall back to the poll's `all_passed` flag.
|
|
68
|
+
* Defensive against the poll response's exact shape: a check counts as green if
|
|
69
|
+
* any of `conclusion==="success"`, `status==="success"`, `green===true`, or
|
|
70
|
+
* `bucket==="pass"`.
|
|
71
|
+
*/
|
|
72
|
+
export function allRequiredChecksGreen(pollResponse, requiredChecks) {
|
|
73
|
+
if (pollResponse === null || typeof pollResponse !== "object")
|
|
74
|
+
return false;
|
|
75
|
+
let obj = pollResponse;
|
|
76
|
+
// pollCiChecksForCommit returns the `{available, reason, action, detail}`
|
|
77
|
+
// envelope; checks/all_passed live under `detail`. Unwrap it (envelope-
|
|
78
|
+
// tolerant, mirroring normalizeCiSnapshot) — reading the top level alone made
|
|
79
|
+
// every required check look missing → merge always failed ci_not_green.
|
|
80
|
+
const maybeDetail = obj.detail;
|
|
81
|
+
if (maybeDetail &&
|
|
82
|
+
typeof maybeDetail === "object" &&
|
|
83
|
+
(Array.isArray(maybeDetail.checks) ||
|
|
84
|
+
"all_passed" in maybeDetail)) {
|
|
85
|
+
obj = maybeDetail;
|
|
86
|
+
}
|
|
87
|
+
const rawChecks = Array.isArray(obj.checks) ? obj.checks : [];
|
|
88
|
+
if (requiredChecks.length === 0) {
|
|
89
|
+
return obj.all_passed === true;
|
|
90
|
+
}
|
|
91
|
+
const byName = new Map();
|
|
92
|
+
for (const c of rawChecks) {
|
|
93
|
+
const name = typeof c.name === "string" ? c.name : null;
|
|
94
|
+
if (name !== null)
|
|
95
|
+
byName.set(name, c);
|
|
96
|
+
}
|
|
97
|
+
const isGreen = (c) => {
|
|
98
|
+
if (!c)
|
|
99
|
+
return false;
|
|
100
|
+
const conclusion = typeof c.conclusion === "string" ? c.conclusion.toLowerCase() : "";
|
|
101
|
+
const status = typeof c.status === "string" ? c.status.toLowerCase() : "";
|
|
102
|
+
const bucket = typeof c.bucket === "string" ? c.bucket.toLowerCase() : "";
|
|
103
|
+
return c.green === true || conclusion === "success" || status === "success" || bucket === "pass";
|
|
104
|
+
};
|
|
105
|
+
return requiredChecks.every((name) => isGreen(byName.get(name)));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Build a `(access, request) => Promise<ConductorMergeResponse>` that performs the
|
|
109
|
+
* merge locally via `gh`. Drop-in replacement for `mergePullRequestForGate` used
|
|
110
|
+
* by {@link processGateMetMerge}'s `merge` seam.
|
|
111
|
+
*/
|
|
112
|
+
export function makeLocalMergeExecutor(options = {}, deps = {}) {
|
|
113
|
+
const method = resolveLocalMergeMethod(options.method);
|
|
114
|
+
const run = deps.runCommand ?? defaultRunCommand;
|
|
115
|
+
const pollCi = deps.pollCi ?? pollCiChecksForCommit;
|
|
116
|
+
// Non-interactive + no-noise env for gh; the caller's env (incl. any token)
|
|
117
|
+
// is overlaid first, then the safety pins.
|
|
118
|
+
const ghEnv = {
|
|
119
|
+
...deps.env,
|
|
120
|
+
GH_PROMPT_DISABLED: "1",
|
|
121
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
122
|
+
};
|
|
123
|
+
return async (access, request) => {
|
|
124
|
+
const pr = request.pr_number;
|
|
125
|
+
const expectedSha = request.expected_head_sha;
|
|
126
|
+
const requiredChecks = request.gate?.required_checks ?? [];
|
|
127
|
+
const baseDetails = {
|
|
128
|
+
action_key: request.action_key,
|
|
129
|
+
repo: request.repo_name,
|
|
130
|
+
pr_number: pr,
|
|
131
|
+
expected_head_sha: expectedSha,
|
|
132
|
+
merge_method: method,
|
|
133
|
+
executor: "local",
|
|
134
|
+
};
|
|
135
|
+
const fail = (reason) => buildResponse(request, "failed", reason, false, [
|
|
136
|
+
{ type: "merge.failed", status: "failed", reason, details: baseDetails },
|
|
137
|
+
]);
|
|
138
|
+
// 1. Approval precondition — never merge when approval is required.
|
|
139
|
+
if (options.approvalRequired) {
|
|
140
|
+
return buildResponse(request, "pending_approval", "local_merge_approval_required", false, [
|
|
141
|
+
{
|
|
142
|
+
type: "merge.pending_approval",
|
|
143
|
+
status: "pending_approval",
|
|
144
|
+
reason: "local_merge_approval_required",
|
|
145
|
+
details: baseDetails,
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
// 2. Re-read PR head + open-state (head-drift / closed guard).
|
|
150
|
+
const view = run("gh", ["pr", "view", String(pr), "--json", "headRefOid,state"], ghEnv);
|
|
151
|
+
if (view.timedOut)
|
|
152
|
+
return fail("gh_pr_view_timeout");
|
|
153
|
+
if (view.status !== 0)
|
|
154
|
+
return fail("gh_pr_view_failed");
|
|
155
|
+
let headOid;
|
|
156
|
+
let state;
|
|
157
|
+
try {
|
|
158
|
+
const parsed = JSON.parse(view.stdout);
|
|
159
|
+
headOid = parsed.headRefOid;
|
|
160
|
+
state = parsed.state;
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return fail("gh_pr_view_unparseable");
|
|
164
|
+
}
|
|
165
|
+
if (typeof state === "string" && state.toUpperCase() !== "OPEN")
|
|
166
|
+
return fail("pr_not_open");
|
|
167
|
+
if (typeof headOid !== "string" || headOid.toLowerCase() !== expectedSha.toLowerCase()) {
|
|
168
|
+
return fail("head_drift");
|
|
169
|
+
}
|
|
170
|
+
// 3. Revalidate required CI green for the exact head SHA.
|
|
171
|
+
let pollResponse;
|
|
172
|
+
try {
|
|
173
|
+
pollResponse = await pollCi(access, expectedSha);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return fail("ci_poll_failed");
|
|
177
|
+
}
|
|
178
|
+
if (!allRequiredChecksGreen(pollResponse, requiredChecks))
|
|
179
|
+
return fail("ci_not_green");
|
|
180
|
+
// 4. Provider merge.
|
|
181
|
+
const merge = run("gh", ["pr", "merge", String(pr), `--${method}`, "--match-head-commit", expectedSha], ghEnv);
|
|
182
|
+
if (merge.status !== 0) {
|
|
183
|
+
const mergeFailReason = merge.timedOut ? "gh_merge_timeout" : "gh_merge_failed";
|
|
184
|
+
return buildResponse(request, "failed", mergeFailReason, false, [
|
|
185
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
186
|
+
{ type: "merge.failed", status: "failed", reason: mergeFailReason, details: baseDetails },
|
|
187
|
+
]);
|
|
188
|
+
}
|
|
189
|
+
// 5. Best-effort resolve the squash/merge commit SHA for the audit trail.
|
|
190
|
+
let mergeCommitSha;
|
|
191
|
+
const post = run("gh", ["pr", "view", String(pr), "--json", "mergeCommit"], ghEnv);
|
|
192
|
+
if (post.status === 0) {
|
|
193
|
+
try {
|
|
194
|
+
const oid = JSON.parse(post.stdout)?.mergeCommit;
|
|
195
|
+
if (oid && typeof oid === "object" && typeof oid.oid === "string") {
|
|
196
|
+
mergeCommitSha = oid.oid;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
/* best-effort only */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const succeededDetails = {
|
|
204
|
+
...baseDetails,
|
|
205
|
+
...(mergeCommitSha ? { merge_commit_sha: mergeCommitSha } : {}),
|
|
206
|
+
};
|
|
207
|
+
return buildResponse(request, "succeeded", null, true, [
|
|
208
|
+
{ type: "merge.attempted", status: "attempted", details: baseDetails },
|
|
209
|
+
{ type: "merge.succeeded", status: "succeeded", details: succeededDetails },
|
|
210
|
+
]);
|
|
211
|
+
};
|
|
212
|
+
}
|
|
@@ -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) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* check. All event types come from the existing conductor taxonomy and use only
|
|
10
10
|
* allowlisted top-level data keys.
|
|
11
11
|
*/
|
|
12
|
-
import { GIT_CI_PRODUCER, } from "./git-ci-types.js";
|
|
12
|
+
import { GIT_CI_PRODUCER, REVIEW_STATE, } from "./git-ci-types.js";
|
|
13
13
|
import { evaluateDoneGate, normalizeCiSnapshot, parseDoneGateConfig } from "./done-gate.js";
|
|
14
14
|
import { observeReviewWithResolved } from "./pr-review-producer.js";
|
|
15
15
|
import { fetchEffectiveSupervisorSetup, fetchActiveEpicRuns, fetchEpicRunState, pollCiChecksForCommit, resolveConductorBridgeApiAccess, } from "./bridge-api-client.js";
|
|
@@ -149,7 +149,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
149
149
|
reason: "observed",
|
|
150
150
|
};
|
|
151
151
|
// 1. PR opened telemetry (independent of CI / gate).
|
|
152
|
-
const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
152
|
+
const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
153
153
|
event_type: "git.pr_opened",
|
|
154
154
|
repo: binding.repo,
|
|
155
155
|
pr_number: binding.pr_number,
|
|
@@ -174,7 +174,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
174
174
|
}
|
|
175
175
|
else {
|
|
176
176
|
result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
|
|
177
|
-
const ciDecision = emitIfNew(ciEvent, {
|
|
177
|
+
const ciDecision = await emitIfNew(ciEvent, {
|
|
178
178
|
event_type: ciEvent.type,
|
|
179
179
|
repo: binding.repo,
|
|
180
180
|
pr_number: binding.pr_number,
|
|
@@ -210,7 +210,7 @@ async function observeWithResolved(binding, access, gateConfig, deps) {
|
|
|
210
210
|
result.gate_met = true;
|
|
211
211
|
const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
|
|
212
212
|
if (gateEvent !== null) {
|
|
213
|
-
const gateDecision = emitIfNew(gateEvent, {
|
|
213
|
+
const gateDecision = await emitIfNew(gateEvent, {
|
|
214
214
|
event_type: "gate.met",
|
|
215
215
|
repo: binding.repo,
|
|
216
216
|
pr_number: binding.pr_number,
|
|
@@ -409,7 +409,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
409
409
|
gate_emitted: false,
|
|
410
410
|
reason: "observed",
|
|
411
411
|
};
|
|
412
|
-
const prDecision = emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
412
|
+
const prDecision = await emitIfNew(buildPrOpenedEventInput(binding, run_id, worker_id), {
|
|
413
413
|
event_type: "git.pr_opened",
|
|
414
414
|
repo: binding.repo,
|
|
415
415
|
pr_number: binding.pr_number,
|
|
@@ -423,7 +423,7 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
423
423
|
return result;
|
|
424
424
|
}
|
|
425
425
|
result.ci_status = ciEvent.type === "ci.passed" ? "passed" : "failed";
|
|
426
|
-
const ciDecision = emitIfNew(ciEvent, {
|
|
426
|
+
const ciDecision = await emitIfNew(ciEvent, {
|
|
427
427
|
event_type: ciEvent.type,
|
|
428
428
|
repo: binding.repo,
|
|
429
429
|
pr_number: binding.pr_number,
|
|
@@ -455,13 +455,23 @@ export async function observePrCiFromPollResponse(commitRef, pollResponse, deps
|
|
|
455
455
|
rawConfig = undefined;
|
|
456
456
|
}
|
|
457
457
|
const gateConfig = parseDoneGateConfig(rawConfig);
|
|
458
|
-
|
|
458
|
+
// F6: this poll-driven path observes CI only — it has NO review snapshot
|
|
459
|
+
// (unlike observeWithResolved, which fetches one via observeReviewWithResolved).
|
|
460
|
+
// A composite gate with any review_state condition can therefore never be
|
|
461
|
+
// satisfied here and would only fail closed silently. Rather than pretend to
|
|
462
|
+
// be a gate path, refuse to evaluate review-gated configs and defer to
|
|
463
|
+
// `wait_for_done_gate` (the complete observer). CI-only gates still emit here.
|
|
464
|
+
const requiresReview = gateConfig.conditions.some((c) => c.type === REVIEW_STATE);
|
|
465
|
+
if (gateConfig.enabled && gateConfig.valid && requiresReview) {
|
|
466
|
+
result.reason = "review-gated config: gate.met deferred to wait_for_done_gate (poll path is CI-only)";
|
|
467
|
+
}
|
|
468
|
+
if (gateConfig.enabled && gateConfig.valid && !requiresReview) {
|
|
459
469
|
const evaluation = evaluateDoneGate(gateConfig, binding, snapshot, now());
|
|
460
470
|
if (evaluation.met) {
|
|
461
471
|
result.gate_met = true;
|
|
462
472
|
const gateEvent = buildGateMetEventInput(binding, evaluation, run_id, worker_id);
|
|
463
473
|
if (gateEvent !== null) {
|
|
464
|
-
const gateDecision = emitIfNew(gateEvent, {
|
|
474
|
+
const gateDecision = await emitIfNew(gateEvent, {
|
|
465
475
|
event_type: "gate.met",
|
|
466
476
|
repo: binding.repo,
|
|
467
477
|
pr_number: binding.pr_number,
|
|
@@ -99,13 +99,13 @@ export async function observeReviewWithResolved(binding, access, gateConfig, dep
|
|
|
99
99
|
};
|
|
100
100
|
if (evalResult.changesRequested) {
|
|
101
101
|
const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_CHANGES_REQUESTED, evalResult.reason, run_id, worker_id);
|
|
102
|
-
const decision = emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
|
|
102
|
+
const decision = await emitIfNew(event, { event_type: REVIEW_CHANGES_REQUESTED, ...baseDimensions });
|
|
103
103
|
result.review_changes_requested_emitted = decision.emitted;
|
|
104
104
|
result.reason = "review changes requested";
|
|
105
105
|
}
|
|
106
106
|
else if (evalResult.passed) {
|
|
107
107
|
const event = buildReviewObservationEventInput(binding, snapshot, REVIEW_PASSED, evalResult.reason, run_id, worker_id);
|
|
108
|
-
const decision = emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
|
|
108
|
+
const decision = await emitIfNew(event, { event_type: REVIEW_PASSED, ...baseDimensions });
|
|
109
109
|
result.review_passed_emitted = decision.emitted;
|
|
110
110
|
result.reason = "review passed";
|
|
111
111
|
}
|
|
@@ -59,13 +59,13 @@ const LEDGER_SCAN_MAX_PAGES = 200;
|
|
|
59
59
|
* full details object is visible. Malformed/non-matching events are skipped; the
|
|
60
60
|
* scan never throws.
|
|
61
61
|
*/
|
|
62
|
-
export function eventAlreadyExists(dedupeKey, deps = {}) {
|
|
62
|
+
export async function eventAlreadyExists(dedupeKey, deps = {}) {
|
|
63
63
|
const pollEvents = deps.pollEvents ?? ((options) => pollConductorEvents(options));
|
|
64
64
|
let sinceSeq = 1;
|
|
65
65
|
for (let page = 0; page < LEDGER_SCAN_MAX_PAGES; page += 1) {
|
|
66
66
|
let result;
|
|
67
67
|
try {
|
|
68
|
-
result = pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
|
|
68
|
+
result = await pollEvents({ since_seq: sinceSeq, data_mode: "full", limit: LEDGER_SCAN_PAGE_LIMIT });
|
|
69
69
|
}
|
|
70
70
|
catch {
|
|
71
71
|
return false;
|
|
@@ -95,10 +95,10 @@ export function eventAlreadyExists(dedupeKey, deps = {}) {
|
|
|
95
95
|
* under `data.details`, then emits. A duplicate-id constraint thrown by a racing
|
|
96
96
|
* producer is classified as a duplicate rather than surfaced as a failure.
|
|
97
97
|
*/
|
|
98
|
-
export function emitConductorEventIfNew(input, dimensions, deps = {}) {
|
|
98
|
+
export async function emitConductorEventIfNew(input, dimensions, deps = {}) {
|
|
99
99
|
const emitEvent = deps.emitEvent ?? emitConductorEvent;
|
|
100
100
|
const dedupeKey = makeProducerDedupeKey(dimensions);
|
|
101
|
-
if (eventAlreadyExists(dedupeKey, deps)) {
|
|
101
|
+
if (await eventAlreadyExists(dedupeKey, deps)) {
|
|
102
102
|
return { emitted: false, reason: "duplicate" };
|
|
103
103
|
}
|
|
104
104
|
const eventId = makeStableProducerEventId(dedupeKey);
|
|
@@ -113,7 +113,7 @@ export function emitConductorEventIfNew(input, dimensions, deps = {}) {
|
|
|
113
113
|
details: { ...existingDetails, dedupe_key: dedupeKey },
|
|
114
114
|
};
|
|
115
115
|
try {
|
|
116
|
-
emitEvent({ ...input, id: eventId, data });
|
|
116
|
+
await emitEvent({ ...input, id: eventId, data });
|
|
117
117
|
return { emitted: true, event_id: eventId };
|
|
118
118
|
}
|
|
119
119
|
catch (error) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-implementation SPEC re-review verdict production (BAPI-445 Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Emits the spec re-review verdict (`spec_review.passed` /
|
|
5
|
+
* `spec_review.changes_requested`) into the LOCAL conductor ledger, correlated
|
|
6
|
+
* to the spec-review run via `BAPI_CONDUCTOR_RUN_ID`. The Epic Supervisor's
|
|
7
|
+
* reconcile pass folds these signals: `passed` → `reviewing → ready` (proceed to
|
|
8
|
+
* implementation), `changes_requested` → `blocked` (escalate; do NOT implement).
|
|
9
|
+
*
|
|
10
|
+
* Why this is a LOCAL producer, not a backend webhook change: the conductor
|
|
11
|
+
* ledger is a per-worker SQLite file (`~/.config/bridge/events.db`). The
|
|
12
|
+
* Heroku-hosted Bridge API backend has no access to it. So — exactly like the
|
|
13
|
+
* PR-review producer ({@link ./pr-review-producer}) — the verdict is emitted by
|
|
14
|
+
* the LOCAL spec-review run, whose environment carries `BAPI_CONDUCTOR_RUN_ID`
|
|
15
|
+
* (injected at dispatch time by {@link ../review-tickets}.orchestrateReviewTickets
|
|
16
|
+
* when an epic identity is present). This corrects the original BAPI-445 plan,
|
|
17
|
+
* which proposed emitting from the backend review webhook
|
|
18
|
+
* (`code_writer_jira.py`) — that path cannot reach the worker-local ledger.
|
|
19
|
+
*
|
|
20
|
+
* The verdict family is kept DISTINCT from the PR-review `review.passed` /
|
|
21
|
+
* `review.changes_requested` verdicts (BAPI-440) so a spec-review outcome is
|
|
22
|
+
* never mis-folded as the implementation PR's review state (Obstacle 2).
|
|
23
|
+
*/
|
|
24
|
+
// TODO(BAPI-445 Phase 2 integration): `emitSpecReviewVerdict` is the verdict
|
|
25
|
+
// EMISSION primitive but currently has no caller. Until the review-ticket
|
|
26
|
+
// completion flow derives a pass/changes-requested decision from the critique
|
|
27
|
+
// and calls this function, no `spec_review.*` event is ever written.
|
|
28
|
+
//
|
|
29
|
+
// This is FAIL-SAFE, not fail-open: epic-state.ts `rebuildObservedState` filters
|
|
30
|
+
// a review run's incidental terminals (run.stopped, …) out of folding, so a
|
|
31
|
+
// completed review with NO verdict leaves the ticket in `reviewing` rather than
|
|
32
|
+
// advancing it to `ready` and dispatching implementation. The reconcile
|
|
33
|
+
// liveness-recovery / escalation path then surfaces the stranded ticket for
|
|
34
|
+
// operator attention. Only an explicit `spec_review.passed` proceeds and only
|
|
35
|
+
// `spec_review.changes_requested` blocks — so wiring this producer in is what
|
|
36
|
+
// makes the gate ACTIVE; without it, opted-in tickets hold safely instead of
|
|
37
|
+
// silently implementing against the review's wishes.
|
|
38
|
+
import { SPEC_REVIEW_CHANGES_REQUESTED, SPEC_REVIEW_PASSED } from "./git-ci-types.js";
|
|
39
|
+
import { emitConductorEventIfNew } from "./producer-ledger.js";
|
|
40
|
+
/** Producer + observed-via identifiers stamped onto emitted spec-review events. */
|
|
41
|
+
export const SPEC_REVIEW_PRODUCER = "spec-review-producer";
|
|
42
|
+
/**
|
|
43
|
+
* Build a `spec_review.passed` / `spec_review.changes_requested` event input for
|
|
44
|
+
* a ticket's spec re-review verdict, correlated to the review run via `runId`.
|
|
45
|
+
*/
|
|
46
|
+
export function buildSpecReviewVerdictEventInput(ticketKey, verdict, reason, runId = null, workerId = null) {
|
|
47
|
+
const eventType = verdict === "passed" ? SPEC_REVIEW_PASSED : SPEC_REVIEW_CHANGES_REQUESTED;
|
|
48
|
+
return {
|
|
49
|
+
source: "review",
|
|
50
|
+
type: eventType,
|
|
51
|
+
subject: ticketKey,
|
|
52
|
+
run_id: runId,
|
|
53
|
+
worker_id: workerId,
|
|
54
|
+
producer: SPEC_REVIEW_PRODUCER,
|
|
55
|
+
observed_via: SPEC_REVIEW_PRODUCER,
|
|
56
|
+
data: {
|
|
57
|
+
summary: verdict === "passed"
|
|
58
|
+
? `Spec re-review passed for ${ticketKey}`
|
|
59
|
+
: `Spec re-review requested changes for ${ticketKey}`,
|
|
60
|
+
status: verdict,
|
|
61
|
+
details: { ticket_key: ticketKey, reason },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Emit a spec re-review verdict for `ticketKey` into the local conductor ledger,
|
|
67
|
+
* correlated to the spec-review run via `BAPI_CONDUCTOR_RUN_ID` (read from the
|
|
68
|
+
* injected env). Idempotent: deduped by `event_type` + `run_id`, so re-emitting
|
|
69
|
+
* the same run's verdict is a no-op. A missing run id still emits (uncorrelated)
|
|
70
|
+
* but is logged in the result so callers can detect the misconfiguration.
|
|
71
|
+
*/
|
|
72
|
+
export async function emitSpecReviewVerdict(ticketKey, verdict, reason, deps = {}) {
|
|
73
|
+
const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
|
|
74
|
+
const run_id = deps.env?.BAPI_CONDUCTOR_RUN_ID?.trim() || null;
|
|
75
|
+
const worker_id = deps.env?.BAPI_CONDUCTOR_WORKER_ID?.trim() || null;
|
|
76
|
+
const eventType = verdict === "passed" ? SPEC_REVIEW_PASSED : SPEC_REVIEW_CHANGES_REQUESTED;
|
|
77
|
+
const event = buildSpecReviewVerdictEventInput(ticketKey, verdict, reason, run_id, worker_id);
|
|
78
|
+
const decision = await emitIfNew(event, {
|
|
79
|
+
event_type: eventType,
|
|
80
|
+
run_id: run_id ?? "",
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
emitted: decision.emitted,
|
|
84
|
+
event_type: eventType,
|
|
85
|
+
run_id,
|
|
86
|
+
reason: run_id ? reason : `${reason} (warning: no BAPI_CONDUCTOR_RUN_ID — verdict uncorrelated)`,
|
|
87
|
+
};
|
|
88
|
+
}
|