@nanobpm/nano-workforce 0.70.1 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/agentSla.test.ts +39 -0
- package/app/agentSla.ts +33 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +239 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +53 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
- package/resources/processes/merge-loop.bpmn +182 -120
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.71.0](https://github.com/nanobpm/nano-workforce/compare/v0.70.2...v0.71.0) (2026-08-15)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add a Tasks page to resolve native user-task escalations in the UI ([#238](https://github.com/nanobpm/nano-workforce/issues/238)) ([4dcd1a1](https://github.com/nanobpm/nano-workforce/commit/4dcd1a1ca1756bec45997ce619ed92bff31d03d6)), closes [210/#220](https://github.com/nanobpm/nano-workforce/issues/220)
|
|
7
|
+
|
|
8
|
+
## [0.70.2](https://github.com/nanobpm/nano-workforce/compare/v0.70.1...v0.70.2) (2026-08-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **merge-loop:** add agent-task liveness SLA backstop for rebase/fix-ci ([#237](https://github.com/nanobpm/nano-workforce/issues/237)) ([c77cdca](https://github.com/nanobpm/nano-workforce/commit/c77cdca974a1e53ef2f9757cc5cedf95068713f1))
|
|
14
|
+
|
|
1
15
|
## [0.70.1](https://github.com/nanobpm/nano-workforce/compare/v0.70.0...v0.70.1) (2026-08-15)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -264,8 +264,12 @@ generic Urban **page runtime** (`@nanobpm/app`, ADR 0042) — no hand-written SP
|
|
|
264
264
|
The page defines status-filtered tabs (active vs. history), a submit form, a
|
|
265
265
|
per-row **Cancel** action, and an expandable detail with the round/escalation
|
|
266
266
|
child grids and a lazily-loaded transcript. The round/escalation grids are
|
|
267
|
-
read-only audit
|
|
268
|
-
|
|
267
|
+
read-only audit. Open native user-task escalations are additionally resolved
|
|
268
|
+
app-side from the **Tasks** page (`pages/tasks.page.json`, issue #236) — a nav
|
|
269
|
+
tab whose per-kind `dataGrid`s list every open escalation (feature / plan-review
|
|
270
|
+
/ trial-merge / PR review / blocked-run) off the `user_tasks` read-model and
|
|
271
|
+
submit the typed decision to the canonical human completer — so an operator no
|
|
272
|
+
longer depends on Urban's read-only `taskInbox` stub at `/tasks`.
|
|
269
273
|
|
|
270
274
|
The app-specific business-logic endpoints are **OpenAPI operations** mounted
|
|
271
275
|
under `api.base` (`/app/api`), each implemented by a delegate module in
|
|
@@ -282,6 +286,7 @@ The full, authoritative contract is `openapi.yaml` (Swagger UI at
|
|
|
282
286
|
| `POST` | `/app/api/actions/start/convergence-loop` | parse the PR ref → create the aggregate + start the process (the ONE submit door — page + external callers) |
|
|
283
287
|
| `POST` | `/app/api/actions/start/plan-fanout` | parse the issue ref → start a plan fan-out run (the ONE plan door) |
|
|
284
288
|
| `POST` | `/app/api/actions/message` (`escalation-answered`) | answer an open merge-loop escalation → publish `escalation-answered` (the four #156 escalation kinds are native user tasks answered via the task inbox) |
|
|
289
|
+
| `POST` | `/app/api/actions/complete-user-task` | complete an open native user-task escalation from the Tasks page (plan-review / trial-merge / PR `wait-answer`) → `completeEscalationAsHuman` (the same resume path the task inbox uses) |
|
|
285
290
|
| `GET`/`POST` | `/app/api/hooks/blackboard` | per-plan coordination blackboard (capability-token side-channel) |
|
|
286
291
|
| `GET` | `/app/api/hooks/abandon` | cooperative abandon check (per-PR capability token) |
|
|
287
292
|
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
latestCompletion,
|
|
22
22
|
revertAgentCompletion,
|
|
23
23
|
type TaskCompletion,
|
|
24
|
+
validateEscalationVariables,
|
|
24
25
|
} from "./agentCompletion.ts";
|
|
25
26
|
|
|
26
27
|
/** A minimal in-memory `Table<T>`: AUTOINCREMENT ids on insert, structural `find`, `get`, `update`. */
|
|
@@ -386,3 +387,71 @@ test("latestCompletion returns the newest row by id regardless of insertion orde
|
|
|
386
387
|
assertEquals(newest.id, 3, "the highest-id row for the key wins, not the first found");
|
|
387
388
|
assertEquals(stores.task_completions.rows[0].id, 3, "the backing array is not reordered");
|
|
388
389
|
});
|
|
390
|
+
|
|
391
|
+
// --- Form-contract enforcement (issue #236 review advisory): a completion must satisfy the linked
|
|
392
|
+
// `.form`'s required-field + select allowed-value contract BEFORE the engine resumes, so a missing or
|
|
393
|
+
// invalid decision can never park the process in an invalid state. Derived from the canonical `.form`,
|
|
394
|
+
// exercised through BOTH completers so agent and human paths reject invalid input identically.
|
|
395
|
+
|
|
396
|
+
test("completer rejects a completion missing a required form field (no engine resume, no ledger row)", async () => {
|
|
397
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
398
|
+
const data = memData(stores);
|
|
399
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-1", elementId: "wait-answer" }]);
|
|
400
|
+
|
|
401
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
402
|
+
userTaskKey: "ut-1",
|
|
403
|
+
operatorId: "alice",
|
|
404
|
+
variables: { answer: " " }, // required, but blank
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
assertEquals(r.ok, false);
|
|
408
|
+
assert(String(r.reason).includes("answer"), "the reason names the missing required field");
|
|
409
|
+
assertEquals(completed.length, 0, "an invalid completion never resumes the process");
|
|
410
|
+
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("completer rejects a select value outside the form's allowed set", async () => {
|
|
414
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
415
|
+
const data = memData(stores);
|
|
416
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-2", elementId: "trial-merge-decision" }]);
|
|
417
|
+
|
|
418
|
+
const r = await completeEscalationAsAgent(data, engine, {
|
|
419
|
+
userTaskKey: "ut-2",
|
|
420
|
+
agentId: "bot",
|
|
421
|
+
variables: { action: "explode" }, // not one of proceed/rebase/abandon
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
assertEquals(r.ok, false);
|
|
425
|
+
assert(String(r.reason).includes("action"), "the reason names the invalid select field");
|
|
426
|
+
assertEquals(completed.length, 0, "an out-of-range decision never resumes the process");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("completer accepts variables that satisfy the form contract (required present + allowed value)", async () => {
|
|
430
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
431
|
+
const data = memData(stores);
|
|
432
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-3", elementId: "plan-review-decision" }]);
|
|
433
|
+
|
|
434
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
435
|
+
userTaskKey: "ut-3",
|
|
436
|
+
operatorId: "alice",
|
|
437
|
+
variables: { directive: "revise", notes: "narrow scope" },
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
assertEquals(r.ok, true);
|
|
441
|
+
assertEquals(completed.length, 1, "a contract-valid completion resumes the process");
|
|
442
|
+
assertEquals(completed[0].variables, { directive: "revise", notes: "narrow scope" });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("validateEscalationVariables derives its contract from the canonical .form files", async () => {
|
|
446
|
+
// wait-answer -> pr-escalation.form (answer required)
|
|
447
|
+
assertEquals(validateEscalationVariables("wait-answer", { answer: "ok" }), null);
|
|
448
|
+
assert(validateEscalationVariables("wait-answer", {}) !== null);
|
|
449
|
+
// trial-merge-decision -> action required, allowed proceed/rebase/abandon
|
|
450
|
+
assertEquals(validateEscalationVariables("trial-merge-decision", { action: "abandon" }), null);
|
|
451
|
+
assert(validateEscalationVariables("trial-merge-decision", { action: "nope" }) !== null);
|
|
452
|
+
// plan-review-decision -> directive required, allowed proceed/revise
|
|
453
|
+
assertEquals(validateEscalationVariables("plan-review-decision", { directive: "proceed" }), null);
|
|
454
|
+
assert(validateEscalationVariables("plan-review-decision", { directive: "" }) !== null);
|
|
455
|
+
// an element with no linked form contract is not enforced
|
|
456
|
+
assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
|
|
457
|
+
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
// SAME `completeUserTaskAttributed` (as `human`), so there is exactly one implementation of "complete
|
|
21
21
|
// an escalation user task" — the agent path is an extension of it, not a parallel copy.
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
23
24
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
24
25
|
|
|
25
26
|
const now = () => new Date().toISOString();
|
|
@@ -69,6 +70,77 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
69
70
|
"wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
|
|
70
71
|
]);
|
|
71
72
|
|
|
73
|
+
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
74
|
+
* BPMN `zeebe:formDefinition formId`). Kept beside `ESCALATION_TASK_ELEMENTS` so the completer
|
|
75
|
+
* validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
|
|
76
|
+
const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
77
|
+
"feature-escalation": "feature-escalation",
|
|
78
|
+
"plan-review-decision": "plan-review-decision",
|
|
79
|
+
"trial-merge-decision": "trial-merge-decision",
|
|
80
|
+
"wait-answer": "pr-escalation",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
interface FormContract {
|
|
84
|
+
/** Field keys marked `validate.required` in the `.form`. */
|
|
85
|
+
required: string[];
|
|
86
|
+
/** `select` field key → its allowed `values`. */
|
|
87
|
+
allowed: Record<string, string[]>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const formContractCache = new Map<string, FormContract>();
|
|
91
|
+
|
|
92
|
+
/** Derive a `.form`'s required-field + select allowed-value contract, cached per formId. The `.form`
|
|
93
|
+
* files (`resources/forms/*.form`, deployed via nano.app.json) are the CANONICAL contract, so this
|
|
94
|
+
* reads them rather than re-encoding the field lists — no drift surface. */
|
|
95
|
+
function formContract(formId: string): FormContract {
|
|
96
|
+
const cached = formContractCache.get(formId);
|
|
97
|
+
if (cached) return cached;
|
|
98
|
+
const raw: {
|
|
99
|
+
components?: { key?: string; validate?: { required?: boolean }; values?: { value?: string }[] }[];
|
|
100
|
+
} = JSON.parse(readFileSync(new URL(`../resources/forms/${formId}.form`, import.meta.url), "utf8"));
|
|
101
|
+
const required: string[] = [];
|
|
102
|
+
const allowed: Record<string, string[]> = {};
|
|
103
|
+
for (const c of raw.components ?? []) {
|
|
104
|
+
if (!c.key) continue;
|
|
105
|
+
if (c.validate?.required) required.push(c.key);
|
|
106
|
+
if (c.values?.length) {
|
|
107
|
+
allowed[c.key] = c.values.map((v) => v.value ?? "").filter((v) => v !== "");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const contract: FormContract = { required, allowed };
|
|
111
|
+
formContractCache.set(formId, contract);
|
|
112
|
+
return contract;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Validate completion `variables` against the escalation's `.form` contract (required fields present
|
|
116
|
+
* + `select` values within the allowed set), so a completion can never resume the process with a
|
|
117
|
+
* missing/invalid decision (e.g. a `wait-answer` with no `answer`, or a `trial-merge-decision` with
|
|
118
|
+
* an `action` outside proceed/rebase/abandon). Returns a human-readable reason on violation, or
|
|
119
|
+
* `null` when the variables satisfy the contract. Derived from the canonical `.form` — the same
|
|
120
|
+
* contract the task inbox renders — so both the agent and human completers reject invalid input the
|
|
121
|
+
* exact same way, with one implementation. An element with no linked form contract is not enforced. */
|
|
122
|
+
export function validateEscalationVariables(
|
|
123
|
+
elementId: string,
|
|
124
|
+
variables: Record<string, unknown>,
|
|
125
|
+
): string | null {
|
|
126
|
+
const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
|
|
127
|
+
if (!formId) return null;
|
|
128
|
+
const { required, allowed } = formContract(formId);
|
|
129
|
+
for (const key of required) {
|
|
130
|
+
const v = variables[key];
|
|
131
|
+
if (v === undefined || v === null || (typeof v === "string" && v.trim() === "")) {
|
|
132
|
+
return `${elementId}: "${key}" is required`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const [key, values] of Object.entries(allowed)) {
|
|
136
|
+
const v = variables[key];
|
|
137
|
+
if (v !== undefined && v !== null && !values.includes(String(v))) {
|
|
138
|
+
return `${elementId}: "${key}" must be one of ${values.join(", ")}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
72
144
|
/** The canonical attributed completer. Records an attribution row in `task_completions` (reversible
|
|
73
145
|
* iff the actor is an agent) and THEN completes the user task with the exact typed `variables` — so
|
|
74
146
|
* the ledger row can never be lost by a resume that fires before the write. If the engine
|
|
@@ -183,6 +255,9 @@ export async function completeEscalationAsAgent(
|
|
|
183
255
|
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
184
256
|
if (!resolved.ok) return resolved;
|
|
185
257
|
|
|
258
|
+
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
259
|
+
if (invalid) return { ok: false, reason: invalid };
|
|
260
|
+
|
|
186
261
|
const { completionId } = await completeUserTaskAttributed(
|
|
187
262
|
data,
|
|
188
263
|
engine,
|
|
@@ -212,6 +287,9 @@ export async function completeEscalationAsHuman(
|
|
|
212
287
|
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
213
288
|
if (!resolved.ok) return resolved;
|
|
214
289
|
|
|
290
|
+
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
291
|
+
if (invalid) return { ok: false, reason: invalid };
|
|
292
|
+
|
|
215
293
|
const { completionId } = await completeUserTaskAttributed(
|
|
216
294
|
data,
|
|
217
295
|
engine,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Unit coverage for the agent-task liveness SLA duration policy. The value is baked into every
|
|
2
|
+
// merge-loop instance's `agentSlaTimeout` process variable and evaluated by the rebase / fix-ci
|
|
3
|
+
// agent tasks' interrupting timer boundary, so a malformed operator env must never deploy an
|
|
4
|
+
// uninterpretable `<bpmn:timeDuration>` — it falls back to the default instead. Run with
|
|
5
|
+
// `node --test`.
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { agentSlaTimeout, DEFAULT_AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
10
|
+
|
|
11
|
+
test("agentSlaTimeout: blank / absent / malformed → default", () => {
|
|
12
|
+
assert.equal(agentSlaTimeout(undefined), DEFAULT_AGENT_SLA_TIMEOUT);
|
|
13
|
+
assert.equal(agentSlaTimeout(""), DEFAULT_AGENT_SLA_TIMEOUT);
|
|
14
|
+
assert.equal(agentSlaTimeout(" "), DEFAULT_AGENT_SLA_TIMEOUT);
|
|
15
|
+
assert.equal(agentSlaTimeout("2h"), DEFAULT_AGENT_SLA_TIMEOUT); // missing leading P/T
|
|
16
|
+
assert.equal(agentSlaTimeout("P"), DEFAULT_AGENT_SLA_TIMEOUT); // no component
|
|
17
|
+
assert.equal(agentSlaTimeout("PT"), DEFAULT_AGENT_SLA_TIMEOUT); // T with no time part
|
|
18
|
+
assert.equal(agentSlaTimeout("garbage"), DEFAULT_AGENT_SLA_TIMEOUT);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("agentSlaTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
|
|
22
|
+
assert.equal(agentSlaTimeout("PT30M"), "PT30M");
|
|
23
|
+
assert.equal(agentSlaTimeout("pt4h"), "PT4H");
|
|
24
|
+
assert.equal(agentSlaTimeout("P1D"), "P1D");
|
|
25
|
+
assert.equal(agentSlaTimeout(" pt90m "), "PT90M");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("agentSlaTimeout: an explicit fallback is honoured for a bad value", () => {
|
|
29
|
+
assert.equal(agentSlaTimeout("nope", "PT10M"), "PT10M");
|
|
30
|
+
assert.equal(agentSlaTimeout("PT45M", "PT10M"), "PT45M");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
|
|
34
|
+
// Validate the default against the grammar with a *distinct* fallback: if the default were
|
|
35
|
+
// malformed it would fall through to the sentinel, so equality to itself proves it parses.
|
|
36
|
+
const sentinel = "PT1S";
|
|
37
|
+
assert.notEqual(DEFAULT_AGENT_SLA_TIMEOUT, sentinel);
|
|
38
|
+
assert.equal(agentSlaTimeout(DEFAULT_AGENT_SLA_TIMEOUT, sentinel), DEFAULT_AGENT_SLA_TIMEOUT);
|
|
39
|
+
});
|
package/app/agentSla.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Agent-task liveness SLA policy — kept as a pure module (no env, no I/O) so it is trivially
|
|
2
|
+
// testable, mirroring app/escalationSla.ts. `app/service.ts` seeds the validated `agentSlaTimeout`
|
|
3
|
+
// process variable when it starts the merge-loop; the merge-loop's AGENT service tasks (rebase,
|
|
4
|
+
// fix-ci) carry an interrupting timer boundary whose `<bpmn:timeDuration>=agentSlaTimeout` evaluates
|
|
5
|
+
// it at timer creation (FEEL-expression timer durations, engine-native).
|
|
6
|
+
//
|
|
7
|
+
// This closes the agent-task liveness gap: unlike an escalation *user* task (whose SLA the
|
|
8
|
+
// escalationSla policy already bounds), an AGENT service task has no human in the loop — if no
|
|
9
|
+
// worker holds its capability, or the agent hangs/crashes without failing the job, the token parks
|
|
10
|
+
// on the task forever (no incident, no escalation). The boundary timer makes that impossible: when
|
|
11
|
+
// the SLA elapses the boundary fires, cancels the stuck job, and routes the token to the existing
|
|
12
|
+
// merge escalation so a human is pulled in. It is a durable, in-process backstop — no external
|
|
13
|
+
// watchdog required.
|
|
14
|
+
|
|
15
|
+
import { isoDuration } from "./reviewWait.ts";
|
|
16
|
+
|
|
17
|
+
/** Default agent-task SLA (ISO-8601 duration): how long a merge-loop agent service task (rebase /
|
|
18
|
+
* fix-ci) may sit without completing before its interrupting timer boundary fires and the process
|
|
19
|
+
* escalates for human attention. Deliberately much shorter than the human-decision escalation SLA
|
|
20
|
+
* (PT24H): an agent that has not even started (unstaffed capability) or is stuck should surface to a
|
|
21
|
+
* human quickly, while still being generous enough not to interrupt a legitimately long rebase. */
|
|
22
|
+
export const DEFAULT_AGENT_SLA_TIMEOUT = "PT2H";
|
|
23
|
+
|
|
24
|
+
/** Validate the operator-supplied agent SLA (env `NANO_PR_AGENT_SLA_TIMEOUT`, ISO-8601 duration),
|
|
25
|
+
* falling back to {@link DEFAULT_AGENT_SLA_TIMEOUT} when absent, blank, or malformed — a bad env
|
|
26
|
+
* value must never deploy an uninterpretable timer expression. Derives its validation from the
|
|
27
|
+
* single canonical {@link isoDuration}. */
|
|
28
|
+
export function agentSlaTimeout(
|
|
29
|
+
raw: string | undefined,
|
|
30
|
+
def: string = DEFAULT_AGENT_SLA_TIMEOUT,
|
|
31
|
+
): string {
|
|
32
|
+
return isoDuration(raw, def);
|
|
33
|
+
}
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import { assert, assertEquals } from "#test-assert";
|
|
9
9
|
import { readFileSync } from "node:fs";
|
|
10
|
-
import { TERMINAL_STATUSES } from "./service.ts";
|
|
10
|
+
import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES, TERMINAL_STATUSES } from "./service.ts";
|
|
11
11
|
import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
|
|
12
|
+
import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
|
|
12
13
|
|
|
13
14
|
interface Binding {
|
|
14
15
|
table: string;
|
|
@@ -73,3 +74,45 @@ test("instanceTracking: plans activeStatuses covers every in-flight status", asy
|
|
|
73
74
|
const b = bindingFor(await bindings(), "plans");
|
|
74
75
|
assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
|
|
75
76
|
});
|
|
77
|
+
|
|
78
|
+
// The app-side `pollUserTasks` scan constant is DERIVED from the manifest at load time (no hand-kept
|
|
79
|
+
// duplicate), so it must match the manifest binding exactly — this closes the drift surface Copilot
|
|
80
|
+
// flagged (a second hard-coded list that could silently diverge from the reconciler's activeStatuses).
|
|
81
|
+
test("PR_ACTIVE_STATUSES is derived from the manifest binding (no drift)", async () => {
|
|
82
|
+
const b = bindingFor(await bindings(), "pull_requests");
|
|
83
|
+
assertEquals([...PR_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Same guard for the plan scan: `pollUserTasks` no longer hard-codes ["planning","dispatched"] but
|
|
87
|
+
// derives PLAN_ACTIVE_STATUSES from the manifest, so the plan-escalation scan can't drift from the
|
|
88
|
+
// reconciler's activeStatuses any more than the PR scan can.
|
|
89
|
+
test("PLAN_ACTIVE_STATUSES is derived from the manifest binding (no drift)", async () => {
|
|
90
|
+
const b = bindingFor(await bindings(), "plans");
|
|
91
|
+
assertEquals([...PLAN_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("instanceTracking: feature_runs activeStatuses excludes every terminal status", async () => {
|
|
95
|
+
const b = bindingFor(await bindings(), "feature_runs");
|
|
96
|
+
for (const terminal of FEATURE_TERMINAL_STATUSES) {
|
|
97
|
+
assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Every instance-alive feature status must be reconcilable: a run parked at `feature-blocked`
|
|
102
|
+
// (awaiting_operator) or `feature-escalation` (escalated) — or still running — keeps a live engine
|
|
103
|
+
// instance, so onTerminated must be able to flip it to `abandoned` if that instance terminates.
|
|
104
|
+
// Notably includes `awaiting_operator`: without it a run that terminates while blocked strands at
|
|
105
|
+
// `awaiting_operator` forever and blocks re-dispatch (the drift Copilot flagged on #238).
|
|
106
|
+
test("instanceTracking: feature_runs activeStatuses covers every in-flight status", async () => {
|
|
107
|
+
const inFlight = ["running", "escalated", "awaiting_operator"];
|
|
108
|
+
const b = bindingFor(await bindings(), "feature_runs");
|
|
109
|
+
assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Same no-drift guard for the feature scan: `pollUserTasks` no longer hard-codes
|
|
113
|
+
// ["running","escalated","awaiting_operator"] but derives FEATURE_ACTIVE_STATUSES from the manifest,
|
|
114
|
+
// so the feature-escalation scan can't drift from the reconciler's activeStatuses.
|
|
115
|
+
test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)", async () => {
|
|
116
|
+
const b = bindingFor(await bindings(), "feature_runs");
|
|
117
|
+
assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
|
|
118
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Integration test for `pollUserTasks` (issue #236) — the reconcile that projects the engine's
|
|
2
|
+
// currently-open native user-task escalations onto the unified `user_tasks` read-model the Tasks page
|
|
3
|
+
// reads. It generalises the two feature pollers across every subject: feature runs (denormalised
|
|
4
|
+
// keys), in-flight plans (`plan-review-decision` / `trial-merge-decision`), and in-flight PRs
|
|
5
|
+
// (`wait-answer`). A completed task's row is removed on the next pass so `showCount` tracks live work.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { assertEquals } from "#test-assert";
|
|
8
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
9
|
+
import { pollUserTasks } from "./service.ts";
|
|
10
|
+
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: in-memory table double, mirrors featureEscalation.test.ts
|
|
12
|
+
function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: Record<string, any[]> } {
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
14
|
+
const stores: Record<string, any[]> = {};
|
|
15
|
+
for (const [k, v] of Object.entries(seed)) stores[k] = v.map((r) => ({ ...r }));
|
|
16
|
+
function tbl(name: string, pk = "id") {
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
18
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
20
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
21
|
+
return {
|
|
22
|
+
async all() {
|
|
23
|
+
return rows.slice();
|
|
24
|
+
},
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
26
|
+
async get(id: any) {
|
|
27
|
+
return rows.find((r) => r[pk] === id);
|
|
28
|
+
},
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
30
|
+
async find(where: any = {}) {
|
|
31
|
+
return rows.filter((r) => match(r, where));
|
|
32
|
+
},
|
|
33
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
34
|
+
async insert(r: any) {
|
|
35
|
+
rows.push({ ...r });
|
|
36
|
+
return r[pk];
|
|
37
|
+
},
|
|
38
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
39
|
+
async update(id: any, patch: any) {
|
|
40
|
+
const r = rows.find((row) => row[pk] === id);
|
|
41
|
+
if (r) Object.assign(r, patch);
|
|
42
|
+
},
|
|
43
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
44
|
+
async delete(id: any) {
|
|
45
|
+
const i = rows.findIndex((r) => r[pk] === id);
|
|
46
|
+
if (i >= 0) rows.splice(i, 1);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
|
|
51
|
+
return { data, stores };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A fake engine whose open user tasks are keyed by processInstanceKey (the only field the poller
|
|
55
|
+
* queries on for plan / PR instances). */
|
|
56
|
+
function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
|
|
57
|
+
return {
|
|
58
|
+
searchUserTasks: (filter?: { processInstanceKey?: string }) =>
|
|
59
|
+
Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
|
|
60
|
+
} as unknown as EngineClient;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait escalations into user_tasks", async () => {
|
|
64
|
+
const { data, stores } = memData({
|
|
65
|
+
feature_runs: [
|
|
66
|
+
{
|
|
67
|
+
feature_key: "o/r#10",
|
|
68
|
+
status: "escalated",
|
|
69
|
+
process_key: "fp-10",
|
|
70
|
+
issue_url: "https://github.com/o/r/issues/10",
|
|
71
|
+
escalation_user_task_key: "ut-feat",
|
|
72
|
+
escalation_question: "which framework?",
|
|
73
|
+
blocked_user_task_key: null,
|
|
74
|
+
delivery_label: null,
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
plans: [
|
|
78
|
+
{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20" },
|
|
79
|
+
{ plan_key: "o/r#21", status: "done", process_key: "pp-21", issue_url: "https://github.com/o/r/issues/21" },
|
|
80
|
+
],
|
|
81
|
+
plan_reviews: [
|
|
82
|
+
{ plan_key: "o/r#20", epoch: 0, round: 0, approved: 0, findings: "scope was fine", created_at: "2025-01-01T00:00:00.000Z" },
|
|
83
|
+
{ plan_key: "o/r#20", epoch: 0, round: 1, approved: 0, findings: "scope too broad", created_at: "2025-01-02T00:00:00.000Z" },
|
|
84
|
+
],
|
|
85
|
+
plan_trial_merges: [
|
|
86
|
+
{ id: 1, plan_key: "o/r#20", wave: 0, result: "suite-failed", summary: "wave 0 red", resolved: 0 },
|
|
87
|
+
],
|
|
88
|
+
pull_requests: [
|
|
89
|
+
{ pr_key: "o/r#30", status: "escalated", process_key: "rp-30", url: "https://github.com/o/r/pull/30" },
|
|
90
|
+
],
|
|
91
|
+
escalations: [{ id: 1, pr_key: "o/r#30", status: "open", question: "conflicting reviews" }],
|
|
92
|
+
});
|
|
93
|
+
const engine = fakeEngine({
|
|
94
|
+
"pp-20": [
|
|
95
|
+
{ userTaskKey: "ut-plan", elementId: "plan-review-decision" },
|
|
96
|
+
{ userTaskKey: "ut-trial", elementId: "trial-merge-decision" },
|
|
97
|
+
],
|
|
98
|
+
"pp-21": [{ userTaskKey: "ut-terminal", elementId: "plan-review-decision" }],
|
|
99
|
+
"rp-30": [{ userTaskKey: "ut-pr", elementId: "wait-answer" }],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await pollUserTasks(data, engine);
|
|
103
|
+
|
|
104
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
105
|
+
assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan", "ut-pr", "ut-trial"]);
|
|
106
|
+
assertEquals(byKey["ut-feat"].kind_label, "Feature escalation");
|
|
107
|
+
assertEquals(byKey["ut-feat"].question, "which framework?");
|
|
108
|
+
assertEquals(byKey["ut-plan"].kind_label, "Plan review");
|
|
109
|
+
assertEquals(byKey["ut-plan"].question, "scope too broad");
|
|
110
|
+
assertEquals(byKey["ut-trial"].kind_label, "Trial merge");
|
|
111
|
+
assertEquals(byKey["ut-trial"].question, "wave 0 red");
|
|
112
|
+
assertEquals(byKey["ut-pr"].kind_label, "PR review");
|
|
113
|
+
assertEquals(byKey["ut-pr"].subject_type, "pr");
|
|
114
|
+
assertEquals(byKey["ut-pr"].question, "conflicting reviews");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
|
|
118
|
+
const { data, stores } = memData({
|
|
119
|
+
user_tasks: [
|
|
120
|
+
{
|
|
121
|
+
user_task_key: "ut-old",
|
|
122
|
+
element_id: "wait-answer",
|
|
123
|
+
kind_label: "PR review",
|
|
124
|
+
subject_type: "pr",
|
|
125
|
+
subject_key: "o/r#30",
|
|
126
|
+
subject_url: null,
|
|
127
|
+
question: null,
|
|
128
|
+
process_key: "rp-30",
|
|
129
|
+
created_at: "2025-01-01T00:00:00.000Z",
|
|
130
|
+
updated_at: "2025-01-01T00:00:00.000Z",
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
pull_requests: [{ pr_key: "o/r#30", status: "converging", process_key: "rp-30", url: "https://github.com/o/r/pull/30" }],
|
|
134
|
+
});
|
|
135
|
+
const engine = fakeEngine({ "rp-30": [] });
|
|
136
|
+
|
|
137
|
+
await pollUserTasks(data, engine);
|
|
138
|
+
|
|
139
|
+
assertEquals(stores.user_tasks, []);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("pollUserTasks: skips terminal plans and PRs without a process key", async () => {
|
|
143
|
+
const { data, stores } = memData({
|
|
144
|
+
plans: [{ plan_key: "o/r#40", status: "planning", process_key: null, issue_url: "https://github.com/o/r/issues/40" }],
|
|
145
|
+
});
|
|
146
|
+
const engine = fakeEngine({});
|
|
147
|
+
|
|
148
|
+
await pollUserTasks(data, engine);
|
|
149
|
+
|
|
150
|
+
assertEquals(stores.user_tasks ?? [], []);
|
|
151
|
+
});
|