@nanobpm/nano-workforce 0.56.0 → 0.58.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/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +83 -1
- package/CHANGELOG.md +14 -0
- package/README.md +10 -2
- package/SPEC.md +27 -24
- package/app/agentCompletion.test.ts +337 -0
- package/app/agentCompletion.ts +219 -0
- package/app/agentGuide.ts +1 -1
- package/app/answer-escalation.test.ts +106 -0
- package/app/answerEscalation.test.ts +67 -0
- package/app/baseGuard.test.ts +9 -1
- package/app/baseGuard.ts +11 -0
- package/app/escalationSla.test.ts +39 -0
- package/app/escalationSla.ts +28 -0
- package/app/escalationTaxonomy.test.ts +115 -0
- package/app/escalationTaxonomy.ts +115 -0
- package/app/feature.test.ts +161 -0
- package/app/feature.ts +173 -0
- package/app/github.test.ts +179 -1
- package/app/github.ts +132 -0
- package/app/mergeProtocol.test.ts +15 -0
- package/app/mergeProtocol.ts +10 -0
- package/app/persist-escalation.test.ts +34 -36
- package/app/plan.test.ts +267 -313
- package/app/plan.ts +173 -231
- package/app/reviewWait.ts +12 -4
- package/app/roundResultDefault.test.ts +111 -2
- package/app/roundResultDefault.ts +35 -0
- package/app/service.test.ts +6 -7
- package/app/service.ts +52 -35
- package/db/migrations/026_agent_completion.sql +36 -0
- package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
- package/db/migrations/028_feature_runs.sql +28 -0
- package/e2e/agent-answerable.e2e.ts +185 -0
- package/e2e/convergence-escalation.e2e.ts +180 -0
- package/e2e/convergence-loop.e2e.ts +1 -1
- package/e2e/feature-run.e2e.ts +231 -0
- package/e2e/plan-fanout-sla.e2e.ts +238 -0
- package/e2e/plan-fanout.e2e.ts +303 -0
- package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
- package/e2e/support/github-admit.ts +99 -0
- package/e2e/user-task-spine.e2e.ts +155 -0
- package/nano.app.json +41 -11
- package/openapi.yaml +237 -84
- package/operations/agentCompleteEscalation.ts +53 -0
- package/operations/listActivePrs.test.ts +39 -6
- package/operations/postMessage.ts +10 -41
- package/operations/revertEscalationCompletion.ts +44 -0
- package/operations/startAndMessage.test.ts +62 -60
- package/operations/startFeature.ts +127 -0
- package/operations/startPlanFanout.admission.integration.test.ts +263 -0
- package/operations/startPlanFanout.ts +70 -11
- package/package.json +4 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +11 -37
- package/pages/epic.page.json +5 -2
- package/pages/feature.page.json +82 -0
- package/pages/home.page.json +6 -18
- package/resources/agent-guide.md +90 -26
- package/resources/forms/feature-escalation.form +27 -0
- package/resources/forms/plan-review-decision.form +27 -0
- package/resources/forms/pr-escalation.form +23 -0
- package/resources/forms/spine-demo.form +15 -0
- package/resources/forms/trial-merge-decision.form +25 -0
- package/resources/processes/convergence-loop.bpmn +127 -75
- package/resources/processes/feature.bpmn +240 -0
- package/resources/processes/plan-fanout.bpmn +322 -222
- package/resources/processes/spine-demo.bpmn +72 -0
- package/scripts/check-migrations.ts +68 -0
- package/workers/answer-escalation/worker.ts +78 -0
- package/workers/converge-feature/worker.ts +51 -0
- package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
- package/workers/ensure-base-branch/worker.test.ts +104 -0
- package/workers/ensure-base-branch/worker.ts +31 -0
- package/workers/finalize/worker.ts +0 -2
- package/workers/mark-merged/worker.ts +0 -2
- package/workers/merge/worker.ts +6 -5
- package/workers/persist-escalation/worker.ts +28 -32
- package/workers/record-feature/worker.ts +61 -0
- package/workers/record-plan-review/worker.test.ts +9 -10
- package/workers/record-plan-review/worker.ts +15 -5
- package/workers/resolve-trial-attention/worker.test.ts +77 -0
- package/workers/resolve-trial-attention/worker.ts +43 -0
- package/operations/answerFeatureEscalation.test.ts +0 -112
- package/operations/answerFeatureEscalation.ts +0 -58
- package/operations/answerPlanEscalation.test.ts +0 -115
- package/operations/answerPlanEscalation.ts +0 -41
- package/workers/persist-plan-escalation/worker.test.ts +0 -80
- package/workers/persist-plan-escalation/worker.ts +0 -73
- package/workers/persist-task-escalation/worker.ts +0 -120
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// End-to-end proof for the PR review-loop escalation → native userTask + form (epic #156, slice
|
|
2
|
+
// U4, issues #597/#599). Boots this whole Urban app in-process against the WASM engine via
|
|
3
|
+
// `bootTestApp` and proves the migrated escalation round-trip:
|
|
4
|
+
//
|
|
5
|
+
// start convergence-loop → the review agent returns `needs_input` with a question → the loop
|
|
6
|
+
// parks on the native `wait-answer` userTask (linked to `pr-escalation.form`), NOT the retired
|
|
7
|
+
// `escalation-answered` message catch → the open escalation is derived from the durable
|
|
8
|
+
// `escalations` audit row (surfaced on GET /app/api/status as `openEscalation`, with NO
|
|
9
|
+
// denormalised PR-row pointer) → an operator completes the task through the taskInbox surface
|
|
10
|
+
// with the typed `{ answer }` → the `record-answer` step retires the escalations row to
|
|
11
|
+
// `answered` and the loop resumes and the answer reaches the next review round.
|
|
12
|
+
//
|
|
13
|
+
// The falsifiable core (mirroring the U0 spine e2e): the WASM engine folds a completed instance's
|
|
14
|
+
// variables away, so "resumes WITH the typed answer" is proven by capturing `job.variables.answer`
|
|
15
|
+
// on the review agent's SECOND activation — an empty/wrong completion would surface a different
|
|
16
|
+
// value. The `senior:pr-review` task is an `externalTaskType` (no app worker), so the test
|
|
17
|
+
// registers a stateful stub for it: needs_input first, converged (capturing the answer) second.
|
|
18
|
+
//
|
|
19
|
+
// Network isolation mirrors the sibling convergence e2e: the app's GitHub transport is forced to
|
|
20
|
+
// `token` mode with no token, so any best-effort GitHub read short-circuits instead of reaching out.
|
|
21
|
+
//
|
|
22
|
+
// Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
|
|
23
|
+
|
|
24
|
+
import assert from "node:assert/strict";
|
|
25
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
|
+
import { after, before, describe, test } from "node:test";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
31
|
+
|
|
32
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
33
|
+
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u4-"));
|
|
34
|
+
|
|
35
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
36
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
37
|
+
GITHUB_TOKEN: "",
|
|
38
|
+
};
|
|
39
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
40
|
+
|
|
41
|
+
const HARNESS_ENV = {
|
|
42
|
+
NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
interface InboxTask {
|
|
46
|
+
userTaskKey: string;
|
|
47
|
+
elementId?: string;
|
|
48
|
+
variables?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface StatusBody {
|
|
52
|
+
prs: Array<{ prKey: string; status: string; openEscalation: string | null }>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface TakenFlow {
|
|
56
|
+
from: string;
|
|
57
|
+
to: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function takenFlows(app: TestApp): string[] {
|
|
61
|
+
const snapshot = app.snapshot();
|
|
62
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
63
|
+
return flows
|
|
64
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
65
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
69
|
+
let app: TestApp;
|
|
70
|
+
// The review agent stub's captured state across activations.
|
|
71
|
+
let reviewCalls = 0;
|
|
72
|
+
let capturedAnswer: unknown;
|
|
73
|
+
|
|
74
|
+
before(async () => {
|
|
75
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
76
|
+
savedEnv.set(k, process.env[k]);
|
|
77
|
+
process.env[k] = v;
|
|
78
|
+
}
|
|
79
|
+
app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
|
|
80
|
+
// `senior:pr-review` is an externalTaskType (no app worker), so register a stateful stub:
|
|
81
|
+
// round 1 escalates (needs_input + question); round 2 captures the resume `answer` and converges.
|
|
82
|
+
await app.engine.registerWorker("senior:pr-review", (job) => {
|
|
83
|
+
reviewCalls += 1;
|
|
84
|
+
if (reviewCalls === 1) {
|
|
85
|
+
return { status: "needs_input", summary: "need a human decision", question: "Which retry cap?" };
|
|
86
|
+
}
|
|
87
|
+
capturedAnswer = (job.variables as Record<string, unknown>).answer;
|
|
88
|
+
return { status: "converged", summary: "resolved after the human answer" };
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
after(async () => {
|
|
93
|
+
await app?.stop();
|
|
94
|
+
for (const [k, v] of savedEnv) {
|
|
95
|
+
if (v === undefined) delete process.env[k];
|
|
96
|
+
else process.env[k] = v;
|
|
97
|
+
}
|
|
98
|
+
rmSync(DB_DIR, { recursive: true, force: true });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a review-loop escalation parks on a userTask; completing it with {answer} resumes the loop", async () => {
|
|
102
|
+
const api = app.api;
|
|
103
|
+
assert.ok(api, "the OpenAPI driver is available");
|
|
104
|
+
|
|
105
|
+
const prKey = "acme/widgets#77";
|
|
106
|
+
const started = await api.call<{ prKey: string }>("startConvergenceLoop", {
|
|
107
|
+
body: { pr: prKey, convergeOnly: true },
|
|
108
|
+
});
|
|
109
|
+
assert.equal(started.status, 202, "start returns 202 Accepted");
|
|
110
|
+
|
|
111
|
+
const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
|
|
112
|
+
"pull_requests",
|
|
113
|
+
"pr_key",
|
|
114
|
+
);
|
|
115
|
+
const row = await prs.findOne({ pr_key: prKey });
|
|
116
|
+
assert.ok(row?.process_key, "the PR row carries the engine process-instance key");
|
|
117
|
+
const processInstanceKey = row!.process_key!;
|
|
118
|
+
|
|
119
|
+
// Drain the first review round: needs_input routes through persist-escalation (which sets the
|
|
120
|
+
// PR `escalated` and returns the `question`) and parks on the native `wait-answer` userTask.
|
|
121
|
+
await app.settle();
|
|
122
|
+
assert.equal(reviewCalls, 1, "the review agent ran exactly once before parking");
|
|
123
|
+
|
|
124
|
+
// The escalation is a native userTask — visible through the taskInbox surface, NOT a message wait.
|
|
125
|
+
const listed = await app.callRoute<InboxTask[]>({
|
|
126
|
+
method: "GET",
|
|
127
|
+
path: "/tasks/api/tasks",
|
|
128
|
+
query: { processInstanceKey },
|
|
129
|
+
});
|
|
130
|
+
assert.equal(listed.status, 200, "the taskInbox surface serves the task list");
|
|
131
|
+
assert.equal(listed.body.length, 1, "exactly one escalation userTask is open");
|
|
132
|
+
const task = listed.body[0];
|
|
133
|
+
assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
|
|
134
|
+
assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
|
|
135
|
+
|
|
136
|
+
// The open escalation is DERIVED from the durable `escalations` audit row on the status
|
|
137
|
+
// endpoint — no denormalised `open_escalation_*` pointer is written or read.
|
|
138
|
+
const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
139
|
+
assert.equal(status.status, 200, "the status endpoint responds");
|
|
140
|
+
const statusRow = status.body.prs.find((p) => p.prKey === prKey);
|
|
141
|
+
assert.ok(statusRow, "the escalated PR is listed as active");
|
|
142
|
+
assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
|
|
143
|
+
assert.equal(
|
|
144
|
+
statusRow?.openEscalation,
|
|
145
|
+
"Which retry cap?",
|
|
146
|
+
"the open escalation question is derived from the open escalations row",
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// Complete the escalation through the taskInbox completion route with the typed `answer`.
|
|
150
|
+
const answer = "Cap the retries at 5 and proceed.";
|
|
151
|
+
const completed = await app.callRoute<{ ok: boolean }>({
|
|
152
|
+
method: "POST",
|
|
153
|
+
path: "/tasks/api/complete",
|
|
154
|
+
body: JSON.stringify({ userTaskKey: task.userTaskKey, variables: { answer } }),
|
|
155
|
+
});
|
|
156
|
+
assert.equal(completed.status, 200, "the completion route accepts the typed submission");
|
|
157
|
+
assert.equal(completed.body.ok, true, "the userTask was completed");
|
|
158
|
+
|
|
159
|
+
// The typed answer resumed the loop back into the review round: the token took
|
|
160
|
+
// wait-answer → record-answer (which retires the escalations row) → review-round, and the
|
|
161
|
+
// review agent saw exactly the submitted answer. An empty or wrong completion would surface a
|
|
162
|
+
// different `capturedAnswer` — this is the falsifiable core.
|
|
163
|
+
await app.settle();
|
|
164
|
+
const flows = takenFlows(app);
|
|
165
|
+
assert.ok(
|
|
166
|
+
flows.includes("wait-answer->record-answer") && flows.includes("record-answer->review-round"),
|
|
167
|
+
`the answer resumed the loop through record-answer back to the review round (flows: ${flows.join(", ")})`,
|
|
168
|
+
);
|
|
169
|
+
assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
|
|
170
|
+
assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
|
|
171
|
+
|
|
172
|
+
// The escalations row was retired to `answered` (the single source of truth the status endpoint
|
|
173
|
+
// derives from), so no open escalation lingers on the status endpoint once answered + resumed.
|
|
174
|
+
const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
175
|
+
const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
|
|
176
|
+
if (afterRow) {
|
|
177
|
+
assert.equal(afterRow.openEscalation, null, "no open escalation lingers after the answer");
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -183,7 +183,7 @@ describe("nano-workforce e2e (urban-testkit pilot)", () => {
|
|
|
183
183
|
|
|
184
184
|
// Advance past the instanceTracking pollMs (derived from nano.app.json above, plus a margin):
|
|
185
185
|
// the reconciler observes TERMINATED and applies the manifest `onTerminated.set` → status
|
|
186
|
-
// `abandoned
|
|
186
|
+
// `abandoned`.
|
|
187
187
|
await app.advanceTime(PR_POLL_MS + 1000);
|
|
188
188
|
const reconciled = await prs.findOne({ pr_key: prKey });
|
|
189
189
|
assert.equal(reconciled?.status, "abandoned", "reconciler abandoned the terminated PR's row");
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// End-to-end proof for the single-issue feature run (issue #172). Boots the whole app against the
|
|
2
|
+
// WASM engine and drives the REAL feature.bpmn through its three outcomes:
|
|
3
|
+
// • raise-only — the agent opens a PR, `converge` is off → the run ends at `opened`, no hand-off;
|
|
4
|
+
// • raise + converge — with `converge` on, the opened PR is enrolled into the convergence loop
|
|
5
|
+
// (a `pull_requests` row appears) and the feature_run lands `converging`;
|
|
6
|
+
// • escalate + resume — the agent escalates, a native `feature-escalation` user task parks the
|
|
7
|
+
// run, and answering re-dispatches the SAME implement task (mirrors the epic slice).
|
|
8
|
+
//
|
|
9
|
+
// The gateway assertions are the falsifiable core: the WASM engine folds a completed instance's
|
|
10
|
+
// variables away, so we assert on the cumulative taken sequence flows (an empty/wrong result takes a
|
|
11
|
+
// different, default branch). Each scenario boots its own app so `takenSequenceFlows`
|
|
12
|
+
// (engine-global, cumulative) reflects exactly one instance's routing.
|
|
13
|
+
//
|
|
14
|
+
// Run with `npm run e2e`.
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { after, before, describe, test } from "node:test";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
23
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
24
|
+
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
25
|
+
|
|
26
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
|
|
28
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
29
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
30
|
+
GITHUB_TOKEN: "",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface InboxTask {
|
|
34
|
+
userTaskKey: string;
|
|
35
|
+
elementId?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TakenFlow {
|
|
39
|
+
from: string;
|
|
40
|
+
to: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function takenFlows(app: TestApp): string[] {
|
|
44
|
+
const snapshot = app.snapshot();
|
|
45
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
46
|
+
return flows
|
|
47
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
48
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Stub = (job: EngineJob) => Record<string, unknown> | void;
|
|
52
|
+
|
|
53
|
+
interface FeatureRow {
|
|
54
|
+
feature_key: string;
|
|
55
|
+
process_key: string | null;
|
|
56
|
+
status: string;
|
|
57
|
+
pr_key: string | null;
|
|
58
|
+
}
|
|
59
|
+
interface PrRow {
|
|
60
|
+
pr_key: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
64
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
65
|
+
let restoreGithub: (() => void) | undefined;
|
|
66
|
+
|
|
67
|
+
before(() => {
|
|
68
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
69
|
+
savedEnv.set(k, process.env[k]);
|
|
70
|
+
process.env[k] = v;
|
|
71
|
+
}
|
|
72
|
+
// ADR 0003: `startFeature` + the `pr.ensure-base-branch` head task pass through base admission,
|
|
73
|
+
// which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
|
|
74
|
+
restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
after(() => {
|
|
78
|
+
restoreGithub?.();
|
|
79
|
+
for (const [k, v] of savedEnv) {
|
|
80
|
+
if (v === undefined) delete process.env[k];
|
|
81
|
+
else process.env[k] = v;
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Boot a fresh app (isolated engine + DB), register the given `senior:*` stubs, run `body`, and
|
|
86
|
+
* tear down. Each scenario gets its own engine so `takenSequenceFlows` is that run's alone. */
|
|
87
|
+
async function withApp(
|
|
88
|
+
stubs: Record<string, Stub>,
|
|
89
|
+
startBody: Record<string, unknown>,
|
|
90
|
+
body: (ctx: { app: TestApp; featureKey: string; processKey: string }) => Promise<void>,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-f172-"));
|
|
93
|
+
const app = await bootTestApp(APP_ROOT, {
|
|
94
|
+
env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
for (const [jobType, stub] of Object.entries(stubs)) {
|
|
98
|
+
await app.engine.registerWorker(jobType, async (job) => stub(job) ?? undefined);
|
|
99
|
+
}
|
|
100
|
+
const featureKey = "owner/repo#7";
|
|
101
|
+
const started = await app.api?.call("startFeature", { body: { issue: featureKey, ...startBody } });
|
|
102
|
+
assert.equal(started?.status, 202, "startFeature accepted the issue");
|
|
103
|
+
await app.settle();
|
|
104
|
+
const run = await app.db
|
|
105
|
+
.table<FeatureRow>("feature_runs", "feature_key")
|
|
106
|
+
.findOne({ feature_key: featureKey });
|
|
107
|
+
assert.ok(run?.process_key, "the feature_runs row carries the engine process-instance key");
|
|
108
|
+
await body({ app, featureKey, processKey: run!.process_key! });
|
|
109
|
+
} finally {
|
|
110
|
+
await app.stop();
|
|
111
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function featureRow(app: TestApp, featureKey: string): Promise<FeatureRow> {
|
|
116
|
+
const run = await app.db.table<FeatureRow>("feature_runs", "feature_key").findOne({ feature_key: featureKey });
|
|
117
|
+
assert.ok(run, `feature_runs row for ${featureKey} exists`);
|
|
118
|
+
return run!;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test("raise-only: an opened PR ends the run at `opened`, taking the raise-only branch", async () => {
|
|
122
|
+
await withApp(
|
|
123
|
+
{ "senior:feature": () => ({ status: "opened", pr: "owner/repo#101", summary: "built it" }) },
|
|
124
|
+
{ baseBranch: "epic/e2e" },
|
|
125
|
+
async ({ app, featureKey }) => {
|
|
126
|
+
const flows = takenFlows(app);
|
|
127
|
+
assert.ok(
|
|
128
|
+
flows.includes("gw-converge->End"),
|
|
129
|
+
`converge=false took the raise-only branch (flows: ${flows.join(", ")})`,
|
|
130
|
+
);
|
|
131
|
+
assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
|
|
132
|
+
|
|
133
|
+
const run = await featureRow(app, featureKey);
|
|
134
|
+
assert.equal(run.status, "opened", "the run settled at opened");
|
|
135
|
+
assert.equal(run.pr_key, "owner/repo#101", "the raised PR key was recorded");
|
|
136
|
+
|
|
137
|
+
const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({});
|
|
138
|
+
assert.equal(prs.length, 0, "raise-only did NOT enroll the PR into the convergence loop");
|
|
139
|
+
},
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("raise + converge: the opened PR is enrolled into the convergence loop", async () => {
|
|
144
|
+
await withApp(
|
|
145
|
+
{ "senior:feature": () => ({ status: "opened", pr: "owner/repo#102", summary: "built it" }) },
|
|
146
|
+
{ baseBranch: "epic/e2e", converge: true },
|
|
147
|
+
async ({ app, featureKey }) => {
|
|
148
|
+
const flows = takenFlows(app);
|
|
149
|
+
assert.ok(
|
|
150
|
+
flows.includes("gw-converge->converge"),
|
|
151
|
+
`converge=true took the hand-off branch (flows: ${flows.join(", ")})`,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const run = await featureRow(app, featureKey);
|
|
155
|
+
assert.equal(run.status, "converging", "the run settled at converging");
|
|
156
|
+
|
|
157
|
+
const pr = await app.db.table<PrRow>("pull_requests", "pr_key").findOne({ pr_key: "owner/repo#102" });
|
|
158
|
+
assert.ok(pr, "converge enrolled the raised PR into pull_requests (submitPr hand-off)");
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("blocked: a non-opened result never enrolls a PR and settles blocked", async () => {
|
|
164
|
+
await withApp(
|
|
165
|
+
{ "senior:feature": () => ({ status: "blocked", summary: "could not proceed" }) },
|
|
166
|
+
{ baseBranch: "epic/e2e", converge: true },
|
|
167
|
+
async ({ app, featureKey }) => {
|
|
168
|
+
const flows = takenFlows(app);
|
|
169
|
+
assert.ok(flows.includes("gw-converge->End"), "a blocked run takes the raise-only branch even with converge=true");
|
|
170
|
+
const run = await featureRow(app, featureKey);
|
|
171
|
+
assert.equal(run.status, "blocked");
|
|
172
|
+
assert.equal(run.pr_key, null);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("escalate + resume: a native userTask parks the run; answering re-dispatches implement-task", async () => {
|
|
178
|
+
let calls = 0;
|
|
179
|
+
await withApp(
|
|
180
|
+
{
|
|
181
|
+
"senior:feature": () => {
|
|
182
|
+
calls += 1;
|
|
183
|
+
return calls === 1
|
|
184
|
+
? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
|
|
185
|
+
: { status: "opened", pr: "owner/repo#103", summary: "resumed and opened" };
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{ baseBranch: "epic/e2e" },
|
|
189
|
+
async ({ app, processKey }) => {
|
|
190
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
|
|
191
|
+
const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
|
|
192
|
+
assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
|
|
193
|
+
|
|
194
|
+
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
195
|
+
await app.settle();
|
|
196
|
+
|
|
197
|
+
const flows = takenFlows(app);
|
|
198
|
+
assert.ok(
|
|
199
|
+
flows.includes("w_gw_answer->implement-task"),
|
|
200
|
+
`answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
|
|
201
|
+
);
|
|
202
|
+
assert.ok(!flows.includes("w_gw_answer->record-feature"), "the abandon (default) flow was NOT taken");
|
|
203
|
+
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
|
|
209
|
+
await withApp(
|
|
210
|
+
{
|
|
211
|
+
"senior:feature": () => ({ status: "escalated", question: "Blocked — abandon?", summary: "parked" }),
|
|
212
|
+
},
|
|
213
|
+
{ baseBranch: "epic/e2e" },
|
|
214
|
+
async ({ app, processKey }) => {
|
|
215
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
|
|
216
|
+
const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
|
|
217
|
+
assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
|
|
218
|
+
|
|
219
|
+
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "abandon" });
|
|
220
|
+
await app.settle();
|
|
221
|
+
|
|
222
|
+
const flows = takenFlows(app);
|
|
223
|
+
assert.ok(
|
|
224
|
+
flows.includes("w_gw_answer->record-feature"),
|
|
225
|
+
`abandon routed to record-feature (flows: ${flows.join(", ")})`,
|
|
226
|
+
);
|
|
227
|
+
assert.ok(!flows.includes("w_gw_answer->implement-task"), "the answer loop was NOT taken");
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// End-to-end proof for the escalation-of-the-escalation SLA timer boundaries and operator
|
|
2
|
+
// assignment on the plan-fanout escalation user tasks (epic #156, slice U5). Boots the whole app
|
|
3
|
+
// against the WASM engine + virtual clock and drives the REAL plan-fanout.bpmn to each of its three
|
|
4
|
+
// human-decision user tasks — the implementation-phase task escalation, the plan-review cap
|
|
5
|
+
// escalation, and the trial-merge escalation — then, WITHOUT answering, advances the virtual clock
|
|
6
|
+
// past the seeded `escalationSlaTimeout` so each task's interrupting timer boundary fires. It
|
|
7
|
+
// asserts the token took the boundary's safe-default auto-proceed arm (never the human-answer arm),
|
|
8
|
+
// and that each escalation task is discoverable by its `operators` candidate group (and not by a
|
|
9
|
+
// bogus group) — i.e. the inbox is assignment-filterable.
|
|
10
|
+
//
|
|
11
|
+
// The boundary path is the falsifiable core (mirroring U0/U2): the WASM engine folds a completed
|
|
12
|
+
// instance's variables away, so we assert on the cumulative taken sequence flows. An unanswered task
|
|
13
|
+
// with no SLA boundary would simply hang — never taking a boundary flow — so a green here proves the
|
|
14
|
+
// durable in-process liveness the slice lands. Each scenario boots its own app so `takenSequenceFlows`
|
|
15
|
+
// (engine-global, cumulative) reflects exactly one instance's routing.
|
|
16
|
+
//
|
|
17
|
+
// Run with `npm run e2e`.
|
|
18
|
+
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
23
|
+
import { after, before, describe, test } from "node:test";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
26
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
27
|
+
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
28
|
+
|
|
29
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
|
+
|
|
31
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
32
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
33
|
+
GITHUB_TOKEN: "",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Advance well past the default escalation SLA (PT24H) so every armed boundary timer is due.
|
|
37
|
+
const PAST_SLA_MS = 25 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
interface InboxTask {
|
|
40
|
+
userTaskKey: string;
|
|
41
|
+
elementId?: string;
|
|
42
|
+
variables?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface TakenFlow {
|
|
46
|
+
from: string;
|
|
47
|
+
to: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function takenFlows(app: TestApp): string[] {
|
|
51
|
+
const snapshot = app.snapshot();
|
|
52
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
53
|
+
return flows
|
|
54
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
55
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Stub = (job: EngineJob) => Record<string, unknown> | void;
|
|
59
|
+
|
|
60
|
+
describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
61
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
62
|
+
let restoreGithub: (() => void) | undefined;
|
|
63
|
+
|
|
64
|
+
before(() => {
|
|
65
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
66
|
+
savedEnv.set(k, process.env[k]);
|
|
67
|
+
process.env[k] = v;
|
|
68
|
+
}
|
|
69
|
+
// ADR 0003: `startPlanFanout` + the `pr.ensure-base-branch` head task now pass through base
|
|
70
|
+
// admission, which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
|
|
71
|
+
restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
after(() => {
|
|
75
|
+
restoreGithub?.();
|
|
76
|
+
for (const [k, v] of savedEnv) {
|
|
77
|
+
if (v === undefined) delete process.env[k];
|
|
78
|
+
else process.env[k] = v;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function withApp(
|
|
83
|
+
stubs: Record<string, Stub>,
|
|
84
|
+
body: (ctx: { app: TestApp; planKey: string; processKey: string }) => Promise<void>,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const dbDir = mkdtempSync(join(tmpdir(), "nwf-u5-"));
|
|
87
|
+
const app = await bootTestApp(APP_ROOT, {
|
|
88
|
+
env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
|
|
89
|
+
});
|
|
90
|
+
try {
|
|
91
|
+
for (const [jobType, stub] of Object.entries(stubs)) {
|
|
92
|
+
await app.engine.registerWorker(jobType, async (job) => stub(job) ?? undefined);
|
|
93
|
+
}
|
|
94
|
+
const planKey = "owner/repo#1";
|
|
95
|
+
const started = await app.api?.call("startPlanFanout", { body: { issue: planKey, baseBranch: "epic/e2e" } });
|
|
96
|
+
assert.equal(started?.status, 202, "startPlanFanout accepted the issue");
|
|
97
|
+
await app.settle();
|
|
98
|
+
const plan = await app.db
|
|
99
|
+
.table<{ plan_key: string; process_key: string | null }>("plans", "plan_key")
|
|
100
|
+
.findOne({ plan_key: planKey });
|
|
101
|
+
assert.ok(plan?.process_key, "the plan row carries the engine process-instance key");
|
|
102
|
+
await body({ app, planKey, processKey: plan!.process_key! });
|
|
103
|
+
} finally {
|
|
104
|
+
await app.stop();
|
|
105
|
+
rmSync(dbDir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
|
|
110
|
+
const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
|
|
111
|
+
const match = tasks.find((t) => t.elementId === elementId);
|
|
112
|
+
assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
|
|
113
|
+
return match!;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Assert the escalation task is discoverable by the `operators` candidate group (the inbox
|
|
117
|
+
* filter) and NOT by a bogus group — proving `zeebe:assignmentDefinition candidateGroups` took. */
|
|
118
|
+
async function assertAssignmentFilterable(app: TestApp, processKey: string, elementId: string) {
|
|
119
|
+
const byOperators = await app.engine.searchUserTasks({
|
|
120
|
+
processInstanceKey: processKey,
|
|
121
|
+
candidateGroup: "operators",
|
|
122
|
+
});
|
|
123
|
+
assert.ok(
|
|
124
|
+
byOperators.some((t) => t.elementId === elementId),
|
|
125
|
+
`${elementId} is filterable by the operators candidate group`,
|
|
126
|
+
);
|
|
127
|
+
const byBogus = await app.engine.searchUserTasks({
|
|
128
|
+
processInstanceKey: processKey,
|
|
129
|
+
candidateGroup: "nobody-here",
|
|
130
|
+
});
|
|
131
|
+
assert.ok(
|
|
132
|
+
!byBogus.some((t) => t.elementId === elementId),
|
|
133
|
+
`${elementId} is NOT surfaced under a group it does not belong to`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
|
|
138
|
+
const approveReview: Stub = () => ({ approved: true, findings: "" });
|
|
139
|
+
|
|
140
|
+
test("task escalation: an unanswered task hits its SLA boundary and auto-abandons the slice", async () => {
|
|
141
|
+
await withApp(
|
|
142
|
+
{
|
|
143
|
+
"senior:plan": singleTaskPlan,
|
|
144
|
+
"senior:plan-review": approveReview,
|
|
145
|
+
"senior:feature": () => ({
|
|
146
|
+
status: "escalated",
|
|
147
|
+
question: "Which API should I use?",
|
|
148
|
+
summary: "parked for a human",
|
|
149
|
+
}),
|
|
150
|
+
},
|
|
151
|
+
async ({ app, processKey }) => {
|
|
152
|
+
await openTask(app, processKey, "feature-escalation");
|
|
153
|
+
await assertAssignmentFilterable(app, processKey, "feature-escalation");
|
|
154
|
+
|
|
155
|
+
// Never answer — let the SLA elapse. The interrupting boundary cancels the parked task and
|
|
156
|
+
// routes to the task-done end (the safe auto-abandon default).
|
|
157
|
+
await app.advanceTime(PAST_SLA_MS);
|
|
158
|
+
|
|
159
|
+
const flows = takenFlows(app);
|
|
160
|
+
assert.ok(
|
|
161
|
+
flows.includes("be_feature_sla->w_end"),
|
|
162
|
+
`the SLA boundary auto-abandoned to the task-done end (flows: ${flows.join(", ")})`,
|
|
163
|
+
);
|
|
164
|
+
assert.ok(
|
|
165
|
+
!flows.includes("w_gw_answer->implement-task"),
|
|
166
|
+
"the human answer loop was NOT taken",
|
|
167
|
+
);
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("plan-review escalation: an unanswered task hits its SLA boundary and auto-revises", async () => {
|
|
173
|
+
await withApp(
|
|
174
|
+
{
|
|
175
|
+
"senior:plan": singleTaskPlan,
|
|
176
|
+
"senior:plan-review": () => ({ approved: false, findings: "not good enough" }),
|
|
177
|
+
"senior:feature": () => ({ status: "blocked", summary: "n/a" }),
|
|
178
|
+
},
|
|
179
|
+
async ({ app, processKey }) => {
|
|
180
|
+
await openTask(app, processKey, "plan-review-decision");
|
|
181
|
+
await assertAssignmentFilterable(app, processKey, "plan-review-decision");
|
|
182
|
+
|
|
183
|
+
await app.advanceTime(PAST_SLA_MS);
|
|
184
|
+
|
|
185
|
+
const flows = takenFlows(app);
|
|
186
|
+
assert.ok(
|
|
187
|
+
flows.includes("be_plan_sla->plan"),
|
|
188
|
+
`the SLA boundary auto-revised back to plan (flows: ${flows.join(", ")})`,
|
|
189
|
+
);
|
|
190
|
+
assert.ok(
|
|
191
|
+
!flows.includes("gw-plan-answer->select-wave"),
|
|
192
|
+
"the human proceed-override arm was NOT taken",
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const twoTaskPlan: Stub = () => ({
|
|
199
|
+
tasks: [
|
|
200
|
+
{ id: "t1", title: "T1", prompt: "do t1" },
|
|
201
|
+
{ id: "t2", title: "T2", prompt: "do t2" },
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
const openBothPrs: Stub = (job) => {
|
|
205
|
+
const taskId = (job.variables as { task?: { id?: string } }).task?.id;
|
|
206
|
+
return taskId === "t2"
|
|
207
|
+
? { status: "opened", pr: "owner/repo#102", summary: "opened t2" }
|
|
208
|
+
: { status: "opened", pr: "owner/repo#101", summary: "opened t1" };
|
|
209
|
+
};
|
|
210
|
+
const trialSuiteFailed: Stub = () => ({ result: "suite-failed", failing: "combined suite red" });
|
|
211
|
+
|
|
212
|
+
test("trial-merge escalation: an unanswered task hits its SLA boundary and auto-reruns", async () => {
|
|
213
|
+
await withApp(
|
|
214
|
+
{
|
|
215
|
+
"senior:plan": twoTaskPlan,
|
|
216
|
+
"senior:plan-review": approveReview,
|
|
217
|
+
"senior:feature": openBothPrs,
|
|
218
|
+
"senior:trial-merge": trialSuiteFailed,
|
|
219
|
+
},
|
|
220
|
+
async ({ app, processKey }) => {
|
|
221
|
+
await openTask(app, processKey, "trial-merge-decision");
|
|
222
|
+
await assertAssignmentFilterable(app, processKey, "trial-merge-decision");
|
|
223
|
+
|
|
224
|
+
await app.advanceTime(PAST_SLA_MS);
|
|
225
|
+
|
|
226
|
+
const flows = takenFlows(app);
|
|
227
|
+
assert.ok(
|
|
228
|
+
flows.includes("be_trial_sla->trial-merge"),
|
|
229
|
+
`the SLA boundary auto-reran the trial merge (flows: ${flows.join(", ")})`,
|
|
230
|
+
);
|
|
231
|
+
assert.ok(
|
|
232
|
+
!flows.includes("gw-trial-answer->gw-more"),
|
|
233
|
+
"the human proceed arm was NOT taken",
|
|
234
|
+
);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
});
|