@nanobpm/urban 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/execContext.d.ts +5 -0
  5. package/dist/runtime/core/extensions.d.ts +131 -0
  6. package/dist/runtime/core/extensions.js +137 -0
  7. package/dist/runtime/core/glob.d.ts +7 -6
  8. package/dist/runtime/core/glob.js +16 -14
  9. package/dist/runtime/core/host.d.ts +4 -1
  10. package/dist/runtime/core/lineage.d.ts +116 -0
  11. package/dist/runtime/core/lineage.js +186 -0
  12. package/dist/runtime/core/modules/agent.d.ts +17 -0
  13. package/dist/runtime/core/modules/agent.js +63 -0
  14. package/dist/runtime/core/modules/datasource.d.ts +6 -0
  15. package/dist/runtime/core/modules/datasource.js +8 -0
  16. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  17. package/dist/runtime/core/modules/deploy.js +62 -16
  18. package/dist/runtime/core/modules/lineage-store.d.ts +81 -0
  19. package/dist/runtime/core/modules/lineage-store.js +175 -0
  20. package/dist/runtime/core/modules/pages.js +397 -20
  21. package/dist/runtime/core/modules/surfaces.js +8 -0
  22. package/dist/runtime/core/modules/workers.js +69 -2
  23. package/dist/runtime/core/phase.d.ts +168 -0
  24. package/dist/runtime/core/phase.js +367 -0
  25. package/dist/runtime/core/runtime.d.ts +17 -0
  26. package/dist/runtime/core/runtime.js +30 -0
  27. package/dist/runtime/engine/nanosdk.js +8 -2
  28. package/dist/runtime/index.d.ts +10 -0
  29. package/dist/runtime/index.js +12 -0
  30. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  31. package/dist/toolkit/derivers/system-brief.js +156 -0
  32. package/dist/toolkit/gen.d.ts +5 -5
  33. package/dist/toolkit/gen.js +20 -14
  34. package/dist/toolkit/index.d.ts +5 -0
  35. package/dist/toolkit/index.js +3 -0
  36. package/dist/toolkit/models.d.ts +4 -3
  37. package/dist/toolkit/models.js +4 -3
  38. package/package.json +1 -1
  39. package/src/runtime/core/events.test.ts +382 -0
  40. package/src/runtime/core/events.ts +370 -0
  41. package/src/runtime/core/execContext.ts +5 -0
  42. package/src/runtime/core/extensions.test.ts +378 -0
  43. package/src/runtime/core/extensions.ts +276 -0
  44. package/src/runtime/core/glob.ts +15 -12
  45. package/src/runtime/core/host.ts +4 -1
  46. package/src/runtime/core/lineage.test.ts +215 -0
  47. package/src/runtime/core/lineage.ts +297 -0
  48. package/src/runtime/core/modules/agent.test.ts +104 -0
  49. package/src/runtime/core/modules/agent.ts +76 -0
  50. package/src/runtime/core/modules/datasource.ts +9 -0
  51. package/src/runtime/core/modules/deploy.test.ts +84 -16
  52. package/src/runtime/core/modules/deploy.ts +59 -16
  53. package/src/runtime/core/modules/lineage-store.test.ts +177 -0
  54. package/src/runtime/core/modules/lineage-store.ts +239 -0
  55. package/src/runtime/core/modules/pages.test.ts +309 -5
  56. package/src/runtime/core/modules/pages.ts +398 -20
  57. package/src/runtime/core/modules/surfaces.ts +9 -0
  58. package/src/runtime/core/modules/workers.test.ts +136 -1
  59. package/src/runtime/core/modules/workers.ts +72 -6
  60. package/src/runtime/core/phase.test.ts +350 -0
  61. package/src/runtime/core/phase.ts +526 -0
  62. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  63. package/src/runtime/core/runtime.ts +49 -0
  64. package/src/runtime/devserver.test.ts +3 -0
  65. package/src/runtime/engine/nanosdk.test.ts +97 -3
  66. package/src/runtime/engine/nanosdk.ts +8 -2
  67. package/src/runtime/index.ts +92 -0
  68. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  69. package/src/toolkit/derivers/system-brief.ts +210 -0
  70. package/src/toolkit/gen.test.ts +28 -4
  71. package/src/toolkit/gen.ts +20 -13
  72. package/src/toolkit/index.ts +14 -0
  73. package/src/toolkit/models.ts +4 -3
