@nanobpm/nano-workforce 0.50.0 → 0.52.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.52.0](https://github.com/nanobpm/nano-workforce/compare/v0.51.0...v0.52.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * presence + registry family over app.data (H1) ([#161](https://github.com/nanobpm/nano-workforce/issues/161)) ([6666ff1](https://github.com/nanobpm/nano-workforce/commit/6666ff148d02bc0f7ae9740511fdc62bbe9c2e70)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#152](https://github.com/nanobpm/nano-workforce/issues/152) [#144](https://github.com/nanobpm/nano-workforce/issues/144)
7
+
8
+ # [0.51.0](https://github.com/nanobpm/nano-workforce/compare/v0.50.0...v0.51.0) (2026-08-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * mount agentic channel hub + family-registration seam (H0) ([#154](https://github.com/nanobpm/nano-workforce/issues/154)) ([96a679e](https://github.com/nanobpm/nano-workforce/commit/96a679e0843c76bf93940f09eef1c6c642a4ebed)), closes [#142](https://github.com/nanobpm/nano-workforce/issues/142) [#143](https://github.com/nanobpm/nano-workforce/issues/143) [#isMounted](https://github.com/nanobpm/nano-workforce/issues/isMounted)
14
+
1
15
  # [0.50.0](https://github.com/nanobpm/nano-workforce/compare/v0.49.0...v0.50.0) (2026-08-12)
2
16
 
3
17
 
@@ -0,0 +1,57 @@
1
+ # `app/agentic/` — the app-tier agentic channel (ADR 0056)
2
+
3
+ This directory hosts the **agentic visibility plane** for nano-workforce (epic #142). H0 (#143)
4
+ owns the keystone wiring; siblings H1/H3/H4 extend it **without touching the boot script**.
5
+
6
+ ## What H0 lands
7
+
8
+ - **`channel.ts`** — `mountAgenticChannel(...)`. Stands up the `@nanobpm/agentic` WebSocket channel
9
+ + `AgenticHub` on the app's **own** HTTP server (`app.httpServer`, same port as the pages and
10
+ `/app/api/hooks/*` — no sidecar port), authenticates upgrades on `/agentic` (ADR 0028 identity
11
+ token + a required capability credential, mirroring the blackboard hook's `?token=…` pattern), and
12
+ mounts every discovered family. Returns a handle whose `teardown()` reverses everything.
13
+ - **`registry.ts`** — the `AgenticFamilyRegistry` seam + the `AgenticFamily` / `AgenticContext`
14
+ contracts. Mounts families on boot, tears them down in **reverse** order on shutdown.
15
+ - **`loader.ts`** — auto-discovers `*.family.ts` modules under `families/`. There is **no central
16
+ registration list** to append to, so siblings never collide on a shared file.
17
+ - **`families/`** — the discovery directory. Drop a family module here; `families/example.family.ts`
18
+ is the copyable no-op template.
19
+
20
+ `main.ts` calls `mountAgenticChannel(...)` once after `runFromEnv`, and its `teardown()` once inside
21
+ the existing `drainAndExit`. **That is the only edit to `main.ts` / `drainAndExit` for the whole
22
+ epic.**
23
+
24
+ ## How a sibling slice extends the channel (H1 / H3 / H4)
25
+
26
+ 1. Copy `families/example.family.ts` to `families/<slice>.family.ts` and implement `mount(ctx)`
27
+ (and optionally `teardown()`). `ctx` carries the reusable handles: `hub`, `registry`,
28
+ `transport`, `data` (the app SQLite `DataLayer`), and `log`.
29
+ 2. Own a message family via `ctx.hub.registerFamilyHandler("<family>", handler)` — the router
30
+ refuses a duplicate, so two slices can't both claim one family.
31
+ 3. **Do not** edit `main.ts`, `drainAndExit`, `channel.ts`, `registry.ts`, or `loader.ts`. You add
32
+ exactly one new file.
33
+
34
+ ## Reserved migration prefixes
35
+
36
+ Forward-only, additive (expand-only). The highest committed prefix when the epic began is `022`, so
37
+ H0 pre-allocates distinct prefixes to stop two siblings independently grabbing "the next" number:
38
+
39
+ | Slice | Reserved migration file |
40
+ | ---------------- | ------------------------------------------- |
41
+ | H1 presence #144 | `db/migrations/023_agentic_presence.sql` |
42
+ | H3 transcript #146 | `db/migrations/024_agentic_transcript.sql` |
43
+ | H4 blackboard #147 | `db/migrations/025_agentic_blackboard.sql` (only if a schema change is needed) |
44
+
45
+ H0 itself needs no migration.
46
+
47
+ ## Configuration
48
+
49
+ - `NANO_AGENTIC_SECRET` (falls back to `NANO_PR_WEBHOOK_SECRET`) — the shared identity secret peers
50
+ present as `?token=…`. When neither is set, the channel is **not mounted** (logged), so the app
51
+ never exposes an unauthenticated upgrade.
52
+
53
+ ## Invariants (ADR 0056)
54
+
55
+ - **App-tier only** — never the engine. The Camunda-8 job protocol (worker⇄engine) is untouched;
56
+ the agentic channel is the only new conversation.
57
+ - **Advisory** — a family never hard-locks or gates a BPMN sequence flow.
@@ -0,0 +1,244 @@
1
+ // Integration tests for the agentic channel mount (ADR 0056, H0 / #143).
2
+ //
3
+ // Exercises the acceptance surface against a locally-constructed `node:http` server: a valid WS
4
+ // client upgrades on `/agentic`, an invalid one is rejected, normal HTTP routes keep working, the
5
+ // hub is visible via `inspect()`, families mount/tear-down through the seam, and shutdown is clean.
6
+ import { type AddressInfo, createServer, type Server } from "node:http";
7
+ import { test } from "node:test";
8
+ import { WebSocket } from "ws";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import { noopLog } from "../../test/log.ts";
11
+ import { type AgenticChannelHandle, mountAgenticChannel } from "./channel.ts";
12
+ import { type AgenticContext, AgenticFamilyRegistry } from "./registry.ts";
13
+
14
+ const SECRET = "test-agentic-secret";
15
+
16
+ /** Start a bare HTTP server with one health route, on an ephemeral port. */
17
+ async function startHttp(): Promise<{ server: Server; port: number }> {
18
+ const server = createServer((req, res) => {
19
+ if (req.url === "/health") {
20
+ res.writeHead(200, { "content-type": "text/plain" });
21
+ res.end("ok");
22
+ return;
23
+ }
24
+ res.writeHead(404);
25
+ res.end();
26
+ });
27
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
28
+ const port = (server.address() as AddressInfo).port;
29
+ return { server, port };
30
+ }
31
+
32
+ /** Close an HTTP server and resolve only once it has stopped listening (server.close() is async). */
33
+ function closeServer(server: Server): Promise<void> {
34
+ return new Promise<void>((resolve, reject) => {
35
+ server.close((err) => (err ? reject(err) : resolve()));
36
+ });
37
+ }
38
+
39
+ /** Open a ws client and resolve on open. Rejects (with the close code) if it closes before opening. */
40
+ function connect(port: number, query: string): Promise<WebSocket> {
41
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/agentic${query}`);
42
+ return new Promise<WebSocket>((resolve, reject) => {
43
+ let opened = false;
44
+ ws.on("open", () => {
45
+ opened = true;
46
+ resolve(ws);
47
+ });
48
+ ws.on("error", () => {
49
+ /* the close frame carries the reason; swallow the paired error event */
50
+ });
51
+ ws.on("close", (code, reason) => {
52
+ if (!opened) reject(new Error(`closed ${code}: ${reason.toString()}`));
53
+ });
54
+ });
55
+ }
56
+
57
+ /**
58
+ * The application close code an authenticator rejection delivers. The hub authenticates AFTER the
59
+ * WebSocket upgrade completes, so a rejected peer momentarily opens and is then closed with the app
60
+ * code (4401/4403) — this waits for that close and returns the code.
61
+ */
62
+ /** How long rejectionCode waits for the close frame before failing (generous for slow CI). */
63
+ const REJECTION_TIMEOUT_MS = 2000;
64
+
65
+ function rejectionCode(port: number, query: string): Promise<number> {
66
+ const ws = new WebSocket(`ws://127.0.0.1:${port}/agentic${query}`);
67
+ return new Promise<number>((resolve, reject) => {
68
+ const timer = setTimeout(
69
+ () => reject(new Error("connection neither closed nor timed out")),
70
+ REJECTION_TIMEOUT_MS,
71
+ );
72
+ timer.unref?.();
73
+ ws.on("error", () => {
74
+ /* swallow the paired error event; the close frame carries the code */
75
+ });
76
+ ws.on("close", (code) => {
77
+ clearTimeout(timer);
78
+ resolve(code);
79
+ });
80
+ });
81
+ }
82
+
83
+ async function mount(
84
+ _port: number,
85
+ server: Server,
86
+ families?: () => AgenticFamilyRegistry,
87
+ ): Promise<AgenticChannelHandle> {
88
+ return mountAgenticChannel({
89
+ server,
90
+ secret: SECRET,
91
+ data: undefined,
92
+ log: noopLog(),
93
+ families,
94
+ });
95
+ }
96
+
97
+ test("a valid identity token + capability credential upgrades on /agentic", async (t) => {
98
+ const { server, port } = await startHttp();
99
+ const channel = await mount(port, server);
100
+ t.after(async () => {
101
+ await channel.teardown();
102
+ await closeServer(server);
103
+ });
104
+
105
+ const ws = await connect(port, `?token=${SECRET}&capability=cap-1`);
106
+ assertEquals(ws.readyState, WebSocket.OPEN);
107
+ // The hub tracked exactly this one connection.
108
+ assertEquals(channel.hub.connectionCount, 1);
109
+ ws.close();
110
+ });
111
+
112
+ test("an invalid identity token is rejected (4401)", async (t) => {
113
+ const { server, port } = await startHttp();
114
+ const channel = await mount(port, server);
115
+ t.after(async () => {
116
+ await channel.teardown();
117
+ await closeServer(server);
118
+ });
119
+
120
+ const closedCode = await rejectionCode(port, "?token=wrong&capability=cap-1");
121
+ assertEquals(closedCode, 4401);
122
+ assertEquals(channel.hub.connectionCount, 0);
123
+ });
124
+
125
+ test("a missing capability credential is rejected (4403)", async (t) => {
126
+ const { server, port } = await startHttp();
127
+ const channel = await mount(port, server);
128
+ t.after(async () => {
129
+ await channel.teardown();
130
+ await closeServer(server);
131
+ });
132
+
133
+ const closedCode = await rejectionCode(port, `?token=${SECRET}`);
134
+ assertEquals(closedCode, 4403);
135
+ });
136
+
137
+ test("normal HTTP routes keep working alongside the channel", async (t) => {
138
+ const { server, port } = await startHttp();
139
+ const channel = await mount(port, server);
140
+ t.after(async () => {
141
+ await channel.teardown();
142
+ await closeServer(server);
143
+ });
144
+
145
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
146
+ assertEquals(res.status, 200);
147
+ assertEquals(await res.text(), "ok");
148
+ });
149
+
150
+ test("the hub is visible via inspect() and mounts registered families", async (t) => {
151
+ const { server, port } = await startHttp();
152
+ const trace: string[] = [];
153
+ const families = () => {
154
+ const reg = new AgenticFamilyRegistry();
155
+ reg.register({
156
+ name: "probe",
157
+ mount(ctx: AgenticContext) {
158
+ trace.push("mount");
159
+ // Prove the real hub handle is threaded through: registering a family handler must work.
160
+ ctx.hub.registerFamilyHandler("register", () => {});
161
+ },
162
+ teardown() {
163
+ trace.push("teardown");
164
+ },
165
+ });
166
+ return reg;
167
+ };
168
+ const channel = await mount(port, server, families);
169
+ t.after(() => closeServer(server));
170
+
171
+ const snap = channel.inspect();
172
+ assertEquals(snap.path, "/agentic");
173
+ assertEquals(snap.families, ["probe"]);
174
+ assertEquals(trace, ["mount"]);
175
+ assert(channel.hub.router.has("register"), "family handler should be registered");
176
+
177
+ await channel.teardown();
178
+ assertEquals(trace, ["mount", "teardown"]);
179
+ });
180
+
181
+ test("teardown closes live connections and is idempotent", async (t) => {
182
+ const { server, port } = await startHttp();
183
+ const channel = await mount(port, server);
184
+ t.after(() => closeServer(server));
185
+
186
+ const ws = await connect(port, `?token=${SECRET}&capability=cap-1`);
187
+ const closed = new Promise<void>((resolve) => ws.on("close", () => resolve()));
188
+ assertEquals(channel.hub.connectionCount, 1);
189
+
190
+ await channel.teardown();
191
+ await channel.teardown(); // second call is a no-op, must not throw
192
+ await closed;
193
+ assertEquals(ws.readyState, WebSocket.CLOSED);
194
+ });
195
+
196
+ test("a family mount failure tears down already-mounted families and closes the hub", async (t) => {
197
+ const { server, port } = await startHttp();
198
+ t.after(() => closeServer(server));
199
+ const trace: string[] = [];
200
+ const families = () => {
201
+ const reg = new AgenticFamilyRegistry();
202
+ reg.register({
203
+ name: "ok",
204
+ mount() {
205
+ trace.push("mount-ok");
206
+ },
207
+ teardown() {
208
+ trace.push("teardown-ok");
209
+ },
210
+ });
211
+ reg.register({
212
+ name: "boom",
213
+ mount() {
214
+ trace.push("mount-boom");
215
+ throw new Error("family boom failed to mount");
216
+ },
217
+ });
218
+ return reg;
219
+ };
220
+
221
+ let threw = false;
222
+ try {
223
+ await mount(port, server, families);
224
+ } catch {
225
+ threw = true;
226
+ }
227
+ assert(threw, "mountAgenticChannel must reject when a family mount throws");
228
+ // The already-mounted family was torn down (reverse order) — no family left half-mounted, and the
229
+ // hub was closed on the same path (see mountAgenticChannel's failure handler).
230
+ assertEquals(trace, ["mount-ok", "mount-boom", "teardown-ok"]);
231
+ });
232
+
233
+ test("a missing secret is refused (never mount an open channel)", async (t) => {
234
+ const { server, port } = await startHttp();
235
+ t.after(() => closeServer(server));
236
+ let threw = false;
237
+ try {
238
+ await mountAgenticChannel({ server, secret: "", data: undefined, log: noopLog() });
239
+ } catch {
240
+ threw = true;
241
+ }
242
+ assert(threw, "mountAgenticChannel must reject an empty secret");
243
+ assertEquals(port > 0, true);
244
+ });
@@ -0,0 +1,124 @@
1
+ // nano-workforce — mount the app-tier agentic channel hub (ADR 0056, H0 / #143).
2
+ //
3
+ // This is the keystone of the agentic-visibility epic (#142): it stands up the WebSocket channel +
4
+ // hub on the app's OWN HTTP server (same port as the pages and `/app/api/hooks/*` — no sidecar
5
+ // port), authenticates each upgrade (ADR 0028 identity token + a capability credential, mirroring
6
+ // the `?token=…` pattern the blackboard hook uses), and mounts every registered family module
7
+ // through the {@link AgenticFamilyRegistry} seam.
8
+ //
9
+ // `main.ts` calls {@link mountAgenticChannel} once after `runFromEnv` and calls the returned
10
+ // handle's `teardown()` inside its existing `drainAndExit`. That is the ONLY place `main.ts` /
11
+ // `drainAndExit` are edited for the whole epic — siblings extend the channel purely by dropping a
12
+ // family module under `app/agentic/families/`.
13
+ //
14
+ // Invariants (ADR 0056): app-tier only, never the engine; the Camunda-8 job protocol (worker⇄engine)
15
+ // is untouched; advisory semantics preserved (a family never gates a BPMN sequence flow).
16
+ import type { Server } from "node:http";
17
+ import {
18
+ AgenticHub,
19
+ sharedSecretAuthenticator,
20
+ WebSocketChannelTransport,
21
+ } from "@nanobpm/agentic/channel";
22
+ import type { DataLayer, Logger } from "@nanobpm/urban";
23
+ import { loadAgenticFamilies } from "./loader.ts";
24
+ import { AgenticFamilyRegistry } from "./registry.ts";
25
+
26
+ /** The path the agentic channel is served on, on the app's own port. */
27
+ export const AGENTIC_PATH = "/agentic";
28
+
29
+ export interface MountAgenticChannelOptions {
30
+ /** The app's own `node:http` server (share its port; `app.httpServer` narrowed to `Server`). */
31
+ readonly server: Server;
32
+ /** The shared-secret ADR 0028 identity token every valid peer must present as `?token=…`. */
33
+ readonly secret: string;
34
+ /** The app's SQLite data layer, threaded to family modules (may be absent when data isn't mounted). */
35
+ readonly data: DataLayer | undefined;
36
+ /** A structured logger for lifecycle lines. */
37
+ readonly log: Logger;
38
+ /**
39
+ * Discover + register family modules from `app/agentic/families/`. Default: the real discovery
40
+ * loader. Tests inject a fixed set to keep the mount hermetic.
41
+ */
42
+ readonly families?: () => Promise<AgenticFamilyRegistry> | AgenticFamilyRegistry;
43
+ }
44
+
45
+ /** The live channel, returned to `main.ts` so it can inspect it and tear it down on shutdown. */
46
+ export interface AgenticChannelHandle {
47
+ readonly hub: AgenticHub;
48
+ readonly transport: WebSocketChannelTransport;
49
+ readonly registry: AgenticFamilyRegistry;
50
+ /** A structured snapshot for `inspect()`/logs. */
51
+ inspect(): Record<string, unknown>;
52
+ /** Tear the families (reverse order) then the hub + transport down. Idempotent. */
53
+ teardown(): Promise<void>;
54
+ }
55
+
56
+ /** Build a family registry from the on-disk discovery loader (the production default). */
57
+ async function discoverRegistry(log: Logger): Promise<AgenticFamilyRegistry> {
58
+ const registry = new AgenticFamilyRegistry();
59
+ registry.registerAll(await loadAgenticFamilies(undefined, log));
60
+ return registry;
61
+ }
62
+
63
+ /**
64
+ * Mount the agentic hub on `server`, authenticate upgrades with `secret`, and mount all discovered
65
+ * family modules. Returns a handle whose `teardown()` reverses everything.
66
+ */
67
+ export async function mountAgenticChannel(
68
+ opts: MountAgenticChannelOptions,
69
+ ): Promise<AgenticChannelHandle> {
70
+ const { server, secret, data, log } = opts;
71
+ if (!secret) throw new Error("mountAgenticChannel requires a non-empty identity secret");
72
+
73
+ const transport = new WebSocketChannelTransport({ server, path: AGENTIC_PATH });
74
+ const hub = new AgenticHub({
75
+ transport,
76
+ // A valid identity token PLUS a required capability credential upgrades; either missing/invalid
77
+ // is rejected (4401 / 4403). Swap in a real ADR 0028 verifier later by passing an Authenticator.
78
+ authenticator: sharedSecretAuthenticator({ secret, requireCredential: true }),
79
+ onError: (err, connectionId) =>
80
+ log.warn("agentic hub error", { connectionId, err: String(err) }),
81
+ });
82
+ // Share the app's port: the transport rode the existing server, so it is already listening.
83
+ await transport.ready();
84
+
85
+ // If discovery or any family mount throws, the transport + hub are already live: tear down whatever
86
+ // mounted (in reverse) and close the hub before rethrowing, so a failed boot never strands upgrade
87
+ // handlers or half-open connections.
88
+ let registry: AgenticFamilyRegistry | undefined;
89
+ try {
90
+ registry = await (opts.families ? opts.families() : discoverRegistry(log));
91
+ await registry.mountAll({ hub, registry: hub.registry, transport, data, log });
92
+ } catch (err) {
93
+ await registry?.teardownAll(log);
94
+ await hub.close();
95
+ throw err;
96
+ }
97
+
98
+ log.info("agentic channel mounted", {
99
+ path: AGENTIC_PATH,
100
+ families: registry.names(),
101
+ });
102
+
103
+ let tornDown = false;
104
+ return {
105
+ hub,
106
+ transport,
107
+ registry,
108
+ inspect() {
109
+ return {
110
+ path: AGENTIC_PATH,
111
+ families: registry.names(),
112
+ connections: hub.connectionCount,
113
+ address: hub.address,
114
+ };
115
+ },
116
+ async teardown() {
117
+ if (tornDown) return;
118
+ tornDown = true;
119
+ await registry.teardownAll(log);
120
+ await hub.close();
121
+ log.info("agentic channel torn down");
122
+ },
123
+ };
124
+ }
@@ -0,0 +1,35 @@
1
+ // nano-workforce — a no-op EXAMPLE agentic family module (ADR 0056, H0 / #143).
2
+ //
3
+ // This is the concrete, copyable pattern H1 (#144), H3 (#146) and H4 (#147) follow. It lives in the
4
+ // discovery directory (`app/agentic/families/`), so the loader ({@link ../loader.ts}) finds it by
5
+ // the `*.family.ts` suffix and the H0 seam mounts + tears it down — a genuine registered sample. It
6
+ // is a deliberate no-op (no message handler, no state), so mounting it in production is harmless.
7
+ //
8
+ // To add a real family slice, copy this file to `app/agentic/families/<slice>.family.ts`, rename it,
9
+ // and implement `mount` (and optionally `teardown`). That is the WHOLE integration: you add ONE NEW
10
+ // FILE and register NOTHING by hand — you never touch `main.ts` or `drainAndExit`.
11
+ //
12
+ // Reserved forward-only migration prefixes (do NOT compute "the next" number — use your reserved one):
13
+ // - H1 presence → `db/migrations/023_agentic_presence.sql`
14
+ // - H3 transcript → `db/migrations/024_agentic_transcript.sql`
15
+ // - H4 blackboard → `db/migrations/025_agentic_blackboard.sql` (only if a schema change is needed)
16
+ import type { AgenticContext, AgenticFamily } from "../registry.ts";
17
+
18
+ /**
19
+ * A minimal, no-op family. A real slice would, inside `mount`:
20
+ * - `ctx.hub.registerFamilyHandler("<family>", (frame, conn) => { … })` to own a message family,
21
+ * - persist via `ctx.data` (the app's SQLite DataLayer — the same store the blackboard uses),
22
+ * - attach presence via `ctx.registry.setPresence(conn.id, …)`,
23
+ * and release those in `teardown`.
24
+ */
25
+ export const family: AgenticFamily = {
26
+ name: "example",
27
+ mount(_ctx: AgenticContext): void {
28
+ // No-op template. Replace with the slice's real wiring against `_ctx`.
29
+ },
30
+ teardown(): void {
31
+ // No-op template. Release anything `mount` acquired here.
32
+ },
33
+ };
34
+
35
+ export default family;