@nanobpm/urban 0.51.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.
Files changed (73) 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/execContext.d.ts +5 -0
  5. package/dist/runtime/core/extensions.d.ts +131 -0
  6. package/dist/runtime/core/extensions.js +137 -0
  7. package/dist/runtime/core/glob.d.ts +7 -6
  8. package/dist/runtime/core/glob.js +16 -14
  9. package/dist/runtime/core/host.d.ts +4 -1
  10. package/dist/runtime/core/lineage.d.ts +116 -0
  11. package/dist/runtime/core/lineage.js +186 -0
  12. package/dist/runtime/core/modules/agent.d.ts +17 -0
  13. package/dist/runtime/core/modules/agent.js +63 -0
  14. package/dist/runtime/core/modules/datasource.d.ts +6 -0
  15. package/dist/runtime/core/modules/datasource.js +8 -0
  16. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  17. package/dist/runtime/core/modules/deploy.js +62 -16
  18. package/dist/runtime/core/modules/lineage-store.d.ts +81 -0
  19. package/dist/runtime/core/modules/lineage-store.js +175 -0
  20. package/dist/runtime/core/modules/pages.js +397 -20
  21. package/dist/runtime/core/modules/surfaces.js +8 -0
  22. package/dist/runtime/core/modules/workers.js +69 -2
  23. package/dist/runtime/core/phase.d.ts +168 -0
  24. package/dist/runtime/core/phase.js +367 -0
  25. package/dist/runtime/core/runtime.d.ts +17 -0
  26. package/dist/runtime/core/runtime.js +30 -0
  27. package/dist/runtime/engine/nanosdk.js +8 -2
  28. package/dist/runtime/index.d.ts +10 -0
  29. package/dist/runtime/index.js +12 -0
  30. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  31. package/dist/toolkit/derivers/system-brief.js +156 -0
  32. package/dist/toolkit/gen.d.ts +5 -5
  33. package/dist/toolkit/gen.js +20 -14
  34. package/dist/toolkit/index.d.ts +5 -0
  35. package/dist/toolkit/index.js +3 -0
  36. package/dist/toolkit/models.d.ts +4 -3
  37. package/dist/toolkit/models.js +4 -3
  38. package/package.json +1 -1
  39. package/src/runtime/core/events.test.ts +382 -0
  40. package/src/runtime/core/events.ts +370 -0
  41. package/src/runtime/core/execContext.ts +5 -0
  42. package/src/runtime/core/extensions.test.ts +378 -0
  43. package/src/runtime/core/extensions.ts +276 -0
  44. package/src/runtime/core/glob.ts +15 -12
  45. package/src/runtime/core/host.ts +4 -1
  46. package/src/runtime/core/lineage.test.ts +215 -0
  47. package/src/runtime/core/lineage.ts +297 -0
  48. package/src/runtime/core/modules/agent.test.ts +104 -0
  49. package/src/runtime/core/modules/agent.ts +76 -0
  50. package/src/runtime/core/modules/datasource.ts +9 -0
  51. package/src/runtime/core/modules/deploy.test.ts +84 -16
  52. package/src/runtime/core/modules/deploy.ts +59 -16
  53. package/src/runtime/core/modules/lineage-store.test.ts +177 -0
  54. package/src/runtime/core/modules/lineage-store.ts +239 -0
  55. package/src/runtime/core/modules/pages.test.ts +309 -5
  56. package/src/runtime/core/modules/pages.ts +398 -20
  57. package/src/runtime/core/modules/surfaces.ts +9 -0
  58. package/src/runtime/core/modules/workers.test.ts +136 -1
  59. package/src/runtime/core/modules/workers.ts +72 -6
  60. package/src/runtime/core/phase.test.ts +350 -0
  61. package/src/runtime/core/phase.ts +526 -0
  62. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  63. package/src/runtime/core/runtime.ts +49 -0
  64. package/src/runtime/devserver.test.ts +3 -0
  65. package/src/runtime/engine/nanosdk.test.ts +97 -3
  66. package/src/runtime/engine/nanosdk.ts +8 -2
  67. package/src/runtime/index.ts +92 -0
  68. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  69. package/src/toolkit/derivers/system-brief.ts +210 -0
  70. package/src/toolkit/gen.test.ts +28 -4
  71. package/src/toolkit/gen.ts +20 -13
  72. package/src/toolkit/index.ts +14 -0
  73. 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,116 @@
