@nanobpm/nano-workforce 0.82.0 → 0.83.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.
@@ -0,0 +1,29 @@
1
+ -- 039_feature_pipeline_stage.sql — issue #254 §1/§5: reify the feature-run pipeline stage and the
2
+ -- Active/History tick-off partition as derived, write-time-projected columns, so the declarative
3
+ -- Feature view can render an intent-first pipeline track and a Done tick-off with only stored
4
+ -- `{"field":…}` bindings and flat `in` filters (the dataGrid page DSL has no OR / IS NULL / TS
5
+ -- callback). These mirror the existing `delivery_label` / `epic_phase` display projections: each is
6
+ -- maintained by the feature_runs gateway (app/feature.ts) from the pure `deriveStage` helper
7
+ -- (app/stage.ts) on every write — never hand-derived in SQL, the page, or a poller.
8
+ --
9
+ -- Columns:
10
+ -- • acknowledged_at — NULL until an operator dismisses a terminal run (§5, acknowledge-done).
11
+ -- • stage — deriveStage(...).stage: Requested|Implementing|PR open|Converging|Merging|Done
12
+ -- (the page's pipeline column binds `activeField` to it).
13
+ -- • stage_state — deriveStage(...).state: ok|failed|blocked|NULL (bound to `stateField`).
14
+ -- • stage_skipped — deriveStage(...).skipped: space-separated not-in-path stage keys (`notInPathField`).
15
+ -- • attention — deriveStage(...).attention: short badge text or NULL (`badgeField`).
16
+ -- • list_bucket — 'active' | 'history': history iff terminal AND acknowledged, else active
17
+ -- (Active = live + terminal-but-unacknowledged; History = acknowledged terminals).
18
+ --
19
+ -- Forward-only, additive (expand): all nullable with no default, so pre-#254 rows grandfather in as
20
+ -- NULL and never gate control flow. `backfillFeatureStages` (app/feature.ts) stamps legacy rows once
21
+ -- at boot, and the gateway keeps every future write fresh. Numbered after the current highest prefix
22
+ -- on origin/main (038); the runner wraps each file in its own transaction, so this file must NOT
23
+ -- contain BEGIN/COMMIT.
24
+ ALTER TABLE feature_runs ADD COLUMN acknowledged_at TEXT;
25
+ ALTER TABLE feature_runs ADD COLUMN stage TEXT;
26
+ ALTER TABLE feature_runs ADD COLUMN stage_state TEXT;
27
+ ALTER TABLE feature_runs ADD COLUMN stage_skipped TEXT;
28
+ ALTER TABLE feature_runs ADD COLUMN attention TEXT;
29
+ ALTER TABLE feature_runs ADD COLUMN list_bucket TEXT;
package/main.ts CHANGED
@@ -96,7 +96,11 @@ async function pollLoop(): Promise<void> {
96
96
  }
97
97
  if (!shuttingDown) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
98
98
  }
99
- if (app.data) pollTimer = setTimeout(() => void pollLoop(), POLL_MS);
99
+ // Run the first pass immediately at boot (not after POLL_MS) so the one-shot feature-stage backfill
100
+ // runs before the UI is relied upon — the Feature Runs grid/tabs filter on the stored `list_bucket`
101
+ // projection, which is NULL on legacy rows until `backfillFeatureStages()` runs inside `pollOnce()`.
102
+ // Deferring the first pass would leave those rows missing from Active/History for up to POLL_MS.
103
+ if (app.data) void pollLoop();
100
104
 
