@nanobpm/nano-workforce 0.117.0 → 0.118.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.
@@ -56,11 +56,14 @@ jobs:
56
56
  - name: Check BPMN diagram freshness (layout)
57
57
  run: npm run layout:check
58
58
 
59
- # Generated-artifact freshness (`urban gen --check`): landscape.gen.html, JSON Schemas / OpenAPI,
60
- # the processos grammar (ir.gbnf) and friends are `derived == f(sources)` re-derive over the
61
- # merged sources and fail if a committed artifact drifted.
62
- - name: Check generated artifacts (urban gen --check)
63
- run: npm run gen:check
59
+ # NOTE: there is deliberately NO `urban gen --check` (`gen:check`) step here. In THIS repo the
60
+ # only `urban gen` output is `nano-generated/`, which is GIT-IGNORED (since the initial commit)
61
+ # and regenerated at build time via `pretypecheck`/`pretest` (`=> urban gen`). It is never a
62
+ # committed artifact, so there is nothing to freshness-check on a clean checkout `urban gen
63
+ # --check` only reports it "missing" and fails every run (#423). Because it is regenerated from
64
+ # the MERGED sources on every typecheck/test, it cannot merge-skew; merged-tree correctness of
65
+ # the generated code is already covered by the `typecheck + test (Node)` job, which runs
66
+ # `urban gen` then compiles on the `merge_group`/`push` commit. Do not re-add `gen:check` here.
64
67
 
65
68
  # Navigation index is another checked-in derived artifact; re-assert it on the merged tree too.
66
69
  - name: Check navigation index freshness
@@ -1,24 +1,72 @@
1
1
  name: Release
2
2
 
3
3
  on:
4
- push:
5
- branches: [main]
4
+ # Gate releases on green CI: run only AFTER the required workflows finish, and
5
+ # only release when EVERY required check is `success` for that commit (#423).
6
+ # A release must never ship from a red `main`.
7
+ workflow_run:
8
+ workflows: ["CI", "Whole-repo invariants (merge-skew guard)"]
9
+ types: [completed]
6
10
 
7
11
  # Prevent overlapping releases from racing on the same branch.
8
12
  concurrency:
9
- group: release-${{ github.ref }}
13
+ group: release-main
10
14
  cancel-in-progress: false
11
15
 
12
16
  permissions:
13
17
  contents: read
14
18
 
15
19
  jobs:
