@nanobpm/nano-workforce 0.70.2 → 0.72.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.
Files changed (38) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +14 -0
  4. package/SPEC.md +7 -2
  5. package/app/agentCompletion.test.ts +69 -0
  6. package/app/agentCompletion.ts +78 -0
  7. package/app/blackboard.test.ts +75 -1
  8. package/app/blackboard.ts +144 -14
  9. package/app/contractReconcile.test.ts +94 -0
  10. package/app/contractReconcile.ts +134 -0
  11. package/app/contracts.test.ts +111 -0
  12. package/app/contracts.ts +446 -0
  13. package/app/instance-tracking.test.ts +44 -1
  14. package/app/pollUserTasks.test.ts +151 -0
  15. package/app/service.ts +229 -2
  16. package/app/trialMerge.ts +5 -0
  17. package/app/userTasks.test.ts +208 -0
  18. package/app/userTasks.ts +217 -0
  19. package/db/migrations/034_user_tasks_inbox.sql +51 -0
  20. package/docs/adr/0004-shared-contract-coordination.md +108 -0
  21. package/nano.app.json +2 -1
  22. package/openapi.yaml +77 -0
  23. package/operations/appendBlackboard.ts +29 -9
  24. package/operations/blackboard.test.ts +49 -0
  25. package/operations/completeUserTask.test.ts +146 -0
  26. package/operations/completeUserTask.ts +72 -0
  27. package/package.json +3 -1
  28. package/pages/cockpit.page.json +1 -0
  29. package/pages/epic-detail.page.json +1 -0
  30. package/pages/epic.page.json +1 -0
  31. package/pages/feature.page.json +1 -0
  32. package/pages/home.page.json +4 -0
  33. package/pages/overview.page.json +1 -0
  34. package/pages/tasks.page.json +297 -0
  35. package/scripts/check-contracts.test.ts +58 -0
  36. package/scripts/check-contracts.ts +151 -0
  37. package/scripts/reconcile-contracts.test.ts +38 -0
  38. package/scripts/reconcile-contracts.ts +104 -0
