@nanobpm/nano-workforce 0.82.1 → 0.84.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,39 @@
1
+ name: PR title lint
2
+
3
+ # Squash-merge uses the PR title as the commit subject on `main`, and
4
+ # semantic-release (Angular preset) only cuts a release for `feat:`/`fix:`/`perf:`.
5
+ # A non-conventional title therefore lands on `main` and is silently skipped by
6
+ # the release job — so gate the title against the Conventional Commits grammar.
7
+ on:
8
+ pull_request:
9
+ types: [opened, edited, synchronize, reopened]
10
+
11
+ permissions:
12
+ pull-requests: read
13
+
14
+ jobs:
15
+ lint-title:
16
+ name: Conventional PR title
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: amannn/action-semantic-pull-request@v5
20
+ env:
21
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22
+ with:
23
+ # Keep in lockstep with the semantic-release commit-analyzer preset.
24
+ types: |
25
+ feat
26
+ fix
27
+ perf
28
+ build
29
+ chore
30
+ ci
31
+ docs
32
+ refactor
33
+ revert
34
+ style
35
+ test
36
+ requireScope: false
37
+ subjectPattern: ^(?![A-Z]).+$
38
+ subjectPatternError: |
39
+ The subject "{subject}" must start with a lowercase letter.
package/AGENTS.md CHANGED
@@ -308,6 +308,16 @@ agents:
308
308
  DCO check.
309
309
  - **Conventional Commits.** `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`,
310
310
  `test:`, imperative mood. Review-comment fix-ups are `chore:`, not `fix:`.
311
+ - **PR titles must be Conventional too — they become the release trigger.** PRs
312
+ land on `main` via **squash merge**, so the **PR title is the commit subject**
313
+ semantic-release analyses. Only `feat:` (minor) and `fix:`/`perf:` (patch) cut a
314
+ release; any other type — or a non-conventional title like `Redesign …` or
315
+ `Foundation: …` — lands on `main` and is **silently skipped** by the release job
316
+ (no version, no changelog, no deploy). A user-facing feature **must** be titled
317
+ `feat:`. The `PR title lint` workflow (`.github/workflows/pr-title-lint.yml`)
318
+ enforces this; if a non-conventional title ever slips through, push one empty
319
+ releasable commit (`git commit --allow-empty -s -m "feat(scope): …"`) to release
320
+ the accumulated changes.
311
321
  - **Feature work in a worktree** off `origin/main`, one branch per change; open a
312
322
  PR and reference the closing issue (`Closes #NN`).
313
323
  - **Never `git push --force` on `main`;** use `--force-with-lease` on feature
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.84.0](https://github.com/nanobpm/nano-workforce/compare/v0.83.0...v0.84.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * **ui:** render narrative epic-detail sections with urban prose renderer ([#270](https://github.com/nanobpm/nano-workforce/issues/270)) ([#271](https://github.com/nanobpm/nano-workforce/issues/271)) ([38c67a4](https://github.com/nanobpm/nano-workforce/commit/38c67a479693730b9a79f3a7b0f88ef574d3a456)), closes [nano-ide#274](https://github.com/nano-ide/issues/274) [#87](https://github.com/nanobpm/nano-workforce/issues/87) [274/#275](https://github.com/nanobpm/nano-workforce/issues/275)
7
+
8
+ # [0.83.0](https://github.com/nanobpm/nano-workforce/compare/v0.82.1...v0.83.0) (2026-08-17)
9
+
10
+
11
+ ### Features
12
+
13
+ * **feature-view:** release intent-first pipeline view with stage chips ([0342edb](https://github.com/nanobpm/nano-workforce/commit/0342edbf67372450d6c499c1d39dc042e8dda342)), closes [#267](https://github.com/nanobpm/nano-workforce/issues/267) [#266](https://github.com/nanobpm/nano-workforce/issues/266)
14
+
1
15
  ## [0.82.1](https://github.com/nanobpm/nano-workforce/compare/v0.82.0...v0.82.1) (2026-08-17)
2
16
 
3
17
 
@@ -239,6 +239,30 @@ test("startFeature: a settled run is restarted in place (status reset, pr/outcom
239
239
  assertEquals(row.process_key, "PI-2");
240
240
  });
241
241
 
242
+ test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn the tick-off)", async () => {
243
+ const stores = {
244
+ feature_runs: {
245
+ rows: [
246
+ {
247
+ feature_key: "owner/repo#42",
248
+ status: "merged",
249
+ process_key: "PI-OLD",
250
+ pr_key: "owner/repo#100",
251
+ acknowledged_at: "2024-01-01T00:00:00Z",
252
+ converge: 1,
253
+ auto_merge: 1,
254
+ },
255
+ ],
256
+ key: "feature_key",
257
+ },
258
+ };
259
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-3" }) } as any;
260
+ await startFeature(memData(stores), engine, PARSED, "main", true, true);
261
+ const row = stores.feature_runs.rows[0];
262
+ assertEquals(row.status, "running");
263
+ assertEquals(row.acknowledged_at, null);
264
+ });
265
+
242
266
  // Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
243
267
  // `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
244
268
  // (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
package/app/feature.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
19
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
+ import { deriveListBucket, deriveStage } from "./stage.ts";
20
21
 
21
22
  /** The BPMN process this module drives (resources/processes/feature.bpmn). */
22
23
  export const FEATURE_PROCESS_ID = "feature";
