@nanobpm/nano-workforce 0.69.1 → 0.70.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/AGENTS.md +18 -0
- package/CHANGELOG.md +14 -0
- package/README.md +10 -8
- package/app/agentic/cockpit/index.ts +18 -0
- package/app/agentic/cockpit/supply-boot-past.test.ts +519 -0
- package/app/agentic/cockpit/supply-boot.test.ts +34 -0
- package/app/agentic/cockpit/supply-boot.ts +256 -21
- package/app/agentic/cockpit/transcript-render.test.ts +110 -0
- package/app/agentic/cockpit/transcript-render.ts +136 -0
- package/app/agentic/cockpit/transcript-view.test.ts +61 -0
- package/app/agentic/cockpit/transcript-view.ts +131 -0
- package/app/agentic/families/relay.family.test.ts +103 -0
- package/app/agentic/families/relay.family.ts +74 -0
- package/app/agentic/transcript-read.test.ts +72 -0
- package/app/agentic/transcript-read.ts +161 -0
- package/app/blackboard.test.ts +15 -7
- package/app/blackboard.ts +4 -4
- package/app/convergeGate.test.ts +406 -0
- package/app/convergeGate.ts +48 -0
- package/app/github.ts +225 -0
- package/app/roundProgress.test.ts +229 -0
- package/app/roundProgress.ts +70 -0
- package/app/service.test.ts +18 -1
- package/app/service.ts +5 -1
- package/db/migrations/033_pr_round_head.sql +16 -0
- package/nano.app.json +8 -0
- package/openapi.yaml +267 -0
- package/operations/getAgenticTranscript.test.ts +165 -0
- package/operations/getAgenticTranscript.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +169 -0
- package/operations/listAgenticTranscripts.ts +61 -0
- package/package.json +1 -1
- package/pages/cockpit/cockpit.css +70 -0
- package/pages/cockpit/mount.js +254 -13
- package/pages/cockpit.page.json +1 -1
- package/prompts/review-round.md +55 -9
- package/resources/processes/convergence-loop.bpmn +236 -65
- package/workers/converge-gate/worker.ts +101 -0
- package/workers/progress-check/worker.ts +77 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// nano-workforce — the transcript READ projection (ADR 0056, H3 / #146, read path #222).
|
|
2
|
+
//
|
|
3
|
+
// The write path (relay.family.ts) flushes an ephemeral agent's PTY stream to a durable transcript on
|
|
4
|
+
// job completion; this module is the READ counterpart the advisory `GET /agentic/transcripts*`
|
|
5
|
+
// endpoints share. It projects a {@link TranscriptStore} row (+ its retained chunks) onto the wire
|
|
6
|
+
// shape and enriches it with the H6 correlation (`app/agentic/correlation.ts`) so a captured session
|
|
7
|
+
// lines up with "that process instance / this plan" — even after the ephemeral agent has exited.
|
|
8
|
+
//
|
|
9
|
+
// Correlation is BEST-EFFORT and advisory: the correlation registry is in-memory and only holds
|
|
10
|
+
// currently-linked jobs, so a completed session's process-instance / plan context is present only
|
|
11
|
+
// while the job is still live. The jobKey itself is always recoverable — it is encoded in the stream
|
|
12
|
+
// id (`job:<jobKey>`), so a past session is never anonymous even once its correlation has been released.
|
|
13
|
+
//
|
|
14
|
+
// Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
|
|
15
|
+
// unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
|
|
16
|
+
|
|
17
|
+
import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
|
|
18
|
+
import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
|
|
19
|
+
import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
|
|
20
|
+
|
|
21
|
+
/** Total captured bytes across a set of retained chunks (UTF-8, the on-the-wire terminal encoding). */
|
|
22
|
+
export function byteLengthOf(chunks: readonly TranscriptChunk[]): number {
|
|
23
|
+
let total = 0;
|
|
24
|
+
for (const c of chunks) total += Buffer.byteLength(c.chunk, "utf8");
|
|
25
|
+
return total;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The correlation fields (jobKey + engine context) a stream id resolves to, best-effort. */
|
|
29
|
+
interface CorrelationFields {
|
|
30
|
+
jobKey?: string;
|
|
31
|
+
processInstanceKey?: string;
|
|
32
|
+
bpmnProcessId?: string;
|
|
33
|
+
elementId?: string;
|
|
34
|
+
planKey?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a stream id to its correlation fields: the jobKey is always decoded from a `job:<jobKey>`
|
|
39
|
+
* stream id; the engine context (process instance / plan) is added only when the correlation registry
|
|
40
|
+
* still holds the (live) job. Non-job streams yield an empty object.
|
|
41
|
+
*/
|
|
42
|
+
export function correlationFieldsFor(stream: string, correlation: CorrelationRegistry | undefined): CorrelationFields {
|
|
43
|
+
const jobKey = jobKeyOfStream(stream);
|
|
44
|
+
if (jobKey === undefined) return {};
|
|
45
|
+
const fields: CorrelationFields = { jobKey };
|
|
46
|
+
const context = correlation?.resolve(jobKey);
|
|
47
|
+
if (context) {
|
|
48
|
+
if (context.processInstanceKey !== undefined) fields.processInstanceKey = context.processInstanceKey;
|
|
49
|
+
if (context.bpmnProcessId !== undefined) fields.bpmnProcessId = context.bpmnProcessId;
|
|
50
|
+
if (context.elementId !== undefined) fields.elementId = context.elementId;
|
|
51
|
+
if (context.planKey !== undefined) fields.planKey = context.planKey;
|
|
52
|
+
}
|
|
53
|
+
return fields;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Project a stored transcript's metadata (+ its retained chunks) onto the list wire shape. */
|
|
57
|
+
export function toTranscript(
|
|
58
|
+
meta: TranscriptStream,
|
|
59
|
+
store: TranscriptStore,
|
|
60
|
+
correlation: CorrelationRegistry | undefined,
|
|
61
|
+
): AgenticTranscript {
|
|
62
|
+
const chunks = store.read(meta.stream);
|
|
63
|
+
const out: AgenticTranscript = {
|
|
64
|
+
stream: meta.stream,
|
|
65
|
+
lifecycle: meta.lifecycle,
|
|
66
|
+
status: meta.status,
|
|
67
|
+
createdAt: meta.createdAt,
|
|
68
|
+
nextOffset: meta.nextOffset,
|
|
69
|
+
byteLength: byteLengthOf(chunks),
|
|
70
|
+
chunkCount: chunks.length,
|
|
71
|
+
};
|
|
72
|
+
if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
|
|
73
|
+
if (meta.firstOffset !== undefined) out.firstOffset = meta.firstOffset;
|
|
74
|
+
const fields = correlationFieldsFor(meta.stream, correlation);
|
|
75
|
+
if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
|
|
76
|
+
if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
|
|
77
|
+
if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
|
|
78
|
+
if (fields.elementId !== undefined) out.elementId = fields.elementId;
|
|
79
|
+
if (fields.planKey !== undefined) out.planKey = fields.planKey;
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The filters {@link listTranscripts} understands (all optional; an empty filter returns everything). */
|
|
84
|
+
export interface TranscriptFilter {
|
|
85
|
+
readonly jobKey?: string;
|
|
86
|
+
readonly processInstanceKey?: string;
|
|
87
|
+
readonly planKey?: string;
|
|
88
|
+
/** ISO-8601 lower bound (inclusive) on the session's createdAt. */
|
|
89
|
+
readonly since?: string;
|
|
90
|
+
/** ISO-8601 upper bound (inclusive) on the session's createdAt. */
|
|
91
|
+
readonly until?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* List every captured session projected to the wire shape, sorted newest-first by createdAt (then by
|
|
96
|
+
* stream for a stable tie-break), after applying the (advisory) filters. jobKey / process-instance /
|
|
97
|
+
* plan filters match the correlation-enriched fields; since/until bound createdAt.
|
|
98
|
+
*/
|
|
99
|
+
export function listTranscripts(
|
|
100
|
+
store: TranscriptStore,
|
|
101
|
+
correlation: CorrelationRegistry | undefined,
|
|
102
|
+
filter: TranscriptFilter = {},
|
|
103
|
+
): AgenticTranscript[] {
|
|
104
|
+
const sinceMs = filter.since !== undefined ? Date.parse(filter.since) : undefined;
|
|
105
|
+
const untilMs = filter.until !== undefined ? Date.parse(filter.until) : undefined;
|
|
106
|
+
const rows = store
|
|
107
|
+
.list()
|
|
108
|
+
.map((meta) => toTranscript(meta, store, correlation))
|
|
109
|
+
.filter((t) => {
|
|
110
|
+
if (filter.jobKey !== undefined && t.jobKey !== filter.jobKey) return false;
|
|
111
|
+
if (filter.processInstanceKey !== undefined && t.processInstanceKey !== filter.processInstanceKey) return false;
|
|
112
|
+
if (filter.planKey !== undefined && t.planKey !== filter.planKey) return false;
|
|
113
|
+
const createdMs = Date.parse(t.createdAt);
|
|
114
|
+
if (sinceMs !== undefined && Number.isFinite(createdMs) && createdMs < sinceMs) return false;
|
|
115
|
+
if (untilMs !== undefined && Number.isFinite(createdMs) && createdMs > untilMs) return false;
|
|
116
|
+
return true;
|
|
117
|
+
});
|
|
118
|
+
// Newest session first (a "past sessions" feed reads best most-recent-first); stable on stream id.
|
|
119
|
+
rows.sort((a, b) => {
|
|
120
|
+
const byTime = b.createdAt.localeCompare(a.createdAt);
|
|
121
|
+
return byTime !== 0 ? byTime : a.stream.localeCompare(b.stream);
|
|
122
|
+
});
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Fetch a stored transcript's bytes from offset `from` (inclusive), projected onto the range/offset
|
|
128
|
+
* wire shape — the SAME resume-from-offset contract the live terminal renders, so the cockpit replays
|
|
129
|
+
* a closed stream through its existing renderer. Returns undefined when the stream has no transcript.
|
|
130
|
+
*/
|
|
131
|
+
export function readTranscriptFrom(
|
|
132
|
+
stream: string,
|
|
133
|
+
from: number,
|
|
134
|
+
store: TranscriptStore,
|
|
135
|
+
correlation: CorrelationRegistry | undefined,
|
|
136
|
+
): AgenticTranscriptData | undefined {
|
|
137
|
+
const meta = store.get(stream);
|
|
138
|
+
if (meta === undefined) return undefined;
|
|
139
|
+
const slice = store.since(stream, from);
|
|
140
|
+
const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
|
|
141
|
+
const out: AgenticTranscriptData = {
|
|
142
|
+
stream: meta.stream,
|
|
143
|
+
lifecycle: meta.lifecycle,
|
|
144
|
+
status: meta.status,
|
|
145
|
+
createdAt: meta.createdAt,
|
|
146
|
+
nextOffset: slice.nextOffset,
|
|
147
|
+
byteLength: byteLengthOf(slice.entries),
|
|
148
|
+
chunkCount: entries.length,
|
|
149
|
+
from,
|
|
150
|
+
gap: slice.gap,
|
|
151
|
+
entries,
|
|
152
|
+
};
|
|
153
|
+
if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
|
|
154
|
+
const fields = correlationFieldsFor(meta.stream, correlation);
|
|
155
|
+
if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
|
|
156
|
+
if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
|
|
157
|
+
if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
|
|
158
|
+
if (fields.elementId !== undefined) out.elementId = fields.elementId;
|
|
159
|
+
if (fields.planKey !== undefined) out.planKey = fields.planKey;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
package/app/blackboard.test.ts
CHANGED
|
@@ -36,15 +36,23 @@ test("publicBaseUrl: honours the env override and trims a trailing slash", () =>
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("publicBaseUrl: a blank/whitespace override falls back instead of yielding a bad URL", () => {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Explicit override args bypass the `process.env.NANO_WORKFORCE_BASE_URL` default, so this test
|
|
40
|
+
// needs no env manipulation — the env-read path is covered by the dedicated test below.
|
|
41
|
+
assertEquals(publicBaseUrl(""), "http://localhost:3000");
|
|
42
|
+
assertEquals(publicBaseUrl(" "), "http://localhost:3000");
|
|
43
|
+
assertEquals(blackboardUrl("t", publicBaseUrl("")), "http://localhost:3000/app/api/hooks/blackboard?token=t");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("publicBaseUrl: reads NANO_WORKFORCE_BASE_URL from the environment by default", () => {
|
|
47
|
+
const prev = process.env.NANO_WORKFORCE_BASE_URL;
|
|
41
48
|
try {
|
|
42
|
-
|
|
43
|
-
assertEquals(publicBaseUrl(
|
|
44
|
-
|
|
49
|
+
process.env.NANO_WORKFORCE_BASE_URL = "https://fleet.example.com/console/app-view/Workforce/";
|
|
50
|
+
assertEquals(publicBaseUrl(), "https://fleet.example.com/console/app-view/Workforce");
|
|
51
|
+
delete process.env.NANO_WORKFORCE_BASE_URL;
|
|
52
|
+
assertEquals(publicBaseUrl(), "http://localhost:3000");
|
|
45
53
|
} finally {
|
|
46
|
-
if (prev === undefined) delete process.env.
|
|
47
|
-
else process.env.
|
|
54
|
+
if (prev === undefined) delete process.env.NANO_WORKFORCE_BASE_URL;
|
|
55
|
+
else process.env.NANO_WORKFORCE_BASE_URL = prev;
|
|
48
56
|
}
|
|
49
57
|
});
|
|
50
58
|
|
package/app/blackboard.ts
CHANGED
|
@@ -70,11 +70,11 @@ export function mintBlackboardToken(): string {
|
|
|
70
70
|
|
|
71
71
|
/** The externally-reachable base URL agents use to reach this app. Must resolve from WHEREVER the
|
|
72
72
|
* agent runs (co-located or remote/containerised), so it is configured, never hardcoded. */
|
|
73
|
-
export function publicBaseUrl(env: string | undefined = process.env.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
73
|
+
export function publicBaseUrl(env: string | undefined = process.env.NANO_WORKFORCE_BASE_URL): string {
|
|
74
|
+
// Skip the override if it is unset OR blank/whitespace, so an explicitly-set-but-empty
|
|
75
|
+
// NANO_WORKFORCE_BASE_URL can't yield a malformed capability URL.
|
|
76
76
|
const base =
|
|
77
|
-
[env,
|
|
77
|
+
[env, "http://localhost:3000"]
|
|
78
78
|
.map((v) => v?.trim())
|
|
79
79
|
.find((v): v is string => Boolean(v)) ?? "http://localhost:3000";
|
|
80
80
|
return base.replace(/\/+$/, "");
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
// Convergence comment-gate — unit tests for the canonical router (app/convergeGate.ts), the
|
|
2
|
+
// suppressed-advisory / ack-marker parsers + review-thread fetch helpers (app/github.ts), the
|
|
3
|
+
// pr.converge-gate worker (fail-closed, with injected GitHub readers), and a structural guard over
|
|
4
|
+
// the committed convergence-loop BPMN.
|
|
5
|
+
//
|
|
6
|
+
// The loop used to declare convergence on the agent's self-reported `status = "converged"` with no
|
|
7
|
+
// deterministic check that Copilot's comments were addressed. On Magikcraft/nano-bpm#770 a
|
|
8
|
+
// suppressed advisory was never applied across 20 rounds, yet the PR converged and auto-merged. The
|
|
9
|
+
// fix inserts a deterministic `pr.converge-gate` step on the converged path that blocks convergence
|
|
10
|
+
// while any review thread is unresolved OR any suppressed advisory lacks a RESOLVED `nano-ack:`
|
|
11
|
+
// thread, escalating to the human `wait-answer` task instead of finalizing.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
15
|
+
import { evaluateConvergeGate } from "./convergeGate.ts";
|
|
16
|
+
import {
|
|
17
|
+
parseAckedAdvisories,
|
|
18
|
+
parseReviewThreadsPage,
|
|
19
|
+
parseSuppressedAdvisories,
|
|
20
|
+
pickLatestCopilotReviewBody,
|
|
21
|
+
type ReviewThread,
|
|
22
|
+
} from "./github.ts";
|
|
23
|
+
|
|
24
|
+
// ── The canonical router ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) converges", () => {
|
|
27
|
+
const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedKeys: [], acknowledgedKeys: [] });
|
|
28
|
+
assertEquals(r.convergeBlocked, false);
|
|
29
|
+
assertEquals(r.convergeBlockReason, "");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
|
|
33
|
+
const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedKeys: [], acknowledgedKeys: [] });
|
|
34
|
+
assertEquals(r.convergeBlocked, true);
|
|
35
|
+
assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
|
|
39
|
+
const r = evaluateConvergeGate({
|
|
40
|
+
unresolvedThreadCount: 0,
|
|
41
|
+
suppressedKeys: ["spec/a.json:613"],
|
|
42
|
+
acknowledgedKeys: [],
|
|
43
|
+
});
|
|
44
|
+
assertEquals(r.convergeBlocked, true);
|
|
45
|
+
assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
|
|
46
|
+
// Singular noun for exactly one advisory (explicit, not "advisor" + "y/ies" concatenation).
|
|
47
|
+
assertStringIncludes(r.convergeBlockReason, "1 unacknowledged suppressed advisory (");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
|
|
51
|
+
const r = evaluateConvergeGate({
|
|
52
|
+
unresolvedThreadCount: 0,
|
|
53
|
+
suppressedKeys: ["spec/a.json:613"],
|
|
54
|
+
acknowledgedKeys: ["spec/a.json:613"],
|
|
55
|
+
});
|
|
56
|
+
assertEquals(r.convergeBlocked, false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("evaluateConvergeGate: multiple unacknowledged advisories use the plural noun", () => {
|
|
60
|
+
const r = evaluateConvergeGate({
|
|
61
|
+
unresolvedThreadCount: 0,
|
|
62
|
+
suppressedKeys: ["x.ts:10", "y.ts:20"],
|
|
63
|
+
acknowledgedKeys: [],
|
|
64
|
+
});
|
|
65
|
+
assertEquals(r.convergeBlocked, true);
|
|
66
|
+
assertStringIncludes(r.convergeBlockReason, "2 unacknowledged suppressed advisories (");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("evaluateConvergeGate: reports both a thread and an advisory when both are outstanding", () => {
|
|
70
|
+
const r = evaluateConvergeGate({
|
|
71
|
+
unresolvedThreadCount: 1,
|
|
72
|
+
suppressedKeys: ["x.ts:10", "y.ts:20"],
|
|
73
|
+
acknowledgedKeys: ["x.ts:10"],
|
|
74
|
+
});
|
|
75
|
+
assertEquals(r.convergeBlocked, true);
|
|
76
|
+
assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
|
|
77
|
+
assertStringIncludes(r.convergeBlockReason, "y.ts:20");
|
|
78
|
+
assert(!r.convergeBlockReason.includes("x.ts:10"), "an acknowledged advisory must not be listed");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ── The parsers (app/github.ts) ─────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const SAMPLE_REVIEW_BODY = [
|
|
84
|
+
"## Pull Request Overview",
|
|
85
|
+
"Some prose that mentions **not/an/advisory:1** in passing.",
|
|
86
|
+
"",
|
|
87
|
+
"<details>",
|
|
88
|
+
"<summary>Suppressed comments (2)</summary>",
|
|
89
|
+
"",
|
|
90
|
+
"**spec-app/nano-app.schema.json:613**",
|
|
91
|
+
"- The description could be clearer about the loopback default.",
|
|
92
|
+
"",
|
|
93
|
+
"**server/src/main.rs:42**",
|
|
94
|
+
"- Consider narrowing this type.",
|
|
95
|
+
"</details>",
|
|
96
|
+
].join("\n");
|
|
97
|
+
|
|
98
|
+
test("parseSuppressedAdvisories: extracts only the keys inside the Suppressed comments block", () => {
|
|
99
|
+
const keys = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
|
|
100
|
+
assertEquals(keys, ["spec-app/nano-app.schema.json:613", "server/src/main.rs:42"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseSuppressedAdvisories: returns [] when there is no suppressed block", () => {
|
|
104
|
+
assertEquals(parseSuppressedAdvisories("## Overview\nLooks good, **file.ts:1** is fine."), []);
|
|
105
|
+
assertEquals(parseSuppressedAdvisories(null), []);
|
|
106
|
+
assertEquals(parseSuppressedAdvisories(undefined), []);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
|
|
110
|
+
const threads: ReviewThread[] = [
|
|
111
|
+
{ isResolved: true, path: "a.ts", bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json:613"] },
|
|
112
|
+
{ isResolved: false, path: "b.ts", bodies: ["nano-ack: server/src/main.rs:42"] }, // open -> ignored
|
|
113
|
+
{ isResolved: true, path: "c.ts", bodies: ["unrelated resolved comment"] },
|
|
114
|
+
];
|
|
115
|
+
const acked = parseAckedAdvisories(threads);
|
|
116
|
+
assertEquals(acked, ["spec-app/nano-app.schema.json:613"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("parseReviewThreadsPage: maps nodes and reports a complete (final) page", () => {
|
|
120
|
+
const page = parseReviewThreadsPage({
|
|
121
|
+
data: {
|
|
122
|
+
repository: {
|
|
123
|
+
pullRequest: {
|
|
124
|
+
reviewThreads: {
|
|
125
|
+
pageInfo: { hasNextPage: false, endCursor: null },
|
|
126
|
+
nodes: [{ isResolved: false, path: "a.ts", comments: { nodes: [{ body: "please fix" }] } }],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
assertEquals(page, {
|
|
133
|
+
threads: [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
|
|
134
|
+
hasNextPage: false,
|
|
135
|
+
endCursor: null,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("parseReviewThreadsPage: a TRUNCATED page reports hasNextPage + its cursor (caller pages on)", () => {
|
|
140
|
+
// >100 threads: the first:100 page cannot see thread 101+, so instead of silently dropping the
|
|
141
|
+
// overflow the mapper surfaces `hasNextPage`/`endCursor` and `fetchReviewThreads` pages to
|
|
142
|
+
// completeness (or fails closed once its bounded page cap is exhausted).
|
|
143
|
+
const page = parseReviewThreadsPage({
|
|
144
|
+
data: {
|
|
145
|
+
repository: {
|
|
146
|
+
pullRequest: {
|
|
147
|
+
reviewThreads: {
|
|
148
|
+
pageInfo: { hasNextPage: true, endCursor: "CURSOR123" },
|
|
149
|
+
nodes: [{ isResolved: true, path: "a.ts", comments: { nodes: [{ body: "ok" }] } }],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
assertEquals(page, {
|
|
156
|
+
threads: [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
157
|
+
hasNextPage: true,
|
|
158
|
+
endCursor: "CURSOR123",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("parseReviewThreadsPage: FAILS CLOSED (null) when the reviewThreads block is MISSING", () => {
|
|
163
|
+
// GraphQL errors, permission issues, or a malformed payload can omit `reviewThreads`. Treating that
|
|
164
|
+
// as "no threads" (empty array) is a fail-OPEN — an unverifiable read must return null so the worker
|
|
165
|
+
// blocks/escalates rather than converging on a read that never happened.
|
|
166
|
+
assertEquals(parseReviewThreadsPage({}), null);
|
|
167
|
+
assertEquals(parseReviewThreadsPage({ data: { repository: { pullRequest: {} } } }), null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("parseReviewThreadsPage: FAILS CLOSED (null) when the completeness signal is UNREADABLE", () => {
|
|
171
|
+
// A present block whose `pageInfo.hasNextPage` is not a readable boolean is unverifiable — we cannot
|
|
172
|
+
// tell whether more pages exist, so we cannot safely page or map it.
|
|
173
|
+
const page = parseReviewThreadsPage({
|
|
174
|
+
data: {
|
|
175
|
+
repository: {
|
|
176
|
+
pullRequest: {
|
|
177
|
+
reviewThreads: {
|
|
178
|
+
nodes: [{ isResolved: true, path: "a.ts", comments: { nodes: [{ body: "ok" }] } }],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
assertEquals(page, null);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("pickLatestCopilotReviewBody: picks the NEWEST Copilot review body (oldest\u2192newest order)", () => {
|
|
188
|
+
const body = pickLatestCopilotReviewBody(
|
|
189
|
+
[
|
|
190
|
+
{ user: { login: "human" }, body: "human review" },
|
|
191
|
+
{ user: { login: "Copilot" }, body: "old copilot review" },
|
|
192
|
+
{ user: { login: "Copilot" }, body: "newest copilot review" },
|
|
193
|
+
],
|
|
194
|
+
false,
|
|
195
|
+
);
|
|
196
|
+
assertEquals(body, "newest copilot review");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('pickLatestCopilotReviewBody: a complete read with NO Copilot review is verified empty ("")', () => {
|
|
200
|
+
assertEquals(pickLatestCopilotReviewBody([{ user: { login: "human" }, body: "hi" }], false), "");
|
|
201
|
+
assertEquals(pickLatestCopilotReviewBody([], false), "");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was TRUNCATED", () => {
|
|
205
|
+
// >100 reviews (a long convergence loop): a first-page-only read returns the OLDEST 100 and misses
|
|
206
|
+
// the genuinely newest Copilot review, so an unverifiable (truncated) read must block, never return
|
|
207
|
+
// a stale page's body \u2014 a fail-OPEN on the advisory dimension is the class this gate prevents.
|
|
208
|
+
assertEquals(
|
|
209
|
+
pickLatestCopilotReviewBody(
|
|
210
|
+
[{ user: { login: "Copilot" }, body: "possibly stale" }],
|
|
211
|
+
true,
|
|
212
|
+
),
|
|
213
|
+
null,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
async function makeUnderTest(deps: {
|
|
218
|
+
readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
|
|
219
|
+
readReviewBody: (repo: string, n: number) => Promise<string | null>;
|
|
220
|
+
}) {
|
|
221
|
+
const { makeHandler } = await import("../workers/converge-gate/worker.ts");
|
|
222
|
+
return makeHandler(deps);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("converge-gate: a clean PR is allowed to converge", async () => {
|
|
226
|
+
const handler = await makeUnderTest({
|
|
227
|
+
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
228
|
+
readReviewBody: async () => "## Overview\nNo suppressed block.",
|
|
229
|
+
});
|
|
230
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
231
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("converge-gate: an unresolved thread blocks convergence", async () => {
|
|
235
|
+
const handler = await makeUnderTest({
|
|
236
|
+
readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
|
|
237
|
+
readReviewBody: async () => "",
|
|
238
|
+
});
|
|
239
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
240
|
+
assertEquals(out.convergeBlocked, true);
|
|
241
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("converge-gate: an unacknowledged suppressed advisory blocks convergence", async () => {
|
|
245
|
+
const handler = await makeUnderTest({
|
|
246
|
+
readThreads: async () => [],
|
|
247
|
+
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
248
|
+
});
|
|
249
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
250
|
+
assertEquals(out.convergeBlocked, true);
|
|
251
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
|
|
255
|
+
const handler = await makeUnderTest({
|
|
256
|
+
readThreads: async () => [
|
|
257
|
+
{ isResolved: true, path: "spec-app/nano-app.schema.json", bodies: ["Applied. nano-ack: spec-app/nano-app.schema.json:613"] },
|
|
258
|
+
{ isResolved: true, path: "server/src/main.rs", bodies: ["Declined, false positive. nano-ack: server/src/main.rs:42"] },
|
|
259
|
+
],
|
|
260
|
+
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
261
|
+
});
|
|
262
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
263
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
|
|
267
|
+
const handler = await makeUnderTest({
|
|
268
|
+
readThreads: async () => null,
|
|
269
|
+
readReviewBody: async () => "",
|
|
270
|
+
});
|
|
271
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
272
|
+
assertEquals(out.convergeBlocked, true);
|
|
273
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("converge-gate: FAILS CLOSED when the review-body read returns null (no transport)", async () => {
|
|
277
|
+
// A null review body is unverifiable, not "no advisories" — the gate must block, not fail open on
|
|
278
|
+
// the suppressed-advisory dimension while the threads read happened to succeed.
|
|
279
|
+
const handler = await makeUnderTest({
|
|
280
|
+
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
281
|
+
readReviewBody: async () => null,
|
|
282
|
+
});
|
|
283
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
284
|
+
assertEquals(out.convergeBlocked, true);
|
|
285
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("converge-gate: FAILS CLOSED when a reader throws", async () => {
|
|
289
|
+
const handler = await makeUnderTest({
|
|
290
|
+
readThreads: async () => {
|
|
291
|
+
throw new Error("boom");
|
|
292
|
+
},
|
|
293
|
+
readReviewBody: async () => "",
|
|
294
|
+
});
|
|
295
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
296
|
+
assertEquals(out.convergeBlocked, true);
|
|
297
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("converge-gate: FAILS CLOSED when the target cannot be resolved", async () => {
|
|
301
|
+
const handler = await makeUnderTest({
|
|
302
|
+
readThreads: async () => [],
|
|
303
|
+
readReviewBody: async () => "",
|
|
304
|
+
});
|
|
305
|
+
const out = await handler({ variables: { prKey: "not-a-pr-key" } } as any, {} as any);
|
|
306
|
+
assertEquals(out.convergeBlocked, true);
|
|
307
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("converge-gate: a non-string prKey does not throw — resolves from repo/prNumber vars", async () => {
|
|
311
|
+
// `parsePr` calls `.trim()`, so a missing/non-string prKey must not reach it: otherwise the job
|
|
312
|
+
// throws and retries instead of running the fail-closed gate. A well-formed job carrying valid
|
|
313
|
+
// repo + prNumber but no prKey must still evaluate normally.
|
|
314
|
+
const handler = await makeUnderTest({
|
|
315
|
+
readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
|
|
316
|
+
readReviewBody: async () => "",
|
|
317
|
+
});
|
|
318
|
+
const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
319
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
|
|
323
|
+
const handler = await makeUnderTest({
|
|
324
|
+
readThreads: async () => [],
|
|
325
|
+
readReviewBody: async () => "",
|
|
326
|
+
});
|
|
327
|
+
const out = await handler({ variables: { prKey: 123 } } as any, {} as any);
|
|
328
|
+
assertEquals(out.convergeBlocked, true);
|
|
329
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("converge-gate: resolves repo/prNumber from the prKey when the vars are absent", async () => {
|
|
333
|
+
let seen: [string, number] | null = null;
|
|
334
|
+
const handler = await makeUnderTest({
|
|
335
|
+
readThreads: async (repo, n) => {
|
|
336
|
+
seen = [repo, n];
|
|
337
|
+
return [];
|
|
338
|
+
},
|
|
339
|
+
readReviewBody: async () => "",
|
|
340
|
+
});
|
|
341
|
+
await handler({ variables: { prKey: "o/r#7" } } as any, {} as any);
|
|
342
|
+
assertEquals(seen, ["o/r", 7]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
346
|
+
|
|
347
|
+
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
348
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
349
|
+
|
|
350
|
+
function flowElement(id: string): string | null {
|
|
351
|
+
const re = new RegExp(
|
|
352
|
+
`<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
|
|
353
|
+
);
|
|
354
|
+
const m = flat.match(re);
|
|
355
|
+
return m ? m[0] : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
test("the converged status arm routes through the check-converge gate, not straight to finalize", () => {
|
|
359
|
+
const f = flowElement("f_converged");
|
|
360
|
+
assert(f, "f_converged flow missing");
|
|
361
|
+
assertStringIncludes(f, 'sourceRef="gw-status"');
|
|
362
|
+
assertStringIncludes(f, 'targetRef="check-converge"');
|
|
363
|
+
assertStringIncludes(f, 'status = "converged"');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("check-converge runs the deterministic converge-gate job and feeds gw-converge-gate", () => {
|
|
367
|
+
const f = flowElement("f_toConvergeGate");
|
|
368
|
+
assert(f, "f_toConvergeGate flow missing");
|
|
369
|
+
assertStringIncludes(f, 'sourceRef="check-converge"');
|
|
370
|
+
assertStringIncludes(f, 'targetRef="gw-converge-gate"');
|
|
371
|
+
assertStringIncludes(flat, 'type="pr.converge-gate"');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("gw-converge-gate blocks on an explicit convergeBlocked = true condition", () => {
|
|
375
|
+
const f = flowElement("f_convergeBlocked");
|
|
376
|
+
assert(f, "f_convergeBlocked flow missing");
|
|
377
|
+
assertStringIncludes(f, 'targetRef="persist-escalation-blockedcomments"');
|
|
378
|
+
assertStringIncludes(f, "convergeBlocked = true");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("gw-converge-gate default arm finalizes with no condition", () => {
|
|
382
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
|
|
383
|
+
assert(gw, "gw-converge-gate gateway missing");
|
|
384
|
+
assertStringIncludes(gw[0], 'default="f_convergeOk"');
|
|
385
|
+
const ok = flowElement("f_convergeOk");
|
|
386
|
+
assert(ok, "f_convergeOk flow missing");
|
|
387
|
+
assertStringIncludes(ok, 'targetRef="persist-converged"');
|
|
388
|
+
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("the blocked-comments escalation lands on the human wait-answer task with an answerable escalation", () => {
|
|
392
|
+
const f = flowElement("f_blockedWait");
|
|
393
|
+
assert(f, "f_blockedWait flow missing");
|
|
394
|
+
assertStringIncludes(f, 'sourceRef="persist-escalation-blockedcomments"');
|
|
395
|
+
assertStringIncludes(f, 'targetRef="wait-answer"');
|
|
396
|
+
const task = flat.match(
|
|
397
|
+
/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-blockedcomments"[^>]*>.*?<\/bpmn:serviceTask>/,
|
|
398
|
+
);
|
|
399
|
+
assert(task, "persist-escalation-blockedcomments task missing");
|
|
400
|
+
assertStringIncludes(task[0], 'type="pr.persist-escalation"');
|
|
401
|
+
assertStringIncludes(task[0], 'target="status"');
|
|
402
|
+
assertStringIncludes(task[0], 'target="question"');
|
|
403
|
+
assertStringIncludes(task[0], 'target="recordRound"');
|
|
404
|
+
// The human sees the gate's own reason (the unresolved threads / unacknowledged advisories).
|
|
405
|
+
assertStringIncludes(task[0], "convergeBlockReason");
|
|
406
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Convergence comment-gate (issue: don't converge with unaddressed review comments).
|
|
2
|
+
//
|
|
3
|
+
// The review loop declares convergence on the AGENT's self-reported status. That trusts the agent
|
|
4
|
+
// to only say "converged" once every Copilot comment is addressed — which failed on
|
|
5
|
+
// Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
|
|
6
|
+
// the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
|
|
7
|
+
// while either:
|
|
8
|
+
// • any review THREAD is still unresolved, or
|
|
9
|
+
// • any SUPPRESSED advisory (`path:line` in the latest Copilot review body) lacks a matching
|
|
10
|
+
// RESOLVED ack thread (a thread carrying a `nano-ack: <path>:<line>` marker).
|
|
11
|
+
// A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
|
|
12
|
+
|
|
13
|
+
export interface ConvergeGateInput {
|
|
14
|
+
/** Count of review threads with `isResolved === false`. */
|
|
15
|
+
unresolvedThreadCount: number;
|
|
16
|
+
/** `path:line` keys of Copilot's suppressed advisories (latest review body). */
|
|
17
|
+
suppressedKeys: string[];
|
|
18
|
+
/** `path:line` keys acknowledged by RESOLVED `nano-ack:` threads. */
|
|
19
|
+
acknowledgedKeys: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ConvergeGateResult {
|
|
23
|
+
convergeBlocked: boolean;
|
|
24
|
+
convergeBlockReason: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Decide whether a self-reported "converged" round may proceed to finalize. Pure; the worker
|
|
28
|
+
* feeds it live GitHub state and fails CLOSED (blocks) when that state cannot be read. */
|
|
29
|
+
export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResult {
|
|
30
|
+
const acked = new Set(input.acknowledgedKeys);
|
|
31
|
+
const unacked = input.suppressedKeys.filter((k) => !acked.has(k));
|
|
32
|
+
const reasons: string[] = [];
|
|
33
|
+
if (input.unresolvedThreadCount > 0) {
|
|
34
|
+
const n = input.unresolvedThreadCount;
|
|
35
|
+
reasons.push(`${n} unresolved review thread${n === 1 ? "" : "s"}`);
|
|
36
|
+
}
|
|
37
|
+
if (unacked.length > 0) {
|
|
38
|
+
const noun = unacked.length === 1 ? "advisory" : "advisories";
|
|
39
|
+
reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.join(", ")})`);
|
|
40
|
+
}
|
|
41
|
+
if (reasons.length === 0) {
|
|
42
|
+
return { convergeBlocked: false, convergeBlockReason: "" };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
convergeBlocked: true,
|
|
46
|
+
convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path>:<line>) for each suppressed advisory before converging.`,
|
|
47
|
+
};
|
|
48
|
+
}
|