@nanobpm/nano-workforce 0.128.0 → 0.129.1
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/.github/workflows/pr-title-lint.yml +5 -3
- package/.releaserc.json +36 -2
- package/AGENTS.md +10 -6
- package/CHANGELOG.md +13 -0
- package/app/abandon.test.ts +16 -2
- package/app/abandon.ts +39 -17
- package/app/conformance.test.ts +2 -1
- package/app/conformance.ts +9 -3
- package/app/featureDelivery.test.ts +2 -1
- package/app/instanceTracking.ts +97 -0
- package/app/lineage.test.ts +2 -1
- package/app/lineage.ts +15 -2
- package/app/mergeEscalationUserTask.test.ts +21 -55
- package/app/mergeLoopBehaviour.test.ts +446 -0
- package/app/promotionPoll.test.ts +2 -1
- package/app/retro.test.ts +2 -1
- package/app/retro.ts +9 -2
- package/app/service.test.ts +15 -14
- package/app/service.ts +17 -24
- package/e2e/convergence-loop.e2e.ts +41 -8
- package/operations/acknowledgeEpic.test.ts +2 -1
- package/operations/checkAbandon.test.ts +2 -1
- package/operations/getLineage.test.ts +2 -1
- package/package.json +4 -3
- package/resources/processes/merge-loop.bpmn +692 -395
- package/test/trackingViews.ts +50 -0
- package/test/worldDb.ts +2 -1
- package/workers/retro-gather/worker.test.ts +2 -1
- package/app/mergeCiReattempt.test.ts +0 -138
- package/app/mergeEscalationQuestion.test.ts +0 -190
- package/app/mergeRebaseArm.test.ts +0 -140
- package/app/mergeRetryArm.test.ts +0 -100
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// Behavioural coverage for the sub-process merge-loop (issue #466).
|
|
2
|
+
//
|
|
3
|
+
// The merge-loop was refactored from a flat state machine into sub-processes
|
|
4
|
+
// (`SP_cifix`, `SP_rebase`) whose outcomes are propagated to the top level via
|
|
5
|
+
// end-event `ciOutcome`/`rebaseOutcome` output mappings and re-discriminated by
|
|
6
|
+
// `gw-ci-outcome`/`gw-rebase-outcome`. The previous merge guards were *structural
|
|
7
|
+
// text assertions* over the flat topology; they broke by construction under any
|
|
8
|
+
// re-shaping and re-encoded the model's shape rather than its behaviour.
|
|
9
|
+
//
|
|
10
|
+
// Per direction (issue #466) these are replaced with **behavioural** tests that
|
|
11
|
+
// deploy the committed model into the real WASM engine (`@nanobpm/urban-testkit`)
|
|
12
|
+
// and drive tokens through it, asserting the observable invariant — activated
|
|
13
|
+
// jobs, taken outcomes, terminal state, escalations, budget counters — so they
|
|
14
|
+
// protect what the loop *does*, not how it is drawn. The invariants preserved
|
|
15
|
+
// here are exactly those the retired guards protected:
|
|
16
|
+
// - mergeRetryArm (#334): transient-retry arm re-arms within budget, escalates
|
|
17
|
+
// when exhausted, advances the attempt counter only on a retry, no agent.
|
|
18
|
+
// - mergeCiReattempt (#134): fix-ci `reattempt`/no-verdict re-arms (never pages
|
|
19
|
+
// a human); blocked reconciles once from ground truth before escalating.
|
|
20
|
+
// - mergeRebaseArm: conflict → bounded rebase agent → re-arm / escalate /
|
|
21
|
+
// reconcile / wait-on-PR.
|
|
22
|
+
// - mergeEscalationQuestion (#329/#454): the four blocked/SLA triggers and the
|
|
23
|
+
// draft verdict each produce a distinct, human-actionable question; a
|
|
24
|
+
// persist-escalation `escalated:false` re-enters the poller instead of
|
|
25
|
+
// parking a dead user task.
|
|
26
|
+
// - mergeEscalationUserTask (#256): escalation parks on the native
|
|
27
|
+
// `wait-merge-answer` user task and the answer reconciles then re-arms.
|
|
28
|
+
// Plus the terminate semantics the refactor had to preserve: `MergeAbandoned`
|
|
29
|
+
// terminates the whole instance (it stays at the root, not inside a sub-process).
|
|
30
|
+
import { after, test } from "node:test";
|
|
31
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
33
|
+
import {
|
|
34
|
+
assertThatInstance,
|
|
35
|
+
assertThatUserTask,
|
|
36
|
+
byProcessId,
|
|
37
|
+
createWasmEngineClient,
|
|
38
|
+
type WasmEngineClient,
|
|
39
|
+
} from "@nanobpm/urban-testkit";
|
|
40
|
+
|
|
41
|
+
const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
|
|
42
|
+
|
|
43
|
+
const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
|
|
44
|
+
|
|
45
|
+
type Output = Record<string, unknown>;
|
|
46
|
+
type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
|
|
47
|
+
|
|
48
|
+
const ALL_JOB_TYPES = [
|
|
49
|
+
"pr.arm-merge",
|
|
50
|
+
"pr.merge",
|
|
51
|
+
"pr.mark-merged",
|
|
52
|
+
"senior:fix-ci",
|
|
53
|
+
"senior:rebase",
|
|
54
|
+
"pr.persist-escalation",
|
|
55
|
+
"pr.answer-escalation",
|
|
56
|
+
"pr.record-dependency",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
const DEFAULT_RESPONSES: Record<string, Responder> = {
|
|
60
|
+
"pr.arm-merge": {},
|
|
61
|
+
"pr.mark-merged": {},
|
|
62
|
+
"pr.record-dependency": {},
|
|
63
|
+
"pr.answer-escalation": {},
|
|
64
|
+
"pr.persist-escalation": { escalated: true },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Every FEEL expression in the model references these; start them defined (null,
|
|
68
|
+
// or a typed zero where the model compares/arithmetics the value, e.g.
|
|
69
|
+
// `failingChecks > 0`) so a missing-variable access can never raise a spurious
|
|
70
|
+
// incident in a test.
|
|
71
|
+
const DEFAULT_VARS: Record<string, unknown> = {
|
|
72
|
+
prKey: "pr-1",
|
|
73
|
+
repo: "acme/app",
|
|
74
|
+
prNumber: 1,
|
|
75
|
+
prUrl: "https://example.test/pr/1",
|
|
76
|
+
ciFixMax: 3,
|
|
77
|
+
rebaseMax: 3,
|
|
78
|
+
mergeRetryMax: 3,
|
|
79
|
+
ciFixRound: 0,
|
|
80
|
+
rebaseRound: 0,
|
|
81
|
+
mergeRetryRound: 0,
|
|
82
|
+
agentSlaTimeout: "PT30M",
|
|
83
|
+
abandonBrief: null,
|
|
84
|
+
failingChecksList: null,
|
|
85
|
+
status: null,
|
|
86
|
+
mergeState: null,
|
|
87
|
+
mergeStatus: null,
|
|
88
|
+
ciBlockedReconciled: null,
|
|
89
|
+
failingChecks: 0,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Deploy the committed merge-loop and start one instance, wired to a per-job-type
|
|
94
|
+
* responder. A responder may be a fixed output, a queue consumed per activation,
|
|
95
|
+
* or a function of the job. A job type mapped to `null` registers **no** worker,
|
|
96
|
+
* so its token parks on the task — used to let an agent SLA boundary fire.
|
|
97
|
+
*/
|
|
98
|
+
async function startMergeLoop(opts: {
|
|
99
|
+
responses?: Record<string, Responder | null>;
|
|
100
|
+
vars?: Record<string, unknown>;
|
|
101
|
+
} = {}): Promise<WasmEngineClient> {
|
|
102
|
+
const engine = await createWasmEngineClient();
|
|
103
|
+
await engine.deployResources([{ name: "merge-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
|
|
104
|
+
const responses: Record<string, Responder | null> = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
|
|
105
|
+
for (const jobType of ALL_JOB_TYPES) {
|
|
106
|
+
const responder = jobType in responses ? responses[jobType] : undefined;
|
|
107
|
+
if (responder === null) continue; // park the token (e.g. to let the SLA timer fire)
|
|
108
|
+
const queue = Array.isArray(responder) ? [...responder] : null;
|
|
109
|
+
await engine.registerWorker(jobType, (job) => {
|
|
110
|
+
// The escalation `question`/`status` are job-LOCAL input mappings on
|
|
111
|
+
// `merge-esc-*` (fed to `pr.persist-escalation`), so they never surface as
|
|
112
|
+
// instance variables — capture them off the job the worker sees instead.
|
|
113
|
+
if (jobType === "pr.persist-escalation") {
|
|
114
|
+
lastEscalationByEngine.set(engine, (job as { variables?: Record<string, unknown> }).variables ?? {});
|
|
115
|
+
}
|
|
116
|
+
if (queue) return queue.length > 1 ? queue.shift()! : queue[0] ?? {};
|
|
117
|
+
if (typeof responder === "function") return responder(job as { variables: Record<string, unknown> });
|
|
118
|
+
return (responder as Output | undefined) ?? {};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
await engine.createInstance({
|
|
122
|
+
processDefinitionId: "merge-loop",
|
|
123
|
+
awaitCompletion: false,
|
|
124
|
+
variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
|
|
125
|
+
});
|
|
126
|
+
return engine;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Positive checks use the engine-testkit `assertThat*` DSL below. The DSL has no
|
|
130
|
+
// *negative* element matcher ("element X did NOT complete") and no *substring*
|
|
131
|
+
// variable matcher (`hasVariable` is deep-equal), so the two readers below cover
|
|
132
|
+
// exactly those gaps. They read via the **same canonical snapshot accessors the
|
|
133
|
+
// DSL uses internally** (`instance.js`): completions from the snapshot-global
|
|
134
|
+
// `elementStats` (`{ elementId, completed }`) and live vars from
|
|
135
|
+
// `instances[].variables`. Sound because every test runs one isolated instance
|
|
136
|
+
// per engine — the single-instance precondition the DSL's own aggregate read
|
|
137
|
+
// relies on.
|
|
138
|
+
|
|
139
|
+
/** Element ids completed by the single instance — mirrors the DSL's `completedElementIds`. */
|
|
140
|
+
function completedElementIds(engine: WasmEngineClient): Set<string> {
|
|
141
|
+
const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
|
|
142
|
+
return new Set((snap.elementStats ?? []).filter((s) => s.completed > 0).map((s) => s.elementId));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The single instance's live variables — mirrors the DSL's `variablesOf`. */
|
|
146
|
+
function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
|
|
147
|
+
const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
|
|
148
|
+
return snap.instances?.[0]?.variables ?? {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The variables the `pr.persist-escalation` worker was last activated with, captured
|
|
153
|
+
* in `startMergeLoop`. The escalation `question`/`status` are job-local `zeebe:input`
|
|
154
|
+
* mappings on `merge-esc-*`, so they are only observable on the persist-escalation job
|
|
155
|
+
* — not as instance variables — this is the canonical read for the escalation payload.
|
|
156
|
+
*/
|
|
157
|
+
const lastEscalationByEngine = new WeakMap<WasmEngineClient, Record<string, unknown>>();
|
|
158
|
+
function escalation(engine: WasmEngineClient): Record<string, unknown> {
|
|
159
|
+
return lastEscalationByEngine.get(engine) ?? {};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const engines: WasmEngineClient[] = [];
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* urban-testkit's `assertThat*` DSL reads through a booted-app port: instance
|
|
166
|
+
* matchers use `app.snapshot()`, user-task matchers use
|
|
167
|
+
* `app.engine.{search,open}UserTasks`. These tests drive the WASM engine
|
|
168
|
+
* directly (no full app boot), so expose the client as its own read-model app:
|
|
169
|
+
* `snapshot()` is native, and `.engine` self-references so the task reads land
|
|
170
|
+
* on the same client.
|
|
171
|
+
*/
|
|
172
|
+
function asReadModelApp(engine: WasmEngineClient): WasmEngineClient {
|
|
173
|
+
(engine as unknown as { engine: WasmEngineClient }).engine = engine;
|
|
174
|
+
return engine;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function boot(opts?: Parameters<typeof startMergeLoop>[0]): Promise<WasmEngineClient> {
|
|
178
|
+
const engine = asReadModelApp(await startMergeLoop(opts));
|
|
179
|
+
engines.push(engine);
|
|
180
|
+
return engine;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The key of the single open `wait-merge-answer` task, via the typed read model. */
|
|
184
|
+
async function mergeAnswerTaskKey(engine: WasmEngineClient): Promise<string> {
|
|
185
|
+
const tasks = await engine.searchUserTasks({});
|
|
186
|
+
const row = tasks.find((t) => t.elementId === "wait-merge-answer");
|
|
187
|
+
assert(row, "expected an open wait-merge-answer user task");
|
|
188
|
+
return row.userTaskKey;
|
|
189
|
+
}
|
|
190
|
+
after(async () => {
|
|
191
|
+
await Promise.all(engines.map((e) => e.close()));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Happy paths
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
test("a ready PR merges and the instance completes via mark-merged", async () => {
|
|
199
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "merged" } } });
|
|
200
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
201
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
202
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasNoIncident().hasCompletedElements("mark-merged");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("a queued merge parks on the event gateway; the landed message marks it merged", async () => {
|
|
206
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
207
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
208
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
209
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElements("wait-landed", "wait-evicted");
|
|
210
|
+
await engine.publishMessage({ name: "merge-landed", correlationKey: "pr-1" });
|
|
211
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("an evicted queued merge re-arms the poller rather than completing", async () => {
|
|
215
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "queued" } } });
|
|
216
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
217
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
218
|
+
await engine.publishMessage({ name: "merge-evicted", correlationKey: "pr-1" });
|
|
219
|
+
// back at the poller's wait, not merged
|
|
220
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
221
|
+
assert(!completedElementIds(engine).has("mark-merged"), "an evicted merge must not mark-merged");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Transient-retry arm (mergeRetryArm, #334)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
test("a transient retry re-arms the poller within budget and advances the retry counter only on retry", async () => {
|
|
229
|
+
const engine = await boot({ responses: { "pr.merge": [{ mergeStatus: "retry" }, { mergeStatus: "merged" }] } });
|
|
230
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
231
|
+
// 1st attempt: retry (base moved) → within budget → re-arm → 2nd attempt: merged
|
|
232
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
233
|
+
// the retry re-armed and re-polled; feed a second mergeable so the 2nd attempt runs
|
|
234
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
235
|
+
assert(!completedElementIds(engine).has("merge-esc-attempt"), "a within-budget retry must NOT escalate");
|
|
236
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
237
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("a retry that exhausts the budget escalates as a repeated race, not a generic refusal", async () => {
|
|
241
|
+
const engine = await boot({
|
|
242
|
+
responses: { "pr.merge": { mergeStatus: "retry" } },
|
|
243
|
+
vars: { mergeRetryMax: 0 }, // first retry → mergeRetryRound 1 > 0 → exhausted
|
|
244
|
+
});
|
|
245
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
246
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
247
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
248
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
249
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "retry budget", "the retry escalation must read as a repeated race");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
async function driveToCiFix(engine: WasmEngineClient): Promise<void> {
|
|
257
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
258
|
+
await engine.publishMessage({
|
|
259
|
+
name: "merge-ready",
|
|
260
|
+
correlationKey: "pr-1",
|
|
261
|
+
variables: { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
test("a fixed CI verdict runs the agent, advances the fix counter, and re-arms the poller", async () => {
|
|
266
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "fixed" } } });
|
|
267
|
+
await driveToCiFix(engine);
|
|
268
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
269
|
+
.isActive()
|
|
270
|
+
.hasActiveElement("wait-mergeable")
|
|
271
|
+
.hasCompletedElements("fix-ci")
|
|
272
|
+
.hasVariable("ciFixRound", 1); // the fix counter advanced across the sub-process boundary
|
|
273
|
+
const done = completedElementIds(engine);
|
|
274
|
+
assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), "a fixed verdict must never escalate");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a reattempt CI verdict re-arms the poller and never pages a human (#134)", async () => {
|
|
278
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "reattempt" } } });
|
|
279
|
+
await driveToCiFix(engine);
|
|
280
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
281
|
+
const done = completedElementIds(engine);
|
|
282
|
+
assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), "a reattempt must not escalate");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a no-verdict CI result reconciles from ground truth (re-arm), not escalation (#134)", async () => {
|
|
286
|
+
const engine = await boot({ responses: { "senior:fix-ci": { summary: "unclear" } } }); // no `status`
|
|
287
|
+
await driveToCiFix(engine);
|
|
288
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
289
|
+
assert(!completedElementIds(engine).has("merge-esc-attempt"), "a missing status must not escalate");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("a blocked CI verdict with nothing pushed reconciles once before escalating", async () => {
|
|
293
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: false } } });
|
|
294
|
+
await driveToCiFix(engine);
|
|
295
|
+
// ci-reconcile re-arms and re-checks mergeable; it does not escalate on the first block
|
|
296
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
297
|
+
.isActive()
|
|
298
|
+
.hasActiveElement("wait-mergeable")
|
|
299
|
+
.hasCompletedElements("ci-reconcile")
|
|
300
|
+
.hasVariable("ciBlockedReconciled", true); // the one-shot reconcile is marked spent
|
|
301
|
+
assert(!completedElementIds(engine).has("merge-esc-attempt"), "the first block episode must reconcile, not page a human");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("a blocked CI verdict that already pushed escalates with a could-not-fix question", async () => {
|
|
305
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "blocked", pushed: true } } });
|
|
306
|
+
await driveToCiFix(engine);
|
|
307
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
308
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
309
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "CI-fix agent could not", "the question must name the could-not-fix trigger");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("a CI-fix that discovers a dependency records it and waits on the other PR", async () => {
|
|
313
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "waiting-on-pr", dependsOn: "acme/app#2" } } });
|
|
314
|
+
await driveToCiFix(engine);
|
|
315
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
316
|
+
.isActive()
|
|
317
|
+
.hasActiveElement("wait-deps")
|
|
318
|
+
.hasCompletedElements("record-merge-dep"); // a waiting-on-pr verdict records the dependency
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("CI-fix budget exhaustion escalates as not-mergeable without running the agent", async () => {
|
|
322
|
+
const engine = await boot({ responses: { "senior:fix-ci": { status: "fixed" } }, vars: { ciFixMax: 0 } });
|
|
323
|
+
await driveToCiFix(engine);
|
|
324
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
325
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
326
|
+
assert(!completedElementIds(engine).has("fix-ci"), "budget exhaustion must not run the fix-ci agent");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("the fix-ci agent SLA interrupts the sub-process and escalates", async () => {
|
|
330
|
+
const engine = await boot({ responses: { "senior:fix-ci": null } }); // park on the agent so the SLA fires
|
|
331
|
+
await driveToCiFix(engine);
|
|
332
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("fix-ci");
|
|
333
|
+
await engine.advanceTime(AGENT_SLA_MS + 1);
|
|
334
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
335
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
336
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "time budget (SLA)", "the SLA escalation must name the SLA trigger");
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Rebase sub-process (SP_rebase) — mergeRebaseArm
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async function driveToRebase(engine: WasmEngineClient): Promise<void> {
|
|
344
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
345
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "conflict" } });
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
test("a conflict runs the bounded rebase agent and a rebased result re-arms the poller", async () => {
|
|
349
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } } });
|
|
350
|
+
await driveToRebase(engine);
|
|
351
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
352
|
+
.isActive()
|
|
353
|
+
.hasActiveElement("wait-mergeable")
|
|
354
|
+
.hasCompletedElements("rebase") // a conflict runs the rebase agent, not page a human
|
|
355
|
+
.hasVariable("rebaseRound", 1); // the rebase counter advanced across the sub-process boundary
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a rebase that cannot resolve escalates with a conflict question", async () => {
|
|
359
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "blocked" } } });
|
|
360
|
+
await driveToRebase(engine);
|
|
361
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
362
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-attempt");
|
|
363
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "rebase agent could not resolve", "the question must name the conflict trigger");
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a no-verdict rebase result reconciles from ground truth, not escalation (#134)", async () => {
|
|
367
|
+
const engine = await boot({ responses: { "senior:rebase": { summary: "unclear" } } }); // no `status`
|
|
368
|
+
await driveToRebase(engine);
|
|
369
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
370
|
+
assert(!completedElementIds(engine).has("merge-esc-attempt"), "a missing rebase status must not escalate");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("rebase budget exhaustion escalates as not-mergeable without running the agent", async () => {
|
|
374
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "rebased" } }, vars: { rebaseMax: 0 } });
|
|
375
|
+
await driveToRebase(engine);
|
|
376
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
377
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
378
|
+
assert(!completedElementIds(engine).has("rebase"), "budget exhaustion must not run the rebase agent");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("the rebase agent SLA interrupts the sub-process and escalates as not-mergeable", async () => {
|
|
382
|
+
const engine = await boot({ responses: { "senior:rebase": null } });
|
|
383
|
+
await driveToRebase(engine);
|
|
384
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("rebase");
|
|
385
|
+
await engine.advanceTime(AGENT_SLA_MS + 1);
|
|
386
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
387
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Escalation user task (mergeEscalationUserTask #256, mergeEscalationQuestion #329/#454)
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
test("an escalation parks on the native user task; answering it reconciles then re-arms the poller", async () => {
|
|
395
|
+
const engine = await boot({ responses: { "senior:rebase": { status: "blocked" } } });
|
|
396
|
+
await driveToRebase(engine);
|
|
397
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
398
|
+
await engine.completeUserTask(await mergeAnswerTaskKey(engine), { answer: "rebased manually, retry" });
|
|
399
|
+
assertThatInstance(engine, byProcessId("merge-loop"))
|
|
400
|
+
.isActive()
|
|
401
|
+
.hasActiveElement("wait-mergeable")
|
|
402
|
+
.hasCompletedElements("record-merge-answer"); // answering runs the pr.answer-escalation reconcile
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("a persist-escalation that opens nothing (escalated:false) re-enters the poller, not a dead user task", async () => {
|
|
406
|
+
const engine = await boot({
|
|
407
|
+
responses: { "senior:rebase": { status: "blocked" }, "pr.persist-escalation": { escalated: false } },
|
|
408
|
+
});
|
|
409
|
+
await driveToRebase(engine);
|
|
410
|
+
assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
|
|
411
|
+
const openTasks = await engine.searchUserTasks({});
|
|
412
|
+
assert(
|
|
413
|
+
!openTasks.some((t) => t.elementId === "wait-merge-answer"),
|
|
414
|
+
"escalated:false must not park a user task",
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("a not-landable gate verdict gives a draft PR an actionable 'mark it ready' question (#454)", async () => {
|
|
419
|
+
const engine = await boot();
|
|
420
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
421
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "draft" } });
|
|
422
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
423
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("merge-esc-conflict");
|
|
424
|
+
assertStringIncludes(String(escalation(engine).question ?? ""), "draft", "a draft PR must get a mark-it-ready question");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Terminate semantics — the refactor kept MergeAbandoned at the root
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
test("an abandoned merge ends the whole instance via the root terminate, without merging", async () => {
|
|
432
|
+
const engine = await boot({ responses: { "pr.merge": { mergeStatus: "abandoned" } } });
|
|
433
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
434
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
435
|
+
// `MergeAbandoned` is a terminate end event at the ROOT scope: it ends all tokens, so the whole
|
|
436
|
+
// instance finishes (BPMN: a terminate end event *completes* the instance — `Completed`, not a
|
|
437
|
+
// cancellation `Terminated`). Had it lived inside `SP_cifix`/`SP_rebase`, the terminate would end
|
|
438
|
+
// only that sub-process scope and the outer poller would re-arm, leaving the instance ACTIVE —
|
|
439
|
+
// so `hasCompleted()` (nothing left running) is exactly what proves the terminate stayed at root.
|
|
440
|
+
const done = completedElementIds(engine);
|
|
441
|
+
assert(done.has("MergeAbandoned"), "the abandon terminate end event must fire");
|
|
442
|
+
assert(!done.has("mark-merged"), "an abandoned PR must not mark-merged");
|
|
443
|
+
assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("MergeAbandoned");
|
|
444
|
+
const open = await engine.searchUserTasks({});
|
|
445
|
+
assert(open.length === 0, "the root terminate must leave nothing running (no re-armed poller, no parked escalation)");
|
|
446
|
+
});
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// recording engine: open exactly one PR, never a duplicate on re-run, never for a converging epic,
|
|
7
7
|
// and never for a `main`-based epic.
|
|
8
8
|
import { test } from "node:test";
|
|
9
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
9
10
|
import { assert, assertEquals } from "#test-assert";
|
|
10
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
12
|
import { resetDefaultBranchCache } from "./github.ts";
|
|
@@ -41,7 +42,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
|
|
45
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
|
|
45
46
|
return { data, stores };
|
|
46
47
|
}
|
|
47
48
|
|
package/app/retro.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { test } from "node:test";
|
|
|
3
3
|
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
4
4
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
5
5
|
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
6
7
|
import { appendEntry } from "./blackboard.ts";
|
|
7
8
|
import { recordTaskDelta } from "./taskDelta.ts";
|
|
8
9
|
import {
|
|
@@ -47,7 +48,7 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
|
47
48
|
},
|
|
48
49
|
};
|
|
49
50
|
}
|
|
50
|
-
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
51
|
+
const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)), source: memBlackboardSource().source } as any as DataLayer;
|
|
51
52
|
return { data, stores };
|
|
52
53
|
}
|
|
53
54
|
|
package/app/retro.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { DataLayer, EngineClient, Logger } from "@nanobpm/urban";
|
|
|
17
17
|
import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
18
18
|
import { hasDeliveredImplementationForPlan } from "./conformance.ts";
|
|
19
19
|
import { TERMINAL_STATUSES } from "./delivery.ts";
|
|
20
|
+
import { derivedTrackingTable } from "./instanceTracking.ts";
|
|
20
21
|
import { planReviews, planTasks } from "./plan.ts";
|
|
21
22
|
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
22
23
|
|
|
@@ -55,7 +56,11 @@ interface PlanRow extends Record<string, unknown> {
|
|
|
55
56
|
|
|
56
57
|
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
57
58
|
const prsTbl = (data: DataLayer) =>
|
|
58
|
-
|
|
59
|
+
derivedTrackingTable<{ pr_key: string; derived_status: string }>(
|
|
60
|
+
data,
|
|
61
|
+
"pull_requests",
|
|
62
|
+
"pr_key",
|
|
63
|
+
);
|
|
59
64
|
const retroStartsTbl = (data: DataLayer) =>
|
|
60
65
|
data.table<{ plan_key: string; started_at: string }>("plan_retro_starts", "plan_key");
|
|
61
66
|
|
|
@@ -77,8 +82,10 @@ export async function isPlanComplete(data: DataLayer, planKey: string): Promise<
|
|
|
77
82
|
if (SETTLED_TASKLESS.has(t.status)) continue;
|
|
78
83
|
// Any task that is meant to yield a PR must have a terminal PR to be settled.
|
|
79
84
|
if (!t.pr_key) return false; // pending/escalated/etc. with no PR yet → still in flight
|
|
85
|
+
// Any task that is meant to yield a PR must have a terminal PR to be settled. Read the ADR-0065
|
|
86
|
+
// derived edge so an out-of-band-terminated (`abandoned`) PR is recognised as terminal here.
|
|
80
87
|
const pr = await prsTbl(data).get(t.pr_key);
|
|
81
|
-
if (!pr || !TERMINAL_PR_STATUSES.has(pr.
|
|
88
|
+
if (!pr || !TERMINAL_PR_STATUSES.has(pr.derived_status)) return false;
|
|
82
89
|
}
|
|
83
90
|
return true;
|
|
84
91
|
}
|
package/app/service.test.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { test } from "node:test";
|
|
9
9
|
import { assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
10
10
|
import { memDataFor } from "../test/worldDb.ts";
|
|
11
|
+
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
11
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
12
13
|
import { WorldStore } from "./world/index.ts";
|
|
13
14
|
import { abandonClosedPr, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
@@ -71,7 +72,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
71
72
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
72
73
|
};
|
|
73
74
|
const data = {
|
|
74
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
75
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
75
76
|
} as any;
|
|
76
77
|
const engine = {
|
|
77
78
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
@@ -137,7 +138,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
|
|
|
137
138
|
pull_requests: { rows: [row], key: "pr_key" },
|
|
138
139
|
};
|
|
139
140
|
const data = {
|
|
140
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
141
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
141
142
|
} as any;
|
|
142
143
|
const headers = { "content-type": "application/json" };
|
|
143
144
|
|
|
@@ -190,7 +191,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
|
|
|
190
191
|
pull_requests: { rows: [noKey, terminal], key: "pr_key" },
|
|
191
192
|
};
|
|
192
193
|
const data = {
|
|
193
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
194
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
194
195
|
} as any;
|
|
195
196
|
const headers = { "content-type": "application/json" };
|
|
196
197
|
|
|
@@ -223,7 +224,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
223
224
|
pull_requests: { rows: [row], key: "pr_key" },
|
|
224
225
|
};
|
|
225
226
|
const data = {
|
|
226
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
227
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
227
228
|
} as any;
|
|
228
229
|
const headers = { "content-type": "application/json" };
|
|
229
230
|
|
|
@@ -262,7 +263,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
262
263
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
263
264
|
};
|
|
264
265
|
const data = {
|
|
265
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
266
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
266
267
|
} as any;
|
|
267
268
|
const engine = {
|
|
268
269
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
@@ -296,7 +297,7 @@ function captureConvergeOnly() {
|
|
|
296
297
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
297
298
|
};
|
|
298
299
|
const data = {
|
|
299
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
300
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
300
301
|
} as any;
|
|
301
302
|
let captured: unknown;
|
|
302
303
|
const engine = {
|
|
@@ -347,7 +348,7 @@ function captureRoot() {
|
|
|
347
348
|
pr_dependencies: { rows: [], key: "pr_key" },
|
|
348
349
|
};
|
|
349
350
|
const data = {
|
|
350
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
351
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
351
352
|
} as any;
|
|
352
353
|
let captured: unknown;
|
|
353
354
|
const engine = {
|
|
@@ -629,7 +630,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
|
|
|
629
630
|
},
|
|
630
631
|
};
|
|
631
632
|
const data = {
|
|
632
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
633
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
633
634
|
} as any;
|
|
634
635
|
|
|
635
636
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -718,7 +719,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
718
719
|
},
|
|
719
720
|
};
|
|
720
721
|
const data = {
|
|
721
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
722
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
722
723
|
} as any;
|
|
723
724
|
|
|
724
725
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -822,7 +823,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
|
|
|
822
823
|
merges: { rows: [], key: "id" },
|
|
823
824
|
};
|
|
824
825
|
const data = {
|
|
825
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
826
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
826
827
|
} as any;
|
|
827
828
|
|
|
828
829
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -878,7 +879,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
|
|
|
878
879
|
merges: { rows: [], key: "id" },
|
|
879
880
|
};
|
|
880
881
|
const data = {
|
|
881
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
882
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
882
883
|
} as any;
|
|
883
884
|
|
|
884
885
|
await abandonClosedPr(data, "owner/repo#70", "closed without merging");
|
|
@@ -906,7 +907,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
|
|
|
906
907
|
merges: { rows: [], key: "id" },
|
|
907
908
|
};
|
|
908
909
|
const data = {
|
|
909
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
910
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
910
911
|
} as any;
|
|
911
912
|
|
|
912
913
|
await abandonClosedPr(data, "owner/repo#71", "closed without merging");
|
|
@@ -932,7 +933,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
|
|
|
932
933
|
merges: { rows: [], key: "id" },
|
|
933
934
|
};
|
|
934
935
|
const data = {
|
|
935
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
936
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
936
937
|
} as any;
|
|
937
938
|
|
|
938
939
|
const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
|
|
@@ -1004,7 +1005,7 @@ function capsProbeExec(ready: boolean) {
|
|
|
1004
1005
|
|
|
1005
1006
|
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
1006
1007
|
return {
|
|
1007
|
-
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
|
|
1008
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1008
1009
|
} as any;
|
|
1009
1010
|
}
|
|
1010
1011
|
|