@@ -70,6 +71,25 @@ export interface FeatureRun {
70
71
  * (`record-blocked-ack` / the acknowledge operation) and, as a self-heal, by `pollFeatureBlocked`
71
72
  * when a previously-observed task is completed out-of-band (see `deriveFeatureBlockedPatch`). */
72
73
  blocked_user_task_key: string | null;
74
+ /** Timestamp an operator dismissed a TERMINAL run (§5, `acknowledge-done`); NULL until then. When
75
+ * set on a terminal row, `list_bucket` flips from 'active' to 'history'. Projection surface. */
76
+ acknowledged_at: string | null;
77
+ /** Projection maintained by the feature_runs gateway (like `delivery_label`): the canonical pipeline
78
+ * stage key from `deriveStage` (Requested|Implementing|PR open|Converging|Merging|Done). The page's
79
+ * pipeline column binds `activeField` to it. NULL only on legacy rows before `backfillFeatureStages`. */
80
+ stage: string | null;
81
+ /** Gateway projection: the active stage's render state from `deriveStage` (`ok`|`failed`|`blocked`|
82
+ * NULL). The page binds `stateField` to it; NULL means in-progress (renderer shows `active`). */
83
+ stage_state: string | null;
84
+ /** Gateway projection: space-separated set of stage keys NOT in this row's path from `deriveStage`
85
+ * (derived from `converge`/`auto_merge`). The page binds `notInPathField` to it. */
86
+ stage_skipped: string | null;
87
+ /** Gateway projection: a short attention badge (`blocked` / `⚠`) for the active stage, or NULL, from
88
+ * `deriveStage`. The page binds `badgeField` to it. */
89
+ attention: string | null;
90
+ /** Gateway projection: the Active/History partition label ('active'|'history'), 'history' iff a
91
+ * terminal row has been acknowledged. The page's tabs filter on it with flat `in` clauses (§5). */
92
+ list_bucket: string | null;
73
93
  created_at: string;
74
94
  updated_at: string;
75
95
  }
@@ -251,7 +271,128 @@ export function deriveFeatureBlockedPatch(
251
271
  return Object.keys(patch).length > 0 ? patch : null;
252
272
  }
253
273
 
254
- export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
274
+ /** The feature_runs fields the projection reads. A patch touching none of these cannot change the
275
+ * derived `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`, so the gateway can skip the
276
+ * read-back+reproject for it (see the `update` proxy). Kept adjacent to `projectFeatureRun` so the two
277
+ * stay in lockstep — every field `projectFeatureRun` reads MUST appear here. */
278
+ const PROJECTION_INPUT_KEYS: readonly (keyof FeatureRun)[] = [
279
+ "status",
280
+ "pr_key",
281
+ "converge",
282
+ "auto_merge",
283
+ "escalation_question",
284
+ "escalation_user_task_key",
285
+ "blocked_user_task_key",
286
+ "acknowledged_at",
287
+ ];
288
+
289
+ /** The feature_runs fields the projection WRITES. Included in the reproject trigger so a caller who
290
+ * writes a derived column directly (e.g. `stage`/`list_bucket`) can never bypass derivation: the
291
+ * gateway re-reads, recomputes, and OVERRIDES the raw value with the canonical derived one, keeping
292
+ * "the gateway is the one projection source" a true invariant. Must mirror `projectFeatureRun`'s keys. */
293
+ const PROJECTION_OUTPUT_KEYS: readonly (keyof FeatureRun)[] = [
294
+ "stage",
295
+ "stage_state",
296
+ "stage_skipped",
297
+ "attention",
298
+ "list_bucket",
299
+ ];
300
+
301
+ /** True when a patch changes at least one field the projection derives from OR one it writes — i.e. the
302
+ * projection must be recomputed. A patch touching only projection-irrelevant fields (e.g. `updated_at`)
303
+ * leaves the stored projection correct, since the gateway is the sole write path (see `featureRuns`); a
304
+ * patch that writes a derived column directly still forces a reproject so derivation can't be bypassed. */
305
+ function patchAffectsProjection(patch: Partial<FeatureRun>): boolean {
306
+ return PROJECTION_INPUT_KEYS.some((k) => k in patch) || PROJECTION_OUTPUT_KEYS.some((k) => k in patch);
307
+ }
308
+
309
+ /** Compute the write-time projection columns for a fully-merged feature_runs row. Centralised so the
310
+ * gateway is the ONE place `deriveStage` / `deriveListBucket` are applied — the page, SQL, pollers and
311
+ * workers never re-derive the mapping (AGENTS.md "derivation over duplication"). */
312
+ function projectFeatureRun(row: Partial<FeatureRun>): Partial<FeatureRun> {
313
+ if (!row.status) return {};
314
+ const { stage, state, skipped, attention } = deriveStage({
315
+ status: row.status,
316
+ pr_key: row.pr_key ?? null,
317
+ converge: row.converge ?? null,
318
+ auto_merge: row.auto_merge ?? null,
319
+ escalation_question: row.escalation_question ?? null,
320
+ escalation_user_task_key: row.escalation_user_task_key ?? null,
321
+ blocked_user_task_key: row.blocked_user_task_key ?? null,
322
+ });
323
+ return {
324
+ stage,
325
+ stage_state: state,
326
+ stage_skipped: skipped,
327
+ attention,
328
+ list_bucket: deriveListBucket(row.status, row.acknowledged_at ?? null),
329
+ };
330
+ }
331
+
332
+ /** The feature_runs record gateway (keyed on `feature_key`). Wrapped in a thin projecting proxy so the
333
+ * derived pipeline columns (`stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`) CANNOT be
334
+ * missed by any writer: `insert` and `update` merge the incoming values over the current stored row,
335
+ * then recompute the projection from that post-write field set and write it alongside — exactly the
336
+ * `delivery_label`-style write-time projection, but hoisted to the single gateway so the many scattered
337
+ * status writers (startFeature, the service pollers/reconcilers, the acknowledge operations, and the
338
+ * feature workers) all stay UNCHANGED and automatically get a correct, fresh projection. `update`
339
+ * skips the read-back+reproject for a patch that touches no projection input (e.g. an `updated_at`-only
340
+ * poller write), avoiding a needless `get` roundtrip — the stored projection is already correct since
341
+ * this gateway is the sole write path. Every other method delegates straight through. This is the sole
342
+ * runtime/app-layer WRITE path to feature_runs (no app-code raw SQL, no other `data.table("feature_runs")`
343
+ * mutation — read-only direct reads in e2e tests, and forward-only data migrations such as
344
+ * `db/migrations/036_backfill_titles.sql`, notwithstanding), so
345
+ * `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket` are
346
+ * always populated and correct for every row and every transition. */
347
+ export const featureRuns = (data: DataLayer) => {
348
+ const table = data.table<FeatureRun>("feature_runs", "feature_key");
349
+ return new Proxy(table, {
350
+ get(target, prop) {
351
+ if (prop === "insert") {
352
+ return (row: Partial<FeatureRun>) => target.insert({ ...row, ...projectFeatureRun(row) });
353
+ }
354
+ if (prop === "update") {
355
+ return async (id: unknown, patch: Partial<FeatureRun>) => {
356
+ // Only re-read + reproject when the patch changes a projection input. A projection-irrelevant
357
+ // patch (e.g. an `updated_at`-only poller write) leaves the stored projection correct — the
358
+ // gateway is the sole write path — so skip the extra `get` roundtrip and delegate straight.
359
+ if (!patchAffectsProjection(patch)) return target.update(id, patch);
360
+ const existing = await target.get(id);
361
+ const merged: Partial<FeatureRun> = { ...existing, ...patch };
362
+ return target.update(id, { ...patch, ...projectFeatureRun(merged) });
363
+ };
364
+ }
365
+ // Delegate every other method straight through. Bind functions to the real target so the
366
+ // gateway's private class fields (`#src`) resolve — a Proxy `this` would not carry them.
367
+ const value = Reflect.get(target, prop, target);
368
+ return typeof value === "function" ? value.bind(target) : value;
369
+ },
370
+ });
371
+ };
372
+
373
+ /** Re-project every feature_runs row through the gateway so rows written before migration 039 (whose
374
+ * projection columns are NULL) get correct `stage`/`stage_state`/`stage_skipped`/`attention`/
375
+ * `list_bucket` values. Idempotent and safe to re-run: it re-derives from each row's own stored fields,
376
+ * so a second pass is a no-op. Runs once at boot (pollOnce) — the gateway keeps every future write
377
+ * fresh, so this only needs to catch legacy rows once. */
378
+ export async function backfillFeatureStages(data: DataLayer): Promise<number> {
379
+ const table = featureRuns(data);
380
+ const rows = await table.all();
381
+ let stamped = 0;
382
+ for (const row of rows) {
383
+ // Only touch rows the projection has never reached — a legacy pre-039 row whose `stage` column is
384
+ // still NULL. The gateway keeps every write fresh, so an already-projected row needs no re-write;
385
+ // skipping them avoids a full-table rewrite on every boot and keeps `stamped` an honest count of
386
+ // rows actually backfilled (not the total row count).
387
+ if (row.stage != null) continue;
388
+ // Re-derive the projection from the legacy row's own stored fields and write it. (An empty patch
389
+ // would now short-circuit the projecting proxy — it only reprojects on a projection-input change —
390
+ // so backfill projects explicitly rather than relying on an empty-patch reproject.)
391
+ await table.update(row.feature_key, projectFeatureRun(row));
392
+ stamped++;
393
+ }
394
+ return stamped;
395
+ }
255
396
 
