@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.
- 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 +666 -35
- 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 +396 -5
- package/src/runtime/core/modules/pages.ts +667 -35
- 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,131 @@
|
|
|
1
|
+
import type { AppApi, Mounted } from "./context.ts";
|
|
2
|
+
import type { Logger } from "./logger.ts";
|
|
3
|
+
import { EventBus, type Disposer, type DispatchMode, type EmitChannel, type ParallelChannel, type SerialChannel, type WaterfallChannel } from "./events.ts";
|
|
4
|
+
export declare const URBAN_EVENT_MODES: {
|
|
5
|
+
/** Boot / start / stop notifications — observers never change the outcome. */
|
|
6
|
+
readonly lifecycle: "emit";
|
|
7
|
+
/** The ordered extension-registration checkpoint (this module's first consumer). */
|
|
8
|
+
readonly "extension/register": "serial";
|
|
9
|
+
/** Request / action dispatch — around-middleware for transform / short-circuit. */
|
|
10
|
+
readonly "request/dispatch": "waterfall";
|
|
11
|
+
/** Security / permission gate — a listener may short-circuit with a deny. */
|
|
12
|
+
readonly "security/gate": "waterfall";
|
|
13
|
+
/** Instance-tracking reconcile — fan-out where every listener gets its chance. */
|
|
14
|
+
readonly reconcile: "parallel";
|
|
15
|
+
};
|
|
16
|
+
export type UrbanEventName = keyof typeof URBAN_EVENT_MODES;
|
|
17
|
+
/** A boot/start/stop lifecycle notification (the `lifecycle` emit seam). */
|
|
18
|
+
export interface LifecycleEvent {
|
|
19
|
+
readonly app: string;
|
|
20
|
+
readonly phase: "starting" | "started" | "stopping" | "stopped";
|
|
21
|
+
}
|
|
22
|
+
/** The payload handed to each extension as it registers (the `extension/register`
|
|
23
|
+
* serial seam): the just-registered extension plus its setup context. */
|
|
24
|
+
export interface ExtensionRegistered {
|
|
25
|
+
readonly extension: string;
|
|
26
|
+
readonly context: ExtensionSetupContext;
|
|
27
|
+
}
|
|
28
|
+
/** A request/action flowing through the `request/dispatch` waterfall seam. An
|
|
29
|
+
* extension middleware may transform it, short-circuit with a response, or
|
|
30
|
+
* recover a downstream throw. Kept transport-agnostic on purpose. */
|
|
31
|
+
export interface DispatchRequest {
|
|
32
|
+
readonly kind: string;
|
|
33
|
+
readonly payload: unknown;
|
|
34
|
+
}
|
|
35
|
+
export interface DispatchResponse {
|
|
36
|
+
readonly handled: boolean;
|
|
37
|
+
readonly payload: unknown;
|
|
38
|
+
}
|
|
39
|
+
/** A permission check flowing through the `security/gate` waterfall seam. A
|
|
40
|
+
* listener that denies short-circuits (never calls `next`). */
|
|
41
|
+
export interface GateRequest {
|
|
42
|
+
readonly subject: string;
|
|
43
|
+
readonly action: string;
|
|
44
|
+
readonly resource: string;
|
|
45
|
+
}
|
|
46
|
+
export interface GateDecision {
|
|
47
|
+
readonly allow: boolean;
|
|
48
|
+
readonly reason?: string;
|
|
49
|
+
}
|
|
50
|
+
/** A reconcile tick fanned out on the `reconcile` parallel seam. */
|
|
51
|
+
export interface ReconcileEvent {
|
|
52
|
+
readonly source: string;
|
|
53
|
+
readonly at: number;
|
|
54
|
+
}
|
|
55
|
+
/** The typed Urban extension-event taxonomy: one channel per seam, each already
|
|
56
|
+
* bound to its declared dispatch mode. Modules and extensions adopt seams by
|
|
57
|
+
* registering on these channels; the dispatch semantics come from the channel,
|
|
58
|
+
* not the call site. */
|
|
59
|
+
export interface UrbanEvents {
|
|
60
|
+
/** **emit** — lifecycle notifications. */
|
|
61
|
+
readonly lifecycle: EmitChannel<LifecycleEvent>;
|
|
62
|
+
/** **serial** — the ordered extension-registration checkpoint. */
|
|
63
|
+
readonly extensionRegister: SerialChannel<ExtensionRegistered>;
|
|
64
|
+
/** **waterfall** — request/action dispatch middleware. */
|
|
65
|
+
readonly requestDispatch: WaterfallChannel<DispatchRequest, DispatchResponse>;
|
|
66
|
+
/** **waterfall** — security/permission gate (short-circuit deny). */
|
|
67
|
+
readonly securityGate: WaterfallChannel<GateRequest, GateDecision>;
|
|
68
|
+
/** **parallel** — instance-tracking reconcile fan-out. */
|
|
69
|
+
readonly reconcile: ParallelChannel<ReconcileEvent>;
|
|
70
|
+
}
|
|
71
|
+
/** Declare the whole Urban taxonomy on a bus — each seam exactly once, with the
|
|
72
|
+
* mode `URBAN_EVENT_MODES` records for it. */
|
|
73
|
+
export declare function createUrbanEvents(bus: EventBus): UrbanEvents;
|
|
74
|
+
/** Assert (at construction) that the typed taxonomy and the documented mode table
|
|
75
|
+
* agree — a guard against the two drifting apart. */
|
|
76
|
+
export declare function urbanEventMode(name: UrbanEventName): DispatchMode;
|
|
77
|
+
/** What an extension's `setup()` gets: the app API, the typed taxonomy to hook
|
|
78
|
+
* onto, a structured logger, and `effect()` to hang any other disposable resource
|
|
79
|
+
* (a timer, a subscription) on the dispose ladder. */
|
|
80
|
+
export interface ExtensionSetupContext {
|
|
81
|
+
readonly app: AppApi;
|
|
82
|
+
readonly events: UrbanEvents;
|
|
83
|
+
readonly log: Logger;
|
|
84
|
+
/** Register a disposable effect (a timer, listener, subscription). It joins the
|
|
85
|
+
* dispose ladder, so `stop()` / HMR tears it down with everything else. Returns
|
|
86
|
+
* the idempotent disposer so an extension can clean up early — which also detaches
|
|
87
|
+
* the effect from the ladder, so `stop()` won't run it a second time. */
|
|
88
|
+
effect(dispose: Disposer): Disposer;
|
|
89
|
+
}
|
|
90
|
+
/** A pluggable Urban extension — the uniform shape an agentic family or a
|
|
91
|
+
* connector pack presents to the runtime. `setup()` wires the extension onto the
|
|
92
|
+
* taxonomy; anything it registers is an effect on the dispose ladder. */
|
|
93
|
+
export interface UrbanExtension {
|
|
94
|
+
readonly name: string;
|
|
95
|
+
/** Explicit ordering for the `extension/register` serial checkpoint; lower runs
|
|
96
|
+
* first. Extensions with equal (or absent) order keep registration order. */
|
|
97
|
+
readonly order?: number;
|
|
98
|
+
setup(context: ExtensionSetupContext): void | Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
export interface ExtensionHost extends Mounted {
|
|
101
|
+
/** The typed taxonomy this host mounted the extensions onto. */
|
|
102
|
+
readonly events: UrbanEvents;
|
|
103
|
+
/** Live registration count on the bus — proxies `EventBus.listenerCount`, so it
|
|
104
|
+
* counts every channel listener AND every `effect()` on the dispose ladder, not
|
|
105
|
+
* only channel listeners. 0 after a `stop()` that owns its bus. Use it for leak
|
|
106
|
+
* assertions. */
|
|
107
|
+
readonly listenerCount: number;
|
|
108
|
+
}
|
|
109
|
+
export interface MountExtensionsOptions {
|
|
110
|
+
/** Reports a contained extension throw (defaults to the app logger's `warn`).
|
|
111
|
+
* Only honoured when `mountExtensions` creates its *own* private bus (i.e. `bus`
|
|
112
|
+
* and `events` are omitted). When a shared `bus` is passed, that bus already owns
|
|
113
|
+
* its error sink and this option is ignored — set the sink on the shared bus. */
|
|
114
|
+
onError?: (err: unknown, info: {
|
|
115
|
+
event: string;
|
|
116
|
+
mode: DispatchMode;
|
|
117
|
+
}) => void;
|
|
118
|
+
/** A pre-built taxonomy/bus to mount onto (the runtime shares one app-wide). When
|
|
119
|
+
* omitted a private bus is created — handy for isolated tests. */
|
|
120
|
+
bus?: EventBus;
|
|
121
|
+
events?: UrbanEvents;
|
|
122
|
+
}
|
|
123
|
+
/** Run `extensions` through the taxonomy: deterministically order them, `setup()`
|
|
124
|
+
* each directly (a throwing extension is contained and never strands the others
|
|
125
|
+
* or app boot), then notify the `extension/register` serial checkpoint so
|
|
126
|
+
* observers react in a deterministic order, and return a `Mounted` whose `stop()`
|
|
127
|
+
* disposes the bus — unwinding the whole dispose ladder LIFO — only when
|
|
128
|
+
* `mountExtensions` created that bus; onto a shared app-wide bus, disposal is the
|
|
129
|
+
* caller's responsibility. So a start→stop→start cycle (or HMR reload) over an
|
|
130
|
+
* owned bus leaks no listeners. */
|
|
131
|
+
export declare function mountExtensions(api: AppApi, extensions: readonly UrbanExtension[], options?: MountExtensionsOptions): Promise<ExtensionHost>;
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
import { EventBus, } from "./events.js";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// The taxonomy — the documented set of typed Urban extension events.
|
|
20
|
+
//
|
|
21
|
+
// Each seam is a canonical event *name* paired with its deliberate *dispatch
|
|
22
|
+
// mode*. This object is the single source of truth for the modes; the ADR's
|
|
23
|
+
// "feature → mechanism map" and the typed `UrbanEvents` channels below are both
|
|
24
|
+
// checked against it — `createUrbanEvents` enumerates the seams by hand, but a
|
|
25
|
+
// drift-guard test asserts every channel's `.mode` equals `URBAN_EVENT_MODES`
|
|
26
|
+
// for its seam (and that coverage is exact), so the docs and the code cannot
|
|
27
|
+
// silently drift.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export const URBAN_EVENT_MODES = {
|
|
30
|
+
/** Boot / start / stop notifications — observers never change the outcome. */
|
|
31
|
+
"lifecycle": "emit",
|
|
32
|
+
/** The ordered extension-registration checkpoint (this module's first consumer). */
|
|
33
|
+
"extension/register": "serial",
|
|
34
|
+
/** Request / action dispatch — around-middleware for transform / short-circuit. */
|
|
35
|
+
"request/dispatch": "waterfall",
|
|
36
|
+
/** Security / permission gate — a listener may short-circuit with a deny. */
|
|
37
|
+
"security/gate": "waterfall",
|
|
38
|
+
/** Instance-tracking reconcile — fan-out where every listener gets its chance. */
|
|
39
|
+
"reconcile": "parallel",
|
|
40
|
+
};
|
|
41
|
+
/** Declare the whole Urban taxonomy on a bus — each seam exactly once, with the
|
|
42
|
+
* mode `URBAN_EVENT_MODES` records for it. */
|
|
43
|
+
export function createUrbanEvents(bus) {
|
|
44
|
+
return {
|
|
45
|
+
lifecycle: bus.emit("lifecycle"),
|
|
46
|
+
extensionRegister: bus.serial("extension/register"),
|
|
47
|
+
requestDispatch: bus.waterfall("request/dispatch"),
|
|
48
|
+
securityGate: bus.waterfall("security/gate"),
|
|
49
|
+
reconcile: bus.parallel("reconcile"),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Assert (at construction) that the typed taxonomy and the documented mode table
|
|
53
|
+
* agree — a guard against the two drifting apart. */
|
|
54
|
+
export function urbanEventMode(name) {
|
|
55
|
+
return URBAN_EVENT_MODES[name];
|
|
56
|
+
}
|
|
57
|
+
/** Run `extensions` through the taxonomy: deterministically order them, `setup()`
|
|
58
|
+
* each directly (a throwing extension is contained and never strands the others
|
|
59
|
+
* or app boot), then notify the `extension/register` serial checkpoint so
|
|
60
|
+
* observers react in a deterministic order, and return a `Mounted` whose `stop()`
|
|
61
|
+
* disposes the bus — unwinding the whole dispose ladder LIFO — only when
|
|
62
|
+
* `mountExtensions` created that bus; onto a shared app-wide bus, disposal is the
|
|
63
|
+
* caller's responsibility. So a start→stop→start cycle (or HMR reload) over an
|
|
64
|
+
* owned bus leaks no listeners. */
|
|
65
|
+
export async function mountExtensions(api, extensions, options = {}) {
|
|
66
|
+
const log = api.log.child({ module: "extensions" });
|
|
67
|
+
// Share a caller-owned bus+taxonomy or build a private pair — never a mismatched
|
|
68
|
+
// one. With only `events`, listeners would register on its bus while `effect()`
|
|
69
|
+
// and disposal target a *different* private bus (leaked registrations, and a
|
|
70
|
+
// reused bus could redeclare channels); with only `bus`, callers lose the typed
|
|
71
|
+
// taxonomy they meant to share. Require both-or-neither.
|
|
72
|
+
if ((options.bus === undefined) !== (options.events === undefined)) {
|
|
73
|
+
throw new Error("mountExtensions: `bus` and `events` must be provided together or both omitted");
|
|
74
|
+
}
|
|
75
|
+
// Ownership: a bus we created is ours to dispose; a shared app-wide bus belongs
|
|
76
|
+
// to the caller (the runtime disposes it in teardown). `stop()` honours this so
|
|
77
|
+
// it never unwinds registrations it did not make.
|
|
78
|
+
const ownsBus = options.bus === undefined;
|
|
79
|
+
const bus = options.bus ??
|
|
80
|
+
new EventBus({
|
|
81
|
+
onError: options.onError ??
|
|
82
|
+
((err, info) => log.warn("extension listener threw and was contained", { event: info.event, mode: info.mode, error: String(err) })),
|
|
83
|
+
});
|
|
84
|
+
const events = options.events ?? createUrbanEvents(bus);
|
|
85
|
+
// Deterministic order: primary by `order` (default 0), stable within ties by the
|
|
86
|
+
// order the host handed them to us. A sibling dropping a family module gets a
|
|
87
|
+
// predictable slot without editing anyone else's registration.
|
|
88
|
+
const ordered = extensions
|
|
89
|
+
.map((extension, index) => ({ extension, index }))
|
|
90
|
+
.sort((a, b) => (a.extension.order ?? 0) - (b.extension.order ?? 0) || a.index - b.index)
|
|
91
|
+
.map((entry) => entry.extension);
|
|
92
|
+
const context = {
|
|
93
|
+
app: api,
|
|
94
|
+
events,
|
|
95
|
+
log,
|
|
96
|
+
effect(dispose) {
|
|
97
|
+
return bus.effect(dispose);
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
for (const extension of ordered) {
|
|
101
|
+
// `setup()` is the checkpoint's unit of work, made atomic by a per-extension
|
|
102
|
+
// rollback scope: a throw is contained here so one bad extension never strands
|
|
103
|
+
// app boot or its siblings, AND its partial registrations are rolled back so a
|
|
104
|
+
// "failed" extension can't leave live listeners/effects that influence later
|
|
105
|
+
// dispatches or leak on teardown.
|
|
106
|
+
const rollback = bus.scope();
|
|
107
|
+
try {
|
|
108
|
+
await extension.setup(context);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
rollback();
|
|
112
|
+
log.warn("extension setup threw and was contained", { extension: extension.name, error: String(err) });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Notify the ordered `extension/register` serial checkpoint so observers react
|
|
116
|
+
// in a deterministic order (also contained, per `SerialChannel.run`).
|
|
117
|
+
await events.extensionRegister.run({ extension: extension.name, context });
|
|
118
|
+
log.info("extension mounted", { extension: extension.name });
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
name: "extensions",
|
|
122
|
+
events,
|
|
123
|
+
get listenerCount() {
|
|
124
|
+
return bus.listenerCount;
|
|
125
|
+
},
|
|
126
|
+
async stop() {
|
|
127
|
+
// Only dispose a bus we own. When the runtime shares its app-wide bus, tearing
|
|
128
|
+
// it down here would unwind unrelated core registrations — disposal is then the
|
|
129
|
+
// owner's job (runtime teardown calls `bus.dispose()`).
|
|
130
|
+
if (ownsBus)
|
|
131
|
+
bus.dispose();
|
|
132
|
+
},
|
|
133
|
+
describe() {
|
|
134
|
+
return { extensions: ordered.map((e) => e.name), listeners: bus.listenerCount };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -4,11 +4,12 @@ export declare function expandPattern(host: HostContext, root: string, pattern:
|
|
|
4
4
|
/** Expand many patterns, de-duplicating while preserving first-seen order. */
|
|
5
5
|
export declare function expandPatterns(host: HostContext, root: string, patterns: string[] | undefined): Promise<string[]>;
|
|
6
6
|
/**
|
|
7
|
-
* Discover deployables under a convention directory (`resources/`), **
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* Discover deployables under a convention directory (`resources/`), **recursively** — every file
|
|
8
|
+
* directly under `dir` and under every sub-directory at any depth (issue #231). Recursive descent
|
|
9
|
+
* is safe because deploy now keys a convention resource by its path *relative to* `resources/`
|
|
10
|
+
* (POSIX), not its bare basename, so two files sharing a filename in different sub-dirs
|
|
11
|
+
* (`a/x.md`, `b/x.md`) resolve to distinct `resourceId`s and can never collide. Descent needs
|
|
12
|
+
* `host.listSubdirs`; a host that can't enumerate directories yields only the top-level files.
|
|
13
|
+
* Returns root-prefixed paths, sorted for determinism.
|
|
13
14
|
*/
|
|
14
15
|
export declare function discoverResources(host: HostContext, root: string, dir: string): Promise<string[]>;
|
|
@@ -50,23 +50,25 @@ export async function expandPatterns(host, root, patterns) {
|
|
|
50
50
|
return out;
|
|
51
51
|
}
|
|
52
52
|
/**
|
|
53
|
-
* Discover deployables under a convention directory (`resources/`), **
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
53
|
+
* Discover deployables under a convention directory (`resources/`), **recursively** — every file
|
|
54
|
+
* directly under `dir` and under every sub-directory at any depth (issue #231). Recursive descent
|
|
55
|
+
* is safe because deploy now keys a convention resource by its path *relative to* `resources/`
|
|
56
|
+
* (POSIX), not its bare basename, so two files sharing a filename in different sub-dirs
|
|
57
|
+
* (`a/x.md`, `b/x.md`) resolve to distinct `resourceId`s and can never collide. Descent needs
|
|
58
|
+
* `host.listSubdirs`; a host that can't enumerate directories yields only the top-level files.
|
|
59
|
+
* Returns root-prefixed paths, sorted for determinism.
|
|
59
60
|
*/
|
|
60
61
|
export async function discoverResources(host, root, dir) {
|
|
61
62
|
const base = joinPath(root, dir);
|
|
62
63
|
const out = [];
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
64
|
+
const walk = async (current) => {
|
|
65
|
+
for (const name of await host.listDir(current))
|
|
66
|
+
out.push(joinPath(current, name));
|
|
67
|
+
const subdirs = host.listSubdirs ? await host.listSubdirs(current) : [];
|
|
68
|
+
for (const sub of subdirs.slice().sort()) {
|
|
69
|
+
await walk(joinPath(current, sub));
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
await walk(base);
|
|
71
73
|
return out.sort();
|
|
72
74
|
}
|
|
@@ -233,7 +233,10 @@ export interface FormSchema {
|
|
|
233
233
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
234
234
|
*/
|
|
235
235
|
export interface EngineClient {
|
|
236
|
-
/** Deploy model resources (BPMN/DMN/forms)
|
|
236
|
+
/** Deploy model resources (BPMN/DMN/forms) and generic resources (prompts, scripts, and other
|
|
237
|
+
* arbitrary files, content-typed e.g. `text/markdown`/`application/octet-stream`; the engine
|
|
238
|
+
* versions each generic resource per its `resourceId` — carried on `name`). Returns the number
|
|
239
|
+
* deployed. */
|
|
237
240
|
deployResources(resources: {
|
|
238
241
|
name: string;
|
|
239
242
|
content: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { RuntimeContext } from "../context.ts";
|
|
2
|
+
import { type Route } from "../router.ts";
|
|
3
|
+
export interface AgentHandle {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
routes: Route[];
|
|
6
|
+
describe(): Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Mount the `/app/agent` (+ `/app/agent.json`) institutional-memory brief routes. Reads the
|
|
10
|
+
* `urban gen`-derived artifacts through the host file port, resolving each under `ctx.root` via
|
|
11
|
+
* the shared `resolveAppPath` (like every other runtime module) so serving never depends on the
|
|
12
|
+
* host's current working directory. A genuinely absent artifact — checked with `host.exists`
|
|
13
|
+
* rather than a blanket catch — yields a 404 (gen never ran, or the app has no models); any other
|
|
14
|
+
* read failure (permissions, transient I/O) is left to surface as a 500 rather than being masked
|
|
15
|
+
* as "not generated".
|
|
16
|
+
*/
|
|
17
|
+
export declare function mountAgent(ctx: RuntimeContext): AgentHandle;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// agent — the app-scoped institutional-memory brief surface (ADR 0060 §2). The app-level mirror
|
|
2
|
+
// of the node `/agent` brief (ADR 0051): where the node brief describes the whole runtime, this
|
|
3
|
+
// serves ONE app's derived `system-brief.md` / `system-brief.json` so an agent (or human) opening
|
|
4
|
+
// work on the app starts grounded in the app's own system model — its processes, service-task call
|
|
5
|
+
// graph, decisions, and ownership.
|
|
6
|
+
//
|
|
7
|
+
// GET /app/agent → nano-generated/system-brief.md (text/markdown)
|
|
8
|
+
// GET /app/agent.json → nano-generated/system-brief.json (application/json)
|
|
9
|
+
//
|
|
10
|
+
// The brief is derived by `urban gen` (systemBriefDeriver, wired in gen.ts) into the app's
|
|
11
|
+
// `nano-generated/` dir, so this route is a thin read-through of that artifact — no re-derivation
|
|
12
|
+
// at request time (the derived file is the single source of truth, kept honest by
|
|
13
|
+
// `urban gen --check`). Mounted unconditionally: the brief is a property of the app's models, not
|
|
14
|
+
// of any opt-in surface, so it is always addressable when a brief has been generated. An app whose
|
|
15
|
+
// models produced no brief (never ran gen) returns 404.
|
|
16
|
+
import { GENERATED_DIR } from "../../../toolkit/artifact.js";
|
|
17
|
+
import { SYSTEM_BRIEF_JSON, SYSTEM_BRIEF_MD } from "../../../toolkit/derivers/system-brief.js";
|
|
18
|
+
import { json } from "../router.js";
|
|
19
|
+
import { resolveAppPath } from "./datasource.js";
|
|
20
|
+
/** The app-relative path of a generated brief artifact. */
|
|
21
|
+
const briefPath = (name) => `${GENERATED_DIR}/${name}`;
|
|
22
|
+
function markdown(body) {
|
|
23
|
+
return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Mount the `/app/agent` (+ `/app/agent.json`) institutional-memory brief routes. Reads the
|
|
27
|
+
* `urban gen`-derived artifacts through the host file port, resolving each under `ctx.root` via
|
|
28
|
+
* the shared `resolveAppPath` (like every other runtime module) so serving never depends on the
|
|
29
|
+
* host's current working directory. A genuinely absent artifact — checked with `host.exists`
|
|
30
|
+
* rather than a blanket catch — yields a 404 (gen never ran, or the app has no models); any other
|
|
31
|
+
* read failure (permissions, transient I/O) is left to surface as a 500 rather than being masked
|
|
32
|
+
* as "not generated".
|
|
33
|
+
*/
|
|
34
|
+
export function mountAgent(ctx) {
|
|
35
|
+
const artifactPath = (name) => resolveAppPath(ctx.root, briefPath(name));
|
|
36
|
+
const notGenerated = () => json({ error: "system brief not generated — run `urban gen`" }, 404);
|
|
37
|
+
const serve = async (name, toResponse) => {
|
|
38
|
+
const path = artifactPath(name);
|
|
39
|
+
if (!(await ctx.host.exists(path)))
|
|
40
|
+
return notGenerated();
|
|
41
|
+
return toResponse(await ctx.host.readTextFile(path));
|
|
42
|
+
};
|
|
43
|
+
const routes = [
|
|
44
|
+
{
|
|
45
|
+
method: "GET",
|
|
46
|
+
path: "/app/agent",
|
|
47
|
+
source: "surface:agent",
|
|
48
|
+
handler: () => serve(SYSTEM_BRIEF_MD, markdown),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
method: "GET",
|
|
52
|
+
path: "/app/agent.json",
|
|
53
|
+
source: "surface:agent",
|
|
54
|
+
handler: () => serve(SYSTEM_BRIEF_JSON, (body) => ({
|
|
55
|
+
status: 200,
|
|
56
|
+
headers: { "content-type": "application/json" },
|
|
57
|
+
body,
|
|
58
|
+
})),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
ctx.host.log("info", "agent brief surface mounted", { routes: routes.length });
|
|
62
|
+
return { name: "agent", routes, describe: () => ({ routes: routes.map((r) => r.path) }) };
|
|
63
|
+
}
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import type { RuntimeContext } from "../context.ts";
|
|
2
|
-
/** The convention directory: deploy-only, walked
|
|
2
|
+
/** The convention directory: deploy-only, walked recursively when no `models` are declared. */
|
|
3
3
|
export declare const RESOURCES_DIR = "resources";
|
|
4
4
|
/**
|
|
5
|
-
* Deploy the app's
|
|
5
|
+
* Deploy the app's resources.
|
|
6
6
|
*
|
|
7
|
-
* With no manifest `models`, deployables are discovered by convention under `resources/`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* With no manifest `models`, deployables are discovered by convention under `resources/`
|
|
8
|
+
* (recursively — every file at any depth) and each is keyed by its path relative to `resources/`
|
|
9
|
+
* (POSIX). With `models` globs declared, those are used verbatim and keyed by basename (a basename
|
|
10
|
+
* collision is a hard error). Content is deployed verbatim (no template substitution); each file is
|
|
11
|
+
* content-typed by extension so a generic resource (e.g. a prompt `.md`) deploys as a versioned
|
|
12
|
+
* generic resource the engine resolves via `zeebe:linkedResource bindingType:latest`.
|
|
10
13
|
*/
|
|
11
14
|
export declare function deployModels(ctx: RuntimeContext): Promise<{
|
|
12
15
|
deployed: number;
|
|
@@ -2,18 +2,42 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Resources are found by ONE of two paths:
|
|
4
4
|
// 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
|
|
5
|
-
// discovered by walking `resources/` —
|
|
6
|
-
// everything under it (and nothing outside it) is
|
|
7
|
-
// top-level `*.md`) live outside `resources/` and are
|
|
5
|
+
// discovered by walking `resources/` **recursively** — every file under it, at any depth
|
|
6
|
+
// (issue #231). `resources/` is deploy-only: everything under it (and nothing outside it) is
|
|
7
|
+
// deployed. Docs (`docs/`, `AGENTS.md`, top-level `*.md`) live outside `resources/` and are
|
|
8
|
+
// never swept in. A convention resource's deploy key (`resourceId`) is its path **relative to
|
|
9
|
+
// `resources/`** (POSIX-normalised), e.g. `resources/prompts/plan.md` → `prompts/plan.md`, so
|
|
10
|
+
// a linked service task references it with `zeebe:linkedResource resourceId="prompts/plan.md"`.
|
|
11
|
+
// The relative path (not the bare basename) preserves sub-directory structure and lets two
|
|
12
|
+
// files sharing a filename in different sub-dirs (`a/x.md`, `b/x.md`) deploy as distinct
|
|
13
|
+
// resources — there is no basename-collision hazard.
|
|
8
14
|
// 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
|
|
9
|
-
// convention walk is skipped) — the escape hatch for a non-convention layout.
|
|
15
|
+
// convention walk is skipped) — the escape hatch for a non-convention layout. Override
|
|
16
|
+
// resources are keyed by basename (filename only), so a basename collision across the declared
|
|
17
|
+
// globs is a hard error, detected up front.
|
|
10
18
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
19
|
+
// Content is deployed **verbatim** — there is no deploy-time `{{token}}` substitution (removed in
|
|
20
|
+
// ADR 0062) — so a generic resource (a prompt `.md`, an RPA/script file) is deployed as-is.
|
|
21
|
+
// The deploy pipeline is **UTF-8 text only**, end-to-end: `host.readTextFile()` yields a `string`
|
|
22
|
+
// and the engine's `deployResources` takes `content: string`, so there is no byte channel and
|
|
23
|
+
// arbitrary binary is out of scope by design. "Verbatim" therefore means the UTF-8 text is passed
|
|
24
|
+
// through unchanged; a non-UTF-8/binary file swept into `resources/` is not a supported input (see
|
|
25
|
+
// `contentTypeFor`'s `application/octet-stream` note).
|
|
14
26
|
import { discoverResources, expandPatterns } from "../glob.js";
|
|
15
|
-
/** The convention directory: deploy-only, walked
|
|
27
|
+
/** The convention directory: deploy-only, walked recursively when no `models` are declared. */
|
|
16
28
|
export const RESOURCES_DIR = "resources";
|
|
29
|
+
/**
|
|
30
|
+
* Classify a file into an engine content type by extension. Models get their real XML/JSON types;
|
|
31
|
+
* generic resources get a real text content type where the extension is known (`.md`, `.json`,
|
|
32
|
+
* `.txt`), falling back to `application/octet-stream` for anything unrecognised (so an unknown
|
|
33
|
+
* extension is never mislabelled as a specific text type). Note the deploy pipeline is UTF-8 text
|
|
34
|
+
* only (`readTextFile` → engine `content: string`): the `octet-stream` fallback is a conservative
|
|
35
|
+
* MIME label for an unrecognised *text* resource (e.g. a script with an uncommon extension), not a
|
|
36
|
+
* promise of binary fidelity — a genuinely binary file cannot be preserved byte-for-byte and is
|
|
37
|
+
* out of scope. The engine deploys a non-model content type as a generic resource
|
|
38
|
+
* (versioned per `resourceId`); a service task resolves it via
|
|
39
|
+
* `zeebe:linkedResource resourceId="<id>" bindingType:latest`.
|
|
40
|
+
*/
|
|
17
41
|
function contentTypeFor(path) {
|
|
18
42
|
if (path.endsWith(".bpmn"))
|
|
19
43
|
return "text/xml";
|
|
@@ -21,14 +45,30 @@ function contentTypeFor(path) {
|
|
|
21
45
|
return "text/xml";
|
|
22
46
|
if (path.endsWith(".form"))
|
|
23
47
|
return "application/json";
|
|
48
|
+
if (path.endsWith(".md"))
|
|
49
|
+
return "text/markdown";
|
|
50
|
+
if (path.endsWith(".json"))
|
|
51
|
+
return "application/json";
|
|
52
|
+
if (path.endsWith(".txt"))
|
|
53
|
+
return "text/plain";
|
|
24
54
|
return "application/octet-stream";
|
|
25
55
|
}
|
|
26
56
|
function baseName(path) {
|
|
27
57
|
const i = path.lastIndexOf("/");
|
|
28
58
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
29
59
|
}
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
60
|
+
/**
|
|
61
|
+
* A convention resource's `resourceId`: its path relative to `<root>/resources/` (POSIX). Given a
|
|
62
|
+
* root-prefixed discovered path (`/app/resources/prompts/plan.md`) it returns `prompts/plan.md`.
|
|
63
|
+
* Falls back to the basename if — defensively — the path is not under the resources prefix.
|
|
64
|
+
*/
|
|
65
|
+
function relativeResourceId(path, root) {
|
|
66
|
+
const prefix = `${root.replace(/\/+$/, "")}/${RESOURCES_DIR}/`;
|
|
67
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : baseName(path);
|
|
68
|
+
}
|
|
69
|
+
/** Fail loudly when two resolved files share a basename (the override dedupe key), naming the
|
|
70
|
+
* colliding paths so the author can rename or relocate one. Only relevant for the `models`
|
|
71
|
+
* override path; convention resources are keyed by relative path and cannot collide. */
|
|
32
72
|
function assertNoBasenameCollisions(files) {
|
|
33
73
|
const byName = new Map();
|
|
34
74
|
for (const path of files) {
|
|
@@ -49,11 +89,14 @@ function assertNoBasenameCollisions(files) {
|
|
|
49
89
|
`the same name would clobber each other. Rename or relocate one of:\n${detail}`);
|
|
50
90
|
}
|
|
51
91
|
/**
|
|
52
|
-
* Deploy the app's
|
|
92
|
+
* Deploy the app's resources.
|
|
53
93
|
*
|
|
54
|
-
* With no manifest `models`, deployables are discovered by convention under `resources/`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
94
|
+
* With no manifest `models`, deployables are discovered by convention under `resources/`
|
|
95
|
+
* (recursively — every file at any depth) and each is keyed by its path relative to `resources/`
|
|
96
|
+
* (POSIX). With `models` globs declared, those are used verbatim and keyed by basename (a basename
|
|
97
|
+
* collision is a hard error). Content is deployed verbatim (no template substitution); each file is
|
|
98
|
+
* content-typed by extension so a generic resource (e.g. a prompt `.md`) deploys as a versioned
|
|
99
|
+
* generic resource the engine resolves via `zeebe:linkedResource bindingType:latest`.
|
|
57
100
|
*/
|
|
58
101
|
export async function deployModels(ctx) {
|
|
59
102
|
const models = ctx.manifest.models;
|
|
@@ -79,9 +122,12 @@ export async function deployModels(ctx) {
|
|
|
79
122
|
}
|
|
80
123
|
return { deployed: 0, files: [] };
|
|
81
124
|
}
|
|
82
|
-
|
|
125
|
+
// Convention resources are keyed by their (unique) relative path; only the override path, whose
|
|
126
|
+
// basename key can collide across globs, needs the collision guard.
|
|
127
|
+
if (!byConvention)
|
|
128
|
+
assertNoBasenameCollisions(files);
|
|
83
129
|
const resources = await Promise.all(files.map(async (path) => ({
|
|
84
|
-
name: baseName(path),
|
|
130
|
+
name: byConvention ? relativeResourceId(path, ctx.root) : baseName(path),
|
|
85
131
|
content: await ctx.host.readTextFile(path),
|
|
86
132
|
contentType: contentTypeFor(path),
|
|
87
133
|
})));
|