@nanobpm/nano-workforce 0.125.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.
@@ -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
- - uses: amannn/action-semantic-pull-request@v5
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. Keep
47
- # in lock-step with the `main` ruleset's required status checks.
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,17 @@
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
+
8
+ # [0.126.0](https://github.com/nanobpm/nano-workforce/compare/v0.125.0...v0.126.0) (2026-08-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * **delivery-graphs:** preview a staged proposal's generated DI, zero deploy ([#469](https://github.com/nanobpm/nano-workforce/issues/469)) ([c275d72](https://github.com/nanobpm/nano-workforce/commit/c275d7279d8a46d1ca8ce82e366016ca7ef0a22f)), closes [Magikcraft/nano-bpm#972](https://github.com/Magikcraft/nano-bpm/issues/972)
14
+
1
15
  # [0.125.0](https://github.com/nanobpm/nano-workforce/compare/v0.124.0...v0.125.0) (2026-08-23)
2
16
 
3
17
 
@@ -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
- const leaf = (taskType: string): TaskDefinitionLeaf => ({ taskType, process: "p", elementId: taskType });
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 not colon-form and pass through the bridge untouched", () => {
45
- const report = buildRegistryReport({ taskDefinitions: [leaf("pr.finalize")], workers: [], now: NOW });
46
- const pr = report.networks.find((n) => n.network === "pr");
47
- assert(pr !== undefined, "pr.finalize stays a pr-network routing token");
48
- assert(pr.tokens.some((t) => t.token === "pr.finalize"));
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("a deployed type that is not a valid routing token is surfaced as nonAgentic, not missing", () => {
72
- const report = buildRegistryReport({ taskDefinitions: [leaf("weird token!")], workers: [], now: NOW });
73
- assert(report.nonAgentic.includes("weird token!"));
74
- assertEquals(report.missing, []);
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
+ });
@@ -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);