256
397
  /** The deterministic task id for a single-issue run — the implementation agent branches
257
398
  * `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
@@ -307,6 +448,11 @@ export async function startFeature(
307
448
  auto_merge: autoMerge ? 1 : 0,
308
449
  outcome: null,
309
450
  delivery_label: null,
451
+ // Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
452
+ // it next settles: a stale `acknowledged_at` from the prior terminal run would make
453
+ // `deriveListBucket` flip the row to 'history' the moment it completes again, skipping the
454
+ // intended operator dismissal. A fresh run must re-earn its tick-off.
455
+ acknowledged_at: null,
310
456
  escalation_question: null,
311
457
  escalation_user_task_key: null,
312
458
  blocked_user_task_key: null,
@@ -0,0 +1,195 @@
1
+ // Tests for the feature_runs gateway's write-time pipeline projection (issue #254 §1/§3) and the
2
+ // one-shot backfill. The gateway wraps the plain table so EVERY writer — no matter which module —
3
+ // automatically gets a fresh `stage`/`stage_state`/`stage_skipped`/`attention`/`list_bucket`
4
+ // projection, without passing them: the single write path is the only place `deriveStage` /
5
+ // `deriveListBucket` are applied.
6
+ import { test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import type { DataLayer } from "@nanobpm/urban";
9
+ import { backfillFeatureStages, featureRuns } from "./feature.ts";
10
+
11
+ // An in-memory record gateway with the same semantics the real Table exposes (get/all/find/insert/
12
+ // update). The featureRuns proxy wraps whatever data.table returns, so this exercises the real proxy.
13
+ function memData(): { data: DataLayer; rows: any[] } {
14
+ const rows: any[] = [];
15
+ function tbl(_name: string, pk = "id") {
16
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
17
+ return {
18
+ async all() {
19
+ return rows.slice();
20
+ },
21
+ async get(id: any) {
22
+ return rows.find((r) => r[pk] === id);
23
+ },
24
+ async find(where: any = {}) {
25
+ return rows.filter((r) => match(r, where));
26
+ },
27
+ async insert(row: any) {
28
+ rows.push({ ...row });
29
+ return row[pk];
30
+ },
31
+ async update(id: any, patch: any) {
32
+ const r = rows.find((row) => row[pk] === id);
33
+ if (r) Object.assign(r, patch);
34
+ return r ? 1 : 0;
35
+ },
36
+ };
37
+ }
38
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as any as DataLayer;
39
+ return { data, rows };
40
+ }
41
+
42
+ test("update with only {status:'opened'} projects PR open / null / active without the caller passing them", async () => {
43
+ const { data, rows } = memData();
44
+ rows.push({ feature_key: "o/r#1", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
45
+ await featureRuns(data).update("o/r#1", { status: "opened" });
46
+ assertEquals(rows[0].stage, "PR open");
47
+ assertEquals(rows[0].stage_state, null);
48
+ assertEquals(rows[0].list_bucket, "active");
49
+ });
50
+
51
+ test("update {status:'failed'} projects Done / failed", async () => {
52
+ const { data, rows } = memData();
53
+ rows.push({ feature_key: "o/r#2", status: "running", converge: 1, auto_merge: 1 });
54
+ await featureRuns(data).update("o/r#2", { status: "failed" });
55
+ assertEquals(rows[0].stage, "Done");
56
+ assertEquals(rows[0].stage_state, "failed");
57
+ });
58
+
59
+ test("update {status:'blocked'} projects Done / blocked", async () => {
60
+ const { data, rows } = memData();
61
+ rows.push({ feature_key: "o/r#3", status: "running", converge: 1, auto_merge: 1 });
62
+ await featureRuns(data).update("o/r#3", { status: "blocked" });
63
+ assertEquals(rows[0].stage, "Done");
64
+ assertEquals(rows[0].stage_state, "blocked");
65
+ });
66
+
67
+ test("update {status:'escalated'} on a row with no pr_key projects Implementing / null", async () => {
68
+ const { data, rows } = memData();
69
+ rows.push({ feature_key: "o/r#4", status: "running", pr_key: null, converge: 1, auto_merge: 1 });
70
+ await featureRuns(data).update("o/r#4", { status: "escalated" });
71
+ assertEquals(rows[0].stage, "Implementing");
72
+ assertEquals(rows[0].stage_state, null);
73
+ });
74
+
75
+ test("a run with converge=false projects stage_skipped containing Converging and Merging", async () => {
76
+ const { data, rows } = memData();
77
+ rows.push({ feature_key: "o/r#5", status: "running", converge: 0, auto_merge: 0 });
78
+ await featureRuns(data).update("o/r#5", { status: "running" });
79
+ assert(rows[0].stage_skipped.includes("Converging"));
80
+ assert(rows[0].stage_skipped.includes("Merging"));
81
+ });
82
+
83
+ test("insert projects the pipeline columns from status", async () => {
84
+ const { data, rows } = memData();
85
+ await featureRuns(data).insert({ feature_key: "o/r#6", status: "running", converge: 1, auto_merge: 1 } as any);
86
+ assertEquals(rows[0].stage, "Implementing");
87
+ assertEquals(rows[0].stage_state, null);
88
+ assertEquals(rows[0].list_bucket, "active");
89
+ });
90
+
91
+ test("setting acknowledged_at on a terminal row flips list_bucket to 'history'", async () => {
92
+ const { data, rows } = memData();
93
+ rows.push({ feature_key: "o/r#7", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
94
+ // Terminal but unacknowledged → still Active.
95
+ await featureRuns(data).update("o/r#7", { status: "merged" });
96
+ assertEquals(rows[0].list_bucket, "active");
97
+ // Acknowledge → History.
98
+ await featureRuns(data).update("o/r#7", { acknowledged_at: "2024-01-01T00:00:00Z" });
99
+ assertEquals(rows[0].list_bucket, "history");
100
+ assertEquals(rows[0].stage, "Done");
101
+ });
102
+
103
+ test("update touching only projection-irrelevant fields skips the read-back and reproject", async () => {
104
+ const { data, rows } = memData();
105
+ rows.push({
106
+ feature_key: "o/r#skip",
107
+ status: "merged",
108
+ converge: 1,
109
+ auto_merge: 1,
110
+ acknowledged_at: null,
111
+ stage: "Done",
112
+ stage_state: "ok",
113
+ stage_skipped: "",
114
+ attention: null,
115
+ list_bucket: "active",
116
+ });
117
+ // Count get() calls to prove the projection-irrelevant path does no read-back roundtrip.
118
+ let gets = 0;
119
+ const raw = (data as any).table;
120
+ (data as any).table = (n: string, pk?: string) => {
121
+ const t = raw(n, pk);
122
+ const origGet = t.get;
123
+ t.get = async (id: any) => {
124
+ gets++;
125
+ return origGet.call(t, id);
126
+ };
127
+ return t;
128
+ };
129
+ await featureRuns(data).update("o/r#skip", { updated_at: "2024-06-01T00:00:00Z" });
130
+ assertEquals(gets, 0);
131
+ assertEquals(rows[0].updated_at, "2024-06-01T00:00:00Z");
132
+ // Untouched projection stays as stored.
133
+ assertEquals(rows[0].list_bucket, "active");
134
+
135
+ // A projection-input change (acknowledged_at) DOES read back and reproject.
136
+ await featureRuns(data).update("o/r#skip", { acknowledged_at: "2024-06-01T00:00:00Z" });
137
+ assertEquals(gets, 1);
138
+ assertEquals(rows[0].list_bucket, "history");
139
+ });
140
+
141
+ test("a direct write of a projection output field forces reprojection and overrides the raw value", async () => {
142
+ const { data, rows } = memData();
143
+ rows.push({
144
+ feature_key: "o/r#bypass",
145
+ status: "running",
146
+ pr_key: null,
147
+ converge: 1,
148
+ auto_merge: 1,
149
+ stage: "Implementing",
150
+ stage_state: null,
151
+ list_bucket: "active",
152
+ });
153
+ // A caller tries to bypass derivation by writing the derived column directly. The gateway must NOT
154
+ // persist the raw value: it re-reads, recomputes from the merged inputs, and overrides it.
155
+ await featureRuns(data).update("o/r#bypass", { stage: "Done", list_bucket: "history" });
156
+ assertEquals(rows[0].stage, "Implementing");
157
+ assertEquals(rows[0].list_bucket, "active");
158
+ });
159
+
160
+ test("backfillFeatureStages stamps legacy terminal and live rows with helper-derived values", async () => {
161
+ const { data, rows } = memData();
162
+ // Legacy rows written before migration 039 → projection columns absent/NULL.
163
+ rows.push({ feature_key: "o/r#8", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: "2024-01-01T00:00:00Z" });
164
+ rows.push({ feature_key: "o/r#9", status: "running", pr_key: null, converge: 0, auto_merge: 0 });
165
+
166
+ const stamped = await backfillFeatureStages(data);
167
+ assertEquals(stamped, 2);
168
+
169
+ const terminal = rows.find((r) => r.feature_key === "o/r#8");
170
+ assertEquals(terminal.stage, "Done");
171
+ assertEquals(terminal.stage_state, "ok");
172
+ assertEquals(terminal.list_bucket, "history");
173
+
174
+ const live = rows.find((r) => r.feature_key === "o/r#9");
175
+ assertEquals(live.stage, "Implementing");
176
+ assertEquals(live.stage_state, null);
177
+ assertEquals(live.stage_skipped, "Converging Merging");
178
+ assertEquals(live.list_bucket, "active");
179
+ });
180
+
181
+ test("backfillFeatureStages skips already-projected rows and counts only rows it stamps", async () => {
182
+ const { data, rows } = memData();
183
+ // One legacy row (no projection) + one already-projected row (gateway kept it fresh).
184
+ rows.push({ feature_key: "o/r#legacy", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null });
185
+ rows.push({ feature_key: "o/r#fresh", status: "merged", converge: 1, auto_merge: 1, acknowledged_at: null, stage: "Done", stage_state: "ok", stage_skipped: "", attention: null, list_bucket: "active", updated_at: "0" });
186
+
187
+ const stamped = await backfillFeatureStages(data);
188
+ // Only the legacy row is stamped; the already-projected row is skipped.
189
+ assertEquals(stamped, 1);
190
+ const legacy = rows.find((r) => r.feature_key === "o/r#legacy");
191
+ assertEquals(legacy.stage, "Done");
192
+ // The already-projected row was not re-written (its sentinel updated_at is untouched).
193
+ const fresh = rows.find((r) => r.feature_key === "o/r#fresh");
194
+ assertEquals(fresh.updated_at, "0");
195
+ });
package/app/service.ts CHANGED
@@ -12,7 +12,7 @@ import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
12
  import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
13
  import { agentSlaTimeout } from "./agentSla.ts";
14
14
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
15
- import { deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
15
+ import { backfillFeatureStages, deriveFeatureBlockedPatch, deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRun, type FeatureRunStatus, featureRuns } from "./feature.ts";
16
16
  import {
17
17
  classifyMergeability,
18
18
  coalesceTitle,
@@ -1647,12 +1647,27 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
1647
1647
  * The wave-merge barrier is now level-triggered and probes the engine's message-subscription state
1648
1648
  * over the same raw-REST search surface, so it runs only when `engineRest` is supplied (as in
1649
1649
  * production — `main.ts` always passes it). */
