@nanobpm/urban 0.51.0 → 0.52.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.
@@ -5,6 +5,8 @@ import { isRecord } from "../guards.js";
5
5
  import { workerJobType } from "../manifest.js";
6
6
  import { runLlmJob } from "./llm.js";
7
7
  import { runInJobContext } from "../execContext.js";
8
+ import { readLineage } from "../lineage.js";
9
+ import { LineageStore } from "./lineage-store.js";
8
10
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
9
11
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
10
12
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -48,6 +50,59 @@ function withJobLog(app, job) {
48
50
  bindings.elementId = job.elementId;
49
51
  return { ...app, log: app.log.child(bindings) };
50
52
  }
53
+ /**
54
+ * Build the ambient {@link JobExecContext} a worker runs its handler within. Beyond the
55
+ * instance/element/jobType used for write-provenance, it resolves the lineage
56
+ * `rootRequestKey` (issue #254): the job's own `_urban.lineage` envelope root when present,
57
+ * else the instance's own key — so an instance with no envelope is treated as a root and any
58
+ * instance/message its handler spawns inherits that root with `causedByInstanceKey` set.
59
+ */
60
+ function jobExecContext(job, jobType) {
61
+ return {
62
+ instanceKey: job.processInstanceKey,
63
+ elementId: job.elementId,
64
+ jobType,
65
+ rootRequestKey: readLineage(job.variables)?.rootRequestKey ?? job.processInstanceKey,
66
+ };
67
+ }
68
+ /**
69
+ * Build the framework lineage projection store over the app's DEFAULT data source, or `undefined`
70
+ * when the app configures none. The store records the `_urban.lineage` edge every activated job
71
+ * reveals (issue #254), so intent→progress lineage materialises for free — but it is a sidecar on
72
+ * the app's own source (like write-provenance), so an app with no datasource simply records
73
+ * nothing. Absent- and error-safe: any failure to provision degrades to no lineage recording, never
74
+ * a failed worker mount.
75
+ */
76
+ function tryLineageStore(app, log) {
77
+ if (!app.data.hasDefaultSource()) {
78
+ // No default data source configured at all — lineage is a sidecar on the app's own source, so
79
+ // this is the expected absent case (like write-provenance): record nothing, silently.
80
+ log("debug", "lineage: no default data source; lineage recording disabled");
81
+ return undefined;
82
+ }
83
+ try {
84
+ const source = app.data.source();
85
+ const store = new LineageStore(source.db);
86
+ store.ensureSchema();
87
+ return store;
88
+ }
89
+ catch (err) {
90
+ // A default IS configured but resolving or provisioning it failed — a missing named source
91
+ // (`no such data source`) or a schema/DB error. That is a genuine fault, not the absent case,
92
+ // so surface it at `warn` rather than disguising it as "no source".
93
+ log("warn", "lineage: failed to provision projection store; lineage recording disabled", { error: String(err) });
94
+ return undefined;
95
+ }
96
+ }
97
+ /** Record an activated job's lineage edge, never letting a projection write break the job. */
98
+ function recordJobLineage(store, job, log) {
99
+ try {
100
+ store.recordFromJob(job);
101
+ }
102
+ catch (err) {
103
+ log("warn", "lineage: failed to record job edge", { jobType: job.jobType, error: String(err) });
104
+ }
105
+ }
51
106
  /**
52
107
  * Resolve a handler for `jobType` from a loaded module, in priority order:
53
108
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -74,6 +129,10 @@ export async function mountWorkers(ctx, app) {
74
129
  const decls = ctx.manifest.workers ?? [];
75
130
  const subs = [];
76
131
  const jobTypes = [];
132
+ // The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
133
+ // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
134
+ // app configures no default data source.
135
+ const lineageStore = tryLineageStore(app, ctx.host.log);
77
136
  // Cache module loads so a multi-type handler module is imported once.
78
137
  const moduleCache = new Map();
79
138
  const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
@@ -114,7 +173,11 @@ export async function mountWorkers(ctx, app) {
114
173
  env: (n) => app.env(n),
115
174
  evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
116
175
  };
117
- const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => runLlmJob(job.variables, binding, rt));
176
+ const wrapped = (job) => {
177
+ if (lineageStore)
178
+ recordJobLineage(lineageStore, job, ctx.host.log);
179
+ return runInJobContext(jobExecContext(job, jobType), () => runLlmJob(job.variables, binding, rt));
180
+ };
118
181
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
119
182
  workerName: `${ctx.manifest.id}:${jobType}`,
120
183
  });
@@ -129,7 +192,11 @@ export async function mountWorkers(ctx, app) {
129
192
  throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
130
193
  `(expected handlers["${jobType}"], a named export, or a default function)`);
131
194
  }
132
- const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, withJobLog(app, job)));
195
+ const wrapped = (job) => {
196
+ if (lineageStore)
197
+ recordJobLineage(lineageStore, job, ctx.host.log);
198
+ return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(app, job)));
199
+ };
133
200
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
134
201
  workerName: `${ctx.manifest.id}:${jobType}`,
135
202
  });
@@ -21,6 +21,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
21
21
  import { isBpmnError } from "../core/host.js";
22
22
  import { buildFormSchema, parseFormSchema, pickFormLinkage, resolveFormIdentifier, } from "../core/form-contract.js";
23
23
  import { isRecord } from "../core/guards.js";
24
+ import { applyAmbientLineage } from "../core/lineage.js";
24
25
  /** Coerce an engine response's process-instance key to a non-empty string, or
25
26
  * throw — a missing key means a malformed/partial response, not a real instance. */
