@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,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/`), **shallow, one level deep**:
8
- * the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
9
- * never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
10
- * deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
11
- * descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
12
- * top-level files. Returns root-prefixed paths, sorted for determinism.
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/`), **shallow, one level deep**:
54
- * the files directly under `dir` plus the files one sub-directory down (`dir/<subdir>/*`), but
55
- * never deeper. The shallow bound is deliberate — deploy dedupes by basename (filename only), so a
56
- * deep recursive walk would reintroduce cross-directory basename-collision risk. Sub-directory
57
- * descent needs `host.listSubdirs`; a host that can't enumerate directories yields only the
58
- * top-level files. Returns root-prefixed paths, sorted for determinism.
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
- for (const name of await host.listDir(base))
64
- out.push(joinPath(base, name));
65
- const subdirs = host.listSubdirs ? await host.listSubdirs(base) : [];
66
- for (const sub of subdirs.slice().sort()) {
67
- const subBase = joinPath(base, sub);
68
- for (const name of await host.listDir(subBase))
69
- out.push(joinPath(subBase, name));
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). Returns the number deployed. */
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 one level deep when no `models` are declared. */
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 processes, decisions and forms.
5
+ * Deploy the app's resources.
6
6
  *
7
- * With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
8
- * one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
9
- * hard error in either mode.
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/` — shallow, one level deep. `resources/` is deploy-only:
6
- // everything under it (and nothing outside it) is deployed. Docs (`docs/`, `AGENTS.md`,
7
- // top-level `*.md`) live outside `resources/` and are never swept in.
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
- // Either way the deploy dedupe key is the resource's basename (filename only), so a basename
12
- // collision — two files with the same name in different directories — would silently clobber one
13
- // resource with another at the engine. We detect that up front and fail loudly instead.
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 one level deep when no `models` are declared. */
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
- /** Fail loudly when two resolved files share a basename (the deploy dedupe key), naming the
31
- * colliding paths so the author can rename or relocate one. */
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 processes, decisions and forms.
92
+ * Deploy the app's resources.
53
93
  *
54
- * With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
55
- * one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
56
- * hard error in either mode.
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
- assertNoBasenameCollisions(files);
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
  })));