@@ -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
  });
@@ -0,0 +1,350 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildScopeIndex,
5
+ derivePhase,
6
+ deriveInstancePhases,
7
+ furthestReached,
8
+ rollupLineagePhase,
9
+ SCOPE_CONTAINERS,
10
+ type InstancePhase,
11
+ type ProvenanceProgressRow,
12
+ } from "./phase.ts";
13
+ import { buildLineageTree, type LineageEdge } from "./lineage.ts";
14
+
15
+ // A representative model in the spirit of the issue's motivating `plan-fanout.bpmn`: a process
16
+ // with named subProcesses ("Plan", "Implement task") wrapping tasks, a flat task at process level,
17
+ // a nano:phase override (attribute form), and a zeebe:property override.
18
+ const MODEL = `<?xml version="1.0" encoding="UTF-8"?>
19
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="http://nanobpm.io/schema/1.0">
20
+ <bpmn:process id="plan-fanout" name="Plan Fanout">
21
+ <bpmn:startEvent id="start" name="Start" />
22
+ <bpmn:subProcess id="plan" name="Plan">
23
+ <bpmn:serviceTask id="draft-plan" name="Draft plan" />
24
+ <bpmn:userTask id="review-plan" name="Review plan" />
25
+ </bpmn:subProcess>
26
+ <bpmn:subProcess id="implement" name="Implement task">
27
+ <bpmn:serviceTask id="write-code" name="Write code">
28
+ <bpmn:extensionElements>
29
+ <zeebe:properties>
30
+ <zeebe:property name="nano:phase" value="Coding (agent)" />
31
+ </zeebe:properties>
32
+ </bpmn:extensionElements>
33
+ </bpmn:serviceTask>
34
+ <bpmn:subProcess id="trial-merge" name="Trial merge">
35
+ <bpmn:serviceTask id="merge-attempt" name="Merge attempt" />
36
+ </bpmn:subProcess>
37
+ </bpmn:subProcess>
38
+ <bpmn:serviceTask id="finalize" name="Finalize plan" nano:phase="Wrap up" />
39
+ <bpmn:endEvent id="dispatched" name="Dispatched" />
40
+ </bpmn:process>
41
+ </bpmn:definitions>`;
42
+
43
+ test("SCOPE_CONTAINERS names the process + nestable scope families", () => {
44
+ assert.deepEqual([...SCOPE_CONTAINERS], ["process", "subProcess", "adHocSubProcess", "transaction"]);
45
+ });
46
+
47
+ test("buildScopeIndex records the process and every id-bearing element with its scope chain", () => {
48
+ const idx = buildScopeIndex(MODEL);
49
+ assert.equal(idx.process?.id, "plan-fanout");
50
+ assert.equal(idx.process?.name, "Plan Fanout");
51
+
52
+ // A flat task at process level: chain is just the process.
53
+ assert.deepEqual(idx.elements.get("start")?.scopeChain, ["plan-fanout"]);
54
+ // A task inside a top-level subProcess.
55
+ assert.deepEqual(idx.elements.get("draft-plan")?.scopeChain, ["plan-fanout", "plan"]);
56
+ // A task inside a nested subProcess: full outermost→innermost chain.
57
+ assert.deepEqual(idx.elements.get("merge-attempt")?.scopeChain, [
58
+ "plan-fanout",
59
+ "implement",
60
+ "trial-merge",
61
+ ]);
62
+ // Scope containers are flagged and carry their own chain (excluding themselves).
63
+ assert.equal(idx.elements.get("trial-merge")?.isScope, true);
64
+ assert.deepEqual(idx.elements.get("trial-merge")?.scopeChain, ["plan-fanout", "implement"]);
65
+ assert.equal(idx.elements.get("write-code")?.isScope, false);
66
+ });
67
+
68
+ test("buildScopeIndex captures nano:phase overrides in attribute and zeebe:property forms", () => {
69
+ const idx = buildScopeIndex(MODEL);
70
+ // Attribute form on a flat task.
71
+ assert.equal(idx.elements.get("finalize")?.phaseOverride, "Wrap up");
72
+ // zeebe:property form nested in extensionElements attaches to the owning service task, not to a
73
+ // sibling or ancestor.
74
+ assert.equal(idx.elements.get("write-code")?.phaseOverride, "Coding (agent)");
75
+ assert.equal(idx.elements.get("implement")?.phaseOverride, undefined);
76
+ // No override → undefined.
77
+ assert.equal(idx.elements.get("draft-plan")?.phaseOverride, undefined);
78
+ });
79
+
80
+ test("buildScopeIndex ignores over-broad matches: bare `phase` attr and non-zeebe/loose `property`", () => {
81
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
82
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
83
+ <bpmn:process id="p" name="P">
84
+ <!-- A bare, unprefixed phase="…" attribute must NOT be treated as an override. -->
85
+ <bpmn:serviceTask id="bare-attr" name="Bare attr" phase="not an override" />
86
+ <!-- A bpmn:property (non-zeebe) carrying name="nano:phase" must NOT override. -->
87
+ <bpmn:serviceTask id="bpmn-prop" name="Bpmn prop">
88
+ <bpmn:extensionElements>
89
+ <bpmn:property name="nano:phase" value="not an override" />
90
+ </bpmn:extensionElements>
91
+ </bpmn:serviceTask>
92
+ <!-- A zeebe:property outside any extensionElements block must NOT override. -->
93
+ <bpmn:serviceTask id="loose-prop" name="Loose prop">
94
+ <zeebe:property name="nano:phase" value="not an override" />
95
+ </bpmn:serviceTask>
96
+ </bpmn:process>
97
+ </bpmn:definitions>`;
98
+ const idx = buildScopeIndex(xml);
99
+ assert.equal(idx.elements.get("bare-attr")?.phaseOverride, undefined);
100
+ assert.equal(idx.elements.get("bpmn-prop")?.phaseOverride, undefined);
101
+ assert.equal(idx.elements.get("loose-prop")?.phaseOverride, undefined);
102
+ });
103
+
104
+ test("derivePhase: a scope element is its own leaf crumb, appended exactly once (no duplicate)", () => {
105
+ // Querying a subProcess id (an element that IS a scope) must place it as the leaf crumb after its
106
+ // enclosing scopes — never omitted and never duplicated. Guards the "always append the element as
107
+ // its own leaf" contract, since `scopeChain` excludes the element itself.
108
+ const idx = buildScopeIndex(MODEL);
109
+ const phase = derivePhase(idx, "trial-merge");
110
+ assert.ok(phase);
111
+ assert.deepEqual(
112
+ phase.breadcrumb.map((c) => c.label),
113
+ ["Plan Fanout", "Implement task", "Trial merge"],
114
+ );
115
+ assert.deepEqual(
116
+ phase.breadcrumb.map((c) => c.kind),
117
+ ["process", "subProcess", "subProcess"],
118
+ );
119
+ assert.equal(phase.fine, "Trial merge");
120
+ });
121
+
122
+ test("derivePhase builds a breadcrumb of enclosing named scopes down to the element", () => {
123
+ const idx = buildScopeIndex(MODEL);
124
+ const phase = derivePhase(idx, "review-plan");
125
+ assert.ok(phase);
126
+ assert.deepEqual(
127
+ phase.breadcrumb.map((c) => c.label),
128
+ ["Plan Fanout", "Plan", "Review plan"],
129
+ );
130
+ assert.deepEqual(
131
+ phase.breadcrumb.map((c) => c.kind),
132
+ ["process", "subProcess", "element"],
133
+ );
134
+ assert.equal(phase.process, "Plan Fanout");
135
+ assert.equal(phase.coarse, "Plan");
136
+ assert.equal(phase.fine, "Review plan");
137
+ });
138
+
139
+ test("derivePhase: coarse is the top-level subProcess even when nested deeper", () => {
140
+ const idx = buildScopeIndex(MODEL);
141
+ const phase = derivePhase(idx, "merge-attempt");
142
+ assert.ok(phase);
143
+ assert.deepEqual(
144
+ phase.breadcrumb.map((c) => c.label),
145
+ ["Plan Fanout", "Implement task", "Trial merge", "Merge attempt"],
146
+ );
147
+ // Outermost meaningful scope = the top-level subProcess directly under the process.
148
+ assert.equal(phase.coarse, "Implement task");
149
+ assert.equal(phase.fine, "Merge attempt");
150
+ });
151
+
152
+ test("derivePhase: a flat (process-level) element yields a task-granular phase for free", () => {
153
+ const idx = buildScopeIndex(MODEL);
154
+ const phase = derivePhase(idx, "start");
155
+ assert.ok(phase);
156
+ assert.deepEqual(
157
+ phase.breadcrumb.map((c) => c.label),
158
+ ["Plan Fanout", "Start"],
159
+ );
160
+ // No subProcess → coarse falls back to the process.
161
+ assert.equal(phase.coarse, "Plan Fanout");
162
+ assert.equal(phase.fine, "Start");
163
+ });
164
+
165
+ test("derivePhase granularity: auto is subProcess-then-element", () => {
166
+ const idx = buildScopeIndex(MODEL);
167
+ // Inside a subProcess → auto picks the coarse subProcess label.
168
+ assert.equal(derivePhase(idx, "review-plan")?.label, "Plan");
169
+ // Flat → auto falls to the element label.
170
+ assert.equal(derivePhase(idx, "start")?.label, "Start");
171
+ });
172
+
173
+ test("derivePhase granularity: subProcess and element force a level", () => {
174
+ const idx = buildScopeIndex(MODEL);
175
+ assert.equal(derivePhase(idx, "merge-attempt", { granularity: "subProcess" })?.label, "Implement task");
176
+ assert.equal(derivePhase(idx, "merge-attempt", { granularity: "element" })?.label, "Merge attempt");
177
+ // A flat element forced to subProcess granularity falls back to the process (no subProcess).
178
+ assert.equal(derivePhase(idx, "start", { granularity: "subProcess" })?.label, "Plan Fanout");
179
+ });
180
+
181
+ test("derivePhase: Tier-1 override replaces the structural label and flags its source", () => {
182
+ const idx = buildScopeIndex(MODEL);
183
+ const attrForm = derivePhase(idx, "finalize");
184
+ assert.ok(attrForm);
185
+ assert.equal(attrForm.fine, "Wrap up");
186
+ assert.equal(attrForm.breadcrumb.at(-1)?.source, "override");
187
+
188
+ const propForm = derivePhase(idx, "write-code");
189
+ assert.ok(propForm);
190
+ assert.equal(propForm.fine, "Coding (agent)");
191
+ assert.equal(propForm.breadcrumb.at(-1)?.source, "override");
192
+ // Its structural ancestors remain structural.
193
+ assert.equal(propForm.breadcrumb[0].source, "structural");
194
+ });
195
+
196
+ test("derivePhase: unnamed element falls back to its id (never blank)", () => {
197
+ const idx = buildScopeIndex(
198
+ `<bpmn:process id="p"><bpmn:task id="t42" /></bpmn:process>`,
199
+ );
200
+ const phase = derivePhase(idx, "t42");
201
+ assert.ok(phase);
202
+ assert.equal(phase.fine, "t42");
203
+ assert.equal(phase.breadcrumb.at(-1)?.source, "structural");
204
+ });
205
+
206
+ test("derivePhase returns undefined for an unknown element", () => {
207
+ const idx = buildScopeIndex(MODEL);
208
+ assert.equal(derivePhase(idx, "no-such-element"), undefined);
209
+ });
210
+
211
+ test("derivePhase tolerates namespace prefixes and entity-encoded names", () => {
212
+ const idx = buildScopeIndex(
213
+ `<process id="p" name="A &amp; B"><subProcess id="s" name="Wave &lt;1&gt;"><task id="t" name="Go" /></subProcess></process>`,
214
+ );
215
+ const phase = derivePhase(idx, "t");
216
+ assert.ok(phase);
217
+ assert.deepEqual(
218
+ phase.breadcrumb.map((c) => c.label),
219
+ ["A & B", "Wave <1>", "Go"],
220
+ );
221
+ });
222
+
223
+ test("buildScopeIndex tolerates a literal `>` inside a quoted attribute value (no tag truncation)", () => {
224
+ const idx = buildScopeIndex(
225
+ `<process id="p" name="Root"><subProcess id="s" name="Wave > 1"><task id="t" name="Go" nano:phase="P > Q" /></subProcess></process>`,
226
+ );
227
+ // The `>` inside `name="Wave > 1"` must not truncate the subProcess tag: its id/name and the
228
+ // task's namespaced phase override (which follows the inner `>`) all parse intact.
229
+ assert.equal(idx.elements.get("s")?.name, "Wave > 1");
230
+ assert.equal(idx.elements.get("t")?.name, "Go");
231
+ assert.equal(idx.elements.get("t")?.phaseOverride, "P > Q");
232
+ assert.deepEqual(idx.elements.get("t")?.scopeChain, ["p", "s"]);
233
+ });
234
+
235
+ test("buildScopeIndex parses single-quoted attribute values (id/name/nano:phase, valid XML)", () => {
236
+ // XML permits single-quoted attribute values; `attr()`/`phaseAttr()` must recognize them so
237
+ // ids, names, and namespaced phase overrides are not silently dropped (which would break scope
238
+ // chains). A double-quoted value that itself contains an apostrophe must survive intact too.
239
+ const idx = buildScopeIndex(
240
+ `<process id='p' name='Root'><subProcess id='s' name="Ada's Wave"><task id='t' name='Go' nano:phase='Do &amp; Review' /></subProcess></process>`,
241
+ );
242
+ assert.equal(idx.elements.get("s")?.name, "Ada's Wave");
243
+ assert.equal(idx.elements.get("t")?.name, "Go");
244
+ assert.equal(idx.elements.get("t")?.phaseOverride, "Do & Review");
245
+ assert.deepEqual(idx.elements.get("t")?.scopeChain, ["p", "s"]);
246
+ });
247
+
248
+ test("furthestReached picks the greatest seq per instance and ignores null joins", () => {
249
+ const rows: ProvenanceProgressRow[] = [
250
+ { instance_key: "i1", element_id: "start", seq: 1, at: "2024-01-01T00:00:00Z" },
251
+ { instance_key: "i1", element_id: "review-plan", seq: 5, at: "2024-01-01T00:05:00Z" },
252
+ { instance_key: "i1", element_id: "draft-plan", seq: 3, at: "2024-01-01T00:03:00Z" },
253
+ { instance_key: "i2", element_id: "finalize", seq: 2, at: "2024-01-01T00:02:00Z" },
254
+ { instance_key: null, element_id: "x", seq: 9 },
255
+ { instance_key: "i3", element_id: null, seq: 9 },
256
+ ];
257
+ const f = furthestReached(rows);
258
+ assert.equal(f.get("i1")?.elementId, "review-plan");
259
+ assert.equal(f.get("i1")?.seq, 5);
260
+ assert.equal(f.get("i2")?.elementId, "finalize");
261
+ assert.equal(f.has("i3"), false);
262
+ assert.equal(f.has(""), false);
263
+ });
264
+
265
+ test("deriveInstancePhases projects furthest element → phase per instance", () => {
266
+ const idx = buildScopeIndex(MODEL);
267
+ const rows: ProvenanceProgressRow[] = [
268
+ { instance_key: "i1", element_id: "draft-plan", seq: 1, at: "2024-01-01T00:01:00Z" },
269
+ { instance_key: "i1", element_id: "review-plan", seq: 2, at: "2024-01-01T00:02:00Z" },
270
+ { instance_key: "i2", element_id: "merge-attempt", seq: 3, at: "2024-01-01T00:03:00Z" },
271
+ // Furthest element belongs to a different deployed model → skipped.
272
+ { instance_key: "i3", element_id: "foreign-element", seq: 4, at: "2024-01-01T00:04:00Z" },
273
+ ];
274
+ const phases = deriveInstancePhases(idx, rows);
275
+ assert.equal(phases.get("i1")?.phase.label, "Plan");
276
+ assert.equal(phases.get("i1")?.seq, 2);
277
+ assert.equal(phases.get("i1")?.at, "2024-01-01T00:02:00Z");
278
+ assert.equal(phases.get("i2")?.phase.coarse, "Implement task");
279
+ assert.equal(phases.has("i3"), false);
280
+ });
281
+
282
+ test("rollupLineagePhase: epic frontier is the most-recently-advanced contributor", () => {
283
+ // Parent epic i-root fans out to two children; the child that advanced latest defines the frontier.
284
+ const edges: LineageEdge[] = [
285
+ { rootRequestKey: "i-root", instanceKey: "i-child-a", causedByInstanceKey: "i-root", edgeType: "weak" },
286
+ { rootRequestKey: "i-root", instanceKey: "i-child-b", causedByInstanceKey: "i-root", edgeType: "weak" },
287
+ ];
288
+ const tree = buildLineageTree("i-root", edges);
289
+ const idx = buildScopeIndex(MODEL);
290
+ const rows: ProvenanceProgressRow[] = [
291
+ { instance_key: "i-root", element_id: "draft-plan", seq: 1, at: "2024-01-01T00:01:00Z" },
292
+ { instance_key: "i-child-a", element_id: "review-plan", seq: 2, at: "2024-01-01T00:02:00Z" },
293
+ { instance_key: "i-child-b", element_id: "merge-attempt", seq: 3, at: "2024-01-01T00:09:00Z" },
294
+ ];
295
+ const phases = deriveInstancePhases(idx, rows);
296
+ const rollup = rollupLineagePhase(tree, phases);
297
+ assert.equal(rollup.rootRequestKey, "i-root");
298
+ assert.equal(rollup.frontier?.instanceKey, "i-child-b");
299
+ assert.equal(rollup.frontier?.phase.coarse, "Implement task");
300
+ assert.equal(rollup.contributions.length, 3);
301
+ });
302
+
303
+ test("rollupLineagePhase: frontier breaks ties on seq and folds in unattached nodes", () => {
304
+ const tree = buildLineageTree("i-root", [
305
+ { rootRequestKey: "i-root", instanceKey: "i-root", edgeType: "weak" },
306
+ ]);
307
+ // Two contributors with the SAME `at` — greater seq wins the tie.
308
+ const phaseByInstance = new Map<string, InstancePhase>([
309
+ [
310
+ "i-root",
311
+ { instanceKey: "i-root", phase: derivePhase(buildScopeIndex(MODEL), "start")!, seq: 1, at: "t" },
312
+ ],
313
+ // An orphan node not reachable from root but present via the phase map is still considered.
314
+ [
315
+ "i-orphan",
316
+ { instanceKey: "i-orphan", phase: derivePhase(buildScopeIndex(MODEL), "finalize")!, seq: 9, at: "t" },
317
+ ],
318
+ ]);
319
+ // Attach the orphan to tree.nodes so the rollup can see it.
320
+ const treeWithOrphan = {
321
+ ...tree,
322
+ nodes: [...tree.nodes, { instanceKey: "i-orphan", attachments: [], children: [] }],
323
+ };
324
+ const rollup = rollupLineagePhase(treeWithOrphan, phaseByInstance);
325
+ assert.equal(rollup.frontier?.instanceKey, "i-orphan");
326
+ assert.equal(rollup.contributions.length, 2);
327
+ });
328
+
329
+ test("rollupLineagePhase: no phased nodes → no frontier", () => {
330
+ const tree = buildLineageTree("i-root", [
331
+ { rootRequestKey: "i-root", instanceKey: "i-root", edgeType: "weak" },
332
+ ]);
333
+ const rollup = rollupLineagePhase(tree, new Map());
334
+ assert.equal(rollup.frontier, undefined);
335
+ assert.deepEqual(rollup.contributions, []);
336
+ });
337
+
338
+ test("buildScopeIndex is resilient to comments, CDATA and self-closing scopes", () => {
339
+ const idx = buildScopeIndex(
340
+ `<bpmn:process id="p" name="P">
341
+ <!-- a comment with <fake id="x" /> inside -->
342
+ <bpmn:subProcess id="s" name="S">
343
+ <bpmn:serviceTask id="t" name="T"><![CDATA[<not id="y"/>]]></bpmn:serviceTask>
344
+ </bpmn:subProcess>
345
+ </bpmn:process>`,
346
+ );
347
+ assert.equal(idx.elements.has("x"), false);
348
+ assert.equal(idx.elements.has("y"), false);
349
+ assert.deepEqual(idx.elements.get("t")?.scopeChain, ["p", "s"]);
350
+ });