@nanobpm/urban 0.52.0 → 0.54.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.
Files changed (54) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/extensions.d.ts +131 -0
  5. package/dist/runtime/core/extensions.js +137 -0
  6. package/dist/runtime/core/glob.d.ts +7 -6
  7. package/dist/runtime/core/glob.js +16 -14
  8. package/dist/runtime/core/host.d.ts +4 -1
  9. package/dist/runtime/core/modules/agent.d.ts +17 -0
  10. package/dist/runtime/core/modules/agent.js +63 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  12. package/dist/runtime/core/modules/deploy.js +62 -16
  13. package/dist/runtime/core/modules/pages.js +666 -35
  14. package/dist/runtime/core/modules/surfaces.js +8 -0
  15. package/dist/runtime/core/phase.d.ts +168 -0
  16. package/dist/runtime/core/phase.js +367 -0
  17. package/dist/runtime/core/runtime.d.ts +17 -0
  18. package/dist/runtime/core/runtime.js +30 -0
  19. package/dist/runtime/index.d.ts +6 -0
  20. package/dist/runtime/index.js +7 -0
  21. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  22. package/dist/toolkit/derivers/system-brief.js +156 -0
  23. package/dist/toolkit/gen.d.ts +5 -5
  24. package/dist/toolkit/gen.js +20 -14
  25. package/dist/toolkit/index.d.ts +5 -0
  26. package/dist/toolkit/index.js +3 -0
  27. package/dist/toolkit/models.d.ts +4 -3
  28. package/dist/toolkit/models.js +4 -3
  29. package/package.json +1 -1
  30. package/src/runtime/core/events.test.ts +382 -0
  31. package/src/runtime/core/events.ts +370 -0
  32. package/src/runtime/core/extensions.test.ts +378 -0
  33. package/src/runtime/core/extensions.ts +276 -0
  34. package/src/runtime/core/glob.ts +15 -12
  35. package/src/runtime/core/host.ts +4 -1
  36. package/src/runtime/core/modules/agent.test.ts +104 -0
  37. package/src/runtime/core/modules/agent.ts +76 -0
  38. package/src/runtime/core/modules/deploy.test.ts +84 -16
  39. package/src/runtime/core/modules/deploy.ts +59 -16
  40. package/src/runtime/core/modules/pages.test.ts +396 -5
  41. package/src/runtime/core/modules/pages.ts +667 -35
  42. package/src/runtime/core/modules/surfaces.ts +9 -0
  43. package/src/runtime/core/phase.test.ts +350 -0
  44. package/src/runtime/core/phase.ts +526 -0
  45. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  46. package/src/runtime/core/runtime.ts +49 -0
  47. package/src/runtime/devserver.test.ts +3 -0
  48. package/src/runtime/index.ts +57 -0
  49. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  50. package/src/toolkit/derivers/system-brief.ts +210 -0
  51. package/src/toolkit/gen.test.ts +28 -4
  52. package/src/toolkit/gen.ts +20 -13
  53. package/src/toolkit/index.ts +14 -0
  54. package/src/toolkit/models.ts +4 -3
