@nanobpm/urban 0.52.0 → 0.53.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/README.md +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- package/dist/runtime/core/modules/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- package/src/runtime/core/modules/pages.test.ts +309 -5
- package/src/runtime/core/modules/pages.ts +398 -20
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/index.ts +57 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- 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
|
+
}
|