@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,155 @@
|
|
|
1
|
+
// End-to-end proof for the user-task + form spine (epic #156, slice U0 — the keystone).
|
|
2
|
+
//
|
|
3
|
+
// Boots this whole Urban app in-process against the WASM engine and a virtual clock via
|
|
4
|
+
// `bootTestApp`, deploys the throwaway `spine-demo` process (a native `userTask` linked to
|
|
5
|
+
// `spine-demo.form`, whose completion drives a data-based decision gateway), and proves the
|
|
6
|
+
// round-trip every later escalation slice builds on:
|
|
7
|
+
//
|
|
8
|
+
// start instance → the task is listed via the `taskInbox` surface route
|
|
9
|
+
// (GET /tasks/api/tasks) → complete it with the typed form field via POST /tasks/api/complete
|
|
10
|
+
// → the process resumes, and the TYPED variable routes the decision gateway before the instance
|
|
11
|
+
// COMPLETES.
|
|
12
|
+
//
|
|
13
|
+
// The gateway is deliberate: a bare "the token advanced" assertion cannot tell a real typed
|
|
14
|
+
// submission apart from an empty one (the WASM engine folds a completed instance's variables away,
|
|
15
|
+
// so they are not observable post-completion). Routing the resume through a FEEL condition on the
|
|
16
|
+
// form's `decision` field makes "resumes WITH those variables" falsifiable — an empty/wrong value
|
|
17
|
+
// would take the gateway's default (reject) flow instead of the approve flow this test asserts. This
|
|
18
|
+
// mirrors the answer/abandon gateway the escalation slices (U2/U3) build on the same spine.
|
|
19
|
+
//
|
|
20
|
+
// Network isolation mirrors the sibling convergence e2e: the app's GitHub transport is forced to
|
|
21
|
+
// `token` mode with no token, so any best-effort GitHub read short-circuits instead of reaching out.
|
|
22
|
+
//
|
|
23
|
+
// Run with `npm run e2e` (a dedicated node:test invocation, kept out of the fast unit `npm test`).
|
|
24
|
+
|
|
25
|
+
import assert from "node:assert/strict";
|
|
26
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { dirname, join, resolve } from "node:path";
|
|
29
|
+
import { after, before, describe, test } from "node:test";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
32
|
+
|
|
33
|
+
// The app root is this repo's root (one level up from `e2e/`) — where nano.app.json + the
|
|
34
|
+
// resources/processes + resources/forms it deploys live.
|
|
35
|
+
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
36
|
+
|
|
37
|
+
// Provision the app's SQLite in a throwaway temp dir so the test never touches (or leaks into) the
|
|
38
|
+
// repo's real ./app.db, and every run starts from a freshly-migrated, empty schema.
|
|
39
|
+
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u0-"));
|
|
40
|
+
|
|
41
|
+
// Force the app fully offline (github.ts reads process.env directly, not the harness env overlay):
|
|
42
|
+
// `token` mode with no GITHUB_TOKEN means every best-effort GitHub read short-circuits to null.
|
|
43
|
+
const GITHUB_ENV_OVERRIDES: Record<string, string> = {
|
|
44
|
+
NANO_PR_GITHUB_TRANSPORT: "token",
|
|
45
|
+
GITHUB_TOKEN: "",
|
|
46
|
+
};
|
|
47
|
+
const savedEnv = new Map<string, string | undefined>();
|
|
48
|
+
|
|
49
|
+
// The harness `env` overlay drives the runtime's `${NANO_APP_DB_URL}` resolution.
|
|
50
|
+
const HARNESS_ENV = {
|
|
51
|
+
NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
interface InboxTask {
|
|
55
|
+
userTaskKey: string;
|
|
56
|
+
elementId?: string;
|
|
57
|
+
variables?: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface TakenFlow {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The engine snapshot's cumulative taken sequence flows, as `from->to` strings. With a single
|
|
66
|
+
* instance in play this is exactly that instance's routing history. */
|
|
67
|
+
function takenFlows(app: TestApp): string[] {
|
|
68
|
+
const snapshot = app.snapshot();
|
|
69
|
+
const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
|
|
70
|
+
return flows
|
|
71
|
+
.filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
|
|
72
|
+
.map((f) => `${f.from}->${f.to}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("nano-workforce user-task spine (U0 keystone)", () => {
|
|
76
|
+
let app: TestApp;
|
|
77
|
+
|
|
78
|
+
before(async () => {
|
|
79
|
+
for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
|
|
80
|
+
savedEnv.set(k, process.env[k]);
|
|
81
|
+
process.env[k] = v;
|
|
82
|
+
}
|
|
83
|
+
app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
after(async () => {
|
|
87
|
+
await app?.stop();
|
|
88
|
+
for (const [k, v] of savedEnv) {
|
|
89
|
+
if (v === undefined) delete process.env[k];
|
|
90
|
+
else process.env[k] = v;
|
|
91
|
+
}
|
|
92
|
+
rmSync(DB_DIR, { recursive: true, force: true });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a userTask bearing a linked .form round-trips list → complete → resume with typed vars", async () => {
|
|
96
|
+
// Boot deploys every model under `models` — including the throwaway spine-demo.bpmn and its
|
|
97
|
+
// linked spine-demo.form. Start one instance of the spine process.
|
|
98
|
+
const created = await app.engine.createInstance({ processDefinitionId: "spine-demo" });
|
|
99
|
+
const processInstanceKey = created.processInstanceKey;
|
|
100
|
+
assert.ok(processInstanceKey, "starting spine-demo returns a process instance key");
|
|
101
|
+
|
|
102
|
+
// The instance parks on the native userTask. It is visible through the `taskInbox` surface's
|
|
103
|
+
// JSON route (GET /tasks/api/tasks) — the manifest-enabled surface this slice lands.
|
|
104
|
+
const listed = await app.callRoute<InboxTask[]>({
|
|
105
|
+
method: "GET",
|
|
106
|
+
path: "/tasks/api/tasks",
|
|
107
|
+
query: { processInstanceKey },
|
|
108
|
+
});
|
|
109
|
+
assert.equal(listed.status, 200, "the taskInbox surface serves the task list");
|
|
110
|
+
assert.equal(listed.body.length, 1, "exactly the one spine userTask is open");
|
|
111
|
+
const task = listed.body[0];
|
|
112
|
+
assert.equal(task.elementId, "decide", "the open task is the spine's `decide` userTask");
|
|
113
|
+
assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
|
|
114
|
+
|
|
115
|
+
// Complete it through the surface's completion route (POST /tasks/api/complete) with exactly the
|
|
116
|
+
// typed field the linked .form declares (`decision`). This is the list → render → complete path
|
|
117
|
+
// an operator drives through the inbox.
|
|
118
|
+
const completed = await app.callRoute<{ ok: boolean }>({
|
|
119
|
+
method: "POST",
|
|
120
|
+
path: "/tasks/api/complete",
|
|
121
|
+
body: JSON.stringify({ userTaskKey: task.userTaskKey, variables: { decision: "approve" } }),
|
|
122
|
+
});
|
|
123
|
+
assert.equal(completed.status, 200, "the completion route accepts the typed form submission");
|
|
124
|
+
assert.equal(completed.body.ok, true, "the userTask was completed");
|
|
125
|
+
|
|
126
|
+
// The typed variable resumed the token through the decision gateway: `decision = "approve"`
|
|
127
|
+
// satisfied the FEEL condition, so the token took the approve flow (NOT the default reject flow).
|
|
128
|
+
// This is the falsifiable core of "resumes WITH those variables" — an empty/wrong value would
|
|
129
|
+
// have fallen through to `end-rejected`.
|
|
130
|
+
const flows = takenFlows(app);
|
|
131
|
+
assert.ok(
|
|
132
|
+
flows.includes("gw-decision->end-approved"),
|
|
133
|
+
`the typed decision routed to the approve end (flows: ${flows.join(", ")})`,
|
|
134
|
+
);
|
|
135
|
+
assert.ok(
|
|
136
|
+
!flows.includes("gw-decision->end-rejected"),
|
|
137
|
+
`the default reject flow was NOT taken (flows: ${flows.join(", ")})`,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// The token advanced start → userTask → gateway → end: the process resumes and reaches COMPLETED,
|
|
141
|
+
// with no open task left behind.
|
|
142
|
+
const instances = await app.engine.searchProcessInstances({
|
|
143
|
+
processInstanceKeys: [processInstanceKey],
|
|
144
|
+
});
|
|
145
|
+
assert.equal(instances.length, 1, "the spine instance is still resolvable");
|
|
146
|
+
assert.equal(instances[0].state, "COMPLETED", "the process resumed and completed");
|
|
147
|
+
|
|
148
|
+
const remaining = await app.callRoute<InboxTask[]>({
|
|
149
|
+
method: "GET",
|
|
150
|
+
path: "/tasks/api/tasks",
|
|
151
|
+
query: { processInstanceKey },
|
|
152
|
+
});
|
|
153
|
+
assert.equal(remaining.body.length, 0, "the completed task is no longer open");
|
|
154
|
+
});
|
|
155
|
+
});
|
package/nano.app.json
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"processes": [
|
|
9
9
|
"resources/processes/*.bpmn"
|
|
10
10
|
],
|
|
11
|
+
"forms": [
|
|
12
|
+
"resources/forms/*.form"
|
|
13
|
+
],
|
|
11
14
|
"templates": [
|
|
12
15
|
"prompts/*.md"
|
|
13
16
|
]
|
|
@@ -39,9 +42,7 @@
|
|
|
39
42
|
],
|
|
40
43
|
"onTerminated": {
|
|
41
44
|
"set": {
|
|
42
|
-
"status": "abandoned"
|
|
43
|
-
"open_escalation_id": null,
|
|
44
|
-
"open_escalation_question": null
|
|
45
|
+
"status": "abandoned"
|
|
45
46
|
}
|
|
46
47
|
},
|
|
47
48
|
"pollMs": 5000
|
|
@@ -60,9 +61,27 @@
|
|
|
60
61
|
}
|
|
61
62
|
},
|
|
62
63
|
"pollMs": 5000
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"table": "feature_runs",
|
|
67
|
+
"keyField": "process_key",
|
|
68
|
+
"statusField": "status",
|
|
69
|
+
"activeStatuses": [
|
|
70
|
+
"running"
|
|
71
|
+
],
|
|
72
|
+
"onTerminated": {
|
|
73
|
+
"set": {
|
|
74
|
+
"status": "abandoned"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"pollMs": 5000
|
|
63
78
|
}
|
|
64
79
|
],
|
|
65
80
|
"workers": [
|
|
81
|
+
{
|
|
82
|
+
"taskType": "pr.ensure-base-branch",
|
|
83
|
+
"handler": "workers/ensure-base-branch/worker.ts"
|
|
84
|
+
},
|
|
66
85
|
{
|
|
67
86
|
"taskType": "pr.persist-round",
|
|
68
87
|
"handler": "workers/persist-round/worker.ts"
|
|
@@ -71,6 +90,10 @@
|
|
|
71
90
|
"taskType": "pr.persist-escalation",
|
|
72
91
|
"handler": "workers/persist-escalation/worker.ts"
|
|
73
92
|
},
|
|
93
|
+
{
|
|
94
|
+
"taskType": "pr.answer-escalation",
|
|
95
|
+
"handler": "workers/answer-escalation/worker.ts"
|
|
96
|
+
},
|
|
74
97
|
{
|
|
75
98
|
"taskType": "pr.finalize",
|
|
76
99
|
"handler": "workers/finalize/worker.ts"
|
|
@@ -108,20 +131,24 @@
|
|
|
108
131
|
"handler": "workers/record-wave/worker.ts"
|
|
109
132
|
},
|
|
110
133
|
{
|
|
111
|
-
"taskType": "pr.record-
|
|
112
|
-
"handler": "workers/record-
|
|
134
|
+
"taskType": "pr.record-feature",
|
|
135
|
+
"handler": "workers/record-feature/worker.ts"
|
|
113
136
|
},
|
|
114
137
|
{
|
|
115
|
-
"taskType": "pr.
|
|
116
|
-
"handler": "workers/
|
|
138
|
+
"taskType": "pr.converge-feature",
|
|
139
|
+
"handler": "workers/converge-feature/worker.ts"
|
|
117
140
|
},
|
|
118
141
|
{
|
|
119
|
-
"taskType": "pr.
|
|
120
|
-
"handler": "workers/
|
|
142
|
+
"taskType": "pr.record-trial-merge",
|
|
143
|
+
"handler": "workers/record-trial-merge/worker.ts"
|
|
121
144
|
},
|
|
122
145
|
{
|
|
123
|
-
"taskType": "pr.
|
|
124
|
-
"handler": "workers/
|
|
146
|
+
"taskType": "pr.resolve-trial-attention",
|
|
147
|
+
"handler": "workers/resolve-trial-attention/worker.ts"
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"taskType": "pr.record-results",
|
|
151
|
+
"handler": "workers/record-results/worker.ts"
|
|
125
152
|
},
|
|
126
153
|
{
|
|
127
154
|
"taskType": "pr.retro-gather",
|
|
@@ -143,6 +170,9 @@
|
|
|
143
170
|
"senior:retro"
|
|
144
171
|
],
|
|
145
172
|
"surfaces": {
|
|
173
|
+
"taskInbox": {
|
|
174
|
+
"enabled": true
|
|
175
|
+
},
|
|
146
176
|
"pages": {
|
|
147
177
|
"enabled": true,
|
|
148
178
|
"pagesDir": "pages",
|