1650
+ /** One-shot guard so the feature-stage backfill (`backfillFeatureStages`) runs at most once per
1651
+ * process, on the first `pollOnce`. Idempotent regardless, but there is no need to re-scan every row
1652
+ * on every poll. */
1653
+ let featureStagesBackfilled = false;
1654
+
1650
1655
  export async function pollOnce(
1651
1656
  data: DataLayer,
1652
1657
  engine: EngineClient,
1653
1658
  token: string,
1654
1659
  engineRest?: { restAddress: string; token?: string },
1655
1660
  ) {
1661
+ // One-shot: re-project any pre-#254 feature_runs rows whose pipeline columns are still NULL. The
1662
+ // gateway keeps every future write fresh, so this only needs to run once per process and is safe to
1663
+ // re-run (it re-derives from each row's own stored fields).
1664
+ if (!featureStagesBackfilled) {
1665
+ // Only arm the one-shot guard AFTER a successful backfill: setting it first would swallow a
1666
+ // transient failure (e.g. a DB blip) and leave legacy rows unprojected forever, since every later
1667
+ // pass would skip. On a throw the guard stays false and the next `pollOnce` retries.
1668
+ await backfillFeatureStages(data);
1669
+ featureStagesBackfilled = true;
1670
+ }
1656
1671
  await pollReviews(data, engine, token);
1657
1672
  await pollMerges(data, engine, token);
1658
1673
  await pollDelivery(data);
@@ -0,0 +1,107 @@
1
+ // Unit tests for the canonical feature-run pipeline stage model (issue #254 §1). `deriveStage` is the
2
+ // ONE source of truth the feature_runs gateway projects onto the stored pipeline columns, so the
3
+ // mapping must be TOTAL and DETERMINISTIC over every FEATURE_RUN_STATUS and emit the urban 0.53.0
4
+ // `kind:"pipeline"` renderer's EXACT vocabulary (state `ok|failed|blocked|null`).
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import { FEATURE_RUN_STATUSES } from "./feature.ts";
8
+ import { deriveListBucket, deriveStage, type StageInput } from "./stage.ts";
9
+
10
+ const base = (over: Partial<StageInput> & { status: string }): StageInput => ({
11
+ pr_key: null,
12
+ converge: 1,
13
+ auto_merge: 1,
14
+ escalation_question: null,
15
+ escalation_user_task_key: null,
16
+ blocked_user_task_key: null,
17
+ ...over,
18
+ });
19
+
20
+ test("deriveStage is TOTAL: every one of the 11 statuses maps to a defined stage and state", () => {
21
+ assertEquals(FEATURE_RUN_STATUSES.length, 11);
22
+ for (const status of FEATURE_RUN_STATUSES) {
23
+ const d = deriveStage(base({ status }));
24
+ assert(d.stage !== undefined, `stage undefined for ${status}`);
25
+ assert(
26
+ ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"].includes(d.stage),
27
+ `stage out of range for ${status}: ${d.stage}`,
28
+ );
29
+ // state is one of the renderer's exact values (null allowed)
30
+ assert([null, "ok", "failed", "blocked"].includes(d.state), `state out of range for ${status}: ${d.state}`);
31
+ }
32
+ });
33
+
34
+ test("status -> stage mapping (each row)", () => {
35
+ // Terminal tier → Done.
36
+ for (const status of ["merged", "converged", "blocked", "failed", "skipped", "abandoned"]) {
37
+ assertEquals(deriveStage(base({ status })).stage, "Done", status);
38
+ }
39
+ // Live/parked tier.
40
+ assertEquals(deriveStage(base({ status: "converging" })).stage, "Converging");
41
+ assertEquals(deriveStage(base({ status: "opened" })).stage, "PR open");
42
+ assertEquals(deriveStage(base({ status: "running", pr_key: "o/r#9" })).stage, "PR open");
43
+ assertEquals(deriveStage(base({ status: "running" })).stage, "Implementing");
44
+ // The pre-start/created initial state (unknown non-terminal, no pr_key) → Requested.
45
+ assertEquals(deriveStage(base({ status: "created" })).stage, "Requested");
46
+ });
47
+
48
+ test("state: the three non-null values plus the null in-progress case", () => {
49
+ assertEquals(deriveStage(base({ status: "merged" })).state, "ok");
50
+ assertEquals(deriveStage(base({ status: "converged" })).state, "ok");
51
+ assertEquals(deriveStage(base({ status: "failed" })).state, "failed");
52
+ assertEquals(deriveStage(base({ status: "skipped" })).state, "failed");
53
+ assertEquals(deriveStage(base({ status: "abandoned" })).state, "failed");
54
+ assertEquals(deriveStage(base({ status: "blocked" })).state, "blocked");
55
+ // Every non-terminal status → null (in-progress).
56
+ for (const status of ["running", "opened", "converging", "escalated", "awaiting_operator"]) {
57
+ assertEquals(deriveStage(base({ status })).state, null, status);
58
+ }
59
+ });
60
+
61
+ test("state emits 'failed' (not 'fail') so the renderer does not degrade a failure to active", () => {
62
+ assertEquals(deriveStage(base({ status: "failed" })).state, "failed");
63
+ });
64
+
65
+ test("skipped: the three converge/auto_merge cases", () => {
66
+ // converge off → skip both.
67
+ assertEquals(deriveStage(base({ status: "running", converge: 0, auto_merge: 0 })).skipped, "Converging Merging");
68
+ // converge on, auto_merge off → skip Merging only.
69
+ assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 0 })).skipped, "Merging");
70
+ // both on → empty.
71
+ assertEquals(deriveStage(base({ status: "running", converge: 1, auto_merge: 1 })).skipped, "");
72
+ });
73
+
74
+ test("attention: blocked, escalation, none", () => {
75
+ assertEquals(deriveStage(base({ status: "awaiting_operator", blocked_user_task_key: "ut-1" })).attention, "blocked");
76
+ assertEquals(deriveStage(base({ status: "escalated", escalation_user_task_key: "ut-2" })).attention, "⚠");
77
+ assertEquals(deriveStage(base({ status: "escalated", escalation_question: "which base?" })).attention, "⚠");
78
+ assertEquals(deriveStage(base({ status: "running" })).attention, null);
79
+ });
80
+
81
+ // The three parked-status rows called out by the plan review.
82
+ test("escalated WITH pr_key → PR open / null", () => {
83
+ const d = deriveStage(base({ status: "escalated", pr_key: "o/r#5" }));
84
+ assertEquals(d.stage, "PR open");
85
+ assertEquals(d.state, null);
86
+ });
87
+
88
+ test("escalated WITHOUT pr_key → Implementing / null", () => {
89
+ const d = deriveStage(base({ status: "escalated", pr_key: null }));
90
+ assertEquals(d.stage, "Implementing");
91
+ assertEquals(d.state, null);
92
+ });
93
+
94
+ test("awaiting_operator WITHOUT pr_key → Implementing / null, attention 'blocked' when parked", () => {
95
+ const d = deriveStage(base({ status: "awaiting_operator", pr_key: null, blocked_user_task_key: "ut-3" }));
96
+ assertEquals(d.stage, "Implementing");
97
+ assertEquals(d.state, null);
98
+ assertEquals(d.attention, "blocked");
99
+ });
100
+
101
+ test("deriveListBucket: history iff terminal AND acknowledged, else active", () => {
102
+ assertEquals(deriveListBucket("merged", null), "active");
103
+ assertEquals(deriveListBucket("merged", "2024-01-01T00:00:00Z"), "history");
104
+ // A non-terminal status is always active, even if (spuriously) acknowledged.
105
+ assertEquals(deriveListBucket("running", "2024-01-01T00:00:00Z"), "active");
106
+ assertEquals(deriveListBucket("blocked", "2024-01-01T00:00:00Z"), "history");
107
+ });
package/app/stage.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Canonical feature-run pipeline stage model (issue #254 §1) — the ONE source of truth for the
2
+ // derived pipeline surface the Feature view renders. Mirrors the single-source-of-truth style of
3
+ // `deriveDelivery` (app/delivery.ts) and `classifyEscalation` (app/escalationTaxonomy.ts): a PURE,
4
+ // read-only function with no data access. The feature_runs gateway (app/feature.ts) projects its
5
+ // output onto the stored `stage`/`stage_state`/`stage_skipped`/`attention` columns at write time,
6
+ // exactly as `delivery_label` is projected — so the declarative dataGrid page consumes ready, stored
7
+ // columns (bound by `{"field":…}`) and never has to call TS or express OR/null in its flat filter DSL.
8
+ //
9
+ // The mapping is TOTAL and DETERMINISTIC over all 11 FEATURE_RUN_STATUSES, computed from ONLY the
10
+ // fields stored on the row — never a "previous"/"underlying" stage, because a FeatureRun stores only
11
+ // its CURRENT status (any prior stage was overwritten on transition). Do NOT duplicate this mapping
12
+ // anywhere (not in SQL, not in the page, not in each poller/worker): every writer flows through the
13
+ // gateway, which is the single caller.
14
+
15
+ /** The canonical pipeline stage keys, in path order. `Merging` is a path/visual stage the renderer
16
+ * fills as upcoming — no status maps to it as the ACTIVE stage (intentional). */
17
+ export const STAGE_KEYS = ["Requested", "Implementing", "PR open", "Converging", "Merging", "Done"] as const;
18
+ export type StageKey = (typeof STAGE_KEYS)[number];
19
+
20
+ /** The active stage's render state, in the urban 0.53.0 `kind:"pipeline"` column's EXACT vocabulary:
21
+ * `ok` (Done ✓ success), `failed` (Done ✕ failure), `blocked` (blocked glyph), or `null` (in-progress
22
+ * → the renderer treats it as `active`). Any OTHER string silently degrades to `active` in the
23
+ * renderer, so a failed run MUST emit `'failed'` (not `'fail'`) to render as a failure. */
24
+ export type StageState = "ok" | "failed" | "blocked" | null;
25
+
26
+ /** The 6 TRULY-terminal statuses that map to the `Done` stage. Distinct from
27
+ * `FEATURE_TERMINAL_STATUSES` (app/feature.ts), which is the redispatch-settled set and also counts
28
+ * `opened`/`converging` as terminal — those are LIVE pipeline stages (`PR open`/`Converging`), NOT
29
+ * Done, so this list must stay separate. Also the basis of the `list_bucket` history partition. */
30
+ export const STAGE_DONE_STATUSES: readonly string[] = [
31
+ "merged",
32
+ "converged",
33
+ "blocked",
34
+ "failed",
35
+ "skipped",
36
+ "abandoned",
37
+ ];
38
+
39
+ /** The subset of a FeatureRun `deriveStage` reads. FeatureRun (app/feature.ts) structurally satisfies
40
+ * this; keeping the input structural avoids a stage.ts ↔ feature.ts import cycle. */
41
+ export interface StageInput {
42
+ status: string;
43
+ pr_key?: string | null;
44
+ converge?: number | boolean | null;
45
+ auto_merge?: number | boolean | null;
46
+ escalation_question?: string | null;
47
+ escalation_user_task_key?: string | null;
48
+ blocked_user_task_key?: string | null;
49
+ }
50
+
51
+ /** The derived pipeline projection for one run. `skipped` is a space-separated set of stage keys not
52
+ * in this row's path (bound to the renderer's `notInPathField`). */
53
+ export interface DerivedStage {
54
+ stage: StageKey;
55
+ state: StageState;
56
+ skipped: string;
57
+ attention: string | null;
58
+ }
59
+
60
+ const truthy = (v: number | boolean | null | undefined): boolean => v === true || (typeof v === "number" && v !== 0);
61
+
62
+ /** Derive the canonical pipeline stage, its render state, its not-in-path set, and its attention badge
63
+ * for one feature run. Pure and read-only — TOTAL over all 11 statuses (never returns undefined). */
64
+ export function deriveStage(run: StageInput): DerivedStage {
65
+ const { status } = run;
66
+
67
+ // TERMINAL tier — the 6 truly-terminal statuses collapse to Done. Unconditional: terminal `blocked`
68
+ // is the issue §1 'Done ✕' row (state `blocked`), NOT Implementing.
69
+ let stage: StageKey;
70
+ let state: StageState;
71
+ if (STAGE_DONE_STATUSES.includes(status)) {
72
+ stage = "Done";
73
+ state =
74
+ status === "merged" || status === "converged"
75
+ ? "ok"
76
+ : status === "blocked"
77
+ ? "blocked"
78
+ : "failed"; // failed / skipped / abandoned
79
+ } else {
80
+ // LIVE/PARKED tier — one shared rule for every non-terminal status. `escalated`/`awaiting_operator`
81
+ // are parked but their stored fields still describe WHERE in the pipeline they stalled, so they run
82
+ // through the same rule as a live run; their attention comes from the badge (below), not the stage.
83
+ if (status === "converging") stage = "Converging";
84
+ else if ((run.pr_key ?? "") !== "" || status === "opened") stage = "PR open";
85
+ else if (status === "running" || status === "escalated" || status === "awaiting_operator") stage = "Implementing";
86
+ else stage = "Requested";
87
+ state = null;
88
+ }
89
+
90
+ // `skipped`: stages not in this row's path, purely from converge/auto_merge.
91
+ const converge = truthy(run.converge);
92
+ const autoMerge = truthy(run.auto_merge);
93
+ const skippedKeys: StageKey[] = !converge ? ["Converging", "Merging"] : !autoMerge ? ["Merging"] : [];
94
+
95
+ // `attention`: a short badge for the active stage (the renderer colours it from `state`). This is how
96
+ // a parked `awaiting_operator`/`escalated` run surfaces as attention WITHOUT altering its stage.
97
+ const attention = run.blocked_user_task_key
98
+ ? "blocked"
99
+ : run.escalation_user_task_key || run.escalation_question
100
+ ? "⚠"
101
+ : null;
102
+
103
+ return { stage, state, skipped: skippedKeys.join(" "), attention };
104
+ }
105
+
106
+ /** The Active/History partition label (§5), maintained at write time so the flat-DSL page tabs filter
107
+ * on a stored `list_bucket` column with only `in` clauses. `history` iff the row is in a truly-terminal
108
+ * status AND acknowledged; otherwise `active` (live runs + terminal-but-UNACKNOWLEDGED runs). */
109
+ export function deriveListBucket(status: string, acknowledgedAt: string | null | undefined): "active" | "history" {
110
+ return STAGE_DONE_STATUSES.includes(status) && acknowledgedAt != null ? "history" : "active";
111
+ }
@@ -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.1",
3
+ "version": "0.84.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.54.0"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@biomejs/biome": "^2.4.11",
@@ -137,38 +137,36 @@
137
137
  }
