@nanobpm/nano-workforce 0.133.0 → 0.134.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,65 @@
1
+ -- Feature-run read model: fold the ADR-0065 DERIVED terminal edge into the projection (issue #503 —
2
+ -- the feature_runs row of the "migrate remaining terminal-edge readers to derived_status" class).
3
+ --
4
+ -- Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a writer:
5
+ -- on cancel/terminate it feeds urban's instance projection and the terminal edge (`onTerminated →
6
+ -- abandoned`) is RECOMPUTED ON READ as `feature_runs__tracking.derived_status` — it NO LONGER writes
7
+ -- `abandoned` onto the base `feature_runs.status` column. `feature_runs` had NO derived reader, so a
8
+ -- terminated feature run's base row stayed frozen at `running`/`escalated`/`awaiting_operator` and
9
+ -- 076's `feature_read_model` rendered it "Implementing" (never "failed") on the Feature history grid
10
+ -- forever.
11
+ --
12
+ -- 076_feature_read_model_declare_once.sql authored the projection ONCE (`defineReadModel`,
13
+ -- app/featureReadModel.ts) and emitted each derived column VERBATIM from that declaration; it read the
14
+ -- base `feature_runs.status`. This migration SUPERSEDES 076's VIEW body: the declaration's `baseTable`
15
+ -- is now the auto-provisioned `feature_runs__tracking` derived VIEW (which re-exports `feature_runs.*`
16
+ -- plus a terminal-folded `derived_status`), and every status-classifying derived column below reads
17
+ -- `fr."derived_status"` instead of `fr."status"`. So a cancelled/terminated run renders `Done`/`failed`
18
+ -- with no worker write. 076 is a MERGED, IMMUTABLE migration — never edited; this is a NEW migration
19
+ -- superseding its VIEW body (the same pattern by which 076 superseded 073/075).
20
+ --
21
+ -- Every DERIVED column body is emitted VERBATIM from the ONE declaration
22
+ -- (`featureReadModel.sqlSelectFor(col, { baseAlias: "fr" })`), which ALSO drives the runtime TS via
23
+ -- `fnFor` — the two lowerings fall out of the same closed-DSL AST and cannot diverge. The drift guard
24
+ -- (app/featureReadModel.test.ts) fails if this file stops matching the declaration, and
25
+ -- `assertReadModelParity` proves the SQL and TS lowerings agree. SEMANTICS are unchanged from 076 EXCEPT
26
+ -- the status source (base transient → terminal-folded `derived_status`): `attention` still derives from
27
+ -- ENGINE TRUTH (an OPEN `user_tasks` row, issue #422); `stage_skipped` is still a pure function of
28
+ -- `converge`/`auto_merge`.
29
+ --
30
+ -- `feature_runs__tracking` is the managed VIEW urban provisions at mount (`<table>__tracking`); SQLite
31
+ -- does not validate a view body at CREATE time, so this migration (which runs before the runtime mount
32
+ -- that provisions the managed VIEW) is created fine and resolves once the managed VIEW exists. Base
33
+ -- columns stay aliased pass-throughs (so the static pages↔schema contract guard still sees the VIEW
34
+ -- columns), now sourced off `feature_runs__tracking`'s re-export of `base.*`; `feature_runs__tracking fr`
35
+ -- is the sole top-level FROM (the user_tasks lookups are nested EXISTS subqueries at paren depth >= 1).
36
+ --
37
+ -- Forward-only VIEW redefinition (DROP then CREATE). The runner wraps each file in its own transaction,
38
+ -- so this file must NOT contain BEGIN/COMMIT. Numbered after 079.
39
+
40
+ DROP VIEW IF EXISTS feature_read_model;
41
+
42
+ CREATE VIEW feature_read_model AS
43
+ SELECT
44
+ fr.feature_key AS feature_key,
45
+ fr.repo AS repo,
46
+ fr.issue_number AS issue_number,
47
+ fr.issue_url AS issue_url,
48
+ fr.title AS title,
49
+ fr.base_branch AS base_branch,
50
+ fr.status AS status,
51
+ fr.process_key AS process_key,
52
+ fr.pr_key AS pr_key,
53
+ fr.converge AS converge,
54
+ fr.auto_merge AS auto_merge,
55
+ fr.outcome AS outcome,
56
+ fr.delivery_label AS delivery_label,
57
+ fr.acknowledged_at AS acknowledged_at,
58
+ fr.created_at AS created_at,
59
+ fr.updated_at AS updated_at,
60
+ CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("fr"."derived_status" = 'converging'), 0) THEN 'Converging' WHEN COALESCE((COALESCE(("fr"."pr_key" <> ''), 0) OR COALESCE(("fr"."derived_status" = 'opened'), 0)), 0) THEN 'PR open' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'running'), 0) OR COALESCE(("fr"."derived_status" = 'escalated'), 0) OR COALESCE(("fr"."derived_status" = 'awaiting_operator'), 0)), 0) THEN 'Implementing' ELSE 'Requested' END AS stage,
61
+ CASE WHEN COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0)), 0) THEN 'ok' WHEN COALESCE(("fr"."derived_status" = 'blocked'), 0) THEN 'blocked' WHEN COALESCE((COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
62
+ CASE WHEN (NOT COALESCE("fr"."converge", 0)) THEN 'Converging Merging' WHEN (NOT COALESCE("fr"."auto_merge", 0)) THEN 'Merging' ELSE '' END AS stage_skipped,
63
+ CASE WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-blocked'), 0)), 0)) THEN 'blocked' WHEN EXISTS (SELECT 1 FROM "user_tasks" AS "__urban_proj_0" WHERE COALESCE((COALESCE(("__urban_proj_0"."subject_type" = 'feature'), 0) AND COALESCE(("__urban_proj_0"."subject_key" = "fr"."feature_key"), 0) AND COALESCE(("__urban_proj_0"."element_id" = 'feature-escalation'), 0)), 0)) THEN '⚠' ELSE NULL END AS attention,
64
+ CASE WHEN COALESCE((COALESCE((COALESCE(("fr"."derived_status" = 'merged'), 0) OR COALESCE(("fr"."derived_status" = 'converged'), 0) OR COALESCE(("fr"."derived_status" = 'blocked'), 0) OR COALESCE(("fr"."derived_status" = 'failed'), 0) OR COALESCE(("fr"."derived_status" = 'skipped'), 0) OR COALESCE(("fr"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("fr"."acknowledged_at" = "fr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket
65
+ FROM feature_runs__tracking fr;
package/openapi.yaml CHANGED
@@ -1407,6 +1407,15 @@ components:
1407
1407
  type: string
1408
1408
  maxLength: 20000
1409
1409
  description: OPTIONAL steering prompt appended to the node's job brief.
1410
+ timeout:
1411
+ type: string
1412
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1413
+ maxLength: 64
1414
+ description: >-
1415
+ OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
1416
+ (and the `PT1H` default) for THIS node's bounded-timeout → escalate boundary timer, so
1417
+ a legitimately-long node (e.g. a full `senior:feature` implementation) can outlast a
1418
+ quick gate without a spurious escalation. Absent → the run/default value.
1410
1419
  DeliveryNodeWait:
1411
1420
  description: >-
1412
1421
  A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
@@ -1496,6 +1505,14 @@ components:
1496
1505
  type: object
1497
1506
  additionalProperties: true
1498
1507
  description: Minimal forward-declared payload stub — the concrete connector payload schema is deferred (ADR non-goal).
1508
+ timeout:
1509
+ type: string
1510
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1511
+ maxLength: 64
1512
+ description: >-
1513
+ OPTIONAL per-node ISO-8601 SLA timeout (#505). Overrides the run-level `nodeTimeout`
1514
+ (and the `PT1H` default) for THIS connector node's bounded-timeout → escalate boundary
1515
+ timer. Absent → the run/default value.
1499
1516
  DeliveryEdge:
1500
1517
  description: >-
1501
1518
  A dependency edge — "`to` proceeds once fact `from` is observable" (ADR 0005 Decision 3).
@@ -1596,6 +1613,28 @@ components:
1596
1613
  type: string
1597
1614
  maxLength: 255
1598
1615
  description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
1616
+ nodeTimeout:
1617
+ type: string
1618
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1619
+ maxLength: 64
1620
+ description: >-
1621
+ OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
1622
+ bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
1623
+ per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
1624
+ probeTimeout:
1625
+ type: string
1626
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1627
+ maxLength: 64
1628
+ description: >-
1629
+ OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
1630
+ the `PT30M` default. An invalid duration is rejected at submit.
1631
+ escalationSlaTimeout:
1632
+ type: string
1633
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
1634
+ maxLength: 64
1635
+ description: >-
1636
+ OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
1637
+ outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
1599
1638
  DeliveryGraphProposalBpmnRequest:
1600
1639
  description: >-
1601
1640
  Request the compiled BPMN of a staged delivery-graph proposal for read-only DI PREVIEW. Carries
@@ -182,4 +182,69 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
182
182
  assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "expired");
183
183
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
184
184
  });
185
+
186
+ test("an invalid run-level nodeTimeout duration is rejected at submit → 400, nothing launched (#505)", async () => {
187
+ const app = await boot();
188
+ assert.ok(app.api);
189
+ const api = app.api;
190
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
191
+ const res = await api.call<{ ok?: boolean; error?: string; issues?: Array<{ path: string }> }>("dispatchDeliveryGraph", {
192
+ body: { digest: staged.body.digest, nodeTimeout: "2 hours" },
193
+ });
194
+ // Rejected at submit — either by the edge shape-validator (openapi `pattern`) or the door's own
195
+ // ISO-8601 guard; both surface a 400. Nothing launches and the proposal stays staged (dispatchable).
196
+ assert.equal(res.status, 400);
197
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
198
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
199
+ });
200
+
201
+ test("an oversized invalid duration never bloats the 400 response — rejected with a bounded error, nothing launched (#505)", async () => {
202
+ const app = await boot();
203
+ assert.ok(app.api);
204
+ const api = app.api;
205
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
206
+ const huge = `PT${"9".repeat(5000)}X`;
207
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
208
+ body: { digest: staged.body.digest, nodeTimeout: huge },
209
+ });
210
+ assert.equal(res.status, 400);
211
+ // The 5000-char blob is never echoed back verbatim — the edge pattern rejects it, and the door's
212
+ // own guard (`truncateForEcho`) caps the echo when the edge is bypassed. Either way the response
213
+ // stays bounded, so a malformed input can't bloat logs/response bodies.
214
+ assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
215
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
216
+ });
217
+
218
+ test("a syntactically-valid but oversized duration is rejected at the door → 400, nothing launched (#505)", async () => {
219
+ const app = await boot();
220
+ assert.ok(app.api);
221
+ const api = app.api;
222
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
223
+ // Matches the ISO-8601 grammar but exceeds the door's MAX_DURATION_LEN (64) — the door re-enforces the
224
+ // openapi `maxLength: 64` so an oversized value is refused even if the edge validator is bypassed.
225
+ const longValid = `PT${"9".repeat(70)}H`;
226
+ const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
227
+ body: { digest: staged.body.digest, nodeTimeout: longValid },
228
+ });
229
+ assert.equal(res.status, 400);
230
+ assert.ok((res.body.error ?? "").length < 300, `error body should be bounded, got ${(res.body.error ?? "").length} chars`);
231
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
232
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
233
+ });
234
+
235
+ test("a valid run-level nodeTimeout override dispatches the run → 202 running (#505)", async () => {
236
+ const app = await boot();
237
+ assert.ok(app.api);
238
+ const api = app.api;
239
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
240
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
241
+ body: { digest: staged.body.digest, nodeTimeout: "PT2H" },
242
+ });
243
+ assert.equal(res.status, 202);
244
+ assert.equal(res.body.ok, true);
245
+ assert.equal(res.body.status, "running");
246
+ await app.settle();
247
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
248
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
249
+ });
185
250
  });
@@ -12,9 +12,39 @@
12
12
 
13
13
  import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
14
14
  import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
15
+ import { isValidIsoDuration } from "../app/reviewWait.ts";
15
16
  import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
16
17
  import { defineOperation } from "../nano-generated/operations.ts";
17
18
 
19
+ /** Cap an untrusted, rejected duration string before it is echoed into logs/response bodies. `openapi.yaml`
20
+ * caps these dispatch duration fields at `maxLength: 64` at the edge, and the door re-enforces that bound
21
+ * (see `MAX_DURATION_LEN`); this truncation is defense-in-depth for when the edge validator is bypassed,
22
+ * so a very large malformed value can never bloat either the logs or the response. */
23
+ const MAX_ECHO_LEN = 80;
24
+ function truncateForEcho(value: string): string {
25
+ return value.length > MAX_ECHO_LEN ? `${value.slice(0, MAX_ECHO_LEN)}… (${value.length} chars)` : value;
26
+ }
27
+
28
+ /** Door-level cap on a duration override, mirroring the `maxLength: 64` on these fields in `openapi.yaml`.
29
+ * Re-enforced here so a syntactically-valid-but-oversized duration is still rejected when the edge
30
+ * validator is bypassed (internal calls/tests), keeping seeded process variables and error/log output bounded. */
31
+ const MAX_DURATION_LEN = 64;
32
+
33
+ /** Validate an OPTIONAL run-level ISO-8601 duration override off the dispatch body (#505). Blank/
34
+ * whitespace is treated as absent (→ the runner default). A present-but-malformed value returns
35
+ * `{ ok: false, invalid }` so the door can reject it at submit rather than silently deploy an
36
+ * uninterpretable timer. Reuses the canonical `reviewWait` grammar so accept/reject never drifts from
37
+ * the runner's normalise-or-default one, and enforces `MAX_DURATION_LEN` so an oversized value is
38
+ * rejected even if the OpenAPI `maxLength` edge check is bypassed. */
39
+ function validateDurationOverride(raw: unknown): { ok: true; value: string | undefined } | { ok: false; invalid: string } {
40
+ if (raw === undefined || raw === null) return { ok: true, value: undefined };
41
+ if (typeof raw !== "string") return { ok: false, invalid: String(raw) };
42
+ const trimmed = raw.trim();
43
+ if (trimmed === "") return { ok: true, value: undefined };
44
+ if (trimmed.length > MAX_DURATION_LEN || !isValidIsoDuration(trimmed)) return { ok: false, invalid: trimmed };
45
+ return { ok: true, value: trimmed.toUpperCase() };
46
+ }
47
+
18
48
  export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) => {
19
49
  const digest = body && typeof body === "object" && "digest" in body && typeof body.digest === "string" ? body.digest.trim() : "";
20
50
  if (digest === "") {
@@ -24,6 +54,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
24
54
  const idemRaw = body && typeof body === "object" && "idempotencyKey" in body && typeof body.idempotencyKey === "string" ? body.idempotencyKey.trim() : "";
25
55
  const idempotencyKey = idemRaw !== "" ? idemRaw : undefined;
26
56
 
57
+ // Run-level timeout overrides (#505) — exposed at submission, validated as ISO-8601 durations here so
58
+ // an invalid value is a clean 400 (never a deployed, uninterpretable timer). Absent → runner defaults.
59
+ const rawTimeouts: Record<"nodeTimeout" | "probeTimeout" | "escalationSlaTimeout", unknown> =
60
+ body && typeof body === "object"
61
+ ? {
62
+ nodeTimeout: "nodeTimeout" in body ? body.nodeTimeout : undefined,
63
+ probeTimeout: "probeTimeout" in body ? body.probeTimeout : undefined,
64
+ escalationSlaTimeout: "escalationSlaTimeout" in body ? body.escalationSlaTimeout : undefined,
65
+ }
66
+ : { nodeTimeout: undefined, probeTimeout: undefined, escalationSlaTimeout: undefined };
67
+ const timeouts: { nodeTimeout?: string; probeTimeout?: string; escalationSlaTimeout?: string } = {};
68
+ for (const field of ["nodeTimeout", "probeTimeout", "escalationSlaTimeout"] as const) {
69
+ const parsed = validateDurationOverride(rawTimeouts[field]);
70
+ if (!parsed.ok) {
71
+ const shown = truncateForEcho(parsed.invalid);
72
+ app.log.warn("dispatch-delivery-graph rejected: invalid duration", { field, value: shown, invalidLength: parsed.invalid.length });
73
+ return { status: 400, body: { ok: false, error: `\`${field}\` must be an ISO-8601 duration (e.g. \`PT2H\`); got \`${shown}\`` } };
74
+ }
75
+ if (parsed.value !== undefined) timeouts[field] = parsed.value;
76
+ }
77
+
27
78
  // Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
