@nanobpm/nano-workforce 0.126.0 → 0.127.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/invariants.yml +8 -0
- package/.github/workflows/pr-title-lint.yml +9 -1
- package/.github/workflows/release.yml +8 -2
- package/AGENTS.md +18 -0
- package/CHANGELOG.md +7 -0
- package/app/agentCompletion.ts +11 -0
- package/app/agentic/vocab/demand-report.test.ts +23 -10
- package/app/pollUserTasks.test.ts +66 -2
- package/app/service.ts +21 -8
- package/app/tasksPage.test.ts +44 -0
- package/app/userTasks.test.ts +19 -0
- package/app/userTasks.ts +13 -1
- package/db/migrations/077_user_tasks_form_key.sql +25 -0
- package/docs/adr/0006-delivery-units-one-representation.md +59 -20
- package/e2e/delivery-graph.e2e.ts +7 -0
- package/e2e/feature-preflight.e2e.ts +7 -0
- package/e2e/inter-epic-dependency.e2e.ts +8 -0
- package/e2e/plan-fanout-preflight.e2e.ts +7 -0
- package/e2e/plan-fanout-sla.e2e.ts +4 -3
- package/e2e/plan-fanout.e2e.ts +2 -2
- package/e2e/readiness-gate.e2e.ts +8 -37
- package/e2e/support/probe-exec.test.ts +78 -0
- package/e2e/support/probe-exec.ts +69 -0
- package/e2e/support/time.test.ts +42 -0
- package/e2e/support/time.ts +33 -0
- package/package.json +3 -3
- package/pages/tasks.page.json +24 -554
|
@@ -11,11 +11,19 @@ name: Whole-repo invariants (merge-skew guard)
|
|
|
11
11
|
#
|
|
12
12
|
# This lean workflow re-asserts those whole-repo invariants where the merge actually happens:
|
|
13
13
|
# - `merge_group` — the queue's PROSPECTIVE merged commit, so a skew is blocked BEFORE it lands.
|
|
14
|
+
# THIS is the merge-skew guard: `derived == f(sources_A ∪ sources_B)`.
|
|
15
|
+
# - `pull_request` — required so the SAME check (matched by job name) also reports on the PR head.
|
|
16
|
+
# GitHub's merge queue will not enqueue a PR until every required status check
|
|
17
|
+
# has reported on it, and a check that fires only on `merge_group` never does —
|
|
18
|
+
# it stays "expected" and the PR can never enter the queue (deadlock). The PR-head
|
|
19
|
+
# run also validates the PR's OWN tree (a self-collision / stale artifact the PR
|
|
20
|
+
# introduces alone), catching it before the queue rather than at merge time.
|
|
14
21
|
# - `push: [main]` — a fast backstop that fails a `main`-scoped build within minutes if something
|
|
15
22
|
# slipped through, instead of first surfacing on an unrelated open PR.
|
|
16
23
|
# - `schedule` — a daily catch-all for any skew introduced by a merge that bypassed the queue.
|
|
17
24
|
on:
|
|
18
25
|
merge_group:
|
|
26
|
+
pull_request:
|
|
19
27
|
push:
|
|
20
28
|
branches: [main]
|
|
21
29
|
schedule:
|
|
@@ -7,6 +7,13 @@ name: PR title lint
|
|
|
7
7
|
on:
|
|
8
8
|
pull_request:
|
|
9
9
|
types: [opened, edited, synchronize, reopened]
|
|
10
|
+
# Required status checks in the `main` merge queue must ALSO report on the
|
|
11
|
+
# queue's speculative `merge_group` commit, or the queue stalls forever waiting
|
|
12
|
+
# for a check that only ran on the PR. A `merge_group` commit has no PR title to
|
|
13
|
+
# lint (the title was already validated when the PR entered the queue), so the
|
|
14
|
+
# job runs with its only step skipped and reports success — the green the queue
|
|
15
|
+
# needs, without re-linting.
|
|
16
|
+
merge_group:
|
|
10
17
|
|
|
11
18
|
permissions:
|
|
12
19
|
pull-requests: read
|
|
@@ -16,7 +23,8 @@ jobs:
|
|
|
16
23
|
name: Conventional PR title
|
|
17
24
|
runs-on: ubuntu-latest
|
|
18
25
|
steps:
|
|
19
|
-
-
|
|
26
|
+
- if: github.event_name == 'pull_request'
|
|
27
|
+
uses: amannn/action-semantic-pull-request@v5
|
|
20
28
|
env:
|
|
21
29
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
22
30
|
with:
|
|
@@ -43,8 +43,14 @@ jobs:
|
|
|
43
43
|
REPO: ${{ github.repository }}
|
|
44
44
|
run: |
|
|
45
45
|
set -euo pipefail
|
|
46
|
-
# These must ALL be `success` on the release commit before we ship.
|
|
47
|
-
#
|
|
46
|
+
# These must ALL be `success` on the release commit before we ship.
|
|
47
|
+
# This is the `main` ruleset's required-check set MINUS the PR-only
|
|
48
|
+
# checks: `Conventional PR title` gates the merge queue but never runs
|
|
49
|
+
# on `push:main` (it has no PR title to lint on a merge commit — see
|
|
50
|
+
# pr-title-lint.yml), so requiring it here would deadlock every release.
|
|
51
|
+
# With the merge queue enabled these checks already passed on the
|
|
52
|
+
# speculative merged commit before it landed; this `push:main` gate is
|
|
53
|
+
# the backstop. Keep in lock-step with the ruleset's required checks.
|
|
48
54
|
required=("typecheck + test (Node)" "whole-repo invariants")
|
|
49
55
|
# Dedupe to ONE row per check name — the latest run (max .id) — so re-runs
|
|
50
56
|
# can't leave us reading a stale conclusion. Slurp every page into a
|
package/AGENTS.md
CHANGED
|
@@ -370,6 +370,24 @@ agents:
|
|
|
370
370
|
- **Never `git push --force` on `main`;** use `--force-with-lease` on feature
|
|
371
371
|
branches.
|
|
372
372
|
|
|
373
|
+
### Merging to `main` (merge queue)
|
|
374
|
+
|
|
375
|
+
`main` is protected by an **active ruleset** with a **GitHub merge queue**. You
|
|
376
|
+
do not merge a PR directly — you **add it to the queue** (UI "Merge when ready",
|
|
377
|
+
or the product merge loop's queued-merge path). The queue then builds the
|
|
378
|
+
**prospective merged commit** (`merge_group`) and only lands it if the required
|
|
379
|
+
checks pass **on that speculative tree**:
|
|
380
|
+
|
|
381
|
+
- `typecheck + test (Node)`, `whole-repo invariants`, `Conventional PR title`.
|
|
382
|
+
|
|
383
|
+
`whole-repo invariants` (the merge-skew guard, #366) runs only on the
|
|
384
|
+
`merge_group` commit and on `push:main`, never on a plain PR — so a
|
|
385
|
+
green-in-isolation PR that would collide once merged (a duplicate migration
|
|
386
|
+
prefix, a layout/artifact drift) is **blocked at merge time**, not after it has
|
|
387
|
+
already reddened `main`. Grouping is `ALLGREEN` (SQUASH merge), so any red entry
|
|
388
|
+
invalidates the whole batch. `Conventional PR title` passes through on
|
|
389
|
+
`merge_group` (the title was validated when the PR entered the queue).
|
|
390
|
+
|
|
373
391
|
## Distributed fleet: NANO_WORKFORCE_BASE_URL
|
|
374
392
|
|
|
375
393
|
The abandon and blackboard hooks are how a **distributed worker fleet** calls back
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.127.0](https://github.com/nanobpm/nano-workforce/compare/v0.126.0...v0.127.0) (2026-08-23)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **tasks:** collapse Tasks page to one user_tasks list, complete via engine forms ([#461](https://github.com/nanobpm/nano-workforce/issues/461)) ([#484](https://github.com/nanobpm/nano-workforce/issues/484)) ([bbc0745](https://github.com/nanobpm/nano-workforce/commit/bbc0745ddc7b5f79cfee5096dfbd07cf29ea0f38)), closes [nano-ide#457](https://github.com/nano-ide/issues/457) [#445](https://github.com/nanobpm/nano-workforce/issues/445) [#458](https://github.com/nanobpm/nano-workforce/issues/458)
|
|
7
|
+
|
|
1
8
|
# [0.126.0](https://github.com/nanobpm/nano-workforce/compare/v0.125.0...v0.126.0) (2026-08-23)
|
|
2
9
|
|
|
3
10
|
|
package/app/agentCompletion.ts
CHANGED
|
@@ -122,6 +122,17 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
122
122
|
// (returns `null`) and the completer accepts the captured form variables for the emit binder to type.
|
|
123
123
|
};
|
|
124
124
|
|
|
125
|
+
/** The fixed `.form` linkage (a `zeebe:formDefinition formId`) that governs a fixed-form escalation
|
|
126
|
+
* kind's completion, or `undefined` for a kind with no static form (the delivery-graph `human` node,
|
|
127
|
+
* which renders DIFFERENT forms per node — its `formKey` only ever comes from the engine at runtime).
|
|
128
|
+
* Exposed so the Tasks-inbox poller can denormalise a row's `form_key` from this SAME single source of
|
|
129
|
+
* truth when the raw `/v2/user-tasks/search` result omits the engine-resolved key (issue #461) — the
|
|
130
|
+
* REST gateway addresses a deployed form by value whether that value is a deploy key or an authored
|
|
131
|
+
* form id, so this id resolves the same deployed `.form` the completer validates against. */
|
|
132
|
+
export function escalationFormId(elementId: string): string | undefined {
|
|
133
|
+
return ESCALATION_FORM_BY_ELEMENT[elementId];
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
126
137
|
* (`=<ref> != "<value>"` / `=<ref> == "<value>"`). A required field is only enforced when it is
|
|
127
138
|
* actually shown, so an "answer only when resolution=answer" field is not demanded on the abandon
|
|
@@ -9,7 +9,12 @@ import { CREW_VOCAB_VERSION } from "./crew-vocab.ts";
|
|
|
9
9
|
import { buildRegistryReport, engineRestAddress, toWireReport } from "./demand-report.ts";
|
|
10
10
|
|
|
11
11
|
const NOW = new Date(0);
|
|
12
|
-
|
|
12
|
+
// A demanded taskDefinition leaf. `agentic` is the structural signal the engine reads from a task's
|
|
13
|
+
// `linkName="prompt"` side-car (agentic package >=0.4): true for an external agent task (its type is a
|
|
14
|
+
// routing token matched against crew supply), false for a deterministic in-process host job (bucketed
|
|
15
|
+
// as nonAgentic, out of the demand×supply accounting). Defaults to an agent task since most demand
|
|
16
|
+
// here is agentic; the host-job cases pass `false` explicitly.
|
|
17
|
+
const leaf = (taskType: string, agentic = true): TaskDefinitionLeaf => ({ taskType, process: "p", elementId: taskType, agentic });
|
|
13
18
|
|
|
14
19
|
const plannerFrontier: RegisteredWorker = { instance: "w-front", capability: { cognition: "planning", weight: 5, family: "frontier" } };
|
|
15
20
|
const plannerKimi: RegisteredWorker = { instance: "w-kimi", capability: { cognition: "planning", weight: 5, family: "kimi" } };
|
|
@@ -41,11 +46,14 @@ test("an agent job type with no enrolled senior worker is flagged missing, not n
|
|
|
41
46
|
assertEquals(report.status, "red");
|
|
42
47
|
});
|
|
43
48
|
|
|
44
|
-
test("ordinary host jobs (pr.*) are
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
test("ordinary host jobs (pr.*) are non-agentic (no prompt link) and excluded from demand×supply", () => {
|
|
50
|
+
// A deterministic in-process host job carries no `linkName="prompt"` side-car, so the engine reports
|
|
51
|
+
// it agentic:false and the model buckets it as nonAgentic — off the agentic demand board, never a
|
|
52
|
+
// false RED. The colon-form bridge still leaves its dot-form type untouched (it is not `<rank>:<task>`).
|
|
53
|
+
const report = buildRegistryReport({ taskDefinitions: [leaf("pr.finalize", false)], workers: [], now: NOW });
|
|
54
|
+
assert(report.nonAgentic.includes("pr.finalize"), "pr.finalize is a non-agentic host job");
|
|
55
|
+
assert(!report.networks.some((n) => n.network === "pr"), "and is not counted as agentic pr-network demand");
|
|
56
|
+
assertEquals(report.missing, []);
|
|
49
57
|
});
|
|
50
58
|
|
|
51
59
|
test("flags a demanded leaf with no supplier as missing (red) and a supplied leaf as satisfied", () => {
|
|
@@ -68,10 +76,15 @@ test("flags a demanded leaf with no supplier as missing (red) and a supplied lea
|
|
|
68
76
|
assertEquals(report.demandUnavailable, false);
|
|
69
77
|
});
|
|
70
78
|
|
|
71
|
-
test("
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
test("an agentic leaf whose type resolves to no crew role is a missing (red) gap, not silently dropped", () => {
|
|
80
|
+
// Under the structural model, nonAgentic means "no prompt link", NOT "unparseable/unknown token": an
|
|
81
|
+
// agent task (agentic:true) demanding a rank no crew role serves is a real RED supply gap the board
|
|
82
|
+
// must surface. `principal:*` is a new rank with no crew role — the #323 bridge derives `principal`,
|
|
83
|
+
// which resolves to no supply and trips this regression guard rather than being swept into nonAgentic.
|
|
84
|
+
const report = buildRegistryReport({ taskDefinitions: [leaf("principal:feature")], workers: [], now: NOW });
|
|
85
|
+
assert(report.missing.includes("principal"), "an unsuppliable agent rank is missing, not nonAgentic");
|
|
86
|
+
assertEquals(report.nonAgentic, []);
|
|
87
|
+
assertEquals(report.status, "red");
|
|
75
88
|
});
|
|
76
89
|
|
|
77
90
|
test("the diversity SLO reads the correlated supply — green for distinct-family spar seats", () => {
|
|
@@ -55,7 +55,7 @@ function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: R
|
|
|
55
55
|
/** A single engine-reported user task in the fixture. `state` mirrors the engine lifecycle; it
|
|
56
56
|
* defaults to `"CREATED"` (the only open/answerable state) so existing fixtures read as live tasks.
|
|
57
57
|
* A looping instance holds multiple tasks for one element (COMPLETED from prior rounds + the live one). */
|
|
58
|
-
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED" };
|
|
58
|
+
type FakeTask = { userTaskKey: string; elementId?: string; state?: "CREATED" | "COMPLETED" | "CANCELED"; formKey?: string };
|
|
59
59
|
|
|
60
60
|
/** A fake engine whose user tasks are keyed by processInstanceKey (the only field the poller queries on
|
|
61
61
|
* for plan / PR instances). It models the real engine's two accessors from ONE fixture so a test
|
|
@@ -370,7 +370,7 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
|
|
|
370
370
|
|
|
371
371
|
/** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
|
|
372
372
|
* (the typed seam omits it) so the sweep can map a task back to its subject for enrichment. */
|
|
373
|
-
type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; state?: string };
|
|
373
|
+
type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; state?: string; formKey?: string | number | null };
|
|
374
374
|
|
|
375
375
|
/** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
|
|
376
376
|
* Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
|
|
@@ -611,3 +611,67 @@ test("pollUserTasks (typed-seam fallback): projects an inlined delivery-human ta
|
|
|
611
611
|
assertEquals(byKey["35002"].subject_key, "delivery-graph-403eb22e");
|
|
612
612
|
assertEquals(byKey["35002"].subject_title, "release runbook");
|
|
613
613
|
});
|
|
614
|
+
|
|
615
|
+
// ── form_key denormalisation (issue #461) ─────────────────────────────────────────────────────────
|
|
616
|
+
// The collapsed Tasks page renders ONE `user_tasks` grid and completes each heterogeneous row via its
|
|
617
|
+
// ENGINE-declared form (nano-ide#457). That needs the task's engine `formKey` denormalised onto the
|
|
618
|
+
// row so the grid can resolve the deployed `.form` per row. The poller derives it in the SAME canonical
|
|
619
|
+
// path it derives `kind_label`: read `formKey` from the `/v2/user-tasks/search` result, falling back to
|
|
620
|
+
// `ESCALATION_FORM_BY_ELEMENT` for the fixed-form kinds the search omits it for.
|
|
621
|
+
|
|
622
|
+
test("pollUserTasks (engine-first): a delivery-graph escalation row is present in the single list AND carries its engine form_key (issue #461)", async () => {
|
|
623
|
+
// The regressed case: an armed delivery-graph run escalated (a bounded service node's timeout twin,
|
|
624
|
+
// dynamic id `delivery-human-task__<node>__esc`) — counted by the `filter: []` badge but rendered by no
|
|
625
|
+
// allowlisted grid. Under the single grid it must (a) surface and (b) be completable via its engine form,
|
|
626
|
+
// so its `formKey` (reported by the engine on the task) is denormalised onto the row.
|
|
627
|
+
const { data, stores } = memData({
|
|
628
|
+
delivery_graph_runs: [
|
|
629
|
+
{ run_key: "delivery-graph-407178305d01", process_key: "dg-1", status: "running", title: "ship the release" },
|
|
630
|
+
],
|
|
631
|
+
});
|
|
632
|
+
const restore = stubUserTaskSearch([
|
|
633
|
+
{ userTaskKey: "39354", elementId: "delivery-human-task__n1_task__esc", processInstanceKey: "dg-1", state: "CREATED", formKey: "form-esc-88" },
|
|
634
|
+
]);
|
|
635
|
+
try {
|
|
636
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
637
|
+
} finally {
|
|
638
|
+
restore();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
642
|
+
assertEquals(Object.keys(byKey), ["39354"]); // present in the single list
|
|
643
|
+
assertEquals(byKey["39354"].element_id, "delivery-human-task__n1_task__esc");
|
|
644
|
+
assertEquals(byKey["39354"].kind_label, "Delivery: human step");
|
|
645
|
+
assertEquals(byKey["39354"].form_key, "form-esc-88"); // completable via its engine-declared form
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("pollUserTasks (engine-first): a fixed-kind escalation whose search omits formKey derives form_key from its .form linkage (issue #461)", async () => {
|
|
649
|
+
// Fallback: the raw search can omit `formKey` for a task; a fixed-form kind's `.form` linkage is a
|
|
650
|
+
// static single source of truth (`ESCALATION_FORM_BY_ELEMENT`), so the row is still completable.
|
|
651
|
+
const { data, stores } = memData({});
|
|
652
|
+
const restore = stubUserTaskSearch([
|
|
653
|
+
{ userTaskKey: "ut-plan", elementId: "plan-review-decision", processInstanceKey: "pi-1", state: "CREATED" },
|
|
654
|
+
]);
|
|
655
|
+
try {
|
|
656
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
657
|
+
} finally {
|
|
658
|
+
restore();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
662
|
+
assertEquals(byKey["ut-plan"].form_key, "plan-review-decision");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test("pollUserTasks (typed-seam fallback): denormalises the engine form_key from the typed openUserTasks seam (issue #461)", async () => {
|
|
666
|
+
const { data, stores } = memData({
|
|
667
|
+
plans: [{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: null, title: "epic" }],
|
|
668
|
+
});
|
|
669
|
+
const engine = fakeEngine({
|
|
670
|
+
"pp-20": [{ userTaskKey: "ut-plan", elementId: "plan-review-decision", formKey: "form-77" }],
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
await pollUserTasks(data, engine); // no engineRest → typed-seam fallback
|
|
674
|
+
|
|
675
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
676
|
+
assertEquals(byKey["ut-plan"].form_key, "form-77");
|
|
677
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
12
|
import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
13
|
+
import { escalationFormId } from "./agentCompletion.ts";
|
|
13
14
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
15
|
import {
|
|
15
16
|
CAPS_RESOLVED_MESSAGE,
|
|
@@ -2186,6 +2187,10 @@ interface UserTaskSearchItem {
|
|
|
2186
2187
|
elementId?: string;
|
|
2187
2188
|
processInstanceKey?: string | number;
|
|
2188
2189
|
state?: string;
|
|
2190
|
+
/** The engine's resolution of the task's `.form` linkage (its `formId="X"`) to the deployed form's
|
|
2191
|
+
* key, attached to the open task. Denormalised onto the row so the collapsed Tasks grid can render
|
|
2192
|
+
* the deployed form per row (issue #461). The wire may send a JSON number or string. */
|
|
2193
|
+
formKey?: string | number;
|
|
2189
2194
|
}
|
|
2190
2195
|
|
|
2191
2196
|
/** One discovered open escalation user task, normalised for projection. */
|
|
@@ -2193,6 +2198,9 @@ interface OpenUserTask {
|
|
|
2193
2198
|
userTaskKey: string;
|
|
2194
2199
|
elementId: string;
|
|
2195
2200
|
processInstanceKey: string;
|
|
2201
|
+
/** The engine-reported `formKey`, or "" when the search omitted it (the poller then falls back to the
|
|
2202
|
+
* kind's static `.form` linkage). */
|
|
2203
|
+
formKey: string;
|
|
2196
2204
|
}
|
|
2197
2205
|
|
|
2198
2206
|
/** Engine-first sweep (issue #358): read EVERY open (`CREATED`) native user task from the engine over
|
|
@@ -2234,7 +2242,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2234
2242
|
const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
|
|
2235
2243
|
if (!userTaskKey || seen.has(userTaskKey)) continue;
|
|
2236
2244
|
seen.add(userTaskKey);
|
|
2237
|
-
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey) });
|
|
2245
|
+
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
|
|
2238
2246
|
}
|
|
2239
2247
|
if (items.length < limit) break; // last page
|
|
2240
2248
|
from += items.length;
|
|
@@ -2379,7 +2387,7 @@ export async function pollUserTasks(
|
|
|
2379
2387
|
// element + the instance it parks on) into its desired-row context, enriching from its subject row
|
|
2380
2388
|
// when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
|
|
2381
2389
|
// non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
|
|
2382
|
-
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
|
|
2390
|
+
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
|
|
2383
2391
|
if (userTaskKindLabel(elementId) === undefined) return null;
|
|
2384
2392
|
const subj = subjectByInstance.get(processInstanceKey);
|
|
2385
2393
|
// Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
|
|
@@ -2387,6 +2395,11 @@ export async function pollUserTasks(
|
|
|
2387
2395
|
// derived from the predicate rather than the static per-element table.
|
|
2388
2396
|
const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? (isDeliveryHumanElement(elementId) ? "delivery" : "plan");
|
|
2389
2397
|
const subjectKey = subj?.key ?? processInstanceKey;
|
|
2398
|
+
// Denormalise the engine `formKey` so the collapsed Tasks grid renders the deployed `.form` per row
|
|
2399
|
+
// (issue #461). Prefer the engine-resolved key the search reported; fall back to the fixed-form kind's
|
|
2400
|
+
// static `.form` linkage (`escalationFormId`) when the search omitted it — the delivery-graph `human`
|
|
2401
|
+
// node has no static form (varies per node), so it relies wholly on the engine-reported key.
|
|
2402
|
+
const resolvedFormKey = formKey.trim() || escalationFormId(elementId) || null;
|
|
2390
2403
|
let question: string | null = null;
|
|
2391
2404
|
switch (elementId) {
|
|
2392
2405
|
case FEATURE_ESCALATION_ELEMENT:
|
|
@@ -2412,17 +2425,17 @@ export async function pollUserTasks(
|
|
|
2412
2425
|
question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
|
|
2413
2426
|
break;
|
|
2414
2427
|
}
|
|
2415
|
-
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey };
|
|
2428
|
+
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
2416
2429
|
};
|
|
2417
2430
|
|
|
2418
2431
|
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
2419
2432
|
// subject seen under two statuses mid-pass).
|
|
2420
2433
|
const desiredByKey = new Map<string, UserTaskRow>();
|
|
2421
|
-
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string) => {
|
|
2434
|
+
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, formKey: string) => {
|
|
2422
2435
|
if (!elementId) return;
|
|
2423
2436
|
const rowKey = userTaskKey.trim();
|
|
2424
2437
|
if (!rowKey || desiredByKey.has(rowKey)) return;
|
|
2425
|
-
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey);
|
|
2438
|
+
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, formKey);
|
|
2426
2439
|
if (!ctx) return;
|
|
2427
2440
|
const row = buildUserTaskRow(ctx, at);
|
|
2428
2441
|
if (row) desiredByKey.set(rowKey, row);
|
|
@@ -2433,7 +2446,7 @@ export async function pollUserTasks(
|
|
|
2433
2446
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2434
2447
|
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2435
2448
|
for (const t of await sweepOpenEscalationTasks(base, headers)) {
|
|
2436
|
-
await project(t.elementId, t.userTaskKey, t.processInstanceKey);
|
|
2449
|
+
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.formKey);
|
|
2437
2450
|
}
|
|
2438
2451
|
} else {
|
|
2439
2452
|
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
|
|
@@ -2441,14 +2454,14 @@ export async function pollUserTasks(
|
|
|
2441
2454
|
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2442
2455
|
if (!processKey || seen.has(processKey)) return;
|
|
2443
2456
|
seen.add(processKey);
|
|
2444
|
-
let tasks: { userTaskKey: string; elementId?: string }[];
|
|
2457
|
+
let tasks: { userTaskKey: string; elementId?: string; formKey?: string }[];
|
|
2445
2458
|
try {
|
|
2446
2459
|
tasks = await engine.openUserTasks({ processInstanceKey: processKey });
|
|
2447
2460
|
} catch (err) {
|
|
2448
2461
|
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2449
2462
|
return;
|
|
2450
2463
|
}
|
|
2451
|
-
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey);
|
|
2464
|
+
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, t.formKey ?? "");
|
|
2452
2465
|
};
|
|
2453
2466
|
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2454
2467
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Structure guard for the collapsed Tasks page (issue #461). The Tasks page is ONE `user_tasks`
|
|
2
|
+
// dataGrid (`filter: []`, `orderBy updated_at desc`) with `kind_label` as a "Type" column, completing
|
|
3
|
+
// each row via its ENGINE-declared form (nano-ide#457's `detail.engineForm`) — not seven
|
|
4
|
+
// `element_id`-allowlisted grids each with a hand-authored `detail.form` that duplicates a deployed
|
|
5
|
+
// `.form` resource AND leaves dynamic-id delivery-graph tasks (counted by the `filter: []` badge)
|
|
6
|
+
// rendered by no grid. This test pins that end-state so the anti-pattern can't creep back:
|
|
7
|
+
// • exactly one `dataGrid`, over `user_tasks`, unfiltered, recency-ordered → badge ≡ list;
|
|
8
|
+
// • a "Type" column bound to `kind_label`;
|
|
9
|
+
// • an engine-form detail (`form_key` / `user_task_key`), and NO page-local `detail.form`.
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { test } from "node:test";
|
|
13
|
+
import { assert, assertEquals } from "#test-assert";
|
|
14
|
+
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: reading an untyped page manifest for structural assertions
|
|
16
|
+
const page: any = JSON.parse(readFileSync(fileURLToPath(new URL("../pages/tasks.page.json", import.meta.url)), "utf8"));
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
18
|
+
const nodes: any[] = page.nodes ?? [];
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
20
|
+
const grids: any[] = nodes.filter((n) => n.type === "dataGrid");
|
|
21
|
+
|
|
22
|
+
test("Tasks page is exactly ONE dataGrid over user_tasks (filter [], orderBy updated_at desc)", () => {
|
|
23
|
+
assertEquals(grids.length, 1);
|
|
24
|
+
const [grid] = grids;
|
|
25
|
+
assertEquals(grid.props.data.table, "user_tasks");
|
|
26
|
+
assertEquals(grid.props.data.filter, []);
|
|
27
|
+
assertEquals(grid.props.data.orderBy, { field: "updated_at", dir: "desc" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("Tasks grid surfaces kind_label as a 'Type' column", () => {
|
|
31
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
32
|
+
const typeCol = grids[0].props.columns.find((c: any) => c.field === "kind_label");
|
|
33
|
+
assert(typeCol, "a kind_label column must exist");
|
|
34
|
+
assertEquals(typeCol.header, "Type");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("Tasks grid completes each row via its engine-declared form (nano-ide#457), not a page-local detail.form", () => {
|
|
38
|
+
const { detail } = grids[0].props;
|
|
39
|
+
assert(detail?.engineForm, "the grid detail must opt into engineForm rendering");
|
|
40
|
+
assertEquals(detail.engineForm.formKeyField, "form_key");
|
|
41
|
+
assertEquals(detail.engineForm.userTaskKeyField, "user_task_key");
|
|
42
|
+
// The seven bespoke per-type detail.form blocks (each a copy of a deployed `.form`) are gone.
|
|
43
|
+
for (const g of grids) assert(!g.props.detail?.form, "no grid may carry a page-local detail.form");
|
|
44
|
+
});
|
package/app/userTasks.test.ts
CHANGED
|
@@ -49,11 +49,30 @@ test("buildUserTaskRow: a plan-review task becomes a labelled row with its findi
|
|
|
49
49
|
subject_url: "https://github.com/o/r/issues/1",
|
|
50
50
|
question: "cap reached: revise scope",
|
|
51
51
|
process_key: "pk-1",
|
|
52
|
+
form_key: null,
|
|
52
53
|
created_at: AT,
|
|
53
54
|
updated_at: AT,
|
|
54
55
|
});
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
test("buildUserTaskRow: denormalises the engine form_key, trimming blanks to null (issue #461)", () => {
|
|
59
|
+
const withForm = buildUserTaskRow(
|
|
60
|
+
{ userTaskKey: "ut-f", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1", formKey: " form-9 " },
|
|
61
|
+
AT,
|
|
62
|
+
);
|
|
63
|
+
assertEquals(withForm?.form_key, "form-9");
|
|
64
|
+
const blank = buildUserTaskRow(
|
|
65
|
+
{ userTaskKey: "ut-b", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1", formKey: " " },
|
|
66
|
+
AT,
|
|
67
|
+
);
|
|
68
|
+
assertEquals(blank?.form_key, null);
|
|
69
|
+
const absent = buildUserTaskRow(
|
|
70
|
+
{ userTaskKey: "ut-n", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1" },
|
|
71
|
+
AT,
|
|
72
|
+
);
|
|
73
|
+
assertEquals(absent?.form_key, null);
|
|
74
|
+
});
|
|
75
|
+
|
|
57
76
|
test("buildUserTaskRow: a blank question / missing url normalises to null", () => {
|
|
58
77
|
const row = buildUserTaskRow(
|
|
59
78
|
{ userTaskKey: "ut-2", elementId: TRIAL_MERGE_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", question: " " },
|
package/app/userTasks.ts
CHANGED
|
@@ -60,6 +60,11 @@ export interface UserTaskRow {
|
|
|
60
60
|
subject_url: string | null;
|
|
61
61
|
question: string | null;
|
|
62
62
|
process_key: string | null;
|
|
63
|
+
/** The engine `formKey` of the parked user task's engine-declared form, denormalised so the single
|
|
64
|
+
* collapsed Tasks grid can resolve and render the deployed `.form` per row (nano-ide#457). Derived in
|
|
65
|
+
* the poller from the `/v2/user-tasks/search` result, falling back to the fixed-form kinds' static
|
|
66
|
+
* `.form` linkage; NULL when neither resolves (the grid degrades to bare completion). */
|
|
67
|
+
form_key: string | null;
|
|
63
68
|
created_at: string;
|
|
64
69
|
updated_at: string;
|
|
65
70
|
}
|
|
@@ -103,6 +108,10 @@ export interface UserTaskContext {
|
|
|
103
108
|
subjectUrl?: string | null;
|
|
104
109
|
question?: string | null;
|
|
105
110
|
processKey?: string | null;
|
|
111
|
+
/** The engine-resolved `formKey` of the task's engine-declared form, as the poller read it from the
|
|
112
|
+
* `/v2/user-tasks/search` result (or the fixed-form fallback). Optional/blank tolerated —
|
|
113
|
+
* `buildUserTaskRow` normalises a blank to NULL. */
|
|
114
|
+
formKey?: string | null;
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
|
|
@@ -123,6 +132,7 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
|
|
|
123
132
|
const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
|
|
124
133
|
const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
|
|
125
134
|
const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
|
|
135
|
+
const formKey = typeof ctx.formKey === "string" && ctx.formKey.trim() ? ctx.formKey.trim() : null;
|
|
126
136
|
return {
|
|
127
137
|
user_task_key: userTaskKey,
|
|
128
138
|
element_id: ctx.elementId,
|
|
@@ -133,6 +143,7 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
|
|
|
133
143
|
subject_url: ctx.subjectUrl ?? null,
|
|
134
144
|
question,
|
|
135
145
|
process_key: ctx.processKey ?? null,
|
|
146
|
+
form_key: formKey,
|
|
136
147
|
created_at: at,
|
|
137
148
|
updated_at: at,
|
|
138
149
|
};
|
|
@@ -158,7 +169,8 @@ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
|
|
|
158
169
|
a.subject_title === b.subject_title &&
|
|
159
170
|
a.subject_url === b.subject_url &&
|
|
160
171
|
a.question === b.question &&
|
|
161
|
-
a.process_key === b.process_key
|
|
172
|
+
a.process_key === b.process_key &&
|
|
173
|
+
a.form_key === b.form_key
|
|
162
174
|
);
|
|
163
175
|
}
|
|
164
176
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- Denormalise the engine `form_key` onto the unified Tasks-inbox read-model (issue #461) — additive,
|
|
2
|
+
-- nullable. The collapsed Tasks page renders ONE `user_tasks` grid and completes each heterogeneous row
|
|
3
|
+
-- via its ENGINE-declared form (nano-ide#457's `detail.engineForm`), instead of seven
|
|
4
|
+
-- `element_id`-allowlisted grids each with a hand-authored `detail.form` copy of a deployed `.form`.
|
|
5
|
+
-- Rendering the deployed form per row needs the task's engine `formKey` on the row, so the grid can
|
|
6
|
+
-- resolve `GET /app/actions/form?formKey=<row.form_key>` — the SAME single source of truth
|
|
7
|
+
-- `taskInbox` uses (no page-local field duplication).
|
|
8
|
+
--
|
|
9
|
+
-- Derived in the canonical poller path exactly as `kind_label` is (no drift surface): `pollUserTasks`
|
|
10
|
+
-- (app/service.ts) reads the engine-resolved `formKey` from the Camunda `/v2/user-tasks/search` result
|
|
11
|
+
-- and `buildUserTaskRow` (app/userTasks.ts) writes it, falling back to the fixed-form kinds' static
|
|
12
|
+
-- `.form` linkage (`ESCALATION_FORM_BY_ELEMENT`, app/agentCompletion.ts) when the search omits it.
|
|
13
|
+
-- NULL when neither resolves — the grid degrades to bare completion, matching `taskInbox`.
|
|
14
|
+
--
|
|
15
|
+
-- Forward-only, additive (expand): a new nullable column on an existing table, no shape rewrite and no
|
|
16
|
+
-- backfill (the poller repopulates every open row on its next pass). Numbered after the current highest
|
|
17
|
+
-- prefix (076); the runner wraps each file in its own transaction, so this file must NOT contain
|
|
18
|
+
-- BEGIN/COMMIT.
|
|
19
|
+
ALTER TABLE user_tasks ADD COLUMN form_key TEXT;
|
|
20
|
+
|
|
21
|
+
-- The collapsed Tasks page reads `user_tasks` UNFILTERED ordered by `updated_at desc` (pages/tasks.page.json),
|
|
22
|
+
-- an access pattern the existing composite indexes (`(element_id, updated_at)`, `(subject, element_id)`) can't
|
|
23
|
+
-- serve — SQLite would scan + sort the whole table as the inbox grows. Front the unified inbox's sort with a
|
|
24
|
+
-- single-column index on `updated_at`. Additive and idempotent.
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_user_tasks_updated ON user_tasks(updated_at);
|