@@ -0,0 +1,378 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createLogger } from "./logger.ts";
4
+ import type { AppApi } from "./context.ts";
5
+ import {
6
+ URBAN_EVENT_MODES,
7
+ createUrbanEvents,
8
+ mountExtensions,
9
+ urbanEventMode,
10
+ type GateDecision,
11
+ type GateRequest,
12
+ type UrbanEventName,
13
+ type UrbanExtension,
14
+ } from "./extensions.ts";
15
+ import { EventBus } from "./events.ts";
16
+
17
+ // A minimal AppApi double — the extension host only reaches for `log` (and hands
18
+ // `app` through to extensions untouched). The typed-but-unused fields are filled
19
+ // via `JSON.parse` (returns `any`) per AGENTS.md, never an `as`-cast.
20
+ function unused<T>(): T {
21
+ return JSON.parse("null");
22
+ }
23
+
24
+ function fakeApi(): AppApi {
25
+ return {
26
+ manifest: unused(),
27
+ data: unused(),
28
+ engine: unused(),
29
+ env: () => undefined,
30
+ log: createLogger(() => {}),
31
+ };
32
+ }
33
+
34
+ test("taxonomy — the typed channels agree with the documented mode table", () => {
35
+ assert.equal(URBAN_EVENT_MODES.lifecycle, "emit");
36
+ assert.equal(URBAN_EVENT_MODES["extension/register"], "serial");
37
+ assert.equal(URBAN_EVENT_MODES["request/dispatch"], "waterfall");
38
+ assert.equal(URBAN_EVENT_MODES["security/gate"], "waterfall");
39
+ assert.equal(URBAN_EVENT_MODES.reconcile, "parallel");
40
+ assert.equal(urbanEventMode("security/gate"), "waterfall");
41
+ });
42
+
43
+ test("taxonomy — createUrbanEvents declares every seam exactly once on a bus", () => {
44
+ const bus = new EventBus();
45
+ const events = createUrbanEvents(bus);
46
+ assert.equal(events.lifecycle.mode, "emit");
47
+ assert.equal(events.extensionRegister.mode, "serial");
48
+ assert.equal(events.requestDispatch.mode, "waterfall");
49
+ assert.equal(events.securityGate.mode, "waterfall");
50
+ assert.equal(events.reconcile.mode, "parallel");
51
+ });
52
+
53
+ test("taxonomy — every typed channel derives its mode from URBAN_EVENT_MODES, with exact seam coverage (no drift)", () => {
54
+ const bus = new EventBus();
55
+ const events = createUrbanEvents(bus);
56
+ // Bind each typed channel to its canonical seam name, then assert the mode is
57
+ // read from URBAN_EVENT_MODES rather than hardcoded — so changing the table (or
58
+ // a channel's declared mode) without matching the other fails this guard.
59
+ const bindings: ReadonlyArray<[{ readonly event: string; readonly mode: string }, UrbanEventName]> = [
60
+ [events.lifecycle, "lifecycle"],
61
+ [events.extensionRegister, "extension/register"],
62
+ [events.requestDispatch, "request/dispatch"],
63
+ [events.securityGate, "security/gate"],
64
+ [events.reconcile, "reconcile"],
65
+ ];
66
+ for (const [channel, seam] of bindings) {
67
+ assert.equal(channel.event, seam, `channel declares seam "${channel.event}", expected "${seam}"`);
68
+ assert.equal(channel.mode, URBAN_EVENT_MODES[seam], `channel "${seam}" mode drifted from URBAN_EVENT_MODES`);
69
+ }
70
+ // Coverage is exact: every documented seam is bound to a channel and vice versa.
71
+ assert.deepEqual(new Set(bindings.map(([, seam]) => seam)), new Set(Object.keys(URBAN_EVENT_MODES)));
72
+ });
73
+
74
+ test("mountExtensions — runs the connector-pack / agentic-family surface through the taxonomy in a deterministic order", async () => {
75
+ const registered: string[] = [];
76
+ const mk = (name: string, order?: number): UrbanExtension => ({
77
+ name,
78
+ order,
79
+ setup(ctx) {
80
+ registered.push(name);
81
+ ctx.events.lifecycle.on(() => {});
82
+ },
83
+ });
84
+
85
+ // Hand them in a shuffled order; `order` (then registration order) decides.
86
+ const host = await mountExtensions(fakeApi(), [mk("slack-pack", 10), mk("agentic-family"), mk("cron-pack", 5)]);
87
+ assert.deepEqual(registered, ["agentic-family", "cron-pack", "slack-pack"]);
88
+ await host.stop();
89
+ });
90
+
91
+ test("composition — order + short-circuit via the security/gate waterfall, then disposal", async () => {
92
+ const audit: string[] = [];
93
+ // Two connector-pack-style extensions each contribute a permission middleware.
94
+ const allowAll: UrbanExtension = {
95
+ name: "allow-all",
96
+ order: 20,
97
+ setup(ctx) {
98
+ ctx.events.securityGate.on(async (req, next) => {
99
+ audit.push(`allow-all:${req.action}`);
100
+ return next(req);
101
+ });
102
+ },
103
+ };
104
+ const denyDeploy: UrbanExtension = {
105
+ name: "deny-deploy",
106
+ order: 10, // runs first — its short-circuit wins
107
+ setup(ctx) {
108
+ ctx.events.securityGate.on((req, next) => {
109
+ audit.push(`deny-deploy:${req.action}`);
110
+ if (req.action === "deploy") return { allow: false, reason: "deploy locked" };
111
+ // Not our concern — delegate down the chain so later gates still run.
112
+ return next(req);
113
+ });
114
+ },
115
+ };
116
+
117
+ // Private bus (no options): the host owns it, so `stop()` unwinds the whole ladder.
118
+ const host = await mountExtensions(fakeApi(), [allowAll, denyDeploy]);
119
+
120
+ const base = (_req: GateRequest): GateDecision => ({ allow: true });
121
+ const decision = await host.events.securityGate.run({ subject: "u", action: "deploy", resource: "models" }, base);
122
+
123
+ // deny-deploy ran first and short-circuited: allow-all never saw the request.
124
+ assert.deepEqual(decision, { allow: false, reason: "deploy locked" });
125
+ assert.deepEqual(audit, ["deny-deploy:deploy"]);
126
+
127
+ // A non-deploy request is *not* deny-deploy's concern: it delegates via `next`, so
128
+ // the downstream allow-all also runs. (Returning a bare decision instead of
129
+ // `next(req)` would short-circuit the waterfall and starve allow-all.)
130
+ audit.length = 0;
131
+ const allowed = await host.events.securityGate.run({ subject: "u", action: "read", resource: "models" }, base);
132
+ assert.deepEqual(allowed, { allow: true });
133
+ assert.deepEqual(audit, ["deny-deploy:read", "allow-all:read"]);
134
+
135
+ // Disposal: stop() unwinds every registration these extensions made.
136
+ assert.ok(host.listenerCount >= 2);
137
+ await host.stop();
138
+ assert.equal(host.listenerCount, 0);
139
+ });
140
+
141
+ test("containment — a throwing extension setup never strands app boot or its siblings", async () => {
142
+ const registered: string[] = [];
143
+ const good1: UrbanExtension = {
144
+ name: "good-1",
145
+ order: 1,
146
+ setup() {
147
+ registered.push("good-1");
148
+ },
149
+ };
150
+ const bad: UrbanExtension = {
151
+ name: "bad",
152
+ order: 2,
153
+ setup() {
154
+ throw new Error("plugin blew up during setup");
155
+ },
156
+ };
157
+ const good2: UrbanExtension = {
158
+ name: "good-2",
159
+ order: 3,
160
+ setup() {
161
+ registered.push("good-2");
162
+ },
163
+ };
164
+
165
+ // Must resolve (boot not stranded) and still mount the healthy siblings.
166
+ const host = await mountExtensions(fakeApi(), [good1, bad, good2]);
167
+ assert.deepEqual(registered, ["good-1", "good-2"]);
168
+ await host.stop();
169
+ });
170
+
171
+ test("containment — a throwing listener does not strand a request going through the taxonomy", async () => {
172
+ const throwing: UrbanExtension = {
173
+ name: "throws-on-dispatch",
174
+ setup(ctx) {
175
+ ctx.events.requestDispatch.on(() => {
176
+ throw new Error("bad middleware");
177
+ });
178
+ },
179
+ };
180
+ const host = await mountExtensions(fakeApi(), [throwing], { onError: () => {} });
181
+ const res = await host.events.requestDispatch.run({ kind: "http", payload: "ping" }, (req) => ({
182
+ handled: true,
183
+ payload: `pong:${String(req.payload)}`,
184
+ }));
185
+ assert.deepEqual(res, { handled: true, payload: "pong:ping" });
186
+ await host.stop();
187
+ });
188
+
189
+ test("onError — the option is ignored on a shared bus; the shared bus's own sink contains the throw", async () => {
190
+ const sharedErrors: string[] = [];
191
+ const optionErrors: string[] = [];
192
+ const bus = new EventBus({ onError: (_e, info) => sharedErrors.push(info.event) });
193
+ const events = createUrbanEvents(bus);
194
+ const throwing: UrbanExtension = {
195
+ name: "throws-on-lifecycle",
196
+ setup(ctx) {
197
+ ctx.events.lifecycle.on(() => {
198
+ throw new Error("bad listener");
199
+ });
200
+ },
201
+ };
202
+ const host = await mountExtensions(fakeApi(), [throwing], {
203
+ bus,
204
+ events,
205
+ onError: (_e, info) => optionErrors.push(info.event),
206
+ });
207
+ events.lifecycle.emit({ app: "test", phase: "starting" });
208
+ assert.deepEqual(optionErrors, [], "onError option must be ignored when a shared bus is passed");
209
+ assert.ok(sharedErrors.length > 0, "the shared bus's own error sink must contain the throw");
210
+ await host.stop();
211
+ });
212
+
213
+ test("HMR — start→stop→start leaks no listeners across cycles", async () => {
214
+ const ext: UrbanExtension = {
215
+ name: "leaky-candidate",
216
+ setup(ctx) {
217
+ ctx.events.lifecycle.on(() => {});
218
+ ctx.events.reconcile.on(() => {});
219
+ ctx.effect(() => {});
220
+ },
221
+ };
222
+
223
+ for (let cycle = 0; cycle < 3; cycle++) {
224
+ // Private bus per cycle: the host owns disposal, so stop() must fully unwind it.
225
+ const host = await mountExtensions(fakeApi(), [ext]);
226
+ assert.ok(host.listenerCount >= 3, `cycle ${cycle} registered listeners`);
227
+ await host.stop();
228
+ assert.equal(host.listenerCount, 0, `cycle ${cycle} disposed cleanly`);
229
+ }
230
+ });
231
+
232
+ test("ownership — stop() leaves a shared app-wide bus for its owner to dispose", async () => {
233
+ const bus = new EventBus();
234
+ const events = createUrbanEvents(bus);
235
+ // A core registration the owner (e.g. the runtime) makes outside the extension host.
236
+ let coreDisposed = false;
237
+ events.lifecycle.on(() => {});
238
+ bus.effect(() => {
239
+ coreDisposed = true;
240
+ });
241
+
242
+ const ext: UrbanExtension = {
243
+ name: "pack",
244
+ setup(ctx) {
245
+ ctx.events.reconcile.on(() => {});
246
+ },
247
+ };
248
+ const host = await mountExtensions(fakeApi(), [ext], { bus, events });
249
+
250
+ await host.stop();
251
+ // stop() does not own the shared bus, so it tears nothing down — the owner's core
252
+ // registration survives until the owner itself disposes the bus.
253
+ assert.equal(coreDisposed, false);
254
+ assert.ok(bus.listenerCount >= 2);
255
+
256
+ // The owner (runtime teardown) unwinds the whole ladder in one call.
257
+ bus.dispose();
258
+ assert.equal(coreDisposed, true);
259
+ assert.equal(bus.listenerCount, 0);
260
+ });
261
+
262
+ test("mountExtensions — rejects a bus/events pair that is only half-provided", async () => {
263
+ const bus = new EventBus();
264
+ const events = createUrbanEvents(bus);
265
+ await assert.rejects(() => mountExtensions(fakeApi(), [], { bus }), /provided together or both omitted/);
266
+ await assert.rejects(() => mountExtensions(fakeApi(), [], { events }), /provided together or both omitted/);
267
+ });
268
+
269
+ test("effect() — an extension's arbitrary resource is torn down on stop", async () => {
270
+ let timerCleared = false;
271
+ const ext: UrbanExtension = {
272
+ name: "poller",
273
+ setup(ctx) {
274
+ const timer = setInterval(() => {}, 1000);
275
+ ctx.effect(() => {
276
+ clearInterval(timer);
277
+ timerCleared = true;
278
+ });
279
+ },
280
+ };
281
+ const host = await mountExtensions(fakeApi(), [ext]);
282
+ try {
283
+ assert.equal(timerCleared, false);
284
+ } finally {
285
+ await host.stop();
286
+ }
287
+ assert.equal(timerCleared, true);
288
+ });
289
+
290
+ test("reconcile — parallel fan-out gives every extension's listener an independent chance", async () => {
291
+ const bus = new EventBus({ onError: () => {} });
292
+ const events = createUrbanEvents(bus);
293
+ const ticks: string[] = [];
294
+ const a: UrbanExtension = {
295
+ name: "reconciler-a",
296
+ setup(ctx) {
297
+ ctx.events.reconcile.on(async () => {
298
+ await Promise.resolve();
299
+ ticks.push("a");
300
+ });
301
+ },
302
+ };
303
+ const b: UrbanExtension = {
304
+ name: "reconciler-b",
305
+ setup(ctx) {
306
+ ctx.events.reconcile.on(() => {
307
+ throw new Error("b unhealthy");
308
+ });
309
+ },
310
+ };
311
+ const c: UrbanExtension = {
312
+ name: "reconciler-c",
313
+ setup(ctx) {
314
+ ctx.events.reconcile.on(async () => {
315
+ await Promise.resolve();
316
+ ticks.push("c");
317
+ });
318
+ },
319
+ };
320
+ const host = await mountExtensions(fakeApi(), [a, b, c], { bus, events });
321
+ await events.reconcile.run({ source: "test", at: 0 });
322
+ assert.deepEqual(ticks.sort(), ["a", "c"]);
323
+ await host.stop();
324
+ });
325
+
326
+ test("effect() — returns the idempotent disposer so an extension can detach early before stop", async () => {
327
+ let cleared = 0;
328
+ let earlyDisposer: (() => void) | undefined;
329
+ const ext: UrbanExtension = {
330
+ name: "early-detach",
331
+ setup(ctx) {
332
+ earlyDisposer = ctx.effect(() => {
333
+ cleared++;
334
+ });
335
+ },
336
+ };
337
+ const host = await mountExtensions(fakeApi(), [ext]);
338
+ // `effect()` must hand back the disposer so the author can clean up early AND
339
+ // remove the effect from the ladder so `stop()` won't run it a second time.
340
+ assert.equal(typeof earlyDisposer, "function");
341
+ earlyDisposer?.();
342
+ assert.equal(cleared, 1);
343
+ assert.equal(host.listenerCount, 0);
344
+ await host.stop();
345
+ assert.equal(cleared, 1, "already-detached effect must not run again on stop");
346
+ });
347
+
348
+ test("containment — a throwing setup rolls back its own partial registrations (atomic)", async () => {
349
+ const bus = new EventBus({ onError: () => {} });
350
+ const events = createUrbanEvents(bus);
351
+ const seen: string[] = [];
352
+ const partial: UrbanExtension = {
353
+ name: "registers-then-throws",
354
+ order: 1,
355
+ setup(ctx) {
356
+ ctx.events.lifecycle.on(() => seen.push("partial-listener"));
357
+ ctx.effect(() => {});
358
+ throw new Error("boom after partial registration");
359
+ },
360
+ };
361
+ const healthy: UrbanExtension = {
362
+ name: "healthy",
363
+ order: 2,
364
+ setup(ctx) {
365
+ ctx.events.lifecycle.on(() => seen.push("healthy-listener"));
366
+ },
367
+ };
368
+ const host = await mountExtensions(fakeApi(), [partial, healthy], { bus, events });
369
+ // The failed extension left nothing live: only the healthy listener remains.
370
+ assert.equal(host.listenerCount, 1);
371
+ // A contained-and-skipped extension must not influence later dispatches.
372
+ events.lifecycle.emit({ app: "x", phase: "started" });
373
+ assert.deepEqual(seen, ["healthy-listener"]);
374
+ await host.stop();
375
+ // Bus is caller-owned, so the owner disposes it; the healthy listener then unwinds.
376
+ bus.dispose();
377
+ assert.equal(bus.listenerCount, 0);
378
+ });
@@ -0,0 +1,276 @@
1
+ // extensions — the typed Urban **extension-event taxonomy** and the extension host
2
+ // that runs the agentic-family / connector-pack surface through it (issue #262).
3
+ //
4
+ // `runtime.ts` used to wire every module directly and let extensions reach for
5
+ // whatever they needed; ordering and failure-isolation were implicit. This module
6
+ // formalizes the extension seams as a small set of **typed channels**, each with a
7
+ // declared dispatch mode (see `events.ts`), and gives extensions a single, uniform
8
+ // way to plug in: a `UrbanExtension` whose `setup()` registers listeners and
9
+ // disposable effects onto the app's dispose ladder.
10
+ //
11
+ // The first concrete consumer is exactly the informal plugin surface Urban already
12
+ // stresses today: nwf's agentic families ("drop a family module under
13
+ // `app/agentic/families/`, never edit `main.ts`") and connector packs. Each becomes
14
+ // a `UrbanExtension`; the runtime runs them through the `extension/register` serial
15
+ // checkpoint in a deterministic order, and everything they register tears down on
16
+ // `stop()` / dev-server HMR because it rode the dispose ladder.
17
+
18
+ import type { AppApi, Mounted } from "./context.ts";
19
+ import type { Logger } from "./logger.ts";
20
+ import {
21
+ EventBus,
22
+ type Disposer,
23
+ type DispatchMode,
24
+ type EmitChannel,
25
+ type ParallelChannel,
26
+ type SerialChannel,
27
+ type WaterfallChannel,
28
+ } from "./events.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // The taxonomy — the documented set of typed Urban extension events.
32
+ //
33
+ // Each seam is a canonical event *name* paired with its deliberate *dispatch
34
+ // mode*. This object is the single source of truth for the modes; the ADR's
35
+ // "feature → mechanism map" and the typed `UrbanEvents` channels below are both
36
+ // checked against it — `createUrbanEvents` enumerates the seams by hand, but a
37
+ // drift-guard test asserts every channel's `.mode` equals `URBAN_EVENT_MODES`
38
+ // for its seam (and that coverage is exact), so the docs and the code cannot
39
+ // silently drift.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export const URBAN_EVENT_MODES = {
43
+ /** Boot / start / stop notifications — observers never change the outcome. */
44
+ "lifecycle": "emit",
45
+ /** The ordered extension-registration checkpoint (this module's first consumer). */
46
+ "extension/register": "serial",
47
+ /** Request / action dispatch — around-middleware for transform / short-circuit. */
48
+ "request/dispatch": "waterfall",
49
+ /** Security / permission gate — a listener may short-circuit with a deny. */
50
+ "security/gate": "waterfall",
51
+ /** Instance-tracking reconcile — fan-out where every listener gets its chance. */
52
+ "reconcile": "parallel",
53
+ } as const;
54
+
55
+ export type UrbanEventName = keyof typeof URBAN_EVENT_MODES;
56
+
57
+ /** A boot/start/stop lifecycle notification (the `lifecycle` emit seam). */
58
+ export interface LifecycleEvent {
59
+ readonly app: string;
60
+ readonly phase: "starting" | "started" | "stopping" | "stopped";
61
+ }
62
+
63
+ /** The payload handed to each extension as it registers (the `extension/register`
64
+ * serial seam): the just-registered extension plus its setup context. */
65
+ export interface ExtensionRegistered {
66
+ readonly extension: string;
67
+ readonly context: ExtensionSetupContext;
68
+ }
69
+
70
+ /** A request/action flowing through the `request/dispatch` waterfall seam. An
71
+ * extension middleware may transform it, short-circuit with a response, or
72
+ * recover a downstream throw. Kept transport-agnostic on purpose. */
73
+ export interface DispatchRequest {
74
+ readonly kind: string;
75
+ readonly payload: unknown;
76
+ }
77
+
78
+ export interface DispatchResponse {
79
+ readonly handled: boolean;
80
+ readonly payload: unknown;
81
+ }
82
+
83
+ /** A permission check flowing through the `security/gate` waterfall seam. A
84
+ * listener that denies short-circuits (never calls `next`). */
85
+ export interface GateRequest {
86
+ readonly subject: string;
87
+ readonly action: string;
88
+ readonly resource: string;
89
+ }
90
+
91
+ export interface GateDecision {
92
+ readonly allow: boolean;
93
+ readonly reason?: string;
94
+ }
95
+
96
+ /** A reconcile tick fanned out on the `reconcile` parallel seam. */
97
+ export interface ReconcileEvent {
98
+ readonly source: string;
99
+ readonly at: number;
100
+ }
101
+
102
+ /** The typed Urban extension-event taxonomy: one channel per seam, each already
103
+ * bound to its declared dispatch mode. Modules and extensions adopt seams by
104
+ * registering on these channels; the dispatch semantics come from the channel,
105
+ * not the call site. */
106
+ export interface UrbanEvents {
107
+ /** **emit** — lifecycle notifications. */
108
+ readonly lifecycle: EmitChannel<LifecycleEvent>;
109
+ /** **serial** — the ordered extension-registration checkpoint. */
110
+ readonly extensionRegister: SerialChannel<ExtensionRegistered>;
111
+ /** **waterfall** — request/action dispatch middleware. */
112
+ readonly requestDispatch: WaterfallChannel<DispatchRequest, DispatchResponse>;
113
+ /** **waterfall** — security/permission gate (short-circuit deny). */
114
+ readonly securityGate: WaterfallChannel<GateRequest, GateDecision>;
115
+ /** **parallel** — instance-tracking reconcile fan-out. */
116
+ readonly reconcile: ParallelChannel<ReconcileEvent>;
117
+ }
118
+
119
+ /** Declare the whole Urban taxonomy on a bus — each seam exactly once, with the
120
+ * mode `URBAN_EVENT_MODES` records for it. */
121
+ export function createUrbanEvents(bus: EventBus): UrbanEvents {
122
+ return {
123
+ lifecycle: bus.emit<LifecycleEvent>("lifecycle"),
124
+ extensionRegister: bus.serial<ExtensionRegistered>("extension/register"),
125
+ requestDispatch: bus.waterfall<DispatchRequest, DispatchResponse>("request/dispatch"),
126
+ securityGate: bus.waterfall<GateRequest, GateDecision>("security/gate"),
127
+ reconcile: bus.parallel<ReconcileEvent>("reconcile"),
128
+ };
129
+ }
130
+
131
+ /** Assert (at construction) that the typed taxonomy and the documented mode table
132
+ * agree — a guard against the two drifting apart. */
133
+ export function urbanEventMode(name: UrbanEventName): DispatchMode {
134
+ return URBAN_EVENT_MODES[name];
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // The extension host — the first consumer of the taxonomy.
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /** What an extension's `setup()` gets: the app API, the typed taxonomy to hook
142
+ * onto, a structured logger, and `effect()` to hang any other disposable resource
143
+ * (a timer, a subscription) on the dispose ladder. */
144
+ export interface ExtensionSetupContext {
145
+ readonly app: AppApi;
146
+ readonly events: UrbanEvents;
147
+ readonly log: Logger;
148
+ /** Register a disposable effect (a timer, listener, subscription). It joins the
149
+ * dispose ladder, so `stop()` / HMR tears it down with everything else. Returns
150
+ * the idempotent disposer so an extension can clean up early — which also detaches
151
+ * the effect from the ladder, so `stop()` won't run it a second time. */
152
+ effect(dispose: Disposer): Disposer;
153
+ }
154
+
155
+ /** A pluggable Urban extension — the uniform shape an agentic family or a
156
+ * connector pack presents to the runtime. `setup()` wires the extension onto the
157
+ * taxonomy; anything it registers is an effect on the dispose ladder. */
158
+ export interface UrbanExtension {
159
+ readonly name: string;
160
+ /** Explicit ordering for the `extension/register` serial checkpoint; lower runs
161
+ * first. Extensions with equal (or absent) order keep registration order. */
162
+ readonly order?: number;
163
+ setup(context: ExtensionSetupContext): void | Promise<void>;
164
+ }
165
+
166
+ export interface ExtensionHost extends Mounted {
167
+ /** The typed taxonomy this host mounted the extensions onto. */
168
+ readonly events: UrbanEvents;
169
+ /** Live registration count on the bus — proxies `EventBus.listenerCount`, so it
170
+ * counts every channel listener AND every `effect()` on the dispose ladder, not
171
+ * only channel listeners. 0 after a `stop()` that owns its bus. Use it for leak
172
+ * assertions. */
173
+ readonly listenerCount: number;
174
+ }
175
+
176
+ export interface MountExtensionsOptions {
177
+ /** Reports a contained extension throw (defaults to the app logger's `warn`).
178
+ * Only honoured when `mountExtensions` creates its *own* private bus (i.e. `bus`
179
+ * and `events` are omitted). When a shared `bus` is passed, that bus already owns
180
+ * its error sink and this option is ignored — set the sink on the shared bus. */
181
+ onError?: (err: unknown, info: { event: string; mode: DispatchMode }) => void;
182
+ /** A pre-built taxonomy/bus to mount onto (the runtime shares one app-wide). When
183
+ * omitted a private bus is created — handy for isolated tests. */
184
+ bus?: EventBus;
185
+ events?: UrbanEvents;
186
+ }
187
+
188
+ /** Run `extensions` through the taxonomy: deterministically order them, `setup()`
189
+ * each directly (a throwing extension is contained and never strands the others
190
+ * or app boot), then notify the `extension/register` serial checkpoint so
191
+ * observers react in a deterministic order, and return a `Mounted` whose `stop()`
192
+ * disposes the bus — unwinding the whole dispose ladder LIFO — only when
193
+ * `mountExtensions` created that bus; onto a shared app-wide bus, disposal is the
194
+ * caller's responsibility. So a start→stop→start cycle (or HMR reload) over an
195
+ * owned bus leaks no listeners. */
196
+ export async function mountExtensions(
197
+ api: AppApi,
198
+ extensions: readonly UrbanExtension[],
199
+ options: MountExtensionsOptions = {},
200
+ ): Promise<ExtensionHost> {
201
+ const log = api.log.child({ module: "extensions" });
202
+ // Share a caller-owned bus+taxonomy or build a private pair — never a mismatched
203
+ // one. With only `events`, listeners would register on its bus while `effect()`
204
+ // and disposal target a *different* private bus (leaked registrations, and a
205
+ // reused bus could redeclare channels); with only `bus`, callers lose the typed
206
+ // taxonomy they meant to share. Require both-or-neither.
207
+ if ((options.bus === undefined) !== (options.events === undefined)) {
208
+ throw new Error("mountExtensions: `bus` and `events` must be provided together or both omitted");
209
+ }
210
+ // Ownership: a bus we created is ours to dispose; a shared app-wide bus belongs
211
+ // to the caller (the runtime disposes it in teardown). `stop()` honours this so
212
+ // it never unwinds registrations it did not make.
213
+ const ownsBus = options.bus === undefined;
214
+ const bus =
215
+ options.bus ??
216
+ new EventBus({
217
+ onError:
218
+ options.onError ??
219
+ ((err, info) => log.warn("extension listener threw and was contained", { event: info.event, mode: info.mode, error: String(err) })),
220
+ });
221
+ const events = options.events ?? createUrbanEvents(bus);
222
+
223
+ // Deterministic order: primary by `order` (default 0), stable within ties by the
224
+ // order the host handed them to us. A sibling dropping a family module gets a
225
+ // predictable slot without editing anyone else's registration.
226
+ const ordered = extensions
227
+ .map((extension, index) => ({ extension, index }))
228
+ .sort((a, b) => (a.extension.order ?? 0) - (b.extension.order ?? 0) || a.index - b.index)
229
+ .map((entry) => entry.extension);
230
+
231
+ const context: ExtensionSetupContext = {
232
+ app: api,
233
+ events,
234
+ log,
235
+ effect(dispose) {
236
+ return bus.effect(dispose);
237
+ },
238
+ };
239
+
240
+ for (const extension of ordered) {
241
+ // `setup()` is the checkpoint's unit of work, made atomic by a per-extension
242
+ // rollback scope: a throw is contained here so one bad extension never strands
243
+ // app boot or its siblings, AND its partial registrations are rolled back so a
244
+ // "failed" extension can't leave live listeners/effects that influence later
245
+ // dispatches or leak on teardown.
246
+ const rollback = bus.scope();
247
+ try {
248
+ await extension.setup(context);
249
+ } catch (err) {
250
+ rollback();
251
+ log.warn("extension setup threw and was contained", { extension: extension.name, error: String(err) });
252
+ continue;
253
+ }
254
+ // Notify the ordered `extension/register` serial checkpoint so observers react
255
+ // in a deterministic order (also contained, per `SerialChannel.run`).
256
+ await events.extensionRegister.run({ extension: extension.name, context });
257
+ log.info("extension mounted", { extension: extension.name });
258
+ }
259
+
260
+ return {
261
+ name: "extensions",
262
+ events,
263
+ get listenerCount() {
264
+ return bus.listenerCount;
265
+ },
266
+ async stop() {
267
+ // Only dispose a bus we own. When the runtime shares its app-wide bus, tearing
268
+ // it down here would unwind unrelated core registrations — disposal is then the
269
+ // owner's job (runtime teardown calls `bus.dispose()`).
270
+ if (ownsBus) bus.dispose();
271
+ },
272
+ describe() {
273
+ return { extensions: ordered.map((e) => e.name), listeners: bus.listenerCount };
274
+ },
275
+ };
276
+ }