28
79
  // dispatched digest cleanly (no run is launched).
29
80
  const proposal = await getStagedProposal(app.data, digest);
@@ -47,7 +98,7 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
47
98
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
48
99
  }
49
100
 
50
- const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title });
101
+ const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, ...timeouts });
51
102
  if (!dispatched.ok) {
52
103
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
53
104
  const outBody: DeliveryGraphTextResult = {
@@ -5,6 +5,7 @@ import { test } from "node:test";
5
5
  import { assert, assertEquals } from "#test-assert";
6
6
  import type { AppApi } from "@nanobpm/urban";
7
7
  import { noopLog } from "../test/log.ts";
8
+ import { withTrackingViews } from "../test/trackingViews.ts";
8
9
  import handler from "./listActivePrs.ts";
9
10
 
10
11
  function memApp(rows: any[], escalations: any[] = []): AppApi {
@@ -24,7 +25,7 @@ function memApp(rows: any[], escalations: any[] = []): AppApi {
24
25
  },
25
26
  };
26
27
  };
27
- return { data: { table }, log: noopLog() } as any as AppApi;
28
+ return { data: { table: withTrackingViews(table) }, log: noopLog() } as any as AppApi;
28
29
  }
29
30
 
30
31
  function input(headers: Record<string, string> = {}) {
@@ -18,6 +18,7 @@ import { assertEquals } from "#test-assert";
18
18
  import type { AppApi } from "@nanobpm/urban";
19
19
  import { resetDefaultBranchCache } from "../app/github.ts";
20
20
  import { noopLog } from "../test/log.ts";
21
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
22
  import startEpicSet from "./startEpicSet.ts";
22
23
 
23
24
  // ── in-memory github model (mirrors startPlanFanout.admission.integration.test.ts) ───────────────
@@ -116,7 +117,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
116
117
  };