20
+ # Whichever gating workflow finishes LAST (successfully) re-triggers this and
21
+ # finds every required check green → releases. An earlier trigger, while a
22
+ # sibling gate is still pending, resolves to `should_release=false` (neutral —
23
+ # no release, no failure). If any required check fails, no trigger ever sees an
24
+ # all-green commit, so nothing releases.
25
+ gate:
26
+ name: release gate
27
+ runs-on: ubuntu-latest
28
+ if: >-
29
+ github.event.workflow_run.event == 'push' &&
30
+ github.event.workflow_run.head_branch == 'main' &&
31
+ github.event.workflow_run.conclusion == 'success'
32
+ permissions:
33
+ checks: read
34
+ contents: read
35
+ outputs:
36
+ should_release: ${{ steps.gate.outputs.should_release }}
37
+ steps:
38
+ - name: Require every required check green for this commit
39
+ id: gate
40
+ env:
41
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42
+ SHA: ${{ github.event.workflow_run.head_sha }}
43
+ REPO: ${{ github.repository }}
44
+ run: |
45
+ set -euo pipefail
46
+ # These must ALL be `success` on the release commit before we ship. Keep
47
+ # in lock-step with the `main` ruleset's required status checks.
48
+ required=("typecheck + test (Node)" "whole-repo invariants")
49
+ # Dedupe to ONE row per check name — the latest run (max .id) — so re-runs
50
+ # can't leave us reading a stale conclusion. Slurp every page into a
51
+ # single array with `jq -s` (NOT `gh --slurp`, which gh rejects when
52
+ # combined with `--jq`) so `group_by` sees every run for the SHA; jq's
53
+ # `group_by` sorts by the key first, so unsorted input can't split a name.
54
+ runs="$(gh api "repos/$REPO/commits/$SHA/check-runs?per_page=100" --paginate \
55
+ | jq -r -s '[.[].check_runs[]] | group_by(.name) | map(max_by(.id))
56
+ | .[] | [.name, (.conclusion // "pending")] | @tsv')"
57
+ should=true
58
+ for name in "${required[@]}"; do
59
+ concl="$(printf '%s\n' "$runs" | awk -F'\t' -v n="$name" '$1==n{print $2}')"
60
+ echo "gate: '$name' = ${concl:-missing}"
61
+ [ "${concl:-missing}" = "success" ] || should=false
62
+ done
63
+ echo "should_release=$should" >> "$GITHUB_OUTPUT"
64
+
16
65
  release:
17
66
  name: semantic-release
67
+ needs: gate
68
+ if: needs.gate.outputs.should_release == 'true'
18
69
  runs-on: ubuntu-latest
19
- # `[skip ci]` on the release commit already prevents a re-trigger, but guard
20
- # against loops from any automated release commit as well.
21
- if: "!contains(github.event.head_commit.message, '[skip ci]')"
22
70
  permissions:
23
71
  contents: write # create tags, GitHub Releases, push CHANGELOG/version bump
24
72
  issues: write # comment on released issues
@@ -28,6 +76,7 @@ jobs:
28
76
  - name: Checkout
29
77
  uses: actions/checkout@v4
30
78
  with:
79
+ ref: ${{ github.event.workflow_run.head_sha }} # release the exact gated commit
31
80
  fetch-depth: 0 # semantic-release needs full history + tags
32
81
  persist-credentials: true # let @semantic-release/git push the release commit
33
82
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ## [0.118.1](https://github.com/nanobpm/nano-workforce/compare/v0.118.0...v0.118.1) (2026-08-21)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **ci:** gate releases on green CI and drop perma-red gen:check ([#424](https://github.com/nanobpm/nano-workforce/issues/424)) ([a57746b](https://github.com/nanobpm/nano-workforce/commit/a57746bafa264bf480eb8053cc2e133aa450f8b7))
7
+ * **release:** use jq -s to slurp check-runs; gh --slurp rejects --jq ([#425](https://github.com/nanobpm/nano-workforce/issues/425)) ([d5c3fce](https://github.com/nanobpm/nano-workforce/commit/d5c3fce2652f0b275a5d71d6c8e45e47c403a289))
8
+
9
+ # [0.118.0](https://github.com/nanobpm/nano-workforce/compare/v0.117.0...v0.118.0) (2026-08-21)
10
+
11
+
12
+ ### Features
13
+
14
+ * **delivery-graphs:** human-facing Delivery Graphs UI surface ([#386](https://github.com/nanobpm/nano-workforce/issues/386)) ([#418](https://github.com/nanobpm/nano-workforce/issues/418)) ([3e5877d](https://github.com/nanobpm/nano-workforce/commit/3e5877d2e9f7dc4c7d7934b556e4eefd93685f0f)), closes [#405](https://github.com/nanobpm/nano-workforce/issues/405) [#397](https://github.com/nanobpm/nano-workforce/issues/397) [#374](https://github.com/nanobpm/nano-workforce/issues/374)
15
+
1
16
  # [0.117.0](https://github.com/nanobpm/nano-workforce/compare/v0.116.0...v0.117.0) (2026-08-21)
2
17
 
3
18
 
package/README.md CHANGED
@@ -246,6 +246,27 @@ active epic already targets the same custom base. See
246
246
 
247
247
  ---
248
248
 
249
+ ## Delivery graphs
250
+
251
+ Beyond a single PR (review convergence) and an epic (plan → fan-out), Nano Workforce can
252
+ run an **arbitrary, heterogeneous, cross-repo, partly-human delivery graph** — e.g.
253
+ *merge PR #A → un-draft+merge PR #B → a human runs a manual OTP publish → PR #C consumes
254
+ the just-published version*. You author the graph as **JSON over a closed node vocabulary**
255
+ (`agent` | `wait` | `human` | `connector`) — never BPMN or code — whose edges are
256
+ **discovered facts** (`from: "<node>.<fact>"`). A deterministic compiler turns it into an
257
+ engine-native process; a human approves the rendered preview before any side effect runs.
258
+
259
+ Two doors: a **pure** `POST /app/api/actions/compile-delivery-graph` (validate + preview,
260
+ side-effect-free — hammer it while drafting) and a **gated, idempotent**
261
+ `POST /app/api/actions/start/delivery-graph` (approve → dispatch).
262
+
263
+ The agent guide served at `GET /app/api/agent` documents the full vocabulary, both
264
+ operation contracts, and a complete worked example — see §9 there, or point your coding
265
+ agent at that URL to author, compile, and submit a graph unaided. See
266
+ [ADR 0005](docs/adr/0005-agent-authored-delivery-graphs.md) for the design and rationale.
267
+
268
+ ---
269
+
249
270
  ## Configuration
250
271
 
251
272
  | env | default | purpose |
@@ -126,6 +126,18 @@ test("registeredWorkers: returns the canonical {instance, capability} supply row
126
126
  ]);
127
127
  });
128
128
 
129
+ test("instanceForConnection: resolves the worker instance owning a connection (H6 write-side seam)", () => {
130
+ const store = createPresenceStore(memSqlite());
131
+ store.ensureSchema();
132
+ store.register({ instance: "w1", connectionId: "c1", identity: "leaf", capability: {} });
133
+ store.register({ instance: "w2", connectionId: "c2", identity: "leaf", capability: {} });
134
+ const registry = new PresenceRegistry(store, () => new Set(["c1", "c2"]));
135
+ assertEquals(registry.instanceForConnection("c1"), "w1");
136
+ assertEquals(registry.instanceForConnection("c2"), "w2");
137
+ assertEquals(registry.instanceForConnection("nope"), undefined, "unknown connection → undefined");
138
+ assertEquals(registry.instanceForConnection(""), undefined, "empty connection → undefined");
139
+ });
140
+
129
141
  test("reconcile: removes rows whose connection the hub has closed, keeps live ones", () => {
130
142
  const store = createPresenceStore(memSqlite());
131
143
  store.ensureSchema();
@@ -133,6 +133,21 @@ export class PresenceRegistry {
133
133
  return this.#store.list().map((row) => ({ instance: row.instance, capability: { ...row.capability } }));
134
134
  }
135
135
 
136
+ /**
137
+ * The worker instance registered on a given connection, or undefined when none is. This is the
138
+ * connection → instance resolver the relay/correlation slice (H6, #149) uses to attribute a
139
+ * `produce` frame for a `job:<jobKey>` stream to the presence instance owning the producing
140
+ * connection — the one fact the relay holds (the connection id) turned into the join key the
141
+ * correlation registry needs. An empty connection id (or an unknown one) resolves to undefined.
142
+ */
143
+ instanceForConnection(connectionId: string): string | undefined {
144
+ if (connectionId === "") return undefined;
145
+ for (const row of this.#store.list()) {
146
+ if (row.connectionId === connectionId) return row.instance;
147
+ }
148
+ return undefined;
149
+ }
150
+
136
151
  /**
137
152
  * Eagerly drop presence rows whose connection the hub has already closed (a disconnect the hub's
138
153
  * single close listener removed from its in-memory registry). Rows also age out on the presence
@@ -19,7 +19,9 @@ import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
19
19
  import { type SqliteDb, TRANSCRIPT_SCHEMA_SQL } from "@nanobpm/agentic/transcript";
20
20
  import { assert, assertEquals } from "#test-assert";
21
21
  import { noopLog } from "../../../test/log.ts";
22
+ import { CorrelationRegistry, jobStream } from "../correlation.ts";
22
23
  import {
24
+ type CorrelationLink,
23
25
  createRelayFamily,
24
26
  currentRelayTranscriptService,
25
27
  family as relayFamily,
@@ -131,6 +133,25 @@ function mkService(registry: ConnectionRegistry, db: SqliteDb | undefined): {
131
133
  return { service, hub };
132
134
  }
133
135
 
136
+ /** Build a service with the H6 correlation write-side wired (a real registry + a connection→instance map). */
137
+ function mkCorrelatedService(
138
+ registry: ConnectionRegistry,
139
+ db: SqliteDb | undefined,
140
+ correlation: CorrelationRegistry,
141
+ byConnection: Map<string, string>,
142
+ ): { service: RelayTranscriptService; hub: CapturingHub } {
143
+ const hub = capturingHub();
144
+ const service = new RelayTranscriptService({
145
+ hub,
146
+ registry,
147
+ db,
148
+ log: noopLog(),
149
+ correlation: () => correlation,
150
+ instanceForConnection: (id) => byConnection.get(id),
151
+ });
152
+ return { service, hub };
153
+ }
154
+
134
155
  test("the family exports a valid AgenticFamily named 'relay'", () => {
135
156
  assertEquals(relayFamily.name, RELAY_FAMILY_NAME);
136
157
  assertEquals(relayFamily.name, "relay");
@@ -262,6 +283,212 @@ test("retention: a disconnected producer auto-completes its ephemeral stream on
262
283
  service.teardown();
263
284
  });
264
285
 
286
+ test("H6 correlation write-side: a produce on job:<k> links instance→[k]; stream completion releases it", () => {
287
+ const registry = new ConnectionRegistry();
288
+ const correlation = new CorrelationRegistry();
289
+ const byConnection = new Map([["prod", "worker-A"]]);
290
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
291
+ const p = connect("prod", registry);
292
+
293
+ // The first `produce` for a job-scoped stream links the producing worker instance → jobKey, from
294
+ // data already crossing the wire (jobKey decoded from the stream id; instance from the connection).
295
+ hub.handler?.(produce(jobStream("k1"), 1, "chunk"), p.conn);
296
+ assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "instance → [jobKey] is now linked");
297
+ assertEquals(correlation.resolve("k1")?.stream, jobStream("k1"), "context carries the job-scoped stream");
298
+ assertEquals(correlation.count(), 1);
299
+
300
+ // Job end (stream completion) releases the correlation, so the worker's supply row clears it.
301
+ service.completeStream(jobStream("k1"));
302
+ assertEquals(correlation.jobKeysFor("worker-A"), [], "completion releases the job");
303
+ assertEquals(correlation.resolve("k1"), undefined);
304
+ assertEquals(correlation.count(), 0);
305
+ service.teardown();
306
+ });
307
+
308
+ test("H6 correlation write-side: an unpersisted stream completion releases its job correlation and stops reconcile retrying", () => {
309
+ const registry = new ConnectionRegistry();
310
+ const correlation = new CorrelationRegistry();
311
+ const byConnection = new Map([["prod", "worker-U"]]);
312
+ // No DataLayer → the relay runs unpersisted. A direct completeStream (job end, not a disconnect)
313
+ // must still transition in-memory state: unlink correlation, mark ephemeral completed, drop the
314
+ // producer — otherwise #reconcile keeps re-completing the stream on every subsequent frame.
315
+ const { service, hub } = mkCorrelatedService(registry, undefined, correlation, byConnection);
316
+ const p = connect("prod", registry);
317
+ hub.handler?.(produce(jobStream("kU"), 1, "x"), p.conn);
318
+ assertEquals(correlation.jobKeysFor("worker-U"), ["kU"]);
319
+
320
+ service.completeStream(jobStream("kU"));
321
+ assertEquals(correlation.jobKeysFor("worker-U"), [], "unpersisted completion releases the job");
322
+ assertEquals(correlation.count(), 0);
323
+
324
+ // The producer is still live, but the stream is now completed: a later frame's #reconcile must not
325
+ // re-link or otherwise resurrect the released correlation.
326
+ hub.handler?.(grant(0), p.conn);
327
+ assertEquals(correlation.count(), 0, "completed stream stays released; reconcile does not retry");
328
+ service.teardown();
329
+ });
330
+
331
+ test("H6 correlation write-side: a late produce after completion does not resurrect the released correlation", () => {
332
+ const registry = new ConnectionRegistry();
333
+ const correlation = new CorrelationRegistry();
334
+ const byConnection = new Map([["prod", "worker-R"]]);
335
+ const { service, hub } = mkCorrelatedService(registry, undefined, correlation, byConnection);
336
+ const p = connect("prod", registry);
337
+ hub.handler?.(produce(jobStream("kR"), 1, "x"), p.conn);
338
+ assertEquals(correlation.jobKeysFor("worker-R"), ["kR"]);
339
+
340
+ // Job end completes the stream and releases the correlation.
341
+ service.completeStream(jobStream("kR"));
342
+ assertEquals(correlation.count(), 0, "completion releases the job");
343
+
344
+ // A late `produce` frame for the same, still-live producer must NOT re-link/re-own the completed
345
+ // stream — otherwise it resurrects a jobKey after it was released.
346
+ hub.handler?.(produce(jobStream("kR"), 1, "late"), p.conn);
347
+ assertEquals(correlation.count(), 0, "a late produce does not resurrect a completed stream");
348
+ assertEquals(correlation.jobKeysFor("worker-R"), [], "released jobKey stays released after a late produce");
349
+ service.teardown();
350
+ });
351
+
352
+ /**
353
+ * A {@link CorrelationLink} wrapper that delegates to a real registry but can be flipped to throw on
354
+ * `link()`/`releaseJob()`, exercising the advisory-resilience contract: `#link`/`#unlink` are
355
+ * documented "never throws into the frame handler", so a throwing injectable correlation must not
356
+ * take down the relay frame handler, and the stream must stay retryable.
357
+ */
358
+ function throwingCorrelation(inner: CorrelationRegistry): {
359
+ correlation: CorrelationLink;
360
+ failLink: (on: boolean) => void;
361
+ failRelease: (on: boolean) => void;
362
+ } {
363
+ let linkFails = false;
364
+ let releaseFails = false;
365
+ return {
366
+ correlation: {
367
+ link: (instance, jobKey, context) => {
368
+ if (linkFails) throw new Error("correlation.link boom");
369
+ inner.link(instance, jobKey, context);
370
+ },
371
+ releaseJob: (jobKey) => {
372
+ if (releaseFails) throw new Error("correlation.releaseJob boom");
373
+ inner.releaseJob(jobKey);
374
+ },
375
+ },
376
+ failLink: (on) => {
377
+ linkFails = on;
378
+ },
379
+ failRelease: (on) => {
380
+ releaseFails = on;
381
+ },
382
+ };
383
+ }
384
+
385
+ test("H6 correlation write-side: a throwing correlation.link() never escapes the frame handler and leaves the stream retryable", () => {
386
+ const registry = new ConnectionRegistry();
387
+ const inner = new CorrelationRegistry();
388
+ const { correlation, failLink } = throwingCorrelation(inner);
389
+ const byConnection = new Map([["prod", "worker-L"]]);
390
+ const hub = capturingHub();
391
+ const service = new RelayTranscriptService({
392
+ hub,
393
+ registry,
394
+ db: undefined,
395
+ log: noopLog(),
396
+ correlation: () => correlation,
397
+ instanceForConnection: (id) => byConnection.get(id),
398
+ });
399
+ const p = connect("prod", registry);
400
+
401
+ // The first `produce` links — but the injected correlation throws. It must be swallowed (advisory),
402
+ // so the frame handler does not throw and the stream is left UNLINKED so a later produce retries.
403
+ failLink(true);
404
+ hub.handler?.(produce(jobStream("kL"), 1, "x"), p.conn);
405
+ assertEquals(inner.count(), 0, "a throwing link is swallowed and records no correlation");
406
+
407
+ // A later produce (link now succeeds) retries the link — proving the stream was left unlinked.
408
+ failLink(false);
409
+ hub.handler?.(produce(jobStream("kL"), 1, "y"), p.conn);
410
+ assertEquals(inner.jobKeysFor("worker-L"), ["kL"], "the link retries and succeeds on a later frame");
411
+ assertEquals(inner.count(), 1);
412
+ service.teardown();
413
+ });
414
+
415
+ test("H6 correlation write-side: a throwing correlation.releaseJob() never escapes the frame handler; completion still finalizes", () => {
416
+ const registry = new ConnectionRegistry();
417
+ const inner = new CorrelationRegistry();
418
+ const { correlation, failRelease } = throwingCorrelation(inner);
419
+ const byConnection = new Map([["prod", "worker-X"]]);
420
+ const hub = capturingHub();
421
+ const service = new RelayTranscriptService({
422
+ hub,
423
+ registry,
424
+ db: undefined,
425
+ log: noopLog(),
426
+ correlation: () => correlation,
427
+ instanceForConnection: (id) => byConnection.get(id),
428
+ });
429
+ const p = connect("prod", registry);
430
+ hub.handler?.(produce(jobStream("kX"), 1, "x"), p.conn);
431
+ assertEquals(inner.jobKeysFor("worker-X"), ["kX"]);
432
+
433
+ // A producer disconnect drives #reconcile → #unlink, but releaseJob throws. The advisory contract
434
+ // is "never throws into the frame handler": the throw must be swallowed so the reconcile pass (and
435
+ // the completion it drives) do not bubble out and take down unrelated streams' relay processing.
436
+ failRelease(true);
437
+ registry.remove("prod");
438
+ const other = connect("cons", registry);
439
+ hub.handler?.(grant(0), other.conn); // must NOT throw despite releaseJob throwing
440
+
441
+ // The stream still finalizes (terminal) so #reconcile does not thrash it every frame, and a later
442
+ // frame's reconcile is a clean no-op rather than a repeated crash.
443
+ hub.handler?.(produce(jobStream("kX"), 2, "late"), p.conn);
444
+ assertEquals(
445
+ inner.jobKeysFor("worker-X"),
446
+ ["kX"],
447
+ "a swallowed release leaves the correlation held (linked), never a crash",
448
+ );
449
+ service.teardown();
450
+ });
451
+
452
+ test("H6 correlation write-side: a producer disconnect releases its job correlation even unpersisted", () => {
453
+ const registry = new ConnectionRegistry();
454
+ const correlation = new CorrelationRegistry();
455
+ const byConnection = new Map([["prod", "worker-B"]]);
456
+ // No DataLayer → the relay runs unpersisted; correlation release must still fire (store-independent).
457
+ const { service, hub } = mkCorrelatedService(registry, undefined, correlation, byConnection);
458
+ const p = connect("prod", registry);
459
+ hub.handler?.(produce(jobStream("k2"), 1, "x"), p.conn);
460
+ assertEquals(correlation.jobKeysFor("worker-B"), ["k2"]);
461
+
462
+ // Producer drops; a subsequent frame from any live connection reconciles the dead producer.
463
+ registry.remove("prod");
464
+ const other = connect("cons", registry);
465
+ hub.handler?.(grant(0), other.conn);
466
+ assertEquals(correlation.jobKeysFor("worker-B"), [], "disconnect releases the job");
467
+ assertEquals(correlation.count(), 0);
468
+ service.teardown();
469
+ });
470
+
471
+ test("H6 correlation write-side: non-job streams are never linked; a link retries until the instance resolves", () => {
472
+ const registry = new ConnectionRegistry();
473
+ const correlation = new CorrelationRegistry();
474
+ const byConnection = new Map<string, string>();
475
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
476
+ const p = connect("prod", registry);
477
+
478
+ // A plain (non-`job:`) stream carries no jobKey → never correlated.
479
+ hub.handler?.(produce("plain-stream", 1, "x"), p.conn);
480
+ assertEquals(correlation.count(), 0, "non-job streams are not linked");
481
+
482
+ // A job stream whose producer's presence instance is not yet known does not link — but a later
483
+ // frame (after the register lands) retries and links, closing the register/produce race.
484
+ hub.handler?.(produce(jobStream("k3"), 1, "a"), p.conn);
485
+ assertEquals(correlation.count(), 0, "no presence instance yet → not linked");
486
+ byConnection.set("prod", "worker-C");
487
+ hub.handler?.(produce(jobStream("k3"), 1, "b"), p.conn);
488
+ assertEquals(correlation.jobKeysFor("worker-C"), ["k3"], "retried link succeeds once the instance resolves");
489
+ service.teardown();
490
+ });
491
+
265
492
  test("retention: a long-lived stream is checkpointed + reattachable and never auto-completed", () => {
266
493
  const registry = new ConnectionRegistry();
267
494
  const db = memoryDb();