138
138
  },
139
139
  {
140
- "type": "dataGrid",
140
+ "type": "prose",
141
141
  "id": "plan-reviews",
142
142
  "props": {
143
143
  "title": "Plan review trace",
144
144
  "refreshMs": 5000,
145
145
  "collapsible": true,
146
146
  "defaultCollapsed": true,
147
+ "measure": 80,
148
+ "empty": "No plan reviews recorded yet.",
147
149
  "data": {
148
150
  "kind": "datasource",
149
151
  "source": "app",
150
152
  "table": "plan_reviews",
151
- "orderBy": { "field": "round", "dir": "asc" },
153
+ "orderBy": { "field": "created_at", "dir": "asc" },
152
154
  "filter": [{ "field": "plan_key", "eqParam": true }]
153
155
  },
154
- "columns": [
155
- { "field": "round", "header": "Round" },
156
- { "field": "epoch", "header": "Epoch" },
157
- { "field": "approved", "header": "Approved? (1/0)" },
158
- { "field": "findings", "header": "Reviewer findings" },
159
- { "field": "created_at", "header": "Recorded" }
160
- ]
156
+ "header": "Round {{round}} · epoch {{epoch}} · approved {{approved}} · {{created_at}}",
157
+ "body": "findings"
161
158
  }
162
159
  },