@@ -0,0 +1,146 @@
1
+ // Tests for the POST /app/api/actions/complete-user-task operation `completeUserTask` (issue #236).
2
+ // The nwf Tasks page's decision affordance for the plan-review / trial-merge / PR `wait-answer`
3
+ // escalations: it routes the typed form variables through the canonical human completer
4
+ // (completeEscalationAsHuman → completeUserTaskAttributed), resuming the process exactly as the task
5
+ // inbox would, and drops the answered task's read-model row so the grid stops offering a decision.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { AppApi } from "@nanobpm/urban";
9
+ import { noopLog } from "../test/log.ts";
10
+ import handler from "./completeUserTask.ts";
11
+
12
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors acknowledgeBlocked.test.ts
13
+ function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
14
+ app: AppApi;
15
+ // biome-ignore lint/suspicious/noExplicitAny: see above
16
+ stores: Record<string, any[]>;
17
+ completed: { userTaskKey: string; variables: Record<string, unknown> }[];
18
+ } {
19
+ // biome-ignore lint/suspicious/noExplicitAny: see above
20
+ const stores: Record<string, any[]> = {};
21
+ const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
22
+ function tbl(name: string, pk: string) {
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ const rows = (stores[name] ??= [] as any[]);
25
+ return {
26
+ // biome-ignore lint/suspicious/noExplicitAny: see above
27
+ async insert(row: any) {
28
+ rows.push({ ...row });
29
+ return rows.length;
30
+ },
31
+ // biome-ignore lint/suspicious/noExplicitAny: see above
32
+ async get(id: any) {
33
+ return rows.find((r) => r[pk] === id);
34
+ },
35
+ // biome-ignore lint/suspicious/noExplicitAny: see above
36
+ async find(where: any = {}) {
37
+ return rows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
38
+ },
39
+ // biome-ignore lint/suspicious/noExplicitAny: see above
40
+ async delete(id: any) {
41
+ const i = rows.findIndex((r) => r[pk] === id);
42
+ if (i >= 0) rows.splice(i, 1);
43
+ },
44
+ // biome-ignore lint/suspicious/noExplicitAny: see above
45
+ async update(id: any, patch: any) {
46
+ const r = rows.find((row) => row[pk] === id);
47
+ if (r) Object.assign(r, patch);
48
+ },
49
+ };
50
+ }
51
+ const engine = {
52
+ searchUserTasks: async () => openTasks,
53
+ completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
54
+ completed.push({ userTaskKey, variables });
55
+ },
56
+ };
57
+ const app = {
58
+ data: { table: (n: string, pk: string) => tbl(n, pk) },
59
+ engine,
60
+ log: noopLog(),
61
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
62
+ } as any as AppApi;
63
+ return { app, stores, completed };
64
+ }
65
+
66
+ async function call(app: AppApi, body: unknown) {
67
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
68
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
69
+ }
70
+
71
+ test("complete-user-task: completes a plan-review escalation and drops its read-model row", async () => {
72
+ const { app, stores, completed } = memApp([{ userTaskKey: "ut-1", elementId: "plan-review-decision" }]);
73
+ stores.user_tasks = [{ user_task_key: "ut-1", element_id: "plan-review-decision" }];
74
+
75
+ const res = await call(app, { userTaskKey: "ut-1", variables: { directive: "revise", notes: "narrow scope" } });
76
+
77
+ assertEquals(res.status, 200);
78
+ assertEquals(res.body.ok, true);
79
+ assertEquals(res.body.elementId, "plan-review-decision");
80
+ assertEquals(completed, [{ userTaskKey: "ut-1", variables: { directive: "revise", notes: "narrow scope" } }]);
81
+ assertEquals(stores.user_tasks, []);
82
+ // Attribution recorded as a human completion.
83
+ assertEquals(stores.task_completions.length, 1);
84
+ assertEquals(stores.task_completions[0].actor_kind, "human");
85
+ });
86
+
87
+ test("complete-user-task: completes a trial-merge escalation with the typed action variable", async () => {
88
+ const { app, completed } = memApp([{ userTaskKey: "ut-2", elementId: "trial-merge-decision" }]);
89
+
90
+ const res = await call(app, { userTaskKey: "ut-2", variables: { action: "rebase" } });
91
+
92
+ assertEquals(res.status, 200);
93
+ assertEquals(completed, [{ userTaskKey: "ut-2", variables: { action: "rebase" } }]);
94
+ });
95
+
96
+ test("complete-user-task: a missing userTaskKey is a 400", async () => {
97
+ const { app } = memApp([]);
98
+ const res = await call(app, { variables: { answer: "x" } });
99
+ assertEquals(res.status, 400);
100
+ assertEquals(res.body.ok, false);
101
+ });
102
+
103
+ test("complete-user-task: non-object variables are rejected 400", async () => {
104
+ const { app } = memApp([{ userTaskKey: "ut-3", elementId: "wait-answer" }]);
105
+ const res = await call(app, { userTaskKey: "ut-3", variables: "nope" });
106
+ assertEquals(res.status, 400);
107
+ });
108
+
109
+ test("complete-user-task: an unknown key is a 404 (no open escalation task)", async () => {
110
+ const { app } = memApp([]);
111
+ const res = await call(app, { userTaskKey: "ghost", variables: { answer: "x" } });
112
+ assertEquals(res.status, 404);
113
+ });
114
+
115
+ test("complete-user-task: refuses a non-escalation user task (400)", async () => {
116
+ const { app, completed } = memApp([{ userTaskKey: "ut-4", elementId: "feature-blocked" }]);
117
+ const res = await call(app, { userTaskKey: "ut-4", variables: { note: "x" } });
118
+ assertEquals(res.status, 400);
119
+ assertEquals(completed, []);
120
+ });
121
+
122
+ test("complete-user-task: a read-model cleanup failure does not mask a resumed completion (200)", async () => {
123
+ const { app, completed } = memApp([{ userTaskKey: "ut-5", elementId: "plan-review-decision" }]);
124
+ // The engine has already resumed the process; a transient delete failure on the latency-optimising
125
+ // read-model cleanup must not surface as a 5xx for a task that IS completed (poller reconciles).
126
+ const realTable = app.data.table.bind(app.data);
127
+ // biome-ignore lint/suspicious/noExplicitAny: test harness cast, mirrors sibling op tests
128
+ (app.data as any).table = (name: string, pk: string) => {
129
+ const t = realTable(name, pk);
130
+ if (name === "user_tasks") {
131
+ return {
132
+ ...t,
133
+ delete: async () => {
134
+ throw new Error("transient DB error");
135
+ },
136
+ };
137
+ }
138
+ return t;
139
+ };
140
+
141
+ const res = await call(app, { userTaskKey: "ut-5", variables: { directive: "revise", notes: "narrow scope" } });
142
+
143
+ assertEquals(res.status, 200);
144
+ assertEquals(res.body.ok, true);
145
+ assertEquals(completed, [{ userTaskKey: "ut-5", variables: { directive: "revise", notes: "narrow scope" } }]);
146
+ });
@@ -0,0 +1,72 @@
1
+ // POST /app/api/actions/complete-user-task → operationId `completeUserTask` (issue #236).
2
+ //
3
+ // The nwf **Tasks** page's decision affordance for the epic/PR native escalations that had no
4
+ // app-side completion path — `plan-review-decision`, `trial-merge-decision`, and the PR review-loop
5
+ // `wait-answer`. An operator submits the parked task's typed `.form` variables (e.g. a plan-review
6
+ // `{ directive, notes }`, a trial-merge `{ action, notes }`, or a PR `{ answer }`) directly from the
7
+ // Tasks inbox instead of only from Urban's read-only task-inbox stub.
8
+ //
9
+ // It routes through the ONE canonical human completer (`completeEscalationAsHuman` →
10
+ // `completeUserTaskAttributed`), so the completion uses the exact same typed variables and engine
11
+ // resume path a human drives from the task inbox — no parallel completion — while recording WHO
12
+ // answered in the `task_completions` ledger. That completer refuses any user task that is not one of
13
+ // the migrated escalation elements (`ESCALATION_TASK_ELEMENTS`), so this generic door can never
14
+ // complete an arbitrary internal user task. The feature-run kinds keep their own reconcile-bearing
15
+ // operations (`answer-escalation` / `acknowledge-blocked`); this door is for the escalation kinds
16
+ // whose completion is a straight typed pass-through.
17
+ //
18
+ // On success it removes the answered task's `user_tasks` read-model row so the Tasks grid stops
19
+ // offering a decision for a task that is now completed, without waiting a poll cycle; the poller
20
+ // (`pollUserTasks`) is the durable source of truth and will re-derive the exact same empty state.
21
+ // That read-model cleanup is best-effort — a transient delete failure is logged and swallowed rather
22
+ // than masking a completion the engine has already resumed with a spurious 5xx.
23
+ //
24
+ // The runtime validates the body against openapi.yaml (`userTaskKey` + `variables` required); this
25
+ // delegate narrows the validated shape and applies the shared human-completion contract.
26
+
27
+ import { completeEscalationAsHuman } from "../app/agentCompletion.ts";
28
+ import { userTasks } from "../app/userTasks.ts";
29
+ import { defineOperation } from "../nano-generated/operations.ts";
30
+
31
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
32
+
33
+ export default defineOperation("completeUserTask", async ({ body }, app) => {
34
+ if (!body || typeof body !== "object") {
35
+ app.log.warn("complete-user-task rejected: missing request body");
36
+ return { status: 400, body: { ok: false, error: "userTaskKey and variables are required" } };
37
+ }
38
+
39
+ const userTaskKey = str(body.userTaskKey);
40
+ if (!userTaskKey) return { status: 400, body: { ok: false, error: "userTaskKey is required" } };
41
+
42
+ const variables = body.variables;
43
+ if (!variables || typeof variables !== "object" || Array.isArray(variables)) {
44
+ return { status: 400, body: { ok: false, error: "variables must be an object" } };
45
+ }
46
+
47
+ // The completing operator, for the attribution ledger. Optional — the UI has no per-operator auth,
48
+ // so default to a generic handle rather than blocking the completion.
49
+ const operatorId = str(body.operator) || "operator";
50
+
51
+ const r = await completeEscalationAsHuman(app.data, app.engine, { userTaskKey, operatorId, variables });
52
+ if (r.ok) {
53
+ // Reconcile this operation's OWN action immediately: drop the read-model row so the Tasks page
54
+ // stops offering a decision for a task that is now completed. The poller re-derives the same
55
+ // state, so this is a latency optimisation, not the source of truth — make it best-effort so a
56
+ // transient cleanup failure never masks a completion the engine has already resumed (which would
57
+ // return a spurious 5xx to the UI for a task that IS done). The poller re-derives the empty state.
58
+ try {
59
+ await userTasks(app.data).delete(userTaskKey);
60
+ } catch (err) {
61
+ app.log.warn("complete-user-task: read-model cleanup failed (poller will reconcile)", {
62
+ userTaskKey,
63
+ error: err instanceof Error ? err.message : String(err),
64
+ });
65
+ }
66
+ app.log.info("operator completed user task", { userTaskKey, elementId: r.elementId });
67
+ return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
68
+ }
69
+ const status = r.reason === "no open escalation task" ? 404 : 400;
70
+ app.log.warn("complete-user-task: not completed", { userTaskKey, reason: r.reason });
71
+ return { status, body: { ok: false, error: r.reason } };
72
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.70.2",
3
+ "version": "0.72.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -37,6 +37,8 @@
37
37
  "pretypecheck": "urban gen",
38
38
  "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
39
39
  "check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
40
+ "check:contracts": "node --experimental-strip-types scripts/check-contracts.ts",
41
+ "reconcile:contracts": "node --experimental-strip-types scripts/reconcile-contracts.ts",
40
42
  "gen": "urban gen",
41
43
  "gen:check": "urban gen --check",
42
44
  "layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
@@ -13,6 +13,7 @@
13
13
  { "label": "Convergence", "page": "home" },
14
14
  { "label": "Epics", "page": "epic" },
15
15
  { "label": "Feature", "page": "feature" },
16
+ { "label": "Tasks", "page": "tasks" },
16
17
  { "label": "Cockpit", "page": "cockpit" }
17
18
  ],
18
19
  "sticky": true
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -25,6 +25,10 @@
25
25
  "label": "Feature",
26
26
  "page": "feature"
27
27
  },
