@nanobpm/nano-workforce 0.70.2 → 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 +7 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
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
|
+
|
|
1
8
|
## [0.70.2](https://github.com/nanobpm/nano-workforce/compare/v0.70.1...v0.70.2) (2026-08-15)
|
|
2
9
|
|
|
3
10
|
|
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,
|
|
@@ -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
|
+
});
|