163
160
  {
164
- "type": "dataGrid",
161
+ "type": "prose",
165
162
  "id": "plan-review-escalations",
166
163
  "props": {
167
164
  "title": "Plan-review escalations",
168
- "rowKey": "id",
169
165
  "refreshMs": 5000,
170
166
  "collapsible": true,
171
167
  "defaultCollapsed": true,
168
+ "measure": 80,
169
+ "empty": "No plan-review escalations.",
172
170
  "data": {
173
171
  "kind": "datasource",
174
172
  "source": "app",
@@ -176,16 +174,8 @@
176
174
  "orderBy": { "field": "id", "dir": "desc" },
177
175
  "filter": [{ "field": "plan_key", "eqParam": true }]
178
176
  },
179
- "columns": [
180
- { "field": "epoch", "header": "Epoch" },
181
- { "field": "round", "header": "Round" },
182
- { "field": "findings", "header": "Findings" },
183
- { "field": "status", "header": "Status" },
184
- { "field": "directive", "header": "Directive" },
185
- { "field": "note", "header": "Human note" },
186
- { "field": "asked_at", "header": "Asked" },
187
- { "field": "answered_at", "header": "Answered" }
188
- ]
177
+ "header": "Round {{round}} · epoch {{epoch}} · {{status}} · directive {{directive}} · asked {{asked_at}} · answered {{answered_at}} · note {{note}}",
178
+ "body": "findings"
189
179
  }
190
180
  },