117
118
  };
118
119
  const app = {
119
- data: { table },
120
+ data: { table: withTrackingViews(table) },
120
121
  engine: {
121
122
  createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
122
123
  started.push(req);
@@ -526,7 +527,7 @@ function makeSqliteApp(
526
527
  delete: () => Promise.resolve(),
527
528
  });
528
529
  const app = {
529
- data: { table },
530
+ data: { table: withTrackingViews(table) },
530
531
  engine: { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) },
531
532
  log: noopLog(),
532
533
  } as any as AppApi;
@@ -9,6 +9,7 @@ import { assertEquals } from "#test-assert";
9
9
  import type { AppApi } from "@nanobpm/urban";
10
10
  import { resetDefaultBranchCache } from "../app/github.ts";
11
11
  import { noopLog } from "../test/log.ts";
12
+ import { withTrackingViews } from "../test/trackingViews.ts";
12
13
  import startPlanFanout from "./startPlanFanout.ts";
13
14
 
14
15
  // ── in-memory github model ───────────────────────────────────────────────────
@@ -112,7 +113,7 @@ function makeApp(seedPlans: Record<string, unknown>[] = []) {
112
113
  };
113
114
  };
114
115
  const app = {
115
- data: { table },
116
+ data: { table: withTrackingViews(table) },
116
117
  engine: {
117
118
  createInstance: (req: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
118
119
  started.push(req);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.133.0",
3
+ "version": "0.134.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",
@@ -30,7 +30,17 @@ export function withTrackingViews<F extends TableFn>(base: F): F {
30
30
  const statusField = baseStatusFieldFor(baseName);
31
31
  // biome-ignore lint/suspicious/noExplicitAny: test-only projection over dynamic row shapes.
32
32
  const project = (row: any) =>
33
- row == null ? row : { ...row, [derivedColumn]: row[statusField] };
33
+ row == null
34
+ ? row
35
+ : // Honor an explicitly-seeded `derived_status` so a test can model the ADR-0065 divergence a
36
+ // real terminated instance produces — the base `<statusField>` frozen at its last transient
37
+ // while the derive edge reports the terminal (`abandoned`/`failed`/`reviewed`). When a row
38
+ // seeds no derived column the VIEW's `ELSE base.<statusField>` fall-through applies, so it
39
+ // stays byte-for-byte the pass-through the previous behaviour modelled.
40
+ {
41
+ ...row,
42
+ [derivedColumn]: row[derivedColumn] ?? row[statusField],
43
+ };
34
44
  // biome-ignore lint/suspicious/noExplicitAny: test-only Proxy over a dynamic DataLayer table.
35
45
  return new Proxy(inner, {
36
46
  get(target: any, prop: string) {