28
+ {
29
+ "label": "Tasks",
30
+ "page": "tasks"
31
+ },
28
32
  {
29
33
  "label": "Cockpit",
30
34
  "page": "cockpit"
@@ -14,6 +14,7 @@
14
14
  { "label": "Convergence", "page": "home" },
15
15
  { "label": "Epics", "page": "epic" },
16
16
  { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
17
18
  { "label": "Cockpit", "page": "cockpit" }
18
19
  ]
19
20
  }
@@ -0,0 +1,297 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "title": "Tasks",
4
+ "nodes": [
5
+ {
6
+ "type": "nav",
7
+ "id": "nav",
8
+ "props": {
9
+ "variant": "bar",
10
+ "sticky": true,
11
+ "title": "Nano Workforce",
12
+ "items": [
13
+ { "label": "Overview", "page": "overview" },
14
+ { "label": "Convergence", "page": "home" },
15
+ { "label": "Epics", "page": "epic" },
16
+ { "label": "Feature", "page": "feature" },
17
+ { "label": "Tasks", "page": "tasks" },
18
+ { "label": "Cockpit", "page": "cockpit" }
19
+ ]
20
+ }
21
+ },
22
+ {
23
+ "type": "text",
24
+ "id": "title",
25
+ "props": { "text": "Tasks", "variant": "heading" }
26
+ },
27
+ {
28
+ "type": "text",
29
+ "id": "subtitle",
30
+ "props": {
31
+ "text": "Open native user-task escalations awaiting a human decision \u2014 across feature runs, epics, and PR review loops. Each row parks a process on your decision; submit one and the process resumes on the same canonical path Urban's task inbox uses. Rows disappear once completed (here, via the inbox, or out-of-band).",
32
+ "variant": "sub"
33
+ }
34
+ },
35
+ {
36
+ "type": "dataGrid",
37
+ "id": "feature-escalations",
38
+ "props": {
39
+ "title": "Feature escalations",
40
+ "collapsible": true,
41
+ "defaultCollapsed": false,
42
+ "showCount": true,
43
+ "rowKey": "user_task_key",
44
+ "refreshMs": 5000,
45
+ "data": {
46
+ "kind": "datasource",
47
+ "source": "app",
48
+ "table": "user_tasks",
49
+ "orderBy": { "field": "updated_at", "dir": "desc" },
50
+ "filter": [{ "field": "element_id", "in": ["feature-escalation"] }]
51
+ },
52
+ "columns": [
53
+ { "field": "subject_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "subject_key" } },
54
+ { "field": "question", "header": "Question" },
55
+ { "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
56
+ { "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
57
+ { "field": "updated_at", "header": "Updated" }
58
+ ],
59
+ "rowActions": [
60
+ {
61
+ "label": "Abandon",
62
+ "confirm": "Abandon this escalated task? The run gives up on it (no PR).",
63
+ "showWhenField": "user_task_key",
64
+ "action": {
65
+ "path": "/app/api/actions/answer-escalation",
66
+ "body": { "userTaskKey": "{{row.user_task_key}}", "resolution": "abandon" }
67
+ }
68
+ }
69
+ ],
70
+ "detail": {
71
+ "linkField": "subject_url",
72
+ "fields": [{ "field": "question", "label": "Escalation question" }],
73
+ "form": {
74
+ "showWhenField": "user_task_key",
75
+ "title": "Answer escalation",
76
+ "promptField": "question",
77
+ "inputKey": "answer",
78
+ "inputLabel": "Guidance for the implementation agent",
79
+ "submitLabel": "Answer & re-dispatch",
80
+ "action": {
81
+ "path": "/app/api/actions/answer-escalation",
82
+ "successLabel": "Answered \u2014 the agent will resume",
83
+ "body": {
84
+ "userTaskKey": "{{row.user_task_key}}",
85
+ "resolution": "answer",
86
+ "answer": "{{form.answer}}"
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
92
+ },
93
+ {
94
+ "type": "dataGrid",
95
+ "id": "plan-reviews",
96
+ "props": {
97
+ "title": "Plan-review decisions",
98
+ "collapsible": true,
99
+ "defaultCollapsed": false,
100
+ "showCount": true,
101
+ "rowKey": "user_task_key",
102
+ "refreshMs": 5000,
103
+ "data": {
104
+ "kind": "datasource",
105
+ "source": "app",
106
+ "table": "user_tasks",
107
+ "orderBy": { "field": "updated_at", "dir": "desc" },
108
+ "filter": [{ "field": "element_id", "in": ["plan-review-decision"] }]
109
+ },
110
+ "columns": [
111
+ { "field": "subject_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "subject_key" } },
112
+ { "field": "question", "header": "Findings" },
113
+ { "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
114
+ { "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
115
+ { "field": "updated_at", "header": "Updated" }
116
+ ],
117
+ "rowActions": [
118
+ {
119
+ "label": "Proceed",
120
+ "confirm": "Proceed \u2014 dispatch the current plan as-is?",
121
+ "showWhenField": "user_task_key",
122
+ "action": {
123
+ "path": "/app/api/actions/complete-user-task",
124
+ "body": { "userTaskKey": "{{row.user_task_key}}", "variables": { "directive": "proceed" } }
125
+ }
126
+ }
127
+ ],
128
+ "detail": {
129
+ "linkField": "subject_url",
130
+ "fields": [{ "field": "question", "label": "Review findings" }],
131
+ "form": {
132
+ "showWhenField": "user_task_key",
133
+ "title": "Send the plan back for revision",
134
+ "promptField": "question",
135
+ "inputKey": "notes",
136
+ "inputLabel": "Revision guidance for the planner",
137
+ "submitLabel": "Revise \u2014 re-plan",
138
+ "action": {
139
+ "path": "/app/api/actions/complete-user-task",
140
+ "successLabel": "Sent back \u2014 the planner will revise",
141
+ "body": {
142
+ "userTaskKey": "{{row.user_task_key}}",
143
+ "variables": { "directive": "revise", "notes": "{{form.notes}}" }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ }
149
+ },
150
+ {
151
+ "type": "dataGrid",
152
+ "id": "trial-merges",
153
+ "props": {
154
+ "title": "Trial-merge decisions",
155
+ "collapsible": true,
156
+ "defaultCollapsed": false,
157
+ "showCount": true,
158
+ "rowKey": "user_task_key",
159
+ "refreshMs": 5000,
160
+ "data": {
161
+ "kind": "datasource",
162
+ "source": "app",
163
+ "table": "user_tasks",
164
+ "orderBy": { "field": "updated_at", "dir": "desc" },
165
+ "filter": [{ "field": "element_id", "in": ["trial-merge-decision"] }]
166
+ },
167
+ "columns": [
168
+ { "field": "subject_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "subject_key" } },
169
+ { "field": "question", "header": "Trial merge" },
170
+ { "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
171
+ { "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
172
+ { "field": "updated_at", "header": "Updated" }
173
+ ],
174
+ "rowActions": [
175
+ {
176
+ "label": "Proceed",
177
+ "confirm": "Proceed \u2014 accept the red trial merge and continue?",
178
+ "showWhenField": "user_task_key",
179
+ "action": {
180
+ "path": "/app/api/actions/complete-user-task",
181
+ "body": { "userTaskKey": "{{row.user_task_key}}", "variables": { "action": "proceed" } }
182
+ }
183
+ },
184
+ {
185
+ "label": "Rebase",
186
+ "confirm": "Rebase \u2014 re-run the trial merge?",
187
+ "showWhenField": "user_task_key",
188
+ "action": {
189
+ "path": "/app/api/actions/complete-user-task",
190
+ "body": { "userTaskKey": "{{row.user_task_key}}", "variables": { "action": "rebase" } }
191
+ }
192
+ },
193
+ {
194
+ "label": "Abandon",
195
+ "confirm": "Abandon \u2014 stop and finalize the plan?",
196
+ "showWhenField": "user_task_key",
197
+ "action": {
198
+ "path": "/app/api/actions/complete-user-task",
199
+ "body": { "userTaskKey": "{{row.user_task_key}}", "variables": { "action": "abandon" } }
200
+ }
201
+ }
202
+ ],
203
+ "detail": {
204
+ "linkField": "subject_url",
205
+ "fields": [{ "field": "question", "label": "Trial merge" }]
206
+ }
207
+ }
208
+ },
209
+ {
210
+ "type": "dataGrid",
211
+ "id": "pr-reviews",
212
+ "props": {
213
+ "title": "PR review escalations",
214
+ "collapsible": true,
215
+ "defaultCollapsed": false,
216
+ "showCount": true,
217
+ "rowKey": "user_task_key",
218
+ "refreshMs": 5000,
219
+ "data": {
220
+ "kind": "datasource",
221
+ "source": "app",
222
+ "table": "user_tasks",
223
+ "orderBy": { "field": "updated_at", "dir": "desc" },
224
+ "filter": [{ "field": "element_id", "in": ["wait-answer"] }]
225
+ },
226
+ "columns": [
227
+ { "field": "subject_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "subject_key" } },
228
+ { "field": "question", "header": "Question" },
229
+ { "field": "subject_url", "header": "PR link", "linkField": "subject_url" },
230
+ { "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
231
+ { "field": "updated_at", "header": "Updated" }
232
+ ],
233
+ "detail": {
234
+ "linkField": "subject_url",
235
+ "fields": [{ "field": "question", "label": "Escalation question" }],
236
+ "form": {
237
+ "showWhenField": "user_task_key",
238
+ "title": "Answer the review escalation",
239
+ "promptField": "question",
240
+ "inputKey": "answer",
241
+ "inputLabel": "Your answer (resumes the review loop, handed to the next round)",
242
+ "submitLabel": "Answer \u2014 resume review",
243
+ "action": {
244
+ "path": "/app/api/actions/complete-user-task",
245
+ "successLabel": "Answered \u2014 the review loop will resume",
246
+ "body": {
247
+ "userTaskKey": "{{row.user_task_key}}",
248
+ "variables": { "answer": "{{form.answer}}" }
249
+ }
250
+ }
251
+ }
252
+ }
253
+ }
254
+ },
255
+ {
256
+ "type": "dataGrid",
257
+ "id": "blocked-runs",
258
+ "props": {
259
+ "title": "Blocked feature runs",
260
+ "collapsible": true,
261
+ "defaultCollapsed": false,
262
+ "showCount": true,
263
+ "rowKey": "user_task_key",
264
+ "refreshMs": 5000,
265
+ "data": {
266
+ "kind": "datasource",
267
+ "source": "app",
268
+ "table": "user_tasks",
269
+ "orderBy": { "field": "updated_at", "dir": "desc" },
270
+ "filter": [{ "field": "element_id", "in": ["feature-blocked"] }]
271
+ },
272
+ "columns": [
273
+ { "field": "subject_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "subject_key" } },
274
+ { "field": "question", "header": "Disposition" },
275
+ { "field": "subject_url", "header": "Issue", "linkField": "subject_url" },
276
+ { "field": "process_key", "header": "Process", "link": { "kind": "processExplorer", "keyField": "process_key" } },
277
+ { "field": "updated_at", "header": "Updated" }
278
+ ],
279
+ "rowActions": [
280
+ {
281
+ "label": "Acknowledge blocked",
282
+ "confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
283
+ "showWhenField": "user_task_key",
284
+ "action": {
285
+ "path": "/app/api/actions/acknowledge-blocked",
286
+ "body": { "userTaskKey": "{{row.user_task_key}}" }
287
+ }
288
+ }
289
+ ],
290
+ "detail": {
291
+ "linkField": "subject_url",
292
+ "fields": [{ "field": "question", "label": "Disposition" }]
293
+ }
294
+ }
295
+ }
296
+ ]
297
+ }
@@ -0,0 +1,58 @@
1
+ // Coverage for the config-key SCAN in the contract gate (scripts/check-contracts.ts, PR #229).
2
+ //
3
+ // The gate is only as strong as the read patterns it recognises. Before this fix it matched only
4
+ // dot-access (`process.env.KEY`), so a config-family key smuggled in through the `envVar("KEY")`
5
+ // helper or string-literal bracket-access silently bypassed the "must be declared" invariant — which
6
+ // is how NANO_PR_WEBHOOK_SECRET / NANO_AGENTIC* stayed undeclared while `check:contracts` passed
7
+ // green. These assert `envKeyReads` sees all three patterns so the gate can hold them to the registry.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import { envKeyReads, EXPLICIT_CONFIG_KEYS, toPosixRel } from "./check-contracts.ts";
11
+ import { ENV_CONTRACTS } from "../app/contracts.ts";
12
+
13
+ test("envKeyReads: dot-access is recognised", () => {
14
+ assertEquals(envKeyReads("const x = process.env.NANO_PR_POLL_MS;"), ["NANO_PR_POLL_MS"]);
15
+ });
16
+
17
+ test("envKeyReads: string-literal bracket-access is recognised (double and single quote)", () => {
18
+ assert(envKeyReads('process.env["NANO_PR_WEBHOOK_SECRET"]').includes("NANO_PR_WEBHOOK_SECRET"));
19
+ assert(envKeyReads("process.env['NANO_AGENTIC']").includes("NANO_AGENTIC"));
20
+ });
21
+
22
+ test("envKeyReads: the envVar(\"KEY\") helper is recognised", () => {
23
+ assert(envKeyReads('const s = envVar("NANO_AGENTIC_SECRET") ?? "";').includes("NANO_AGENTIC_SECRET"));
24
+ assert(envKeyReads('envVar( "NANO_WORKFORCE_GIT_SHA" )').includes("NANO_WORKFORCE_GIT_SHA"));
25
+ });
26
+
27
+ test("envKeyReads: catches keys across all patterns in one source", () => {
28
+ const src = [
29
+ "const a = process.env.CAMUNDA_TRANSPORT;",
30
+ 'const b = process.env["NANOBPMN_BASE_URL"];',
31
+ 'const c = envVar("NANO_WORKFORCE_GIT_SHA");',
32
+ ].join("\n");
33
+ const keys = new Set(envKeyReads(src));
34
+ assert(keys.has("CAMUNDA_TRANSPORT"));
35
+ assert(keys.has("NANOBPMN_BASE_URL"));
36
+ assert(keys.has("NANO_WORKFORCE_GIT_SHA"));
37
+ });
38
+
39
+ test("envKeyReads: a dynamic (non-literal) read is NOT matched", () => {
40
+ // `process.env[name]` (a variable) can't be resolved statically, so it isn't reported.
41
+ assertEquals(envKeyReads("const v = process.env[name];"), []);
42
+ });
43
+
44
+ test("GITHUB_TOKEN is enforced by the gate and declared in the registry (PR #229 suppressed advisory)", () => {
45
+ // GITHUB_TOKEN is read in production outside the config families (app/service.ts,
46
+ // operations/startPlanFanout.ts). Pin it into the enforced set so the gate holds it to its
47
+ // registry declaration — a future removal from ENV_CONTRACTS while code still reads it must fail.
48
+ assert(EXPLICIT_CONFIG_KEYS.has("GITHUB_TOKEN"), "GITHUB_TOKEN must be an enforced config key");
49
+ assert("GITHUB_TOKEN" in ENV_CONTRACTS, "GITHUB_TOKEN must be declared in ENV_CONTRACTS");
50
+ });
51
+
52
+ test("toPosixRel: normalises Windows backslashes so EXEMPT_FILES matches cross-platform (PR #229 suppressed advisory)", () => {
53
+ // On Windows `join` yields backslashes, so `scripts\check-contracts.ts` would never match the
54
+ // POSIX-style EXEMPT_FILES entry and the checker would self-scan and explode. Normalise them.
55
+ assertEquals(toPosixRel("C:\\repo\\scripts\\check-contracts.ts", "C:\\repo"), "scripts/check-contracts.ts");
56
+ // POSIX paths are already correct and pass through unchanged.
57
+ assertEquals(toPosixRel("/repo/scripts/check-contracts.ts", "/repo"), "scripts/check-contracts.ts");
58
+ });