@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.
- package/.github/workflows/invariants.yml +8 -5
- package/.github/workflows/release.yml +55 -6
- package/CHANGELOG.md +15 -0
- package/README.md +21 -0
- package/app/agentic/families/presence.family.test.ts +12 -0
- package/app/agentic/families/presence.family.ts +15 -0
- package/app/agentic/families/relay.family.test.ts +227 -0
- package/app/agentic/families/relay.family.ts +137 -20
- package/app/deliveryGraphText.ts +41 -0
- package/docs/agent-guide.md +173 -0
- package/openapi.yaml +166 -0
- package/operations/dispatchDeliveryGraph.test.ts +166 -0
- package/operations/dispatchDeliveryGraph.ts +113 -0
- package/operations/getAgentInstructions.test.ts +22 -0
- package/operations/previewDeliveryGraph.test.ts +74 -0
- package/operations/previewDeliveryGraph.ts +59 -0
- package/package.json +1 -1
- package/pages/_nav.json +1 -0
- package/pages/board.page.json +4 -0
- package/pages/cockpit.page.json +4 -0
- package/pages/delivery-graph-detail.page.json +114 -0
- package/pages/delivery-graphs.page.json +153 -0
- package/pages/epic-detail.page.json +4 -0
- package/pages/epic.page.json +4 -0
- package/pages/feature.page.json +4 -0
- package/pages/home.page.json +4 -0
- package/pages/lineage.page.json +4 -0
- package/pages/overview.page.json +6 -2
- package/pages/tasks.page.json +4 -0
- package/pages/velocity.page.json +4 -0
- package/scripts/pages-contract.test.ts +67 -0
|
@@ -56,11 +56,14 @@ jobs:
|
|
|
56
56
|
- name: Check BPMN diagram freshness (layout)
|
|
57
57
|
run: npm run layout:check
|
|
58
58
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
-
|
|
63
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
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();
|