@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.
@@ -0,0 +1,239 @@
1
+ // lineage-store — the durable read-model projection behind the lineage primitive (issue #254).
2
+ //
3
+ // A framework-level, per-source sidecar (like the write-provenance table) that materialises the
4
+ // lineage edges the SDK auto-threads (`_urban.lineage`) and — where the engine emits it — the
5
+ // native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). `getLineage(root)`
6
+ // unions both and returns the stitched descendant tree for one root request. Apps attach their own
7
+ // domain rows (PRs, tasks) to a node via {@link LineageStore.attach} WITHOUT the framework knowing
8
+ // their schema — the extension point that lets intent→progress visualisation be app-agnostic.
9
+ //
10
+ // The tree assembly itself is the pure {@link buildLineageTree}; this store is only its thin,
11
+ // idempotent persistence + query layer, so recording the same instance twice (an engine job
12
+ // re-activation) collapses to a no-op.
13
+
14
+ import type { SqliteDb } from "../host.ts";
15
+ import {
16
+ buildLineageTree,
17
+ type LineageAttachment,
18
+ type LineageEdge,
19
+ type LineageEdgeType,
20
+ type LineageTree,
21
+ readLineage,
22
+ } from "../lineage.ts";
23
+
24
+ /** The lineage edge table name. Framework bookkeeping, so `_urban_`-prefixed to stay hidden from
25
+ * the domain model / DB Manager (see `SqliteGateway.schema()`), like the migrations ledger. */
26
+ export const LINEAGE_EDGES_TABLE = "_urban_lineage_edges";
27
+ /** The lineage attachment table name (app-registered domain rows on a node). `_urban_`-prefixed for
28
+ * the same reason as {@link LINEAGE_EDGES_TABLE}. */
29
+ export const LINEAGE_ATTACHMENTS_TABLE = "_urban_lineage_attachments";
30
+
31
+ /**
32
+ * The canonical lineage DDL — the single source of truth applied by
33
+ * {@link LineageStore.ensureSchema}, mirrored verbatim by the boot migration
34
+ * `db/migrations/004_urban_lineage.sql` (a drift-guard test asserts they match).
35
+ *
36
+ * Forward-only and additive. `_urban_lineage_edges` is keyed by `(instance_key, edge_type)` so a
37
+ * node can hold both its weak (envelope) and strong (execution) edge at once and a re-record is a
38
+ * no-op; `_urban_lineage_attachments` is keyed by `(node_key, kind, ref)` so re-attaching the same
39
+ * domain row is idempotent. Both carry `root_request_key` with an index so a `getLineage(root)`
40
+ * reads only that thread.
41
+ */
42
+ export const LINEAGE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${LINEAGE_EDGES_TABLE} (
43
+ root_request_key TEXT NOT NULL,
44
+ instance_key TEXT NOT NULL,
45
+ caused_by_instance_key TEXT,
46
+ edge_type TEXT NOT NULL DEFAULT 'weak',
47
+ created_at TEXT NOT NULL,
48
+ PRIMARY KEY (instance_key, edge_type)
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_${LINEAGE_EDGES_TABLE}_root ON ${LINEAGE_EDGES_TABLE} (root_request_key);
51
+ CREATE TABLE IF NOT EXISTS ${LINEAGE_ATTACHMENTS_TABLE} (
52
+ root_request_key TEXT NOT NULL,
53
+ node_key TEXT NOT NULL,
54
+ kind TEXT NOT NULL,
55
+ ref TEXT NOT NULL,
56
+ label TEXT,
57
+ created_at TEXT NOT NULL,
58
+ PRIMARY KEY (node_key, kind, ref)
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_${LINEAGE_ATTACHMENTS_TABLE}_root ON ${LINEAGE_ATTACHMENTS_TABLE} (root_request_key);`;
61
+
62
+ /** A monotonic wall clock, injectable for deterministic `created_at` in tests. */
63
+ export interface Clock {
64
+ now(): number;
65
+ }
66
+
67
+ /** The default clock: `Date.now()`. */
68
+ export const systemClock: Clock = { now: () => Date.now() };
69
+
70
+ /** A minimal job shape {@link LineageStore.recordFromJob} reads — a structural subset of the
71
+ * runtime's `EngineJob` plus the future engine execution edge (`parentProcessInstanceKey`,
72
+ * Magikcraft/nano-bpm#808), consumed as a strong edge where present. */
73
+ export interface LineageJobLike {
74
+ readonly processInstanceKey?: string;
75
+ readonly variables?: Record<string, unknown>;
76
+ /** The engine-native execution parent, when the engine emits it (#808). */
77
+ readonly parentProcessInstanceKey?: string;
78
+ }
79
+
80
+ export interface LineageStoreOptions {
81
+ readonly clock?: Clock;
82
+ }
83
+
84
+ interface EdgeRow {
85
+ root_request_key: string;
86
+ instance_key: string;
87
+ caused_by_instance_key: string | null;
88
+ edge_type: string;
89
+ }
90
+
91
+ interface AttachmentRow {
92
+ root_request_key: string;
93
+ node_key: string;
94
+ kind: string;
95
+ ref: string;
96
+ label: string | null;
97
+ }
98
+
99
+ function normalizeEdgeType(raw: string): LineageEdgeType {
100
+ return raw === "strong" ? "strong" : "weak";
101
+ }
102
+
103
+ export class LineageStore {
104
+ readonly #db: SqliteDb;
105
+ readonly #clock: Clock;
106
+
107
+ constructor(db: SqliteDb, options: LineageStoreOptions = {}) {
108
+ this.#db = db;
109
+ this.#clock = options.clock ?? systemClock;
110
+ }
111
+
112
+ /** Apply the canonical lineage DDL (idempotent). Callers whose app DataLayer applies the boot
113
+ * migration `db/migrations/004_urban_lineage.sql` do not need this; the runtime calls it so the
114
+ * store is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
115
+ ensureSchema(): void {
116
+ this.#db.exec(LINEAGE_SCHEMA_SQL);
117
+ }
118
+
119
+ /**
120
+ * Record one lineage edge, idempotently. A re-record of the same `(instance_key, edge_type)` is a
121
+ * no-op (an engine may re-activate a job on retry), so recording is safe to drive from any
122
+ * at-least-once observation point. A blank `rootRequestKey` or `instanceKey` is ignored.
123
+ */
124
+ recordEdge(edge: LineageEdge): boolean {
125
+ if (!edge.rootRequestKey || !edge.instanceKey) return false;
126
+ const { changes } = this.#db.run(
127
+ `INSERT OR IGNORE INTO ${LINEAGE_EDGES_TABLE}
128
+ (root_request_key, instance_key, caused_by_instance_key, edge_type, created_at)
129
+ VALUES (?, ?, ?, ?, ?)`,
130
+ [
131
+ edge.rootRequestKey,
132
+ edge.instanceKey,
133
+ edge.causedByInstanceKey ?? null,
134
+ edge.edgeType,
135
+ new Date(this.#clock.now()).toISOString(),
136
+ ],
137
+ );
138
+ return changes > 0;
139
+ }
140
+
141
+ /**
142
+ * Record the WEAK edge carried by an instance's `_urban.lineage` envelope. `instanceKey` is the
143
+ * effect/descendant node (a created instance's returned key, or a running job's own instance).
144
+ * A no-op when the variables carry no valid envelope.
145
+ */
146
+ recordEnvelope(instanceKey: string | undefined, variables: unknown): boolean {
147
+ if (!instanceKey) return false;
148
+ const env = readLineage(variables);
149
+ if (!env) return false;
150
+ return this.recordEdge({
151
+ rootRequestKey: env.rootRequestKey,
152
+ instanceKey,
153
+ causedByInstanceKey: env.causedByInstanceKey,
154
+ edgeType: "weak",
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Record every edge an activated job reveals about its own instance: the weak envelope edge, and
160
+ * — where the engine supplies `parentProcessInstanceKey` (#808) — the strong execution edge under
161
+ * the same root. The natural single observation point for the worker runtime, since every instance
162
+ * that does work activates a job carrying its envelope. Never throws for a malformed job.
163
+ */
164
+ recordFromJob(job: LineageJobLike): void {
165
+ const env = readLineage(job.variables);
166
+ if (!env || !job.processInstanceKey) return;
167
+ this.recordEnvelope(job.processInstanceKey, job.variables);
168
+ if (job.parentProcessInstanceKey) {
169
+ this.recordEdge({
170
+ rootRequestKey: env.rootRequestKey,
171
+ instanceKey: job.processInstanceKey,
172
+ causedByInstanceKey: job.parentProcessInstanceKey,
173
+ edgeType: "strong",
174
+ });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Attach an app-defined domain row to a lineage node, idempotently. The framework stores it
180
+ * opaquely by `(node_key, kind, ref)` — it never interprets the app's schema. This is the
181
+ * extension point that lets an app hang its PRs/tasks off the lineage tree.
182
+ */
183
+ attach(rootRequestKey: string, attachment: LineageAttachment): boolean {
184
+ if (!rootRequestKey || !attachment.nodeKey || !attachment.kind || !attachment.ref) return false;
185
+ const { changes } = this.#db.run(
186
+ `INSERT OR IGNORE INTO ${LINEAGE_ATTACHMENTS_TABLE}
187
+ (root_request_key, node_key, kind, ref, label, created_at)
188
+ VALUES (?, ?, ?, ?, ?, ?)`,
189
+ [
190
+ rootRequestKey,
191
+ attachment.nodeKey,
192
+ attachment.kind,
193
+ attachment.ref,
194
+ attachment.label ?? null,
195
+ new Date(this.#clock.now()).toISOString(),
196
+ ],
197
+ );
198
+ return changes > 0;
199
+ }
200
+
201
+ /** The persisted edges for one root, in insertion order. */
202
+ edges(rootRequestKey: string): LineageEdge[] {
203
+ const rows = this.#db.all<EdgeRow>(
204
+ `SELECT root_request_key, instance_key, caused_by_instance_key, edge_type
205
+ FROM ${LINEAGE_EDGES_TABLE} WHERE root_request_key = ? ORDER BY rowid`,
206
+ [rootRequestKey],
207
+ );
208
+ return rows.map((r) => ({
209
+ rootRequestKey: r.root_request_key,
210
+ instanceKey: r.instance_key,
211
+ causedByInstanceKey: r.caused_by_instance_key ?? undefined,
212
+ edgeType: normalizeEdgeType(r.edge_type),
213
+ }));
214
+ }
215
+
216
+ /** The persisted attachments for one root, in insertion order. */
217
+ attachments(rootRequestKey: string): LineageAttachment[] {
218
+ const rows = this.#db.all<AttachmentRow>(
219
+ `SELECT root_request_key, node_key, kind, ref, label
220
+ FROM ${LINEAGE_ATTACHMENTS_TABLE} WHERE root_request_key = ? ORDER BY rowid`,
221
+ [rootRequestKey],
222
+ );
223
+ return rows.map((r) => ({
224
+ nodeKey: r.node_key,
225
+ kind: r.kind,
226
+ ref: r.ref,
227
+ ...(r.label ? { label: r.label } : {}),
228
+ }));
229
+ }
230
+
231
+ /**
232
+ * The stitched descendant tree for `rootRequestKey`, unioning the weak (envelope) and strong
233
+ * (engine execution) edges and hanging each app attachment off its node. The generic read
234
+ * surface any Urban app queries for intent→progress.
235
+ */
236
+ getLineage(rootRequestKey: string): LineageTree {
237
+ return buildLineageTree(rootRequestKey, this.edges(rootRequestKey), this.attachments(rootRequestKey));
238
+ }
239
+ }
@@ -4,8 +4,20 @@ import assert from "node:assert/strict";
4
4
  import type { AppApi, RuntimeContext } from "../context.ts";
5
5
  import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscription } from "../host.ts";
6
6
  import type { AppManifest } from "../manifest.ts";
7
- import { DataLayer } from "./datasource.ts";
7
+ import { DataLayer, type ProvisionedSource } from "./datasource.ts";
8
8
  import { mountWorkers, sdkDecisionEvaluator, type AppJobHandler } from "./workers.ts";
9
+ import { makeGateway } from "./gateway.ts";
10
+ import { LineageStore } from "./lineage-store.ts";
11
+ import {
12
+ __resetExecStoreForTests,
13
+ currentJobContext,
14
+ installExecStore,
15
+ type JobExecContext,
16
+ } from "../execContext.ts";
17
+ import { createNodeHost } from "../../adapters/node.ts";
18
+ import { mkdtemp, rm } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { join } from "node:path";
9
21
 
10
22
  /** A tiny engine that records registrations and can deliver a job to a handler. */
11
23
  class MiniEngine implements EngineClient {
@@ -303,3 +315,126 @@ test("AppJobHandler carries optional In/Out variable types", async () => {
303
315
  assert.deepEqual(await open(openJob, app), { count: 2 });
304
316
  assert.equal(await voidReturn(job, app), undefined);
305
317
  });
318
+
319
+ // --- Lineage threading + projection recording (issue #254) ---------------------
320
+
321
+ /** A DataLayer with a single default sqlite source, so `tryLineageStore` can record into it. */
322
+ async function dataLayerWithSqlite(): Promise<{ data: DataLayer; db: ProvisionedSource["db"]; cleanup: () => Promise<void> }> {
323
+ const dir = await mkdtemp(join(tmpdir(), "urban-workers-lineage-"));
324
+ const host = createNodeHost({ cwd: dir, log: () => {} });
325
+ const db = host.openSqlite(join(dir, "app.db"));
326
+ const src: ProvisionedSource = {
327
+ name: "main",
328
+ driver: "sqlite",
329
+ db,
330
+ source: makeGateway(db),
331
+ migrationsApplied: [],
332
+ close: () => db.close(),
333
+ };
334
+ const data = new DataLayer(new Map([["main", src]]), "main", {});
335
+ return { data, db, cleanup: async () => { try { db.close(); } catch { /* already closed */ } await rm(dir, { recursive: true, force: true }); } };
336
+ }
337
+
338
+ test("mountWorkers threads the lineage rootRequestKey into the ambient job context", async () => {
339
+ __resetExecStoreForTests();
340
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
341
+ installExecStore(() => host.createAsyncStore?.<JobExecContext>());
342
+ const engine = new MiniEngine();
343
+ const { ctx } = makeCtx({ workers: [{ taskType: "spawn", handler: "workers/spawn.ts" }] }, engine);
344
+ const seen: Array<JobExecContext | undefined> = [];
345
+ const handler: AppJobHandler = () => {
346
+ seen.push(currentJobContext());
347
+ };
348
+ ctx.host.importModule = async () => ({ default: handler });
349
+ try {
350
+ await mountWorkers(ctx, makeApp());
351
+ // An instance carrying an envelope propagates its root.
352
+ await engine.deliver("spawn", {
353
+ jobKey: "j1",
354
+ jobType: "spawn",
355
+ processInstanceKey: "pi-child",
356
+ variables: { _urban: { lineage: { rootRequestKey: "ROOT", causedByInstanceKey: "pi-parent" } } },
357
+ });
358
+ // An instance with no envelope is treated as its own root.
359
+ await engine.deliver("spawn", { jobKey: "j2", jobType: "spawn", processInstanceKey: "pi-solo", variables: {} });
360
+ assert.equal(seen[0]?.rootRequestKey, "ROOT");
361
+ assert.equal(seen[1]?.rootRequestKey, "pi-solo");
362
+ } finally {
363
+ __resetExecStoreForTests();
364
+ }
365
+ });
366
+
367
+ test("mountWorkers records each activated job's lineage edge into the projection (idempotently)", async () => {
368
+ const engine = new MiniEngine();
369
+ const { ctx } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
370
+ const handler: AppJobHandler = () => ({ ok: true });
371
+ ctx.host.importModule = async () => ({ default: handler });
372
+ const { data, db, cleanup } = await dataLayerWithSqlite();
373
+ try {
374
+ await mountWorkers(ctx, makeApp({ data }));
375
+ const job = {
376
+ jobKey: "j1",
377
+ jobType: "work",
378
+ processInstanceKey: "pi-child",
379
+ variables: { _urban: { lineage: { rootRequestKey: "ROOT", causedByInstanceKey: "pi-root" } } },
380
+ };
381
+ await engine.deliver("work", job);
382
+ await engine.deliver("work", { ...job, jobKey: "j2" }); // re-activation → idempotent
383
+
384
+ const tree = new LineageStore(db).getLineage("ROOT");
385
+ const child = tree.nodes.find((n) => n.instanceKey === "pi-child");
386
+ assert.equal(child?.causedByInstanceKey, "pi-root");
387
+ assert.equal(child?.edgeType, "weak");
388
+ assert.equal(new LineageStore(db).edges("ROOT").length, 1, "re-activation does not duplicate the edge");
389
+ } finally {
390
+ await cleanup();
391
+ }
392
+ });
393
+
394
+ test("mountWorkers records nothing when the app has no default data source (absent-safe)", async () => {
395
+ const engine = new MiniEngine();
396
+ const { ctx, logs } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
397
+ const handler: AppJobHandler = () => ({ ok: true });
398
+ ctx.host.importModule = async () => ({ default: handler });
399
+ // makeApp's DataLayer has no default source; mounting + delivering must not throw.
400
+ await mountWorkers(ctx, makeApp());
401
+ await engine.deliver("work", { jobKey: "j1", jobType: "work", processInstanceKey: "pi", variables: {} });
402
+ assert.ok(!logs.some((l) => l.level === "error"));
403
+ });
404
+
405
+ test("mountWorkers surfaces a real provisioning failure as a warn (not the silent absent case)", async () => {
406
+ const engine = new MiniEngine();
407
+ const { ctx, logs } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
408
+ const handler: AppJobHandler = () => ({ ok: true });
409
+ ctx.host.importModule = async () => ({ default: handler });
410
+ const { data, db, cleanup } = await dataLayerWithSqlite();
411
+ try {
412
+ // A default source IS configured, but provisioning the projection fails (closed db). This is a
413
+ // genuine fault, not the absent case, so it must be logged at `warn` with the error — never
414
+ // disguised as an expected "no default data source" debug, and never a failed worker mount.
415
+ db.close();
416
+ await mountWorkers(ctx, makeApp({ data }));
417
+ await engine.deliver("work", { jobKey: "j1", jobType: "work", processInstanceKey: "pi", variables: {} });
418
+ const warned = logs.find((l) => l.level === "warn" && l.msg.includes("failed to provision"));
419
+ assert.ok(warned, "a configured-but-broken datasource must warn, not silently degrade");
420
+ assert.ok(!logs.some((l) => l.msg.includes("no default data source")), "must not claim the datasource is absent");
421
+ } finally {
422
+ await cleanup();
423
+ }
424
+ });
425
+
426
+ test("mountWorkers surfaces a configured-but-missing default source as a warn (not the silent absent case)", async () => {
427
+ const engine = new MiniEngine();
428
+ const { ctx, logs } = makeCtx({ workers: [{ taskType: "work", handler: "workers/work.ts" }] }, engine);
429
+ const handler: AppJobHandler = () => ({ ok: true });
430
+ ctx.host.importModule = async () => ({ default: handler });
431
+ // A default source is NAMED but no such source is provisioned, so `data.source()` throws
432
+ // `no such data source "main"`. That is a genuine misconfiguration — it must warn, never be
433
+ // disguised as the expected "no default data source" absent case.
434
+ const data = new DataLayer(new Map(), "main", {});
435
+ await mountWorkers(ctx, makeApp({ data }));
436
+ await engine.deliver("work", { jobKey: "j1", jobType: "work", processInstanceKey: "pi", variables: {} });
437
+ const warned = logs.find((l) => l.level === "warn" && l.msg.includes("failed to provision"));
438
+ assert.ok(warned, "a configured-but-missing default source must warn, not silently degrade");
439
+ assert.ok(!logs.some((l) => l.msg.includes("no default data source")), "must not claim the datasource is absent");
440
+ });
@@ -8,6 +8,9 @@ import type { EngineJob, JobHandler, WorkerSubscription } from "../host.ts";
8
8
  import { workerJobType, type Worker } from "../manifest.ts";
9
9
  import { type DecisionEvaluator, type LlmRuntime, runLlmJob } from "./llm.ts";
10
10
  import { runInJobContext } from "../execContext.ts";
11
+ import type { JobExecContext } from "../execContext.ts";
12
+ import { readLineage } from "../lineage.ts";
13
+ import { LineageStore } from "./lineage-store.ts";
11
14
 
12
15
  /** The subset of the engine SDK the LLM decision-rails need: evaluate a DMN decision,
13
16
  * whose `output` comes back as a JSON string (the orchestration-cluster contract). */
@@ -86,6 +89,60 @@ function withJobLog(app: AppApi, job: EngineJob): AppApi {
86
89
  return { ...app, log: app.log.child(bindings) };
87
90
  }
88
91
 
92
+ /**
93
+ * Build the ambient {@link JobExecContext} a worker runs its handler within. Beyond the
94
+ * instance/element/jobType used for write-provenance, it resolves the lineage
95
+ * `rootRequestKey` (issue #254): the job's own `_urban.lineage` envelope root when present,
96
+ * else the instance's own key — so an instance with no envelope is treated as a root and any
97
+ * instance/message its handler spawns inherits that root with `causedByInstanceKey` set.
98
+ */
99
+ function jobExecContext(job: EngineJob, jobType: string): JobExecContext {
100
+ return {
101
+ instanceKey: job.processInstanceKey,
102
+ elementId: job.elementId,
103
+ jobType,
104
+ rootRequestKey: readLineage(job.variables)?.rootRequestKey ?? job.processInstanceKey,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Build the framework lineage projection store over the app's DEFAULT data source, or `undefined`
110
+ * when the app configures none. The store records the `_urban.lineage` edge every activated job
111
+ * reveals (issue #254), so intent→progress lineage materialises for free — but it is a sidecar on
112
+ * the app's own source (like write-provenance), so an app with no datasource simply records
113
+ * nothing. Absent- and error-safe: any failure to provision degrades to no lineage recording, never
114
+ * a failed worker mount.
115
+ */
116
+ function tryLineageStore(app: AppApi, log: RuntimeContext["host"]["log"]): LineageStore | undefined {
117
+ if (!app.data.hasDefaultSource()) {
118
+ // No default data source configured at all — lineage is a sidecar on the app's own source, so
119
+ // this is the expected absent case (like write-provenance): record nothing, silently.
120
+ log("debug", "lineage: no default data source; lineage recording disabled");
121
+ return undefined;
122
+ }
123
+ try {
124
+ const source = app.data.source();
125
+ const store = new LineageStore(source.db);
126
+ store.ensureSchema();
127
+ return store;
128
+ } catch (err) {
129
+ // A default IS configured but resolving or provisioning it failed — a missing named source
130
+ // (`no such data source`) or a schema/DB error. That is a genuine fault, not the absent case,
131
+ // so surface it at `warn` rather than disguising it as "no source".
132
+ log("warn", "lineage: failed to provision projection store; lineage recording disabled", { error: String(err) });
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ /** Record an activated job's lineage edge, never letting a projection write break the job. */
138
+ function recordJobLineage(store: LineageStore, job: EngineJob, log: RuntimeContext["host"]["log"]): void {
139
+ try {
140
+ store.recordFromJob(job);
141
+ } catch (err) {
142
+ log("warn", "lineage: failed to record job edge", { jobType: job.jobType, error: String(err) });
143
+ }
144
+ }
145
+
89
146
  /**
90
147
  * Resolve a handler for `jobType` from a loaded module, in priority order:
91
148
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -116,6 +173,11 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
116
173
  const subs: WorkerSubscription[] = [];
117
174
  const jobTypes: string[] = [];
118
175
 
176
+ // The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
177
+ // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
178
+ // app configures no default data source.
179
+ const lineageStore = tryLineageStore(app, ctx.host.log);
180
+
119
181
  // Cache module loads so a multi-type handler module is imported once.
120
182
  const moduleCache = new Map<string, Record<string, unknown>>();
121
183
  const joinRoot = (p: string): string =>
@@ -157,11 +219,13 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
157
219
  env: (n) => app.env(n),
158
220
  evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
159
221
  };
160
- const wrapped: JobHandler = (job) =>
161
- runInJobContext(
162
- { instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
222
+ const wrapped: JobHandler = (job) => {
223
+ if (lineageStore) recordJobLineage(lineageStore, job, ctx.host.log);
224
+ return runInJobContext(
225
+ jobExecContext(job, jobType),
163
226
  () => runLlmJob(job.variables, binding, rt),
164
227
  );
228
+ };
165
229
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
166
230
  workerName: `${ctx.manifest.id}:${jobType}`,
167
231
  });
@@ -178,11 +242,13 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
178
242
  `(expected handlers["${jobType}"], a named export, or a default function)`,
179
243
  );
180
244
  }
181
- const wrapped: JobHandler = (job) =>
182
- runInJobContext(
183
- { instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
245
+ const wrapped: JobHandler = (job) => {
246
+ if (lineageStore) recordJobLineage(lineageStore, job, ctx.host.log);
247
+ return runInJobContext(
248
+ jobExecContext(job, jobType),
184
249
  () => handler(job, withJobLog(app, job)),
185
250
  );
251
+ };
186
252
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
187
253
  workerName: `${ctx.manifest.id}:${jobType}`,
188
254
  });
@@ -11,6 +11,9 @@ import {
11
11
  type NanoSdkJobWorkerConfig,
12
12
  } from "./nanosdk.ts";
13
13
  import { BpmnError } from "../core/host.ts";
14
+ import { __resetExecStoreForTests, installExecStore, runInJobContext } from "../core/execContext.ts";
15
+ import { readLineage } from "../core/lineage.ts";
16
+ import { createNodeHost } from "../adapters/node.ts";
14
17
 
15
18
  /** A fake nano-sdk client that records calls and lets a test drive its job worker. */
16
19
  function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient & {
@@ -133,7 +136,8 @@ test("createInstance routes through the SDK and coerces the key", async () => {
133
136
  const engine = new SdkEngineClient(client);
134
137
  const res = await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
135
138
  assert.equal(res.processInstanceKey, "99");
136
- assert.deepEqual(res.variables, { a: 1 });
139
+ // Caller variables are preserved (the SDK also auto-threads a `_urban.lineage` envelope, tested below).
140
+ assert.equal(res.variables?.a, 1);
137
141
  assert.ok(client.calls.includes("createProcessInstance"));
138
142
  });
139
143
 
@@ -162,7 +166,7 @@ test("cancelInstance routes through the SDK", async () => {
162
166
  assert.ok(client.calls.includes("cancelProcessInstance") || seen !== undefined);
163
167
  });
164
168
 
165
- test("publishMessage defaults correlationKey/variables", async () => {
169
+ test("publishMessage defaults correlationKey and auto-threads lineage onto variables", async () => {
166
170
  let seen: Record<string, unknown> | undefined;
167
171
  const client = fakeSdkClient({
168
172
  publishMessage: async (input) => {
@@ -172,7 +176,12 @@ test("publishMessage defaults correlationKey/variables", async () => {
172
176
  });
173
177
  const engine = new SdkEngineClient(client);
174
178
  await engine.publishMessage({ name: "m" });
175
- assert.deepEqual(seen, { name: "m", correlationKey: "", variables: {} });
179
+ assert.equal(seen?.name, "m");
180
+ assert.equal(seen?.correlationKey, "");
181
+ // With no ambient job context this is a top-level publish: a fresh root envelope is minted.
182
+ const env = readLineage(seen?.variables);
183
+ assert.ok(env, "a lineage envelope is threaded onto the message variables");
184
+ assert.equal(env?.causedByInstanceKey, undefined);
176
185
  });
177
186
 
178
187
  test("searchUserTasks passes zero-wait consistency and maps items", async () => {
@@ -670,3 +679,88 @@ test("createNanoSdkEngineClient uses an injected client factory with the resolve
670
679
  assert.deepEqual(seen, { restAddress: "http://x/v2", token: "t", transport: "falcon" });
671
680
  assert.ok(engine);
672
681
  });
682
+
683
+ // --- Lineage auto-threading (issue #254) ---------------------------------------
684
+
685
+ /** A fake client that captures the variables handed to createProcessInstance/publishMessage. */
686
+ function capturingClient(): NanoSdkClient & { createVars?: Record<string, unknown>; msgVars?: Record<string, unknown> } {
687
+ const base = fakeSdkClient();
688
+ const cap: NanoSdkClient & { createVars?: Record<string, unknown>; msgVars?: Record<string, unknown> } = {
689
+ ...base,
690
+ async createProcessInstance(input) {
691
+ cap.createVars = input.variables;
692
+ return { processInstanceKey: 99, variables: input.variables };
693
+ },
694
+ async publishMessage(input) {
695
+ cap.msgVars = input.variables;
696
+ return { key: 1 };
697
+ },
698
+ };
699
+ return cap;
700
+ }
701
+
702
+ test("createInstance mints a fresh root envelope for a top-level request (no ambient)", async () => {
703
+ __resetExecStoreForTests();
704
+ const client = capturingClient();
705
+ const engine = new SdkEngineClient(client);
706
+ await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
707
+ const env = readLineage(client.createVars);
708
+ assert.ok(env, "an envelope was threaded onto the variables");
709
+ assert.notEqual(env?.rootRequestKey, "");
710
+ assert.equal(env?.causedByInstanceKey, undefined, "a fresh root has no cause");
711
+ assert.equal(client.createVars?.a, 1, "caller variables are preserved");
712
+ });
713
+
714
+ test("createInstance propagates the ambient lineage and stamps causedByInstanceKey", async () => {
715
+ __resetExecStoreForTests();
716
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
717
+ installExecStore(() => host.createAsyncStore?.());
718
+ const client = capturingClient();
719
+ const engine = new SdkEngineClient(client);
720
+ try {
721
+ await runInJobContext({ instanceKey: "pi-parent", rootRequestKey: "root-1", jobType: "w" }, () =>
722
+ engine.createInstance({ processDefinitionId: "child" }),
723
+ );
724
+ } finally {
725
+ __resetExecStoreForTests();
726
+ }
727
+ assert.deepEqual(readLineage(client.createVars), {
728
+ rootRequestKey: "root-1",
729
+ causedByInstanceKey: "pi-parent",
730
+ });
731
+ });
732
+
733
+ test("createInstance leaves an explicit caller-supplied envelope untouched (override wins)", async () => {
734
+ __resetExecStoreForTests();
735
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
736
+ installExecStore(() => host.createAsyncStore?.());
737
+ const client = capturingClient();
738
+ const engine = new SdkEngineClient(client);
739
+ try {
740
+ await runInJobContext({ instanceKey: "pi-parent", rootRequestKey: "root-1", jobType: "w" }, () =>
741
+ engine.createInstance({
742
+ processDefinitionId: "child",
743
+ variables: { _urban: { lineage: { rootRequestKey: "explicit" } } },
744
+ }),
745
+ );
746
+ } finally {
747
+ __resetExecStoreForTests();
748
+ }
749
+ assert.deepEqual(readLineage(client.createVars), { rootRequestKey: "explicit", causedByInstanceKey: undefined });
750
+ });
751
+
752
+ test("publishMessage threads lineage so message-started instances inherit it", async () => {
753
+ __resetExecStoreForTests();
754
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
755
+ installExecStore(() => host.createAsyncStore?.());
756
+ const client = capturingClient();
757
+ const engine = new SdkEngineClient(client);
758
+ try {
759
+ await runInJobContext({ instanceKey: "pi-9", rootRequestKey: "root-9", jobType: "w" }, () =>
760
+ engine.publishMessage({ name: "start" }),
761
+ );
762
+ } finally {
763
+ __resetExecStoreForTests();
764
+ }
765
+ assert.deepEqual(readLineage(client.msgVars), { rootRequestKey: "root-9", causedByInstanceKey: "pi-9" });
766
+ });
@@ -31,6 +31,7 @@ import {
31
31
  } from "../core/form-contract.ts";
32
32
  import type { EngineSdkClient } from "./sdk.ts";
33
33
  import { isRecord } from "../core/guards.ts";
34
+ import { applyAmbientLineage } from "../core/lineage.ts";
34
35
 
35
36
  /** Coerce an engine response's process-instance key to a non-empty string, or
36
37
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -193,9 +194,12 @@ export class SdkEngineClient implements EngineClient {
193
194
  variables?: Record<string, unknown>;
194
195
  awaitCompletion?: boolean;
195
196
  }): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }> {
197
+ // Auto-thread the `_urban.lineage` envelope (issue #254): a handler that spawns an
198
+ // instance propagates its `rootRequestKey` and stamps `causedByInstanceKey`; a genuine
199
+ // top-level request mints a fresh root. An explicit caller-supplied envelope wins.
196
200
  const body = await this.client.createProcessInstance({
197
201
  processDefinitionId: input.processDefinitionId,
198
- variables: input.variables ?? {},
202
+ variables: applyAmbientLineage(input.variables),
199
203
  awaitCompletion: input.awaitCompletion ?? false,
200
204
  });
201
205
  const rawKey = body.processInstanceKey ?? body.key;
@@ -217,10 +221,12 @@ export class SdkEngineClient implements EngineClient {
217
221
  correlationKey?: string;
218
222
  variables?: Record<string, unknown>;
219
223
  }): Promise<void> {
224
+ // Message-started instances inherit lineage too (issue #254): thread the envelope onto the
225
+ // message variables so the instance the message starts carries `_urban.lineage`.
220
226
  await this.client.publishMessage({
221
227
  name: input.name,
222
228
  correlationKey: input.correlationKey ?? "",
223
- variables: input.variables ?? {},
229
+ variables: applyAmbientLineage(input.variables),
224
230
  });
225
231
  }
226
232