@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.
- package/.github/workflows/ci.yml +7 -0
- package/AGENTS.md +25 -1
- package/CHANGELOG.md +14 -0
- package/SPEC.md +7 -2
- package/app/agentCompletion.test.ts +69 -0
- package/app/agentCompletion.ts +78 -0
- package/app/blackboard.test.ts +75 -1
- package/app/blackboard.ts +144 -14
- package/app/contractReconcile.test.ts +94 -0
- package/app/contractReconcile.ts +134 -0
- package/app/contracts.test.ts +111 -0
- package/app/contracts.ts +446 -0
- package/app/instance-tracking.test.ts +44 -1
- package/app/pollUserTasks.test.ts +151 -0
- package/app/service.ts +229 -2
- package/app/trialMerge.ts +5 -0
- package/app/userTasks.test.ts +208 -0
- package/app/userTasks.ts +217 -0
- package/db/migrations/034_user_tasks_inbox.sql +51 -0
- package/docs/adr/0004-shared-contract-coordination.md +108 -0
- package/nano.app.json +2 -1
- package/openapi.yaml +77 -0
- package/operations/appendBlackboard.ts +29 -9
- package/operations/blackboard.test.ts +49 -0
- package/operations/completeUserTask.test.ts +146 -0
- package/operations/completeUserTask.ts +72 -0
- package/package.json +3 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +1 -0
- package/pages/tasks.page.json +297 -0
- package/scripts/check-contracts.test.ts +58 -0
- package/scripts/check-contracts.ts +151 -0
- package/scripts/reconcile-contracts.test.ts +38 -0
- 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.
|
|
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",
|
package/pages/cockpit.page.json
CHANGED
package/pages/epic.page.json
CHANGED
package/pages/feature.page.json
CHANGED
package/pages/home.page.json
CHANGED
package/pages/overview.page.json
CHANGED
|
@@ -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
|
+
});
|