26
27
  export function requireProcessInstanceKey(key) {
@@ -76,9 +77,12 @@ export class SdkEngineClient {
76
77
  return { deployed: resources.length };
77
78
  }
78
79
  async createInstance(input) {
80
+ // Auto-thread the `_urban.lineage` envelope (issue #254): a handler that spawns an
81
+ // instance propagates its `rootRequestKey` and stamps `causedByInstanceKey`; a genuine
82
+ // top-level request mints a fresh root. An explicit caller-supplied envelope wins.
79
83
  const body = await this.client.createProcessInstance({
80
84
  processDefinitionId: input.processDefinitionId,
81
- variables: input.variables ?? {},
85
+ variables: applyAmbientLineage(input.variables),
82
86
  awaitCompletion: input.awaitCompletion ?? false,
83
87
  });
84
88
  const rawKey = body.processInstanceKey ?? body.key;
@@ -93,10 +97,12 @@ export class SdkEngineClient {
93
97
  await this.client.cancelProcessInstance({ processInstanceKey: input.processInstanceKey });
94
98
  }
95
99
  async publishMessage(input) {
100
+ // Message-started instances inherit lineage too (issue #254): thread the envelope onto the
101
+ // message variables so the instance the message starts carries `_urban.lineage`.
96
102
  await this.client.publishMessage({
97
103
  name: input.name,
98
104
  correlationKey: input.correlationKey ?? "",
99
- variables: input.variables ?? {},
105
+ variables: applyAmbientLineage(input.variables),
100
106
  });
101
107
  }
102
108
  async searchUserTasks(filter) {
@@ -31,6 +31,10 @@ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts"
31
31
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
32
32
  export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
33
33
  export { evalCorrelation } from "./core/modules/triggers.ts";
34
+ export { applyAmbientLineage, buildLineageTree, deriveLineage, LINEAGE_KEY, LINEAGE_NAMESPACE, mintRootRequestKey, readLineage, writeLineage, } from "./core/lineage.ts";
35
+ export type { ApplyLineageOptions, LineageAttachment, LineageEdge, LineageEdgeType, LineageEnvelope, LineageNode, LineageTree, } from "./core/lineage.ts";
36
+ export { LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE, LINEAGE_SCHEMA_SQL, LineageStore, systemClock as lineageSystemClock, } from "./core/modules/lineage-store.ts";
37
+ export type { Clock as LineageClock, LineageJobLike, LineageStoreOptions, } from "./core/modules/lineage-store.ts";
34
38
  export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
35
39
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
36
40
  export type { SchedulerDeps } from "./core/modules/scheduler.ts";
@@ -28,6 +28,11 @@ export { defineOperation, mountApi, resolveOperationHandler, NotImplemented } fr
28
28
  export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
29
29
  export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
30
30
  export { evalCorrelation } from "./core/modules/triggers.js";
31
+ // Lineage — the framework-level lineage primitive (issue #254): the `_urban.lineage`
32
+ // envelope + auto-threading, and the generic read projection that stitches user intent →
33
+ // progress (a root request → the instances/PRs/tasks it causes).
34
+ export { applyAmbientLineage, buildLineageTree, deriveLineage, LINEAGE_KEY, LINEAGE_NAMESPACE, mintRootRequestKey, readLineage, writeLineage, } from "./core/lineage.js";
35
+ export { LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE, LINEAGE_SCHEMA_SQL, LineageStore, systemClock as lineageSystemClock, } from "./core/modules/lineage-store.js";
31
36
  // Model deploy (deploy-by-convention under `resources/`, ADR 0062)
32
37
  export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.js";
33
38
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -21,6 +21,11 @@ export interface JobExecContext {
21
21
  elementId?: string;
22
22
  /** The job type (worker) currently executing. */
23
23
  jobType?: string;
24
+ /** The lineage `rootRequestKey` the running instance belongs to (its own envelope's root,
25
+ * or — for a root instance with no envelope — its own instance key). Threaded so that when a
26
+ * handler spawns an instance or publishes a message, the SDK propagates the same root and
27
+ * sets `causedByInstanceKey` to {@link instanceKey} (issue #254, see `./lineage.ts`). */
28
+ rootRequestKey?: string;
24
29
  }
25
30
 
26
31
  let store: AsyncStore<JobExecContext> | undefined;
@@ -0,0 +1,215 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ applyAmbientLineage,
5
+ buildLineageTree,
6
+ deriveLineage,
7
+ type LineageEdge,
8
+ LINEAGE_KEY,
9
+ LINEAGE_NAMESPACE,
10
+ mintRootRequestKey,
11
+ readLineage,
12
+ writeLineage,
13
+ } from "./lineage.ts";
14
+ import { __resetExecStoreForTests, installExecStore, runInJobContext } from "./execContext.ts";
15
+ import { createNodeHost } from "../adapters/node.ts";
16
+ import { isRecord } from "./guards.ts";
17
+
18
+ function lineageOf(variables: Record<string, unknown>): unknown {
19
+ const ns = variables[LINEAGE_NAMESPACE];
20
+ return isRecord(ns) ? ns[LINEAGE_KEY] : undefined;
21
+ }
22
+
23
+ test("readLineage extracts a well-formed envelope and rejects malformed ones", () => {
24
+ assert.deepEqual(
25
+ readLineage({ _urban: { lineage: { rootRequestKey: "r1", causedByInstanceKey: "c1" } } }),
26
+ { rootRequestKey: "r1", causedByInstanceKey: "c1" },
27
+ );
28
+ // A bare root (no cause) is a valid fresh root.
29
+ assert.deepEqual(readLineage({ _urban: { lineage: { rootRequestKey: "r1" } } }), {
30
+ rootRequestKey: "r1",
31
+ causedByInstanceKey: undefined,
32
+ });
33
+ // Blank/absent rootRequestKey → not a valid envelope.
34
+ assert.equal(readLineage({ _urban: { lineage: { rootRequestKey: "" } } }), undefined);
35
+ assert.equal(readLineage({ _urban: { lineage: {} } }), undefined);
36
+ assert.equal(readLineage({ _urban: {} }), undefined);
37
+ assert.equal(readLineage({}), undefined);
38
+ assert.equal(readLineage(undefined), undefined);
39
+ });
40
+
41
+ test("writeLineage never mutates input and preserves other _urban.* keys", () => {
42
+ const input = { keep: 1, _urban: { other: "x" } };
43
+ const out = writeLineage(input, { rootRequestKey: "r1", causedByInstanceKey: "c1" });
44
+ assert.deepEqual(input, { keep: 1, _urban: { other: "x" } }, "input is not mutated");
45
+ assert.equal(out.keep, 1);
46
+ assert.deepEqual(out._urban, { other: "x", lineage: { rootRequestKey: "r1", causedByInstanceKey: "c1" } });
47
+ });
48
+
49
+ test("writeLineage drops an absent causedByInstanceKey (a fresh root is { rootRequestKey })", () => {
50
+ const out = writeLineage({}, { rootRequestKey: "r1" });
51
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "r1" });
52
+ });
53
+
54
+ test("deriveLineage propagates an ambient root and sets causedByInstanceKey", () => {
55
+ assert.deepEqual(deriveLineage({ rootRequestKey: "root-1", instanceKey: "inst-5" }), {
56
+ rootRequestKey: "root-1",
57
+ causedByInstanceKey: "inst-5",
58
+ });
59
+ });
60
+
61
+ test("deriveLineage treats an ambient instance with no envelope as its own root", () => {
62
+ assert.deepEqual(deriveLineage({ instanceKey: "inst-9" }), {
63
+ rootRequestKey: "inst-9",
64
+ causedByInstanceKey: "inst-9",
65
+ });
66
+ });
67
+
68
+ test("deriveLineage mints a fresh root when there is no ambient job at all", () => {
69
+ assert.deepEqual(deriveLineage(undefined, () => "minted-1"), { rootRequestKey: "minted-1" });
70
+ });
71
+
72
+ test("mintRootRequestKey returns a non-empty, unique-ish value", () => {
73
+ const a = mintRootRequestKey();
74
+ const b = mintRootRequestKey();
75
+ assert.equal(typeof a, "string");
76
+ assert.notEqual(a, "");
77
+ assert.notEqual(a, b);
78
+ });
79
+
80
+ test("applyAmbientLineage: an explicit caller envelope always wins (untouched)", () => {
81
+ const explicit = { _urban: { lineage: { rootRequestKey: "explicit", causedByInstanceKey: "x" } } };
82
+ const out = applyAmbientLineage(explicit, {
83
+ ambient: { rootRequestKey: "ambient-root", instanceKey: "ambient-inst" },
84
+ });
85
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "explicit", causedByInstanceKey: "x" });
86
+ });
87
+
88
+ test("applyAmbientLineage: propagates the ambient root and stamps the cause", () => {
89
+ const out = applyAmbientLineage(
90
+ { payload: 1 },
91
+ { ambient: { rootRequestKey: "root-A", instanceKey: "inst-B" } },
92
+ );
93
+ assert.equal(out.payload, 1);
94
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "root-A", causedByInstanceKey: "inst-B" });
95
+ });
96
+
97
+ test("applyAmbientLineage: mints a fresh root for a genuine top-level request", () => {
98
+ const out = applyAmbientLineage({}, { ambient: undefined, mintRootRequestKey: () => "fresh-1" });
99
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "fresh-1" });
100
+ });
101
+
102
+ test("applyAmbientLineage reads the real ambient job context (runInJobContext)", () => {
103
+ __resetExecStoreForTests();
104
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
105
+ installExecStore(() => host.createAsyncStore?.());
106
+ try {
107
+ const out = runInJobContext({ instanceKey: "pi-1", rootRequestKey: "root-1", jobType: "w" }, () =>
108
+ applyAmbientLineage({ a: 1 }),
109
+ );
110
+ assert.deepEqual(lineageOf(out), { rootRequestKey: "root-1", causedByInstanceKey: "pi-1" });
111
+ } finally {
112
+ __resetExecStoreForTests();
113
+ }
114
+ });
115
+
116
+ test("buildLineageTree stitches a fan-out tree from weak edges", () => {
117
+ const edges: LineageEdge[] = [
118
+ { rootRequestKey: "root", instanceKey: "root", edgeType: "weak" },
119
+ { rootRequestKey: "root", instanceKey: "child-1", causedByInstanceKey: "root", edgeType: "weak" },
120
+ { rootRequestKey: "root", instanceKey: "child-2", causedByInstanceKey: "root", edgeType: "weak" },
121
+ { rootRequestKey: "root", instanceKey: "grand-1", causedByInstanceKey: "child-1", edgeType: "weak" },
122
+ ];
123
+ const tree = buildLineageTree("root", edges);
124
+ assert.equal(tree.root.instanceKey, "root");
125
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey).sort(), ["child-1", "child-2"]);
126
+ const child1 = tree.root.children.find((c) => c.instanceKey === "child-1");
127
+ assert.deepEqual(child1?.children.map((c) => c.instanceKey), ["grand-1"]);
128
+ assert.equal(tree.nodes.length, 4);
129
+ });
130
+
131
+ test("buildLineageTree: a strong (engine) edge supersedes a weak edge on the same node", () => {
132
+ const edges: LineageEdge[] = [
133
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "weak" },
134
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "strong" },
135
+ ];
136
+ const tree = buildLineageTree("root", edges);
137
+ const child = tree.nodes.find((n) => n.instanceKey === "child");
138
+ assert.equal(child?.edgeType, "strong");
139
+ });
140
+
141
+ test("buildLineageTree ignores edges from other roots and hangs attachments on their node", () => {
142
+ const edges: LineageEdge[] = [
143
+ { rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "weak" },
144
+ { rootRequestKey: "other", instanceKey: "stranger", causedByInstanceKey: "other", edgeType: "weak" },
145
+ ];
146
+ const tree = buildLineageTree("root", edges, [
147
+ { nodeKey: "child", kind: "pull_request", ref: "owner/repo#1", label: "PR 1" },
148
+ { nodeKey: "missing", kind: "task", ref: "t-1" },
149
+ ]);
150
+ assert.equal(tree.nodes.some((n) => n.instanceKey === "stranger"), false);
151
+ const child = tree.root.children[0];
152
+ assert.equal(child.instanceKey, "child");
153
+ assert.deepEqual(child.attachments, [{ nodeKey: "child", kind: "pull_request", ref: "owner/repo#1", label: "PR 1" }]);
154
+ // An attachment for an instance with no recorded edge yet is PRESERVED as an unattached node,
155
+ // never silently dropped — attachments are an extension point and must survive the projection.
156
+ const orphan = tree.nodes.find((n) => n.instanceKey === "missing");
157
+ assert.ok(orphan, "attachment for an un-stitched instance must materialise its node");
158
+ assert.deepEqual(orphan.attachments, [{ nodeKey: "missing", kind: "task", ref: "t-1" }]);
159
+ assert.equal(orphan.edgeType, undefined);
160
+ assert.equal(orphan.children.length, 0);
161
+ });
162
+
163
+ test("buildLineageTree: a stray orphan attachment does not shadow a MINTED root's real parentless instance", () => {
164
+ // Defect-class guard: materialising attachment-only nodes must not reintroduce the phantom-root
165
+ // bug. A minted (synthetic) rootRequestKey with an orphan attachment adds a second parentless
166
+ // node; the tree must still root at the real recorded instance, not the empty synthetic phantom.
167
+ const edges: LineageEdge[] = [
168
+ { rootRequestKey: "req-UUID", instanceKey: "inst-root", edgeType: "weak" },
169
+ { rootRequestKey: "req-UUID", instanceKey: "inst-child", causedByInstanceKey: "inst-root", edgeType: "strong" },
170
+ ];
171
+ const tree = buildLineageTree("req-UUID", edges, [{ nodeKey: "inst-orphan", kind: "note", ref: "n-1" }]);
172
+ assert.equal(tree.root.instanceKey, "inst-root");
173
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey), ["inst-child"]);
174
+ assert.equal(tree.nodes.some((n) => n.instanceKey === "req-UUID"), false);
175
+ // The orphan attachment is preserved (unattached), and the synthetic key never leaks as a node.
176
+ const orphan = tree.nodes.find((n) => n.instanceKey === "inst-orphan");
177
+ assert.ok(orphan, "orphan attachment node must be preserved");
178
+ assert.deepEqual(orphan.attachments, [{ nodeKey: "inst-orphan", kind: "note", ref: "n-1" }]);
179
+ });
180
+
181
+ test("buildLineageTree roots a MINTED (synthetic) rootRequestKey at the parentless instance, not a phantom", () => {
182
+ // A genuine top-level request MINTS a UUID `rootRequestKey` that is NOT any instance key
183
+ // (`deriveLineage` with no ambient job). The real root instance carries that key with no
184
+ // cause, and its descendants hang off the instance. The tree must be rooted at the real
185
+ // parentless instance — never at an empty synthetic node while the real root floats detached.
186
+ const edges: LineageEdge[] = [
187
+ { rootRequestKey: "req-UUID", instanceKey: "inst-root", edgeType: "weak" },
188
+ { rootRequestKey: "req-UUID", instanceKey: "inst-child", causedByInstanceKey: "inst-root", edgeType: "weak" },
189
+ { rootRequestKey: "req-UUID", instanceKey: "inst-grand", causedByInstanceKey: "inst-child", edgeType: "strong" },
190
+ ];
191
+ const tree = buildLineageTree("req-UUID", edges);
192
+ assert.equal(tree.root.instanceKey, "inst-root");
193
+ assert.deepEqual(tree.root.children.map((c) => c.instanceKey), ["inst-child"]);
194
+ assert.deepEqual(tree.root.children[0].children.map((c) => c.instanceKey), ["inst-grand"]);
195
+ // The synthetic correlation key must not leak into the flat view as a phantom node.
196
+ assert.equal(tree.nodes.some((n) => n.instanceKey === "req-UUID"), false);
197
+ assert.equal(tree.nodes.length, 3);
198
+ });
199
+
200
+ test("buildLineageTree keeps a lone real root (parentless, no descendants) as the tree root", () => {
201
+ // Guard the fallback boundary: a real root instance whose key IS the rootRequestKey and which
202
+ // has no descendants must stay the root — it must not be mistaken for a replaceable phantom.
203
+ const edges: LineageEdge[] = [{ rootRequestKey: "root", instanceKey: "root", edgeType: "weak" }];
204
+ const tree = buildLineageTree("root", edges);
205
+ assert.equal(tree.root.instanceKey, "root");
206
+ assert.equal(tree.root.children.length, 0);
207
+ assert.equal(tree.nodes.length, 1);
208
+ });
209
+
210
+ test("buildLineageTree keeps the synthetic root when no edges exist at all", () => {
211
+ const tree = buildLineageTree("req-UUID", []);
212
+ assert.equal(tree.root.instanceKey, "req-UUID");
213
+ assert.equal(tree.root.children.length, 0);
214
+ assert.deepEqual(tree.nodes.map((n) => n.instanceKey), ["req-UUID"]);
215
+ });