@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.
- package/dist/runtime/core/execContext.d.ts +5 -0
- package/dist/runtime/core/lineage.d.ts +116 -0
- package/dist/runtime/core/lineage.js +186 -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/lineage-store.d.ts +81 -0
- package/dist/runtime/core/modules/lineage-store.js +175 -0
- package/dist/runtime/core/modules/workers.js +69 -2
- package/dist/runtime/engine/nanosdk.js +8 -2
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/core/execContext.ts +5 -0
- package/src/runtime/core/lineage.test.ts +215 -0
- package/src/runtime/core/lineage.ts +297 -0
- package/src/runtime/core/modules/datasource.ts +9 -0
- 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/workers.test.ts +136 -1
- package/src/runtime/core/modules/workers.ts +72 -6
- package/src/runtime/engine/nanosdk.test.ts +97 -3
- package/src/runtime/engine/nanosdk.ts +8 -2
- package/src/runtime/index.ts +35 -0
|
@@ -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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|