@nanobpm/nano-workforce 0.132.0 → 0.133.1

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,75 @@
1
+ -- Fold the ADR-0065 DERIVED terminal edge into the epic read model (issue #503 — the plans row of the
2
+ -- "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 `plans__tracking.derived_status` — it NO LONGER writes
7
+ -- `abandoned` onto the base `plans.status` column. `plans` had NO derived reader, so a terminated epic's
8
+ -- base row stayed frozen at `planning`/`dispatched` and 074's `plan_read_model` bucketed it ACTIVE
9
+ -- forever (a terminated epic rendered active on the epic index/detail).
10
+ --
11
+ -- 074_plan_read_model_derive_bucket.sql made `plan_read_model` the composite VIEW the epic pages bind
12
+ -- and DERIVED `list_bucket`/`ack_open` from the base `plans.status` (+ `acknowledged_at` + the derived
13
+ -- `plan_delivery.delivery` signal). This migration redefines `plan_read_model` (same name, so no page
14
+ -- repoint is needed) to read the EFFECTIVE status off the auto-provisioned `plans__tracking` derived
15
+ -- VIEW instead of the frozen base column: the projected `status` and the bucket/ack derivations now
16
+ -- fold in the reconciler's terminal edge, so a cancelled/terminated epic drops out of Active with no
17
+ -- worker write and no poller pass.
18
+ --
19
+ -- `plans__tracking` is the managed VIEW urban provisions at mount (`<table>__tracking`, ADR-0065),
20
+ -- re-exporting `plans.*` plus a `derived_status` column that is `abandoned` on a terminated instance and
21
+ -- the base `plans.status` otherwise. SQLite does not validate a view body at CREATE time, so this
22
+ -- migration (which runs before the runtime mount that provisions `plans__tracking`) is created fine and
23
+ -- resolves once the managed VIEW exists. The `COALESCE(t.derived_status, pl.status)` fallback degrades to
24
+ -- the previous base-column behaviour only for an unexpected NULL `derived_status` or a missing joined row
25
+ -- (the LEFT JOIN yielding no `t` row) — it does NOT protect against the `plans__tracking` VIEW being
26
+ -- absent, which would fail this VIEW's query at read time.
27
+ --
28
+ -- A merged view is not editable in place (that would edit a shipped migration), so this DROPs and
29
+ -- re-CREATEs it. `plan_read_model` is a leaf — no other view builds on it — so the DROP is safe. Its
30
+ -- output column set is UNCHANGED (only the SOURCE of `status`/`list_bucket`/`ack_open` moved from the
31
+ -- base column to the derived VIEW), so the pages↔schema contract guard and every page binding stay
32
+ -- valid.
33
+ --
34
+ -- Forward-only. NO BEGIN/COMMIT — the runner wraps each file in its own transaction. Numbered after 078.
35
+
36
+ DROP VIEW plan_read_model;
37
+
38
+ CREATE VIEW plan_read_model AS
39
+ SELECT
40
+ pl.plan_key AS plan_key,
41
+ pl.repo AS repo,
42
+ pl.issue_number AS issue_number,
43
+ pl.issue_url AS issue_url,
44
+ pl.title AS title,
45
+ COALESCE(t.derived_status, pl.status) AS status,
46
+ pl.task_count AS task_count,
47
+ pl.process_key AS process_key,
48
+ pl.outcome AS outcome,
49
+ pl.updated_at AS updated_at,
50
+ pl.epic_phase AS epic_phase,
51
+ pl.base_branch AS base_branch,
52
+ pl.wait_gate_label AS wait_gate_label,
53
+ pl.bound_artifacts AS bound_artifacts,
54
+ pl.promotion_pr AS promotion_pr,
55
+ pl.promotion_state AS promotion_state,
56
+ (CASE
57
+ WHEN COALESCE(t.derived_status, pl.status) IN ('planning', 'dispatched') THEN 'active'
58
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' AND d.delivery = 'converging' THEN 'active'
59
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' AND pl.acknowledged_at IS NULL THEN 'active'
60
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' THEN 'history'
61
+ ELSE 'history'
62
+ END) AS list_bucket,
63
+ (CASE
64
+ WHEN COALESCE(t.derived_status, pl.status) = 'done' AND d.delivery IS NOT 'converging' AND pl.acknowledged_at IS NULL THEN 1
65
+ ELSE 0
66
+ END) AS ack_open,
67
+ wl.wave_count AS wave_count,
68
+ wl.current_wave AS current_wave,
69
+ wl.wave_label AS wave_label,
70
+ d.delivery AS delivery,
71
+ d.delivery_label AS delivery_label
72
+ FROM plans pl
73
+ LEFT JOIN plans__tracking t ON t.plan_key = pl.plan_key
74
+ LEFT JOIN plan_wave_label wl ON wl.plan_key = pl.plan_key
75
+ LEFT JOIN plan_delivery d ON d.plan_key = pl.plan_key;
@@ -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;
@@ -99,6 +99,24 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
99
99
  - **`human`** — a scheduled user task + form (§4).
100
100
  - **`connector`** — an automated, side-effecting outbound action (the connector I/O surface).
101
101
 
102
+ > **Amendment (issue #500): the connector's first REAL target landed — `converge` / `converge-merge`.**
103
+ > The connector I/O surface shipped in slice S4 with a deliberately forward-declared STUB action
104
+ > (`performConnectorAction`), the real target dispatch deferred to a later slice. That slice is this:
105
+ > a `connector` node whose `target` is **`converge-merge`** (or **`converge`** for converge-only)
106
+ > enrolls its `payload.pr` into the app's shared convergence (+ merge) loop via `submitPr` — the SAME
107
+ > seam the feature cell reuses (`workers/converge-feature`), no duplicated machinery. Enrollment is
108
+ > defined in the worker (it has `app.data`/`app.engine`) but **injected into `dispatchConnector` as the
109
+ > connector's action**, so the existing at-most-once ledger fence wraps the enrollment itself: it fires
110
+ > only on the claim winner (or a resumed crashed claim), and a `deduped` redelivery — a restart / lost
111
+ > ack / graph resume that lands AFTER the PR settled — never re-runs it. That matters because `submitPr`
112
+ > deliberately RE-OPENS a terminal PR; an unfenced re-call would flip a `merged`/`converged`/`abandoned`
113
+ > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
+ > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
+ > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the MVP
117
+ > sources `pr` as a literal (auto-emitting it from the `agent` node as a typed `pr` fact is a deferred
118
+ > follow-up). Other connector targets remain the forward-declared stub.
119
+
102
120
  Crucially, **execution stays engine-native**: each node kind is a real, already-deployed
103
121
  sub-process / call activity (`readiness-gate`, a user task, the implementation task, a connector
104
122
  invocation). The graph layer owns **scheduling** (which nodes' edges are satisfied → dispatch), not a
@@ -435,7 +435,7 @@ layer schedules, it does not re-implement execution):
435
435
  | `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
436
436
  | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`. Read-only. | yes (binds observed facts) |
437
437
  | `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
438
- | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). *(payload is a forward-declared stub.)* | yes |
438
+ | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
439
439
 
440
440
  A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
441
441
  intake uses): `{ kind, target, onTimeout?, match?, poll? }`. The **`pr` kind** watches an
@@ -559,3 +559,49 @@ To swap the manual PR-#303 path for a **capability** edge instead of a raw `pr`
559
559
  the consumer a `wait` node with `kind: "capability"` (resolving *which published
560
560
  `pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
561
561
  fact — the fact-edge syntax is identical.
562
+
563
+ ### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge`)
564
+
565
+ A `connector` node with **`target: "converge-merge"`** (or **`"converge"`**) enrolls an
566
+ agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
567
+ standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
568
+ the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
569
+ only job was "go run convergence yourself".
570
+
571
+ - **`converge-merge`** — drive review convergence **and then the merge loop** (the PR merges
572
+ once converged + green). Equivalent to a submit with `convergeOnly: false`.
573
+ - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
574
+ handing off to the merge loop (equivalent to `convergeOnly: true`).
575
+
576
+ **Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
577
+ required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
578
+ `convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
579
+ into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
580
+ at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
581
+ redelivery never double-enrolls.
582
+
583
+ **Canonical shape** — the agent opens the PR, the connector enrolls it, and a `wait[pr, merged]`
584
+ gate binds `mergedSha` when it lands, with **no human node**:
585
+
586
+ ```json
587
+ {
588
+ "name": "open → converge+merge → wait merged",
589
+ "nodes": [
590
+ { "id": "open", "kind": "agent",
591
+ "agent": { "jobType": "senior:feature", "prompt": "Implement the change in acme/repo and open a PR." } },
592
+ { "id": "land", "kind": "connector",
593
+ "connector": { "target": "converge-merge", "payload": { "pr": "acme/repo#123" } } },
594
+ { "id": "merged", "kind": "wait",
595
+ "wait": { "kind": "pr", "target": "acme/repo#123", "match": { "prState": "merged" }, "onTimeout": "escalate" } }
596
+ ],
597
+ "edges": [
598
+ { "from": "open", "to": "land" },
599
+ { "from": "land", "to": "merged" }
600
+ ]
601
+ }
602
+ ```
603
+
604
+ > **Follow-up (not shipped):** the MVP sources the connector's `pr` as a **literal**. Auto-emitting
605
+ > the opened PR from the `agent` node as a typed `pr` fact (so the connector/`wait` bind it instead of
606
+ > a literal) is a later slice — not required for the graph above.
607
+
@@ -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.132.0",
3
+ "version": "0.133.1",
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) {
@@ -4,7 +4,83 @@
4
4
  // `payload`/`boundFacts` is coerced to null with a surfaced warning rather than passed through.
5
5
  import { test } from "node:test";
6
6
  import { assert, assertEquals, assertThrows } from "#test-assert";
7
- import { readConnectorInput } from "./worker.ts";
7
+ import { PROCESS_ID } from "../../app/service.ts";
8
+ import { withTrackingViews } from "../../test/trackingViews.ts";
9
+ import handler, { readConnectorInput, readConvergeInput, safeStringify } from "./worker.ts";
10
+
11
+ function memTable(rows: Record<string, unknown>[], key: string) {
12
+ return {
13
+ get: (k: unknown) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
14
+ all: () => Promise.resolve([...rows]),
15
+ find: (q: Record<string, unknown>) =>
16
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
17
+ findOne: (q: Record<string, unknown>) =>
18
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
19
+ insert: (r: Record<string, unknown>) => {
20
+ rows.push(r);
21
+ return Promise.resolve(r);
22
+ },
23
+ update: (k: unknown, patch: Record<string, unknown>) => {
24
+ const r = rows.find((x) => x[key] === k);
25
+ if (r) Object.assign(r, patch);
26
+ return Promise.resolve(r);
27
+ },
28
+ delete: (k: unknown) => {
29
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
30
+ return Promise.resolve();
31
+ },
32
+ };
33
+ }
34
+
35
+ /** A hermetic `app` over in-memory tables + a createInstance-capturing engine, with the GitHub
36
+ * transport forced off so `submitPr`'s best-effort meta fetch is skipped. Returns the created
37
+ * convergence-loop instances so a test can assert the exact enrollment `submitPr` performed. */
38
+ function fakeApp() {
39
+ const stores: Record<string, { rows: Record<string, unknown>[]; key: string }> = {
40
+ pull_requests: { rows: [], key: "pr_key" },
41
+ escalations: { rows: [], key: "id" },
42
+ pr_dependencies: { rows: [], key: "pr_key" },
43
+ delivery_connector_dispatches: { rows: [], key: "id" },
44
+ };
45
+ const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
46
+ let nextId = 1;
47
+ const data = {
48
+ table: withTrackingViews((name: string, key: string) => {
49
+ const store = stores[name] ?? { rows: [], key };
50
+ stores[name] ??= store;
51
+ // The ledger PK is auto-assigned on insert (mimics the RAD Table<T> autoincrement).
52
+ if (name === "delivery_connector_dispatches") {
53
+ const base = memTable(store.rows, store.key);
54
+ return { ...base, insert: (r: Record<string, unknown>) => {
55
+ const id = nextId++;
56
+ store.rows.push({ ...r, id });
57
+ return Promise.resolve(id);
58
+ } } as ReturnType<typeof memTable>;
59
+ }
60
+ return memTable(store.rows, store.key);
61
+ }),
62
+ };
63
+ const engine = {
64
+ createInstance: (req: { processDefinitionId?: string; variables?: Record<string, unknown> }) => {
65
+ created.push(req);
66
+ return Promise.resolve({ processInstanceKey: `PI-${created.length}` });
67
+ },
68
+ };
69
+ const app = { data, engine, log: { info() {}, warn() {}, error() {} } };
70
+ return { app: app as unknown as Parameters<typeof handler>[1], stores, created };
71
+ }
72
+
73
+ function withGithubOff(run: () => Promise<void>): Promise<void> {
74
+ const prevMode = process.env.NANO_PR_GITHUB_TRANSPORT;
75
+ const prevTok = process.env.GITHUB_TOKEN;
76
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token"; // no token below -> fetchPrMeta returns null
77
+ delete process.env.GITHUB_TOKEN;
78
+ return run().finally(() => {
79
+ if (prevMode !== undefined) process.env.NANO_PR_GITHUB_TRANSPORT = prevMode;
80
+ else delete process.env.NANO_PR_GITHUB_TRANSPORT;
81
+ if (prevTok !== undefined) process.env.GITHUB_TOKEN = prevTok;
82
+ });
83
+ }
8
84
 
9
85
  test("readConnectorInput: a blank/missing target fails closed (a connector with no destination is meaningless)", () => {
10
86
  for (const target of [undefined, "", " "]) {
@@ -42,3 +118,165 @@ test("readConnectorInput: an array payload is rejected (arrays are not plain obj
42
118
  assertEquals(r.payload, null);
43
119
  assertEquals(r.warnings.length, 1);
44
120
  });
121
+
122
+ // --- converge / converge-merge targets: enroll a PR into the shared convergence loop (issue #500) ---
123
+
124
+ test("readConvergeInput: parses pr; convergeOnly defaults from the target; dependsOn is optional", () => {
125
+ // `converge-merge` drives the merge loop → convergeOnly defaults false.
126
+ const merge = readConvergeInput("converge-merge", { pr: "owner/repo#7" });
127
+ assertEquals(merge.parsed.prKey, "owner/repo#7");
128
+ assertEquals(merge.convergeOnly, false);
129
+ assertEquals(merge.dependsOn, []);
130
+ // `converge` is review-only → convergeOnly defaults true.
131
+ const conv = readConvergeInput("converge", { pr: "owner/repo#7" });
132
+ assertEquals(conv.convergeOnly, true);
133
+ });
134
+
135
+ test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
136
+ const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] });
137
+ assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
138
+ assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
139
+ });
140
+
141
+ test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
142
+ assertThrows(() => readConvergeInput("converge-merge", null), Error, "payload.pr");
143
+ assertThrows(() => readConvergeInput("converge-merge", {}), Error, "payload.pr");
144
+ assertThrows(() => readConvergeInput("converge", { pr: "not-a-pr" }), Error, "payload.pr");
145
+ });
146
+
147
+ test("readConvergeInput: an unparseable pr whose value is not JSON-serializable still fails CLOSED with the intended error (not a serializer TypeError)", () => {
148
+ // `p.pr` is user-controlled payload data; a BigInt (or a circular object) makes JSON.stringify
149
+ // throw, which must NOT mask the intended "requires payload.pr" error.
150
+ assertThrows(() => readConvergeInput("converge", { pr: 10n as unknown as string }), Error, "payload.pr");
151
+ const circular: Record<string, unknown> = {};
152
+ circular.self = circular;
153
+ assertThrows(() => readConvergeInput("converge", { pr: circular as unknown as string }), Error, "payload.pr");
154
+ });
155
+
156
+ test("safeStringify: always returns a string, even for values JSON.stringify serializes to undefined (Symbol/undefined/function)", () => {
157
+ // JSON.stringify returns `undefined` (WITHOUT throwing) for a Symbol, a bare undefined, or a
158
+ // function. safeStringify is typed `: string`, so it must fall back to String(value) rather than
159
+ // leak that `undefined` through and violate its own contract.
160
+ assertEquals(typeof safeStringify(Symbol("x")), "string");
161
+ assertEquals(typeof safeStringify(undefined), "string");
162
+ assertEquals(typeof safeStringify(() => 0), "string");
163
+ // A normal serializable value still round-trips through JSON.stringify.
164
+ assertEquals(safeStringify({ a: 1 }), '{"a":1}');
165
+ });
166
+
167
+ test("handler: a `converge-merge` connector enrolls the PR into the convergence loop via submitPr (row + started convergence-loop instance)", async () => {
168
+ await withGithubOff(async () => {
169
+ const { app, stores, created } = fakeApp();
170
+ await handler(
171
+ { variables: { target: "converge-merge", payload: { pr: "owner/repo#7" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
172
+ app,
173
+ );
174
+ // The identical row + loop a `converge-feature` enrollment produces.
175
+ assertEquals(stores.pull_requests.rows.length, 1, "exactly one pull_requests row is registered");
176
+ const pr = stores.pull_requests.rows[0];
177
+ assertEquals(pr.pr_key, "owner/repo#7");
178
+ assertEquals(pr.status, "converging");
179
+ assertEquals(created.length, 1, "the convergence-loop instance was started");
180
+ assertEquals(created[0]?.processDefinitionId, PROCESS_ID);
181
+ // converge-merge → not converge-only, so the merge loop is authorised.
182
+ assertEquals(created[0]?.variables?.convergeOnly, false);
183
+ assertEquals(created[0]?.variables?.prKey, "owner/repo#7");
184
+ // Lineage roots on the stable per-node dedupe key (graph-derived here).
185
+ assertEquals(created[0]?.variables?.rootRequestKey, "PI-graph:n2");
186
+ // The connector ledger still recorded the dispatch (the at-most-once fence around the stub).
187
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1);
188
+ });
189
+ });
190
+
191
+ test("handler: a `converge` connector enrolls converge-ONLY (stops at converged, never hands to the merge loop)", async () => {
192
+ await withGithubOff(async () => {
193
+ const { app, created } = fakeApp();
194
+ await handler(
195
+ { variables: { target: "converge", payload: { pr: "owner/repo#8" } }, processInstanceKey: "PI-g", elementId: "n1" } as never,
196
+ app,
197
+ );
198
+ assertEquals(created.length, 1);
199
+ assertEquals(created[0]?.variables?.convergeOnly, true);
200
+ });
201
+ });
202
+
203
+ test("handler: re-dispatch (at-least-once redelivery) does NOT double-enroll (ledger fence + submitPr prKey idempotency)", async () => {
204
+ await withGithubOff(async () => {
205
+ const { app, stores, created } = fakeApp();
206
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#9" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
207
+ await handler(job, app);
208
+ await handler(job, app); // the graph resumes / the job is redelivered
209
+ assertEquals(stores.pull_requests.rows.length, 1, "still exactly one PR row");
210
+ assertEquals(created.length, 1, "the convergence-loop is started exactly once (submitPr collapses the repeat)");
211
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "one ledger row — the dispatch fence deduped");
212
+ });
213
+ });
214
+
215
+ test("handler: a redelivery AFTER the PR reached a terminal state does NOT re-enroll (the connector's at-most-once fence, not submitPr's short-circuit)", async () => {
216
+ await withGithubOff(async () => {
217
+ const { app, stores, created } = fakeApp();
218
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#11" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
219
+ await handler(job, app);
220
+ assertEquals(created.length, 1, "the first delivery enrolls the PR");
221
+ // The convergence (+ merge) loop ran to completion; the PR row is now TERMINAL.
222
+ stores.pull_requests.rows[0].status = "merged";
223
+ // An at-least-once redelivery (worker restart / lost ack / graph resume) lands AFTER settlement.
224
+ // `submitPr` deliberately RE-OPENS a terminal row, so the connector must not call it again — the
225
+ // node instance already fired exactly once, and the ledger fence must suppress the redelivery.
226
+ await handler(job, app);
227
+ assertEquals(created.length, 1, "the settled PR is NOT re-enrolled — no second convergence-loop instance");
228
+ assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR is never flipped back to converging");
229
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 1, "still one ledger row — the connector fence deduped the redelivery");
230
+ });
231
+ });
232
+
233
+ test("handler: a converge redelivery whose prior claim CRASHED before recording delivery still enrolls (resume, not lost)", async () => {
234
+ await withGithubOff(async () => {
235
+ const { app, stores, created } = fakeApp();
236
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#12" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
237
+ // Simulate a crash BETWEEN claiming the ledger row and recording delivery: a `claimed` row with no
238
+ // enrollment yet. The redelivery must RESUME (perform the enrollment), never dedupe on the un-acted claim.
239
+ stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
240
+ await handler(job, app);
241
+ assertEquals(created.length, 1, "the crashed claim is resumed — the enrollment fires exactly once now");
242
+ assertEquals(stores.pull_requests.rows.length, 1, "the PR was enrolled on resume");
243
+ assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered");
244
+ });
245
+ });
246
+
247
+ test("handler: a crash-window RESUME whose PR already SETTLED to terminal does NOT re-open it (the enrollment action is terminal-safe)", async () => {
248
+ await withGithubOff(async () => {
249
+ const { app, stores, created } = fakeApp();
250
+ const job = { variables: { target: "converge-merge", payload: { pr: "owner/repo#13" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never;
251
+ // The first attempt claimed the ledger AND enrolled the PR, which then ran the convergence (+ merge)
252
+ // loop to completion (`merged`) — but the worker crashed BEFORE recording `delivered`, leaving a
253
+ // still-`claimed` row. A redelivery RESUMES that claim (perform-again, since it never recorded done).
254
+ stores.delivery_connector_dispatches.rows.push({ id: 1, dedupe_key: "PI-graph:n2", target: "converge-merge", outcome: "claimed", detail: null, dispatched_at: "t0" });
255
+ stores.pull_requests.rows.push({ pr_key: "owner/repo#13", status: "merged" });
256
+ // `submitPr` deliberately RE-OPENS a terminal PR (it only short-circuits a NON-terminal row), so a
257
+ // resumed re-perform would flip the settled PR back to `converging`. The enrollment action must be
258
+ // terminal-safe: on resume against an already-settled PR it no-ops (no submitPr, no new instance).
259
+ await handler(job, app);
260
+ assertEquals(created.length, 0, "the settled PR is NOT re-enrolled on resume — no new convergence-loop instance");
261
+ assertEquals(stores.pull_requests.rows[0].status, "merged", "the terminal PR stays terminal (never flipped back to converging)");
262
+ assertEquals(stores.delivery_connector_dispatches.rows[0].outcome, "delivered", "the resumed claim is recorded delivered (the dispatch is done, no side effect was needed)");
263
+ });
264
+ });
265
+
266
+ test("handler: a misconfigured converge connector (no parseable pr) fails CLOSED and writes NO ledger row", async () => {
267
+ await withGithubOff(async () => {
268
+ const { app, stores, created } = fakeApp();
269
+ let threw = false;
270
+ try {
271
+ await handler(
272
+ { variables: { target: "converge-merge", payload: { pr: "garbage" } }, processInstanceKey: "PI", elementId: "n1" } as never,
273
+ app,
274
+ );
275
+ } catch {
276
+ threw = true;
277
+ }
278
+ assert(threw, "a converge connector with no target PR fails closed");
279
+ assertEquals(created.length, 0, "no convergence-loop instance is started");
280
+ assertEquals(stores.delivery_connector_dispatches.rows.length, 0, "no junk ledger row is claimed");
281
+ });
282
+ });