@nanobpm/nano-workforce 0.49.0 → 0.51.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.51.0](https://github.com/nanobpm/nano-workforce/compare/v0.50.0...v0.51.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * 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)
7
+
8
+ # [0.50.0](https://github.com/nanobpm/nano-workforce/compare/v0.49.0...v0.50.0) (2026-08-12)
9
+
10
+
11
+ ### Features
12
+
13
+ * **merge:** adopt @nanobpm/urban/effect matchTags for exhaustive land dispatch ([#139](https://github.com/nanobpm/nano-workforce/issues/139)) ([c251763](https://github.com/nanobpm/nano-workforce/commit/c251763a050858ea610ce45e59a34f859163c29c)), closes [nano-ide#215](https://github.com/nano-ide/issues/215)
14
+
1
15
  # [0.49.0](https://github.com/nanobpm/nano-workforce/compare/v0.48.2...v0.49.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;
@@ -0,0 +1,79 @@
1
+ // Unit tests for the agentic family DISCOVERY loader (ADR 0056, H0 / #143).
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { test } from "node:test";
6
+ import { assert, assertEquals } from "#test-assert";
7
+ import { noopLog } from "../../test/log.ts";
8
+ import { loadAgenticFamilies } from "./loader.ts";
9
+
10
+ /** A discovered family module's source: exports a named `family` with the given `name`. */
11
+ function familyModuleSource(name: string): string {
12
+ return `export const family = { name: ${JSON.stringify(name)}, mount() {}, teardown() {} };\n`;
13
+ }
14
+
15
+ async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> {
16
+ const dir = await mkdtemp(join(tmpdir(), "agentic-loader-"));
17
+ try {
18
+ await fn(dir);
19
+ } finally {
20
+ await rm(dir, { recursive: true, force: true });
21
+ }
22
+ }
23
+
24
+ test("discovers *.family.ts modules in sorted (deterministic) order", async () => {
25
+ await withTempDir(async (dir) => {
26
+ await writeFile(join(dir, "relay.family.ts"), familyModuleSource("relay"));
27
+ await writeFile(join(dir, "presence.family.ts"), familyModuleSource("presence"));
28
+ await writeFile(join(dir, "blackboard.family.ts"), familyModuleSource("blackboard"));
29
+ const families = await loadAgenticFamilies(dir, noopLog());
30
+ // Sorted by filename: blackboard.family.ts < presence.family.ts < relay.family.ts.
31
+ assertEquals(families.map((f) => f.name), ["blackboard", "presence", "relay"]);
32
+ });
33
+ });
34
+
35
+ test("ignores non-family files, test files, and READMEs", async () => {
36
+ await withTempDir(async (dir) => {
37
+ await writeFile(join(dir, "presence.family.ts"), familyModuleSource("presence"));
38
+ await writeFile(join(dir, "presence.family.test.ts"), "export const nope = 1;\n");
39
+ await writeFile(join(dir, "helper.ts"), "export const nope = 2;\n");
40
+ await writeFile(join(dir, "README.md"), "# families\n");
41
+ const families = await loadAgenticFamilies(dir, noopLog());
42
+ assertEquals(families.map((f) => f.name), ["presence"]);
43
+ });
44
+ });
45
+
46
+ test("a missing families directory yields no families (not an error)", async () => {
47
+ const families = await loadAgenticFamilies(join(tmpdir(), "does-not-exist-agentic-xyz"), noopLog());
48
+ assertEquals(families, []);
49
+ });
50
+
51
+ test("accepts a default export as well as a named `family` export", async () => {
52
+ await withTempDir(async (dir) => {
53
+ await writeFile(
54
+ join(dir, "def.family.ts"),
55
+ "export default { name: 'viaDefault', mount() {} };\n",
56
+ );
57
+ const families = await loadAgenticFamilies(dir, noopLog());
58
+ assertEquals(families.map((f) => f.name), ["viaDefault"]);
59
+ });
60
+ });
61
+
62
+ test("skips a module that exports no valid family, without crashing discovery", async () => {
63
+ await withTempDir(async (dir) => {
64
+ await writeFile(join(dir, "ok.family.ts"), familyModuleSource("ok"));
65
+ // No `family`/`default`; and a malformed one (missing mount).
66
+ await writeFile(join(dir, "empty.family.ts"), "export const something = 1;\n");
67
+ await writeFile(join(dir, "bad.family.ts"), "export const family = { name: 'bad' };\n");
68
+ const families = await loadAgenticFamilies(dir, noopLog());
69
+ assertEquals(families.map((f) => f.name), ["ok"]);
70
+ });
71
+ });
72
+
73
+ test("the real families/ directory discovers the copyable example no-op", async () => {
74
+ const families = await loadAgenticFamilies(undefined, noopLog());
75
+ assert(
76
+ families.some((f) => f.name === "example"),
77
+ "expected the shipped example family to be discovered",
78
+ );
79
+ });
@@ -0,0 +1,84 @@
1
+ // nano-workforce — the agentic family DISCOVERY loader (ADR 0056, H0 / #143).
2
+ //
3
+ // Siblings drop a `*.family.ts` module into `app/agentic/families/`; this loader finds it by
4
+ // convention and imports it. There is deliberately NO central registration array for siblings to
5
+ // append to (that would merely relocate the shared-file collision the plan review flagged): a family
6
+ // is discovered purely by living in the conventional directory with the conventional suffix.
7
+ //
8
+ // A discovered module contributes its family via a default export OR a named `family` export. Any
9
+ // `*.test.ts` file is ignored (test files never carry a family), and a module that exports no valid
10
+ // family is skipped with a warning rather than crashing boot.
11
+ import { readdir } from "node:fs/promises";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath, pathToFileURL } from "node:url";
14
+ import type { Logger } from "@nanobpm/urban";
15
+ import type { AgenticFamily } from "./registry.ts";
16
+
17
+ /** The conventional directory holding sibling family modules, resolved next to this loader. */
18
+ export const FAMILIES_DIR = join(dirname(fileURLToPath(import.meta.url)), "families");
19
+
20
+ /** The filename suffix a family module must carry to be discovered. */
21
+ const FAMILY_SUFFIX = ".family.ts";
22
+
23
+ /** Read a property off an unknown object without an unsafe cast. */
24
+ function prop(obj: object, key: string): unknown {
25
+ return Object.hasOwn(obj, key) ? Object.getOwnPropertyDescriptor(obj, key)?.value : undefined;
26
+ }
27
+
28
+ /** A type guard proving an unknown value structurally satisfies {@link AgenticFamily}. */
29
+ function isAgenticFamily(candidate: unknown): candidate is AgenticFamily {
30
+ if (!candidate || typeof candidate !== "object") return false;
31
+ const name = prop(candidate, "name");
32
+ if (typeof name !== "string" || name.trim() === "") return false;
33
+ if (typeof prop(candidate, "mount") !== "function") return false;
34
+ const teardown = prop(candidate, "teardown");
35
+ return teardown === undefined || typeof teardown === "function";
36
+ }
37
+
38
+ /** Structurally validate a discovered module's contribution as an {@link AgenticFamily}. */
39
+ function asFamily(mod: unknown): AgenticFamily | undefined {
40
+ if (!mod || typeof mod !== "object") return undefined;
41
+ const candidate = prop(mod, "family") ?? prop(mod, "default");
42
+ return isAgenticFamily(candidate) ? candidate : undefined;
43
+ }
44
+
45
+ /**
46
+ * Discover every family module under `dir` (default {@link FAMILIES_DIR}), imported in a stable
47
+ * (sorted-by-filename) order so mount/teardown order is deterministic across hosts. A missing
48
+ * directory yields no families (the epic's first slice ships before any sibling exists). A module
49
+ * that fails to import or exports no valid family is logged and skipped, never fatal to boot.
50
+ */
51
+ export async function loadAgenticFamilies(
52
+ dir: string = FAMILIES_DIR,
53
+ log?: Logger,
54
+ ): Promise<AgenticFamily[]> {
55
+ let entries: string[];
56
+ try {
57
+ entries = await readdir(dir);
58
+ } catch (err) {
59
+ // A missing families directory is the expected steady state before any sibling lands.
60
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return [];
61
+ throw err;
62
+ }
63
+ const files = entries
64
+ .filter((name) => name.endsWith(FAMILY_SUFFIX))
65
+ .sort();
66
+ const families: AgenticFamily[] = [];
67
+ for (const name of files) {
68
+ const href = pathToFileURL(join(dir, name)).href;
69
+ let mod: unknown;
70
+ try {
71
+ mod = await import(href);
72
+ } catch (err) {
73
+ log?.error("agentic family module failed to import", { file: name, err: String(err) });
74
+ continue;
75
+ }
76
+ const family = asFamily(mod);
77
+ if (!family) {
78
+ log?.warn("agentic family module exported no valid family; skipping", { file: name });
79
+ continue;
80
+ }
81
+ families.push(family);
82
+ }
83
+ return families;
84
+ }