191
181
  {
@@ -214,14 +204,15 @@
214
204
  }
215
205
  },
216
206
  {
217
- "type": "dataGrid",
207
+ "type": "prose",
218
208
  "id": "coordination-notes",
219
209
  "props": {
220
210
  "title": "Coordination notes",
221
- "rowKey": "id",
222
211
  "refreshMs": 5000,
223
212
  "collapsible": true,
224
213
  "defaultCollapsed": true,
214
+ "measure": 80,
215
+ "empty": "No coordination notes posted yet.",
225
216
  "data": {
226
217
  "kind": "datasource",
227
218
  "source": "app",
@@ -229,14 +220,8 @@
229
220
  "orderBy": { "field": "id", "dir": "asc" },
230
221
  "filter": [{ "field": "plan_key", "eqParam": true }]
231
222
  },
232
- "columns": [
233
- { "field": "wave", "header": "Wave" },
234
- { "field": "author_task", "header": "Agent" },
235
- { "field": "kind", "header": "Kind" },
236
- { "field": "files", "header": "Files" },
237
- { "field": "body", "header": "Note" },
238
- { "field": "created_at", "header": "Posted" }
239
- ]
223
+ "header": "Wave {{wave}} · {{author_task}} · {{kind}} · files {{files}} · {{created_at}}",
224
+ "body": "body"
240
225
  }
241
226
  },