1
+ import { type JobExecContext } from "./execContext.ts";
2
+ /** The reserved variable namespace the lineage envelope lives under on every
3
+ * instance/message (`_urban.lineage`). Kept as one object so future framework
4
+ * conventions can share the `_urban.*` namespace without colliding. */
5
+ export declare const LINEAGE_NAMESPACE = "_urban";
6
+ /** The key of the lineage envelope within {@link LINEAGE_NAMESPACE}. */
7
+ export declare const LINEAGE_KEY = "lineage";
8
+ /**
9
+ * The lineage envelope, a reserved convention on every instance/message:
10
+ * `_urban.lineage = { rootRequestKey, causedByInstanceKey? }`.
11
+ * - `rootRequestKey` threads every descendant of a single top-level request back to
12
+ * it; it is PRESERVED verbatim across the whole causal tree.
13
+ * - `causedByInstanceKey` is the instance that directly caused this one (the weak
14
+ * parent edge). Absent on a genuine top-level request (a fresh root).
15
+ */
16
+ export interface LineageEnvelope {
17
+ readonly rootRequestKey: string;
18
+ readonly causedByInstanceKey?: string;
19
+ }
20
+ /** Options for {@link applyAmbientLineage}, injectable for deterministic tests. */
21
+ export interface ApplyLineageOptions {
22
+ /** Override the ambient job context (defaults to {@link currentJobContext}). */
23
+ readonly ambient?: JobExecContext | undefined;
24
+ /** Override the fresh-root minter (defaults to {@link mintRootRequestKey}). */
25
+ readonly mintRootRequestKey?: () => string;
26
+ }
27
+ /** Mint a fresh `rootRequestKey` for a genuine top-level request (no ambient lineage).
28
+ * Uses `crypto.randomUUID` where available (Node ≥ 16.7 / Deno expose it as a global),
29
+ * with a time+random fallback so this stays host-agnostic and never throws. */
30
+ export declare function mintRootRequestKey(): string;
31
+ /**
32
+ * Read the `_urban.lineage` envelope from a variables map, or `undefined` when absent or
33
+ * malformed (no/blank `rootRequestKey`). Tolerant by design: a bad envelope degrades to
34
+ * "no ambient lineage" rather than throwing on the hot instance-creation path.
35
+ */
36
+ export declare function readLineage(variables: unknown): LineageEnvelope | undefined;
37
+ /**
38
+ * Return a COPY of `variables` with `envelope` written under `_urban.lineage`, preserving
39
+ * any other `_urban.*` keys. Never mutates the input.
40
+ */
41
+ export declare function writeLineage(variables: Record<string, unknown> | undefined, envelope: LineageEnvelope): Record<string, unknown>;
42
+ /**
43
+ * Derive the envelope to attach to a NEW instance/message given the ambient job context.
44
+ * - Ambient lineage present → PROPAGATE it: preserve `rootRequestKey`, set
45
+ * `causedByInstanceKey` to the current instance (the cause).
46
+ * - Ambient job with an instance but no envelope → that instance is itself a root; the
47
+ * child inherits `rootRequestKey = <ambient instance>` and is caused by it.
48
+ * - No ambient job at all (a genuine top-level request) → MINT a fresh `rootRequestKey`.
49
+ */
50
+ export declare function deriveLineage(ambient: JobExecContext | undefined, mint?: () => string): LineageEnvelope;
51
+ /**
52
+ * The single, adapter-agnostic step that auto-threads lineage onto a `createInstance` /
53
+ * `publishMessage` call's variables. Both the live `SdkEngineClient` and the testkit's
54
+ * `WasmEngineClient` route through this (No Drift Surfaces), so lineage threads identically
55
+ * in production and in-harness.
56
+ *
57
+ * An EXPLICIT caller-supplied `_urban.lineage` always wins (start a new root or re-parent
58
+ * deliberately) — it is returned untouched. Otherwise the envelope is derived from the
59
+ * ambient job context ({@link deriveLineage}) and merged in.
60
+ */
61
+ export declare function applyAmbientLineage(variables: Record<string, unknown> | undefined, opts?: ApplyLineageOptions): Record<string, unknown>;
62
+ /** How a node connects to its cause. `weak` = the causal envelope edge; `strong` = the
63
+ * engine-native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). */
64
+ export type LineageEdgeType = "weak" | "strong";
65
+ /** One directed edge cause → effect in a lineage tree. `causedByInstanceKey` is absent on a
66
+ * root's own edge. */
67
+ export interface LineageEdge {
68
+ readonly rootRequestKey: string;
69
+ /** The effect / descendant instance this edge lands on. */
70
+ readonly instanceKey: string;
71
+ /** The cause / parent instance, or `undefined` for a root. */
72
+ readonly causedByInstanceKey?: string;
73
+ readonly edgeType: LineageEdgeType;
74
+ }
75
+ /** An app-registered domain row attached to a lineage node (a PR, a task, …). The framework
76
+ * stores it opaquely by `(nodeKey, kind, ref)` without knowing the app's schema. */
77
+ export interface LineageAttachment {
78
+ /** The instance key this row hangs off. */
79
+ readonly nodeKey: string;
80
+ /** App-defined category, e.g. `"pull_request"`, `"task"`. */
81
+ readonly kind: string;
82
+ /** App-defined reference into its own store, e.g. a PR key. */
83
+ readonly ref: string;
84
+ /** Optional human-readable label. */
85
+ readonly label?: string;
86
+ }
87
+ /** A node in the stitched lineage tree. */
88
+ export interface LineageNode {
89
+ readonly instanceKey: string;
90
+ /** How this node connects to its parent; `undefined` for the root. */
91
+ readonly edgeType?: LineageEdgeType;
92
+ readonly causedByInstanceKey?: string;
93
+ readonly attachments: LineageAttachment[];
94
+ readonly children: LineageNode[];
95
+ }
96
+ /** The stitched descendant tree for one `rootRequestKey`. */
97
+ export interface LineageTree {
98
+ readonly rootRequestKey: string;
99
+ /** The root node of the stitched tree, always present. Normally the top-level request's own
100
+ * instance; when `rootRequestKey` is a minted synthetic key, the parentless root instance it
101
+ * threads. Falls back to a synthetic node keyed by `rootRequestKey` when no edges exist. */
102
+ readonly root: LineageNode;
103
+ /** Every node keyed by instance, in insertion order — a convenience flat view. */
104
+ readonly nodes: LineageNode[];
105
+ }
106
+ /**
107
+ * Build the stitched descendant tree for `rootRequestKey` by unioning weak and strong edges.
108
+ * When both edge sources connect the SAME node, the STRONG (engine execution) edge wins — it
109
+ * is the authoritative parent — so #808's native edge supersedes the envelope's weak guess
110
+ * without duplicating it. Nodes whose parent chain does not reach the root stay unattached
111
+ * (they still appear in `nodes`). The root is the instance keyed by `rootRequestKey` when that
112
+ * is itself an instance, else the single parentless recorded instance (a minted `rootRequestKey`
113
+ * is a synthetic correlation key, not an instance), else a synthetic node. Pure and
114
+ * deterministic — the store is a thin persistence layer over this.
115
+ */
116
+ export declare function buildLineageTree(rootRequestKey: string, edges: readonly LineageEdge[], attachments?: readonly LineageAttachment[]): LineageTree;
@@ -0,0 +1,186 @@
1
+ // lineage — the framework-level lineage primitive (issue #254).
2
+ //
3
+ // Any Urban app wants to stitch *user intent → progress*: a single root request (a
4
+ // button press, an inbound webhook, an API call) fans out into the process instances,
5
+ // PRs, tasks and sub-loops it *causes*, and an operator wants to see that whole thread.
6
+ // Today every app hand-rolls that correlation. This module makes it a framework
7
+ // primitive so the next app gets intent→progress threading for free.
8
+ //
9
+ // Two DISTINCT relationships stitch the thread, and they must stay separate:
10
+ //
11
+ // - the LINEAGE / CAUSATION edge (this module): asynchronous causation — the cause does
12
+ // NOT block the effect, edges fan out (1→N), descendants settle independently and
13
+ // survive their ancestor's termination, and they are created by message-start / API
14
+ // `createInstance` / webhooks as much as by call activities. Carried as a reserved
15
+ // `_urban.lineage` envelope on every instance/message the SDK starts, AUTO-THREADED
16
+ // from the ambient job context (see {@link applyAmbientLineage}).
17
+ // - the EXECUTION edge (`parentProcessInstanceKey`, engine-native — Magikcraft/nano-bpm#808):
18
+ // synchronous delegation where the parent token blocks on the child. This module does
19
+ // NOT duplicate it; the read projection ({@link buildLineageTree}) CONSUMES it as a
20
+ // strong edge, unioned with the weak/causal edges of the envelope.
21
+ //
22
+ // This file is intentionally `node:*`-free (the host contract): the only ambient
23
+ // capability it reaches for is `crypto.randomUUID` (a Web/global, not `node:crypto`),
24
+ // with a portable fallback.
25
+ import { currentJobContext } from "./execContext.js";
26
+ import { isRecord } from "./guards.js";
27
+ /** The reserved variable namespace the lineage envelope lives under on every
28
+ * instance/message (`_urban.lineage`). Kept as one object so future framework
29
+ * conventions can share the `_urban.*` namespace without colliding. */
30
+ export const LINEAGE_NAMESPACE = "_urban";
31
+ /** The key of the lineage envelope within {@link LINEAGE_NAMESPACE}. */
32
+ export const LINEAGE_KEY = "lineage";
33
+ /** Mint a fresh `rootRequestKey` for a genuine top-level request (no ambient lineage).
34
+ * Uses `crypto.randomUUID` where available (Node ≥ 16.7 / Deno expose it as a global),
35
+ * with a time+random fallback so this stays host-agnostic and never throws. */
36
+ export function mintRootRequestKey() {
37
+ const uuid = globalThis.crypto?.randomUUID?.();
38
+ if (uuid)
39
+ return uuid;
40
+ return `root-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
41
+ }
42
+ /** Coerce a value to a non-empty string, or `undefined`. */
43
+ function nonEmptyString(value) {
44
+ return typeof value === "string" && value !== "" ? value : undefined;
45
+ }
46
+ /**
47
+ * Read the `_urban.lineage` envelope from a variables map, or `undefined` when absent or
48
+ * malformed (no/blank `rootRequestKey`). Tolerant by design: a bad envelope degrades to
49
+ * "no ambient lineage" rather than throwing on the hot instance-creation path.
50
+ */
51
+ export function readLineage(variables) {
52
+ if (!isRecord(variables))
53
+ return undefined;
54
+ const ns = variables[LINEAGE_NAMESPACE];
55
+ if (!isRecord(ns))
56
+ return undefined;
57
+ const env = ns[LINEAGE_KEY];
58
+ if (!isRecord(env))
59
+ return undefined;
60
+ const rootRequestKey = nonEmptyString(env.rootRequestKey);
61
+ if (!rootRequestKey)
62
+ return undefined;
63
+ return { rootRequestKey, causedByInstanceKey: nonEmptyString(env.causedByInstanceKey) };
64
+ }
65
+ /** Normalise an envelope for storage in variables — drop an absent `causedByInstanceKey`
66
+ * rather than serialising `undefined`, so a fresh root's envelope is `{ rootRequestKey }`. */
67
+ function toEnvelopeRecord(envelope) {
68
+ return envelope.causedByInstanceKey
69
+ ? { rootRequestKey: envelope.rootRequestKey, causedByInstanceKey: envelope.causedByInstanceKey }
70
+ : { rootRequestKey: envelope.rootRequestKey };
71
+ }
72
+ /**
73
+ * Return a COPY of `variables` with `envelope` written under `_urban.lineage`, preserving
74
+ * any other `_urban.*` keys. Never mutates the input.
75
+ */
76
+ export function writeLineage(variables, envelope) {
77
+ const base = isRecord(variables) ? variables : {};
78
+ const ns = isRecord(base[LINEAGE_NAMESPACE]) ? base[LINEAGE_NAMESPACE] : {};
79
+ return { ...base, [LINEAGE_NAMESPACE]: { ...ns, [LINEAGE_KEY]: toEnvelopeRecord(envelope) } };
80
+ }
81
+ /**
82
+ * Derive the envelope to attach to a NEW instance/message given the ambient job context.
83
+ * - Ambient lineage present → PROPAGATE it: preserve `rootRequestKey`, set
84
+ * `causedByInstanceKey` to the current instance (the cause).
85
+ * - Ambient job with an instance but no envelope → that instance is itself a root; the
86
+ * child inherits `rootRequestKey = <ambient instance>` and is caused by it.
87
+ * - No ambient job at all (a genuine top-level request) → MINT a fresh `rootRequestKey`.
88
+ */
89
+ export function deriveLineage(ambient, mint = mintRootRequestKey) {
90
+ const rootRequestKey = ambient?.rootRequestKey ?? ambient?.instanceKey;
91
+ if (rootRequestKey) {
92
+ return { rootRequestKey, causedByInstanceKey: ambient?.instanceKey };
93
+ }
94
+ return { rootRequestKey: mint() };
95
+ }
96
+ /**
97
+ * The single, adapter-agnostic step that auto-threads lineage onto a `createInstance` /
98
+ * `publishMessage` call's variables. Both the live `SdkEngineClient` and the testkit's
99
+ * `WasmEngineClient` route through this (No Drift Surfaces), so lineage threads identically
100
+ * in production and in-harness.
101
+ *
102
+ * An EXPLICIT caller-supplied `_urban.lineage` always wins (start a new root or re-parent
103
+ * deliberately) — it is returned untouched. Otherwise the envelope is derived from the
104
+ * ambient job context ({@link deriveLineage}) and merged in.
105
+ */
106
+ export function applyAmbientLineage(variables, opts = {}) {
107
+ const base = isRecord(variables) ? variables : {};
108
+ if (readLineage(base))
109
+ return { ...base };
110
+ const ambient = opts.ambient !== undefined ? opts.ambient : currentJobContext();
111
+ return writeLineage(base, deriveLineage(ambient, opts.mintRootRequestKey));
112
+ }
113
+ /**
114
+ * Build the stitched descendant tree for `rootRequestKey` by unioning weak and strong edges.
115
+ * When both edge sources connect the SAME node, the STRONG (engine execution) edge wins — it
116
+ * is the authoritative parent — so #808's native edge supersedes the envelope's weak guess
117
+ * without duplicating it. Nodes whose parent chain does not reach the root stay unattached
118
+ * (they still appear in `nodes`). The root is the instance keyed by `rootRequestKey` when that
119
+ * is itself an instance, else the single parentless recorded instance (a minted `rootRequestKey`
120
+ * is a synthetic correlation key, not an instance), else a synthetic node. Pure and
121
+ * deterministic — the store is a thin persistence layer over this.
122
+ */
123
+ export function buildLineageTree(rootRequestKey, edges, attachments = []) {
124
+ // Collapse to one edge per effect instance: strong supersedes weak.
125
+ const byInstance = new Map();
126
+ for (const e of edges) {
127
+ if (e.rootRequestKey !== rootRequestKey)
128
+ continue;
129
+ const prev = byInstance.get(e.instanceKey);
130
+ if (!prev || (prev.edgeType === "weak" && e.edgeType === "strong")) {
131
+ byInstance.set(e.instanceKey, e);
132
+ }
133
+ }
134
+ const nodes = new Map();
135
+ const ensure = (instanceKey) => {
136
+ let n = nodes.get(instanceKey);
137
+ if (!n) {
138
+ n = { instanceKey, attachments: [], children: [] };
139
+ nodes.set(instanceKey, n);
140
+ }
141
+ return n;
142
+ };
143
+ // Speculatively materialise the `rootRequestKey` node so descendants whose cause IS the root
144
+ // (a child inherits the root's own instance key as `rootRequestKey`) can attach to it below.
145
+ const rootByKey = ensure(rootRequestKey);
146
+ for (const e of byInstance.values()) {
147
+ const node = ensure(e.instanceKey);
148
+ node.edgeType = e.edgeType;
149
+ node.causedByInstanceKey = e.causedByInstanceKey;
150
+ }
151
+ // Attachments are an app extension point keyed by instance. MATERIALISE the target node even
152
+ // when no edge has been recorded for it yet, so an attachment for an as-yet-unstitched instance
153
+ // is preserved in `tree.nodes` (unattached until its edge lands) rather than silently dropped.
154
+ for (const a of attachments) {
155
+ const node = ensure(a.nodeKey);
156
+ node.attachments.push({ nodeKey: a.nodeKey, kind: a.kind, ref: a.ref, ...(a.label ? { label: a.label } : {}) });
157
+ }
158
+ for (const node of nodes.values()) {
159
+ const parentKey = node.causedByInstanceKey;
160
+ if (parentKey && parentKey !== node.instanceKey && nodes.has(parentKey)) {
161
+ ensure(parentKey).children.push(node);
162
+ }
163
+ }
164
+ // Resolve the root node. Usually `rootRequestKey` IS the root instance's own key, so `rootByKey`
165
+ // carries that instance's edge and descendants. But a genuine top-level request (no ambient job)
166
+ // MINTS a synthetic `rootRequestKey` — a UUID that is not any instance — so `rootByKey` is an
167
+ // empty phantom while the real root is the single recorded instance with no cause. Prefer that
168
+ // parentless instance and drop the phantom, so the tree is never rooted at a childless synthetic
169
+ // node with the real root left floating detached (the bug this guards against).
170
+ let root = rootByKey;
171
+ const rootIsPhantom = rootByKey.edgeType === undefined && rootByKey.children.length === 0 && rootByKey.attachments.length === 0;
172
+ if (rootIsPhantom) {
173
+ // A candidate real root is a parentless node that is genuinely part of the causal structure —
174
+ // it has its own recorded edge or descendants. Attachment-only orphan nodes (materialised above
175
+ // with no edge and no children) are NOT candidates, so a stray attachment can never shadow the
176
+ // real parentless root and re-introduce the phantom-root bug guarded by the minted-root case.
177
+ const parentlessInstances = [...nodes.values()].filter((n) => n !== rootByKey &&
178
+ n.causedByInstanceKey === undefined &&
179
+ (n.edgeType !== undefined || n.children.length > 0));
180
+ if (parentlessInstances.length === 1) {
181
+ root = parentlessInstances[0];
182
+ nodes.delete(rootRequestKey);
183
+ }
184
+ }
185
+ return { rootRequestKey, root, nodes: [...nodes.values()] };
186
+ }