@nanobpm/nano-workforce 0.104.0 → 0.106.0
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/CHANGELOG.md +14 -0
- package/README.md +1 -0
- package/app/abandon.ts +12 -3
- package/app/agentCompletion.test.ts +28 -0
- package/app/agentCompletion.ts +14 -2
- package/app/conformance.test.ts +313 -0
- package/app/conformance.ts +329 -0
- package/app/dbFence.ts +18 -0
- package/app/instance-tracking.test.ts +24 -0
- package/app/migration053.test.ts +84 -0
- package/app/plan.ts +6 -0
- package/app/pollUserTasks.test.ts +37 -0
- package/app/retro.test.ts +32 -2
- package/app/retro.ts +26 -10
- package/app/service.test.ts +191 -3
- package/app/service.ts +163 -2
- package/app/userTasks.test.ts +20 -0
- package/app/userTasks.ts +2 -0
- package/app/waves.test.ts +12 -0
- package/app/world/store.ts +6 -6
- package/db/migrations/004_planning.sql +1 -1
- package/db/migrations/052_plan_conformance.sql +28 -0
- package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
- package/db/migrations/054_conformance_review_tracking.sql +27 -0
- package/nano.app.json +24 -1
- package/package.json +1 -1
- package/pages/tasks.page.json +84 -0
- package/resources/forms/conformance-escalation.form +17 -0
- package/resources/processes/retro.bpmn +123 -11
- package/resources/prompts/conformance.md +105 -0
- package/resources/prompts/retro.md +5 -0
- package/workers/conformance-ack/worker.test.ts +50 -0
- package/workers/conformance-ack/worker.ts +31 -0
- package/workers/conformance-record/worker.test.ts +241 -0
- package/workers/conformance-record/worker.ts +127 -0
- package/workers/merge/worker.test.ts +4 -0
- package/workers/merge/worker.ts +13 -18
- package/workers/retro-gather/worker.test.ts +6 -0
- package/workers/retro-gather/worker.ts +17 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* escalate unmet/undisclosed conformance deviations to the Tasks inbox ([#356](https://github.com/nanobpm/nano-workforce/issues/356)) ([5ad24bc](https://github.com/nanobpm/nano-workforce/commit/5ad24bcadf833934804973a25fd96f4eb4d28a84)), closes [#354](https://github.com/nanobpm/nano-workforce/issues/354) [#354](https://github.com/nanobpm/nano-workforce/issues/354)
|
|
7
|
+
|
|
8
|
+
# [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* examine epic implementation against spec in retro (conformance) ([#355](https://github.com/nanobpm/nano-workforce/issues/355)) ([c52d059](https://github.com/nanobpm/nano-workforce/commit/c52d0594d767c551d31ed967d1ef79cb30e82ceb)), closes [#217](https://github.com/nanobpm/nano-workforce/issues/217) [#216](https://github.com/nanobpm/nano-workforce/issues/216) [#217](https://github.com/nanobpm/nano-workforce/issues/217)
|
|
14
|
+
|
|
1
15
|
# [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/README.md
CHANGED
|
@@ -153,6 +153,7 @@ capability):
|
|
|
153
153
|
| `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
|
|
154
154
|
| `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
|
|
155
155
|
| `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
|
|
156
|
+
| `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue, and escalate an unmet/undisclosed deviation to the Tasks inbox as a non-blocking ack |
|
|
156
157
|
|
|
157
158
|
- `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
|
|
158
159
|
prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
|
package/app/abandon.ts
CHANGED
|
@@ -20,11 +20,20 @@
|
|
|
20
20
|
import type { DataLayer } from "@nanobpm/urban";
|
|
21
21
|
import { publicBaseUrl } from "./blackboard.ts";
|
|
22
22
|
|
|
23
|
-
/** The one app-row status
|
|
24
|
-
*
|
|
23
|
+
/** The one app-row status meaning a PR is terminally abandoned — the run must not be worked on
|
|
24
|
+
* further. Two disjoint producers flip a row here, and both are non-completion terminals that must
|
|
25
|
+
* stop a servicing agent:
|
|
26
|
+
* 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive, via the
|
|
27
|
+
* `instanceTracking` `onTerminated.set` patch), and
|
|
28
|
+
* 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
|
|
29
|
+
* merging** (#352) — for both `pull_requests` and its `plan_tasks`.
|
|
30
|
+
* Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
|
|
31
|
+
* case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
|
|
32
|
+
* true` is correct. */
|
|
25
33
|
export const ABANDONED_STATUS = "abandoned";
|
|
26
34
|
|
|
27
|
-
/** True when a PR's app-row status
|
|
35
|
+
/** True when a PR's app-row status is terminally abandoned (run cancelled, or PR closed-unmerged)
|
|
36
|
+
* and the agent must not act. */
|
|
28
37
|
export function isAbandoned(status: string | null | undefined): boolean {
|
|
29
38
|
return status === ABANDONED_STATUS;
|
|
30
39
|
}
|
|
@@ -229,6 +229,34 @@ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332
|
|
|
229
229
|
assertEquals(completed[0].variables, { note: "reassigned to a human" });
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
test("conformance-escalation is HUMAN-completable but NOT agent-completable (issue #216)", async () => {
|
|
233
|
+
// The retro conformance ack mirrors feature-blocked: a HUMAN operator retires it via
|
|
234
|
+
// `completeEscalationAsHuman`, but it stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) —
|
|
235
|
+
// an agent must never acknowledge a conformance review on a human's behalf.
|
|
236
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
237
|
+
const data = memData(stores);
|
|
238
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-c", elementId: "conformance-escalation" }]);
|
|
239
|
+
|
|
240
|
+
const asAgent = await completeEscalationAsAgent(data, engine, {
|
|
241
|
+
userTaskKey: "ut-c",
|
|
242
|
+
agentId: "bot",
|
|
243
|
+
variables: { note: "n" },
|
|
244
|
+
});
|
|
245
|
+
assertEquals(asAgent.ok, false, "the agent completer refuses conformance-escalation");
|
|
246
|
+
assertEquals(asAgent.reason, "not a completable task");
|
|
247
|
+
assertEquals(completed.length, 0);
|
|
248
|
+
|
|
249
|
+
const asHuman = await completeEscalationAsHuman(data, engine, {
|
|
250
|
+
userTaskKey: "ut-c",
|
|
251
|
+
operatorId: "alice",
|
|
252
|
+
variables: { note: "filed follow-up" },
|
|
253
|
+
});
|
|
254
|
+
assertEquals(asHuman.ok, true, "the human completer retires conformance-escalation");
|
|
255
|
+
assertEquals(asHuman.elementId, "conformance-escalation");
|
|
256
|
+
assertEquals(completed.length, 1);
|
|
257
|
+
assertEquals(completed[0].variables, { note: "filed follow-up" });
|
|
258
|
+
});
|
|
259
|
+
|
|
232
260
|
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
233
261
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
234
262
|
const data = memData(stores);
|
package/app/agentCompletion.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { readFileSync } from "node:fs";
|
|
24
24
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
25
|
+
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
25
26
|
|
|
26
27
|
const now = () => new Date().toISOString();
|
|
27
28
|
|
|
@@ -79,13 +80,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
79
80
|
* `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
|
|
80
81
|
export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
|
|
81
82
|
|
|
83
|
+
/** The `conformance-escalation` operator user-task element id (retro.bpmn) — the native ack a retro
|
|
84
|
+
* run parks on when the spec-conformance audit finds the epic did NOT cleanly meet its spec (a
|
|
85
|
+
* reduced / not-verified slice, or an unraised deviation). Like `feature-blocked` it is a human-only
|
|
86
|
+
* acknowledgement (never agent-answerable), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only
|
|
87
|
+
* the HUMAN completer accepts it (issue #216). Re-exported from the canonical
|
|
88
|
+
* `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
|
|
89
|
+
export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
|
|
90
|
+
|
|
82
91
|
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
83
92
|
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
84
|
-
*
|
|
85
|
-
* so widening the human surface never lets an agent retire
|
|
93
|
+
* and `conformance-escalation` acknowledgements. The AGENT completer stays scoped to
|
|
94
|
+
* `ESCALATION_TASK_ELEMENTS` (neither ack), so widening the human surface never lets an agent retire
|
|
95
|
+
* a blocked run or a conformance review. */
|
|
86
96
|
export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
87
97
|
...ESCALATION_TASK_ELEMENTS,
|
|
88
98
|
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
99
|
+
CONFORMANCE_ESCALATION_TASK_ELEMENT,
|
|
89
100
|
]);
|
|
90
101
|
|
|
91
102
|
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
@@ -98,6 +109,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
98
109
|
"wait-answer": "pr-escalation",
|
|
99
110
|
"wait-merge-answer": "pr-escalation",
|
|
100
111
|
"feature-blocked": "feature-blocked",
|
|
112
|
+
[CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
|
|
101
113
|
};
|
|
102
114
|
|
|
103
115
|
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
4
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
5
|
+
import { memBlackboardSource } from "../test/blackboardDb.ts";
|
|
6
|
+
import { appendEntry } from "./blackboard.ts";
|
|
7
|
+
import {
|
|
8
|
+
acknowledgeConformance,
|
|
9
|
+
activeConformanceReviews,
|
|
10
|
+
gatherConformance,
|
|
11
|
+
hasDeliveredImplementation,
|
|
12
|
+
hasDeliveredImplementationForPlan,
|
|
13
|
+
recordConformance,
|
|
14
|
+
renderConformanceBrief,
|
|
15
|
+
} from "./conformance.ts";
|
|
16
|
+
|
|
17
|
+
// In-memory record gateway matching the Table<T> subset conformance.ts uses.
|
|
18
|
+
function memData(): { data: DataLayer; stores: Record<string, any[]> } {
|
|
19
|
+
const stores: Record<string, any[]> = {};
|
|
20
|
+
const seq: Record<string, number> = {};
|
|
21
|
+
function tbl(name: string, pk = "id") {
|
|
22
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
23
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
24
|
+
return {
|
|
25
|
+
async insert(row: any) {
|
|
26
|
+
if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
|
|
27
|
+
throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
|
|
28
|
+
}
|
|
29
|
+
const id = (seq[name] = (seq[name] ?? 0) + 1);
|
|
30
|
+
rows.push(pk === "id" ? { id, ...row } : { ...row });
|
|
31
|
+
return pk === "id" ? id : row[pk];
|
|
32
|
+
},
|
|
33
|
+
async find(where: any = {}) {
|
|
34
|
+
return rows.filter((r) => match(r, where));
|
|
35
|
+
},
|
|
36
|
+
async findOne(where: any = {}) {
|
|
37
|
+
return rows.find((r) => match(r, where));
|
|
38
|
+
},
|
|
39
|
+
async get(id: any) {
|
|
40
|
+
return rows.find((row) => row[pk] === id);
|
|
41
|
+
},
|
|
42
|
+
async update(id: any, patch: any) {
|
|
43
|
+
const r = rows.find((row) => row[pk] === id);
|
|
44
|
+
if (r) Object.assign(r, patch);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
|
|
49
|
+
return { data, stores };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PLAN = "acme/widgets#7";
|
|
53
|
+
|
|
54
|
+
function seedPlan(stores: Record<string, any[]>) {
|
|
55
|
+
stores["plans"] = [{
|
|
56
|
+
plan_key: PLAN,
|
|
57
|
+
repo: "acme/widgets",
|
|
58
|
+
issue_url: "https://github.com/acme/widgets/issues/7",
|
|
59
|
+
title: "Widgets epic",
|
|
60
|
+
status: "done",
|
|
61
|
+
}];
|
|
62
|
+
}
|
|
63
|
+
function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
|
|
64
|
+
(stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
|
|
65
|
+
}
|
|
66
|
+
function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
|
|
67
|
+
(stores["pull_requests"] ??= []).push({ pr_key, status });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
test("gatherConformance: collects the spec, only LANDED PRs, and raised scope-changes", async () => {
|
|
71
|
+
const { data, stores } = memData();
|
|
72
|
+
seedPlan(stores);
|
|
73
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", pr_key: "acme/widgets#10" });
|
|
74
|
+
seedTask(stores, { id: 2, task_index: 1, task_id: "t2", title: "Rate limit", prompt: "add rate limiting", status: "opened", pr_key: "acme/widgets#11" });
|
|
75
|
+
seedTask(stores, { id: 3, task_index: 2, task_id: "t3", title: "Webhook", prompt: "retry webhooks", status: "opened", pr_key: "acme/widgets#12" });
|
|
76
|
+
seedTask(stores, { id: 4, task_index: 3, task_id: "t4", title: "Docs", prompt: "write docs", status: "skipped", pr_key: null });
|
|
77
|
+
seedPr(stores, "acme/widgets#10", "merged"); // landed
|
|
78
|
+
seedPr(stores, "acme/widgets#11", "converged"); // landed (review-only)
|
|
79
|
+
seedPr(stores, "acme/widgets#12", "abandoned"); // NOT landed
|
|
80
|
+
await appendEntry(data, PLAN, { author_task: "t2", kind: "scope-change", body: "narrowed rate limit to per-IP only" });
|
|
81
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "not a scope change" });
|
|
82
|
+
|
|
83
|
+
const d = await gatherConformance(data, PLAN);
|
|
84
|
+
assertEquals(d.repo, "acme/widgets");
|
|
85
|
+
assertEquals(d.issueUrl, "https://github.com/acme/widgets/issues/7");
|
|
86
|
+
assertEquals(d.slices.length, 4);
|
|
87
|
+
// Only merged/converged PRs are "delivered"; abandoned and task-less slices are excluded.
|
|
88
|
+
assertEquals(d.deliveredPrs, ["acme/widgets#10", "acme/widgets#11"]);
|
|
89
|
+
assertEquals(d.slices.find((s) => s.taskId === "t3")?.landed, false);
|
|
90
|
+
assertEquals(d.slices.find((s) => s.taskId === "t4")?.landed, false);
|
|
91
|
+
// Only scope-change entries surface as raised deviations — learnings are ignored.
|
|
92
|
+
assertEquals(d.scopeChanges.length, 1);
|
|
93
|
+
assertEquals(d.scopeChanges[0].author_task, "t2");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("gatherConformance: sorts slices by task_index", async () => {
|
|
97
|
+
const { data, stores } = memData();
|
|
98
|
+
seedPlan(stores);
|
|
99
|
+
seedTask(stores, { id: 1, task_index: 2, task_id: "t3", status: "skipped", pr_key: null });
|
|
100
|
+
seedTask(stores, { id: 2, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
|
|
101
|
+
seedTask(stores, { id: 3, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
|
|
102
|
+
const d = await gatherConformance(data, PLAN);
|
|
103
|
+
assertEquals(d.slices.map((s) => s.taskId), ["t1", "t2", "t3"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("gatherConformance: uses pre-fetched blackboard entries instead of re-scanning", async () => {
|
|
107
|
+
const { data, stores } = memData();
|
|
108
|
+
seedPlan(stores);
|
|
109
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
|
|
110
|
+
// A scope-change lives in the store, but the caller passes an EMPTY pre-fetched snapshot — the
|
|
111
|
+
// function must honour what it was handed and not re-read the store.
|
|
112
|
+
await appendEntry(data, PLAN, { author_task: "t1", kind: "scope-change", body: "should be ignored" });
|
|
113
|
+
const d = await gatherConformance(data, PLAN, []);
|
|
114
|
+
assertEquals(d.scopeChanges.length, 0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("hasDeliveredImplementation: true iff at least one PR landed", async () => {
|
|
118
|
+
const { data, stores } = memData();
|
|
119
|
+
seedPlan(stores);
|
|
120
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
121
|
+
seedPr(stores, "acme/widgets#10", "abandoned");
|
|
122
|
+
assert(!hasDeliveredImplementation(await gatherConformance(data, PLAN)));
|
|
123
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "merged" }];
|
|
124
|
+
assert(hasDeliveredImplementation(await gatherConformance(data, PLAN)));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("hasDeliveredImplementationForPlan: matches the digest without a blackboard scan", async () => {
|
|
128
|
+
const { data, stores } = memData();
|
|
129
|
+
seedPlan(stores);
|
|
130
|
+
seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
|
|
131
|
+
seedTask(stores, { id: 2, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
|
|
132
|
+
seedPr(stores, "acme/widgets#10", "abandoned");
|
|
133
|
+
// No landed PR yet — agrees with the full-digest helper.
|
|
134
|
+
assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), false);
|
|
135
|
+
assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), false);
|
|
136
|
+
// A landed PR flips both to true.
|
|
137
|
+
stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converged" }];
|
|
138
|
+
assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), true);
|
|
139
|
+
assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), true);
|
|
140
|
+
// The cheap check must not touch the blackboard.
|
|
141
|
+
const before = (stores["blackboard"] ?? []).length;
|
|
142
|
+
await hasDeliveredImplementationForPlan(data, PLAN);
|
|
143
|
+
assertEquals((stores["blackboard"] ?? []).length, before);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("renderConformanceBrief: lists PRs to examine, the spec, and raised deviations", () => {
|
|
147
|
+
const brief = renderConformanceBrief({
|
|
148
|
+
planKey: PLAN,
|
|
149
|
+
repo: "acme/widgets",
|
|
150
|
+
issueUrl: "https://x/7",
|
|
151
|
+
title: "Epic",
|
|
152
|
+
slices: [
|
|
153
|
+
{ taskId: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", prKey: "acme/widgets#10", landed: true },
|
|
154
|
+
{ taskId: "t2", title: "Docs", prompt: null, status: "skipped", prKey: null, landed: false },
|
|
155
|
+
],
|
|
156
|
+
deliveredPrs: ["acme/widgets#10"],
|
|
157
|
+
scopeChanges: [{ author_task: "t1", body: "narrowed to per-IP", created_at: "now" }],
|
|
158
|
+
});
|
|
159
|
+
assertStringIncludes(brief, "gh pr diff");
|
|
160
|
+
assertStringIncludes(brief, "acme/widgets#10");
|
|
161
|
+
assertStringIncludes(brief, "add JWT auth");
|
|
162
|
+
assertStringIncludes(brief, "narrowed to per-IP");
|
|
163
|
+
assertStringIncludes(brief, "RAISED during implementation");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("renderConformanceBrief: states 'none' for no delivered PRs and no scope-changes", () => {
|
|
167
|
+
const brief = renderConformanceBrief({
|
|
168
|
+
planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
|
|
169
|
+
slices: [], deliveredPrs: [], scopeChanges: [],
|
|
170
|
+
});
|
|
171
|
+
assertStringIncludes(brief, "no implementation to verify");
|
|
172
|
+
assertStringIncludes(brief, "treat any deviation you find as UNRAISED");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("recordConformance: inserts then updates the same plan_key row in place", async () => {
|
|
176
|
+
const { data, stores } = memData();
|
|
177
|
+
await recordConformance(data, PLAN, {
|
|
178
|
+
status: "filed",
|
|
179
|
+
commentUrl: "https://x/7#issuecomment-1",
|
|
180
|
+
slicesMet: 4,
|
|
181
|
+
slicesReduced: 1,
|
|
182
|
+
slicesNotVerified: 1,
|
|
183
|
+
deviationsRaised: 2,
|
|
184
|
+
deviationsUnraised: 1,
|
|
185
|
+
hasDeviations: true,
|
|
186
|
+
summary: "6 items, 4 met",
|
|
187
|
+
report: "full report",
|
|
188
|
+
});
|
|
189
|
+
assertEquals(stores["plan_conformance"].length, 1);
|
|
190
|
+
const row = stores["plan_conformance"][0];
|
|
191
|
+
assertEquals(row.status, "filed");
|
|
192
|
+
assertEquals(row.comment_url, "https://x/7#issuecomment-1");
|
|
193
|
+
assertEquals(row.slices_met, 4);
|
|
194
|
+
assertEquals(row.has_deviations, 1);
|
|
195
|
+
|
|
196
|
+
await recordConformance(data, PLAN, { status: "skipped", summary: "nothing shipped" });
|
|
197
|
+
assertEquals(stores["plan_conformance"].length, 1, "same plan_key must not duplicate");
|
|
198
|
+
assertEquals(stores["plan_conformance"][0].status, "skipped");
|
|
199
|
+
assertEquals(stores["plan_conformance"][0].has_deviations, 0);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
|
|
203
|
+
let updated = false;
|
|
204
|
+
const table = {
|
|
205
|
+
async insert() {
|
|
206
|
+
throw new Error("FOREIGN KEY constraint failed");
|
|
207
|
+
},
|
|
208
|
+
async update() {
|
|
209
|
+
updated = true;
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
const data = { table: () => table } as any as DataLayer;
|
|
213
|
+
let threw = false;
|
|
214
|
+
try {
|
|
215
|
+
await recordConformance(data, PLAN, { status: "filed" });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
threw = true;
|
|
218
|
+
assertStringIncludes(String(err), "FOREIGN KEY");
|
|
219
|
+
}
|
|
220
|
+
assert(threw, "the FK error must propagate");
|
|
221
|
+
assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("recordConformance: persists the retro instance key and the escalation review_status (issue #216)", async () => {
|
|
225
|
+
const { data, stores } = memData();
|
|
226
|
+
await recordConformance(data, PLAN, {
|
|
227
|
+
status: "filed",
|
|
228
|
+
commentUrl: "https://x/7#c",
|
|
229
|
+
hasDeviations: true,
|
|
230
|
+
summary: "slice 2 reduced",
|
|
231
|
+
processKey: "retro-inst-7",
|
|
232
|
+
reviewStatus: "reviewing",
|
|
233
|
+
});
|
|
234
|
+
const row = stores["plan_conformance"][0];
|
|
235
|
+
assertEquals(row.process_key, "retro-inst-7");
|
|
236
|
+
assertEquals(row.review_status, "reviewing");
|
|
237
|
+
|
|
238
|
+
// Absent tracking fields default: no processKey, and a settled `reviewed`.
|
|
239
|
+
await recordConformance(data, "acme/widgets#8", { status: "skipped" });
|
|
240
|
+
const clean = stores["plan_conformance"].find((r) => r.plan_key === "acme/widgets#8");
|
|
241
|
+
assertEquals(clean.process_key, null);
|
|
242
|
+
assertEquals(clean.review_status, "reviewed");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("recordConformance: rejects an untrackable `reviewing` row with a null processKey (invariant guard)", async () => {
|
|
246
|
+
const { data, stores } = memData();
|
|
247
|
+
// A `reviewing` row with no `process_key` is unreachable: `pollUserTasks` skips rows without a
|
|
248
|
+
// key and the `instanceTracking` binding keys off `process_key`, so it would strand the review
|
|
249
|
+
// forever. The API must refuse to persist that state, not just the worker call site.
|
|
250
|
+
await assertRejects(
|
|
251
|
+
() =>
|
|
252
|
+
recordConformance(data, PLAN, {
|
|
253
|
+
status: "filed",
|
|
254
|
+
hasDeviations: true,
|
|
255
|
+
reviewStatus: "reviewing",
|
|
256
|
+
}),
|
|
257
|
+
Error,
|
|
258
|
+
"untrackable",
|
|
259
|
+
);
|
|
260
|
+
assertEquals(stores["plan_conformance"] ?? [], []);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("activeConformanceReviews: returns only the rows still `reviewing`", async () => {
|
|
264
|
+
const { data, stores } = memData();
|
|
265
|
+
stores["plan_conformance"] = [
|
|
266
|
+
{ plan_key: "a/b#1", process_key: "p1", review_status: "reviewing", summary: "s1" },
|
|
267
|
+
{ plan_key: "a/b#2", process_key: "p2", review_status: "reviewed", summary: "s2" },
|
|
268
|
+
];
|
|
269
|
+
const active = await activeConformanceReviews(data);
|
|
270
|
+
assertEquals(active.map((r) => r.plan_key), ["a/b#1"]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("acknowledgeConformance: settles the row at reviewed and folds the note into the summary", async () => {
|
|
274
|
+
const { data, stores } = memData();
|
|
275
|
+
stores["plan_conformance"] = [
|
|
276
|
+
{ plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
|
|
277
|
+
];
|
|
278
|
+
await acknowledgeConformance(data, PLAN, "filed follow-up #9");
|
|
279
|
+
const row = stores["plan_conformance"][0];
|
|
280
|
+
assertEquals(row.review_status, "reviewed");
|
|
281
|
+
assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("acknowledgeConformance: fails loudly when the plan_conformance row is missing", async () => {
|
|
285
|
+
const { data } = memData();
|
|
286
|
+
await assertRejects(
|
|
287
|
+
() => acknowledgeConformance(data, PLAN, "filed follow-up #9"),
|
|
288
|
+
Error,
|
|
289
|
+
PLAN,
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("acknowledgeConformance: is idempotent — a retry after settling does not re-append the note", async () => {
|
|
294
|
+
const { data, stores } = memData();
|
|
295
|
+
stores["plan_conformance"] = [
|
|
296
|
+
{ plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
|
|
297
|
+
];
|
|
298
|
+
await acknowledgeConformance(data, PLAN, "filed follow-up #9");
|
|
299
|
+
await acknowledgeConformance(data, PLAN, "filed follow-up #9");
|
|
300
|
+
const row = stores["plan_conformance"][0];
|
|
301
|
+
assertEquals(row.review_status, "reviewed");
|
|
302
|
+
assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("acknowledgeConformance: a blank note settles the row without changing the summary", async () => {
|
|
306
|
+
const { data, stores } = memData();
|
|
307
|
+
stores["plan_conformance"] = [
|
|
308
|
+
{ plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "auth cache unverified" },
|
|
309
|
+
];
|
|
310
|
+
await acknowledgeConformance(data, PLAN, " ");
|
|
311
|
+
assertEquals(stores["plan_conformance"][0].review_status, "reviewed");
|
|
312
|
+
assertEquals(stores["plan_conformance"][0].summary, "auth cache unverified");
|
|
313
|
+
});
|