242
227
  {
@@ -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
  ],
@@ -112,6 +112,12 @@ function filterFields(filter: Json): string[] {
112
112
  return filter.map((f: Json) => f?.field).filter(Boolean);
113
113
  }
114
114
 
115
+ // Pull `{{field}}` interpolation names out of a prose renderer's header template (nano-ide#274).
116
+ function templateFields(tpl: Json): string[] {
117
+ if (typeof tpl !== "string") return [];
118
+ return [...tpl.matchAll(/\{\{([^{}]+)\}\}/g)].map((m) => m[1].trim()).filter(Boolean);
119
+ }
120
+
115
121
  function collectRefs(page: string, node: Json, out: Ref[]): void {
116
122
  if (Array.isArray(node)) {
117
123
  for (const v of node) collectRefs(page, v, out);
@@ -120,10 +126,17 @@ function collectRefs(page: string, node: Json, out: Ref[]): void {
120
126
  if (!node || typeof node !== "object") return;
121
127
 
122
128
  // Top-level datasource grid: the datasource lives at `node.data`, while `columns`, `rowKey`,
123
- // `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props).
129
+ // `filter`/`tabs`, and `detail` are siblings on the same `node` (the grid props). A `prose`
130
+ // renderer (nano-ide#274) binds the same `node.data` but has no `columns`: its displayed content
131
+ // is the header template's `{{field}}` refs plus the single `body` field, so fold those in as
132
+ // "columns" so the field-existence + surface guards below apply to prose sections too.
124
133
  const data = node.data;
125
134
  if (data && data.kind === "datasource" && typeof data.table === "string") {
126
- const columns: string[] = (node.columns ?? []).map((c: Json) => c.field).filter(Boolean);
135
+ const columns: string[] = [
136
+ ...(node.columns ?? []).map((c: Json) => c.field),
137
+ ...templateFields(node.header),
138
+ ...(typeof node.body === "string" ? [node.body] : []),
139
+ ].filter(Boolean);
127
140
  // Every reference that resolves to a column on this table — the runtime 400s on any of them if
128
141
  // it names a column the migrations never created, so all must be guarded, not just displayed
129
142
  // columns. `detail.fields`/`detail.linkField` render columns of the same top-level row.
@@ -215,17 +228,19 @@ test("issue #87: plan_reviews is surfaced on the per-epic detail page", async ()
215
228
  const onEpicDetail = refs.some(
216
229
  (r) => r.page === "epic-detail.page.json" && r.table === "plan_reviews",
217
230
  );
218
- assert(onEpicDetail, "epic-detail.page.json must bind a grid to plan_reviews (plan-review trace)");
231
+ assert(onEpicDetail, "epic-detail.page.json must bind the plan-review trace to plan_reviews");
219
232
 
220
- // The trace is only useful with the verdict + critique columns, so pin them. Assert against the
221
- // visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field) so
222
- // a column silently dropped from the grid UI can't pass by being referenced elsewhere.
233
+ // The trace is only useful with the verdict + critique surfaced, so pin them. Assert against the
234
+ // visibly displayed `columns` (not `fields`, which also holds binding refs like orderBy.field)
235
+ // for the `prose` renderer (nano-ide#274) these are the header template's `{{round}}`/`{{approved}}`
236
+ // refs plus the `findings` body — so a field silently dropped from the UI can't pass by being
237
+ // referenced elsewhere.
223
238
  const required = ["round", "approved", "findings"];
224
239
  for (const r of refs.filter((x) => x.table === "plan_reviews")) {
225
240
  for (const col of required) {
226
241
  assert(
227
242
  r.columns.includes(col),
228
- `${r.page}: plan_reviews grid must expose the "${col}" column`,
243
+ `${r.page}: plan_reviews trace must surface the "${col}" field`,
229
244
  );
230
245
  }
231
246
  }