101
105
  async function drainAndExit(): Promise<void> {
102
106
  if (shuttingDown) return;
package/openapi.yaml CHANGED
@@ -1501,6 +1501,53 @@ paths:
1501
1501
  application/json:
1502
1502
  schema:
1503
1503
  $ref: "#/components/schemas/MessageResult"
1504
+ /actions/acknowledge-done:
1505
+ post:
1506
+ operationId: acknowledgeDone
1507
+ summary: "Tick off a TERMINAL feature run (issue #254 §5). Stamps `acknowledged_at` on the run so
1508
+ the gateway recomputes its `list_bucket` to 'history', dropping the finished run out of the
1509
+ primary Active list into History. The Done twin of acknowledge-blocked, but a terminal run is
1510
+ not parked at a user task, so this completes no user task and only writes the row. Keyed on the
1511
+ run's `feature_key`; idempotent-safe (re-acknowledging keeps it in History)."
1512
+ requestBody:
1513
+ required: true
1514
+ content:
1515
+ application/json:
1516
+ schema:
1517
+ type: object
1518
+ additionalProperties: false
1519
+ required:
1520
+ - feature_key
1521
+ properties:
1522
+ feature_key:
1523
+ type: string
1524
+ minLength: 1
1525
+ description: The feature run's key (feature_runs.feature_key, `<owner>/<repo>#<n>`).
1526
+ responses:
1527
+ "200":
1528
+ description: The terminal run was acknowledged and moved to History.
1529
+ content:
1530
+ application/json:
1531
+ schema:
1532
+ $ref: "#/components/schemas/MessageResult"
1533
+ "400":
1534
+ description: A required field (feature_key) was missing or invalid.
1535
+ content:
1536
+ application/json:
1537
+ schema:
1538
+ $ref: "#/components/schemas/MessageResult"
1539
+ "404":
1540
+ description: No feature run matches the feature_key.
1541
+ content:
1542
+ application/json:
1543
+ schema:
1544
+ $ref: "#/components/schemas/MessageResult"
1545
+ "409":
1546
+ description: The feature run is not terminal, so it cannot be ticked off yet.
1547
+ content:
1548
+ application/json:
1549
+ schema:
1550
+ $ref: "#/components/schemas/MessageResult"
1504
1551
  /hooks/agent-complete:
1505
1552
  post:
1506
1553
  operationId: agentCompleteEscalation
@@ -0,0 +1,108 @@
1
+ // Tests for the POST /app/api/actions/acknowledge-done operation `acknowledgeDone` (issue #254 §5).
2
+ // The nwf UI's "tick off" affordance for a TERMINAL feature run: it stamps `acknowledged_at` via the
3
+ // feature_runs gateway, which recomputes `list_bucket` to 'history', dropping the run from Active into
4
+ // History. Unlike acknowledgeBlocked it completes NO user task (a terminal run is not parked). Mirrors
5
+ // the acknowledge-blocked twin's shape.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { AppApi } from "@nanobpm/urban";
9
+ import { featureRuns } from "../app/feature.ts";
10
+ import { noopLog } from "../test/log.ts";
11
+ import handler from "./acknowledgeDone.ts";
12
+
13
+ // An in-memory data layer wired through the REAL featureRuns gateway proxy, so the test exercises the
14
+ // gateway's list_bucket projection exactly as production does.
15
+ function memApp(seed: any[]): { app: AppApi; rows: any[] } {
16
+ const stores: Record<string, any[]> = { feature_runs: seed };
17
+ function tbl(name: string, pk = "id") {
18
+ const rows = (stores[name] ??= [] as any[]);
19
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
20
+ return {
21
+ async all() {
22
+ return rows.slice();
23
+ },
24
+ async get(id: any) {
25
+ return rows.find((r) => r[pk] === id);
26
+ },
27
+ async find(where: any = {}) {
28
+ return rows.filter((r) => match(r, where));
29
+ },
30
+ async insert(row: any) {
31
+ rows.push({ ...row });
32
+ return row[pk];
33
+ },
34
+ async update(id: any, patch: any) {
35
+ const r = rows.find((row) => row[pk] === id);
36
+ if (r) Object.assign(r, patch);
37
+ return r ? 1 : 0;
38
+ },
39
+ };
40
+ }
41
+ const app = {
42
+ data: { table: (n: string, pk?: string) => tbl(n, pk) },
43
+ log: noopLog(),
44
+ } as any as AppApi;
45
+ return { app, rows: stores.feature_runs };
46
+ }
47
+
48
+ async function call(app: AppApi, body: unknown) {
49
+ return (await handler({ req: {} as any, params: {}, query: {}, body } as any, app)) as any;
50
+ }
51
+
52
+ test("acknowledge-done: stamps acknowledged_at and flips list_bucket to 'history' on a terminal row", async () => {
53
+ const { app, rows } = memApp([{ feature_key: "o/r#1", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null }]);
54
+ // Seed the projection as the gateway would have on the last write (terminal, unacknowledged → active).
55
+ await featureRuns(app.data).update("o/r#1", { status: "merged" });
56
+ assertEquals(rows[0].list_bucket, "active");
57
+
58
+ const res = await call(app, { feature_key: "o/r#1" });
59
+
60
+ assertEquals(res.status, 200);
61
+ assertEquals(res.body.ok, true);
62
+ assertEquals(typeof rows[0].acknowledged_at, "string");
63
+ assertEquals(rows[0].list_bucket, "history");
64
+ });
65
+
66
+ test("acknowledge-done: idempotent-safe — re-acknowledging keeps the row in History", async () => {
67
+ const { app, rows } = memApp([{ feature_key: "o/r#2", status: "failed", converge: 1, auto_merge: 1, acknowledged_at: null }]);
68
+ const first = await call(app, { feature_key: "o/r#2" });
69
+ assertEquals(first.status, 200);
70
+ assertEquals(rows[0].list_bucket, "history");
71
+ const firstStamp = rows[0].acknowledged_at;
72
+ // Re-acknowledge — still 200, still history.
73
+ const second = await call(app, { feature_key: "o/r#2" });
74
+ assertEquals(second.status, 200);
75
+ assertEquals(rows[0].list_bucket, "history");
76
+ assertEquals(typeof firstStamp, "string");
77
+ });
78
+
79
+ test("acknowledge-done: a missing feature_key → 400", async () => {
80
+ const { app } = memApp([]);
81
+ const res = await call(app, {});
82
+ assertEquals(res.status, 400);
83
+ assertEquals(res.body.ok, false);
84
+ });
85
+
86
+ test("acknowledge-done: no such feature run → 404", async () => {
87
+ const { app } = memApp([]);
88
+ const res = await call(app, { feature_key: "o/r#gone" });
89
+ assertEquals(res.status, 404);
90
+ assertEquals(res.body.ok, false);
91
+ });
92
+
93
+ test("acknowledge-done: a non-terminal run → 409, no acknowledged_at stamped", async () => {
94
+ const { app, rows } = memApp([{ feature_key: "o/r#live", status: "running", converge: 1, auto_merge: 1, acknowledged_at: null }]);
95
+ await featureRuns(app.data).update("o/r#live", { status: "running" });
96
+ const res = await call(app, { feature_key: "o/r#live" });
97
+ assertEquals(res.status, 409);
98
+ assertEquals(res.body.ok, false);
99
+ assertEquals(rows[0].acknowledged_at, null);
100
+ assertEquals(rows[0].list_bucket, "active");
101
+ });
102
+
103
+ test("acknowledge-done: a converging (redispatch-terminal but live) run → 409", async () => {
104
+ const { app, rows } = memApp([{ feature_key: "o/r#conv", status: "converging", converge: 1, auto_merge: 1, acknowledged_at: null }]);
105
+ const res = await call(app, { feature_key: "o/r#conv" });
106
+ assertEquals(res.status, 409);
107
+ assertEquals(rows[0].acknowledged_at, null);
108
+ });
@@ -0,0 +1,53 @@
1
+ // POST /app/api/actions/acknowledge-done → operationId `acknowledgeDone` (issue #254 §5).
2
+ // The nwf UI's "tick off" affordance for a TERMINAL feature run: an operator dismisses a finished
3
+ // run (Done ✓ / Done ✕) directly from the Feature / Overview pages so it drops out of the primary
4
+ // Active list into History. It is the DONE twin of `acknowledgeBlocked` — but a terminal run is NOT
5
+ // parked at a user task, so this op does NOT complete a user task and touches no engine/ledger: it
6
+ // simply stamps `acknowledged_at` on the row via the feature_runs gateway. It rejects (409) a run that
7
+ // is not yet truly terminal, so it can never pre-seed the tick-off on a still-live run.
8
+ //
9
+ // The gateway (app/feature.ts) recomputes `list_bucket` on that write — a terminal row with
10
+ // `acknowledged_at` set flips to 'history' — so this op NEVER hand-sets `list_bucket` (or any other
11
+ // projection). Keyed on the row's `feature_key`. Idempotent-safe: re-acknowledging simply re-stamps
12
+ // the timestamp and keeps the row in History.
13
+
14
+ import { featureRuns } from "../app/feature.ts";
15
+ import { STAGE_DONE_STATUSES } from "../app/stage.ts";
16
+ import { defineOperation } from "../nano-generated/operations.ts";
17
+
18
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
19
+
20
+ export default defineOperation("acknowledgeDone", async ({ body }, app) => {
21
+ if (!body || typeof body !== "object") {
22
+ app.log.warn("acknowledge-done rejected: missing request body");
23
+ return { status: 400, body: { ok: false, error: "feature_key is required" } };
24
+ }
25
+
26
+ const featureKey = str(body.feature_key);
27
+ if (!featureKey) return { status: 400, body: { ok: false, error: "feature_key is required" } };
28
+
29
+ const runs = featureRuns(app.data);
30
+ const run = await runs.get(featureKey);
31
+ if (!run) {
32
+ app.log.warn("acknowledge-done: no such feature run", { featureKey });
33
+ return { status: 404, body: { ok: false, error: "no such feature run" } };
34
+ }
35
+
36
+ // Guard: only a TRULY-terminal run (a `Done`-stage status — the same set `deriveListBucket` moves to
37
+ // History) may be ticked off. Acknowledging a still-live run (e.g. `running`/`opened`/`converging`)
38
+ // would pre-seed `acknowledged_at`, so the moment it later settles `deriveListBucket` would drop it
39
+ // straight into History, skipping the operator tick-off this op exists to require.
40
+ if (!STAGE_DONE_STATUSES.includes(run.status)) {
41
+ app.log.warn("acknowledge-done rejected: run is not terminal", { featureKey, status: run.status });
42
+ return { status: 409, body: { ok: false, error: "feature run is not terminal" } };
43
+ }
44
+
45
+ // Stamp the dismissal. The gateway recomputes `list_bucket` from the merged row (→ 'history' for a
46
+ // terminal run), so we never hand-set it here. Idempotent: re-acknowledging re-stamps and stays in
47
+ // History.
48
+ const now = new Date().toISOString();
49
+ await runs.update(featureKey, { acknowledged_at: now, updated_at: now });
50
+
51
+ app.log.info("operator ticked off feature run", { featureKey });
52
+ return { status: 200, body: { ok: true, message: "acknowledged" } };
53
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.82.0",
3
+ "version": "0.83.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",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@nanobpm/agentic": "^0.1.0",
56
- "@nanobpm/urban": "^0.52.0"
56
+ "@nanobpm/urban": "^0.53.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@biomejs/biome": "^2.4.11",
@@ -63,23 +63,33 @@
63
63
  "source": "app",
64
64
  "table": "feature_runs",
65
65
  "orderBy": { "field": "updated_at", "dir": "desc" },
66
- "filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }]
66
+ "filter": [{ "field": "list_bucket", "in": ["active"] }]
67
67
  },
68
68
  "tabs": [
69
- { "label": "Active", "filter": [{ "field": "status", "in": ["running", "escalated", "awaiting_operator"] }] },
70
- { "label": "History", "filter": [{ "field": "status", "in": ["opened", "converging", "merged", "converged", "blocked", "skipped", "failed", "abandoned"] }] },
69
+ { "label": "Active", "filter": [{ "field": "list_bucket", "in": ["active"] }] },
70
+ { "label": "History", "filter": [{ "field": "list_bucket", "in": ["history"] }] },
71
71
  { "label": "All", "filter": [] }
72
72
  ],
73
73
  "columns": [
74
- { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "34%", "linkField": "issue_url" },
75
- { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
76
- { "field": "escalation_question", "header": "Escalation", "truncate": true },
77
- { "field": "base_branch", "header": "Base branch" },
78
- { "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
79
- { "field": "delivery_label", "header": "Delivery" },
80
- { "field": "converge", "header": "Converge" },
81
- { "field": "auto_merge", "header": "Auto-merge" },
82
- { "field": "outcome", "header": "Outcome" },
74
+ { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "30%", "linkField": "issue_url" },
75
+ {
76
+ "field": "stage",
77
+ "header": "Pipeline",
78
+ "kind": "pipeline",
79
+ "stages": [
80
+ { "key": "Requested", "label": "Requested" },
81
+ { "key": "Implementing", "label": "Implementing" },
82
+ { "key": "PR open", "label": "PR open" },
83
+ { "key": "Converging", "label": "Converging" },
84
+ { "key": "Merging", "label": "Merging" },
85
+ { "key": "Done", "label": "Done" }
86
+ ],
87
+ "activeField": "stage",
88
+ "stateField": "stage_state",
89
+ "badgeField": "attention",
90
+ "notInPathField": "stage_skipped",
91
+ "locus": { "field": "pr_key", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } }
92
+ },
83
93
  { "field": "updated_at", "header": "Updated", "width": "9rem" }
84
94
  ],
85
95
  "rowActions": [
@@ -100,11 +110,21 @@
100
110
  "path": "/app/api/actions/acknowledge-blocked",
101
111
  "body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
102
112
  }
113
+ },
114
+ {
115
+ "label": "Dismiss",
116
+ "confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
117
+ "showWhenField": "stage_state",
118
+ "action": {
119
+ "path": "/app/api/actions/acknowledge-done",
120
+ "body": { "feature_key": "{{row.feature_key}}" }
121
+ }
103
122
  }
104
123
  ],
105
124
  "detail": {
106
125
  "fields": [
107
126
  { "field": "escalation_question", "label": "Escalation question" },
127
+ { "field": "base_branch", "label": "Base branch" },
108
128
  { "field": "outcome", "label": "Outcome" },
109
129
  { "field": "delivery_label", "label": "Delivery" }
110
130
  ],
@@ -293,9 +293,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
293
293
 
294
294
  // Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
295
295
  // `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
296
- // `gate_wave` is that durable marker; the poller (`pollWaveGates`) clears it and publishes
297
- // `wave-merged` once the wave has landed. Clear it on the final wave so a re-planned issue can't
298
- // inherit a stale gate. Best-effort: a failed marker write must not fail the wave (the poller
296
+ // `gate_wave` is that durable marker; the level-triggered poller (`pollWaveGatesImpl`) publishes
297
+ // `wave-merged` once the wave has landed AND it observes an OPEN subscription, but NEVER clears
298
+ // `gate_wave` record-wave owns the marker's lifecycle. Re-arm it to the next wave here, or
299
+ // clear it on the final wave so a re-planned issue can't inherit a stale gate. Best-effort: a
300
+ // failed marker write must not fail the wave (the poller
299
301
  // reconciles from `plan_tasks`/`pull_requests`), but the loop still relies on it to know which
300
302
  // wave to watch, so we log a failure loudly.
301
303
  try {