@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.
- 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/execContext.d.ts +5 -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/lineage.d.ts +116 -0
- package/dist/runtime/core/lineage.js +186 -0
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/datasource.d.ts +6 -0
- package/dist/runtime/core/modules/datasource.js +8 -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/lineage-store.d.ts +81 -0
- package/dist/runtime/core/modules/lineage-store.js +175 -0
- package/dist/runtime/core/modules/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/modules/workers.js +69 -2
- 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/engine/nanosdk.js +8 -2
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +12 -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/execContext.ts +5 -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/lineage.test.ts +215 -0
- package/src/runtime/core/lineage.ts +297 -0
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/datasource.ts +9 -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/lineage-store.test.ts +177 -0
- package/src/runtime/core/modules/lineage-store.ts +239 -0
- package/src/runtime/core/modules/pages.test.ts +309 -5
- package/src/runtime/core/modules/pages.ts +398 -20
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/modules/workers.test.ts +136 -1
- package/src/runtime/core/modules/workers.ts +72 -6
- 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/engine/nanosdk.test.ts +97 -3
- package/src/runtime/engine/nanosdk.ts +8 -2
- package/src/runtime/index.ts +92 -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,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
|
+
}
|