@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
@@ -5,6 +5,8 @@ import { isRecord } from "../guards.js";
5
5
  import { workerJobType } from "../manifest.js";
6
6
  import { runLlmJob } from "./llm.js";
7
7
  import { runInJobContext } from "../execContext.js";
8
+ import { readLineage } from "../lineage.js";
9
+ import { LineageStore } from "./lineage-store.js";
8
10
  /** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
9
11
  * decision's JSON-string `output` into the value fed back to the LLM job. Validates the
10
12
  * SDK shape and adds decision context to a parse failure rather than surfacing a raw
@@ -48,6 +50,59 @@ function withJobLog(app, job) {
48
50
  bindings.elementId = job.elementId;
49
51
  return { ...app, log: app.log.child(bindings) };
50
52
  }
53
+ /**
54
+ * Build the ambient {@link JobExecContext} a worker runs its handler within. Beyond the
55
+ * instance/element/jobType used for write-provenance, it resolves the lineage
56
+ * `rootRequestKey` (issue #254): the job's own `_urban.lineage` envelope root when present,
57
+ * else the instance's own key — so an instance with no envelope is treated as a root and any
58
+ * instance/message its handler spawns inherits that root with `causedByInstanceKey` set.
59
+ */
60
+ function jobExecContext(job, jobType) {
61
+ return {
62
+ instanceKey: job.processInstanceKey,
63
+ elementId: job.elementId,
64
+ jobType,
65
+ rootRequestKey: readLineage(job.variables)?.rootRequestKey ?? job.processInstanceKey,
66
+ };
67
+ }
68
+ /**
69
+ * Build the framework lineage projection store over the app's DEFAULT data source, or `undefined`
70
+ * when the app configures none. The store records the `_urban.lineage` edge every activated job
71
+ * reveals (issue #254), so intent→progress lineage materialises for free — but it is a sidecar on
72
+ * the app's own source (like write-provenance), so an app with no datasource simply records
73
+ * nothing. Absent- and error-safe: any failure to provision degrades to no lineage recording, never
74
+ * a failed worker mount.
75
+ */
76
+ function tryLineageStore(app, log) {
77
+ if (!app.data.hasDefaultSource()) {
78
+ // No default data source configured at all — lineage is a sidecar on the app's own source, so
79
+ // this is the expected absent case (like write-provenance): record nothing, silently.
80
+ log("debug", "lineage: no default data source; lineage recording disabled");
81
+ return undefined;
82
+ }
83
+ try {
84
+ const source = app.data.source();
85
+ const store = new LineageStore(source.db);
86
+ store.ensureSchema();
87
+ return store;
88
+ }
89
+ catch (err) {
90
+ // A default IS configured but resolving or provisioning it failed — a missing named source
91
+ // (`no such data source`) or a schema/DB error. That is a genuine fault, not the absent case,
92
+ // so surface it at `warn` rather than disguising it as "no source".
93
+ log("warn", "lineage: failed to provision projection store; lineage recording disabled", { error: String(err) });
94
+ return undefined;
95
+ }
96
+ }
97
+ /** Record an activated job's lineage edge, never letting a projection write break the job. */
98
+ function recordJobLineage(store, job, log) {
99
+ try {
100
+ store.recordFromJob(job);
101
+ }
102
+ catch (err) {
103
+ log("warn", "lineage: failed to record job edge", { jobType: job.jobType, error: String(err) });
104
+ }
105
+ }
51
106
  /**
52
107
  * Resolve a handler for `jobType` from a loaded module, in priority order:
53
108
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -74,6 +129,10 @@ export async function mountWorkers(ctx, app) {
74
129
  const decls = ctx.manifest.workers ?? [];
75
130
  const subs = [];
76
131
  const jobTypes = [];
132
+ // The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
133
+ // edge its instance carries, so intent→progress lineage materialises for free. Absent when the
134
+ // app configures no default data source.
135
+ const lineageStore = tryLineageStore(app, ctx.host.log);
77
136
  // Cache module loads so a multi-type handler module is imported once.
78
137
  const moduleCache = new Map();
79
138
  const joinRoot = (p) => p.startsWith("/") ? p : `${ctx.root.replace(/\/+$/, "")}/${p.replace(/^\/+/, "")}`;
@@ -114,7 +173,11 @@ export async function mountWorkers(ctx, app) {
114
173
  env: (n) => app.env(n),
115
174
  evaluateDecision: app.sdk ? sdkDecisionEvaluator(app.sdk) : undefined,
116
175
  };
117
- const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => runLlmJob(job.variables, binding, rt));
176
+ const wrapped = (job) => {
177
+ if (lineageStore)
178
+ recordJobLineage(lineageStore, job, ctx.host.log);
179
+ return runInJobContext(jobExecContext(job, jobType), () => runLlmJob(job.variables, binding, rt));
180
+ };
118
181
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
119
182
  workerName: `${ctx.manifest.id}:${jobType}`,
120
183
  });
@@ -129,7 +192,11 @@ export async function mountWorkers(ctx, app) {
129
192
  throw new Error(`worker "${jobType}": ${decl.handler} exports no handler for it ` +
130
193
  `(expected handlers["${jobType}"], a named export, or a default function)`);
131
194
  }
132
- const wrapped = (job) => runInJobContext({ instanceKey: job.processInstanceKey, elementId: job.elementId, jobType }, () => handler(job, withJobLog(app, job)));
195
+ const wrapped = (job) => {
196
+ if (lineageStore)
197
+ recordJobLineage(lineageStore, job, ctx.host.log);
198
+ return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(app, job)));
199
+ };
133
200
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
134
201
  workerName: `${ctx.manifest.id}:${jobType}`,
135
202
  });
@@ -0,0 +1,168 @@
1
+ import type { LineageTree } from "./lineage.ts";
2
+ /** The BPMN local-names urban treats as *scope containers* — the enclosing scopes whose names
3
+ * become coarse phases. Namespace prefixes (e.g. `bpmn:`) are stripped before matching, so any
4
+ * prefix works. A `process` is the root scope; `subProcess`/`adHocSubProcess`/`transaction` are
5
+ * the nestable intermediate scopes a modeller wraps related activities in (and whose name then
6
+ * becomes the phase automatically — "declaring a phase" collapses into *naming a scope you
7
+ * already have"). */
8
+ export declare const SCOPE_CONTAINERS: readonly string[];
9
+ /** The kind of a breadcrumb crumb: the root `process`, an intermediate `subProcess`-family
10
+ * scope, or the leaf `element` (the flow node the token furthest reached). */
11
+ export type CrumbKind = "process" | "subProcess" | "element";
12
+ /** Where a crumb's label came from: the model's structure (a scope/element `name`, or an
13
+ * id fallback when unnamed), or an explicit `nano:phase` override (Tier 1). */
14
+ export type CrumbSource = "structural" | "override";
15
+ /** One crumb of a phase breadcrumb — a single enclosing scope, or the leaf element. */
16
+ export interface PhaseCrumb {
17
+ /** The BPMN element id of the scope/element this crumb represents. */
18
+ readonly id: string;
19
+ /** The BPMN local-name (namespace prefix stripped), e.g. `process`, `subProcess`, `serviceTask`. */
20
+ readonly type: string;
21
+ /** Whether this crumb is the root process, an intermediate scope, or the leaf element. */
22
+ readonly kind: CrumbKind;
23
+ /** The display label: the `nano:phase` override if present, else the `name`, else an
24
+ * id fallback so a crumb is never blank. */
25
+ readonly label: string;
26
+ /** Whether {@link label} came from structure or an explicit override. */
27
+ readonly source: CrumbSource;
28
+ }
29
+ /** Chosen granularity for the single-value `label` of a {@link Phase}:
30
+ * - `subProcess` — always the coarse (outermost meaningful scope) label.
31
+ * - `element` — always the fine (leaf element) label.
32
+ * - `auto` (default) — the coarse label when the element is inside a subProcess-family scope,
33
+ * else the fine element label ("subProcess-then-element"). */
34
+ export type PhaseGranularity = "subProcess" | "element" | "auto";
35
+ /** A derived domain phase for a single point in a process (the element a token reached). */
36
+ export interface Phase {
37
+ /** The element the phase was derived for (the furthest/active flow node). */
38
+ readonly elementId: string;
39
+ /** The enclosing process's id/name (the root crumb's label), for context. */
40
+ readonly process: string;
41
+ /** The coarse phase: the outermost meaningful scope — the top-level `subProcess` label when the
42
+ * element is inside one, else the process label. */
43
+ readonly coarse: string;
44
+ /** The fine phase: the leaf element's label. */
45
+ readonly fine: string;
46
+ /** The single-value phase for the requested {@link PhaseGranularity}. */
47
+ readonly label: string;
48
+ /** The full ordered breadcrumb, outermost process → … → leaf element. A UI can render at any
49
+ * granularity it picks — roll up to a scope, or drill to the task — all derived. */
50
+ readonly breadcrumb: readonly PhaseCrumb[];
51
+ }
52
+ /** Options for {@link derivePhase} / {@link deriveInstancePhases}. */
53
+ export interface DerivePhaseOptions {
54
+ /** Which granularity the single-value {@link Phase.label} reports. Default `auto`. */
55
+ readonly granularity?: PhaseGranularity;
56
+ }
57
+ /** The parsed structural view of one deployed model: every element's id/type/name/override plus
58
+ * its chain of enclosing scope ids (outermost → innermost). Build once per deployed model with
59
+ * {@link buildScopeIndex}; derive many instances' phases against it. */
60
+ export interface ScopeIndex {
61
+ /** The process's own element (its id is the `bpmnProcessId`). Undefined for a fragment with no
62
+ * `process` element. */
63
+ readonly process?: ScopeElement;
64
+ /** Every id-bearing element (scopes and flow nodes), keyed by element id. */
65
+ readonly elements: ReadonlyMap<string, ScopeElement>;
66
+ }
67
+ /** One id-bearing element parsed from a model: its identity plus its enclosing-scope chain. */
68
+ export interface ScopeElement {
69
+ readonly id: string;
70
+ /** BPMN local-name (prefix stripped), e.g. `process`, `subProcess`, `serviceTask`. */
71
+ readonly type: string;
72
+ /** The element's `name` attribute, if any. */
73
+ readonly name?: string;
74
+ /** An explicit `nano:phase` override for this element/scope, if declared. */
75
+ readonly phaseOverride?: string;
76
+ /** True when {@link type} is one of {@link SCOPE_CONTAINERS}. */
77
+ readonly isScope: boolean;
78
+ /** Ids of the enclosing scope containers, outermost (process) → innermost, NOT including this
79
+ * element itself. */
80
+ readonly scopeChain: readonly string[];
81
+ }
82
+ /** A provenance row (subset) as recorded in `_urban_write_provenance`: which element of which
83
+ * instance wrote a row, and the monotonic `seq`/`at` that order progress. Only these fields are
84
+ * read; the join keys (`source`/`table_name`/`pk_value`) are irrelevant to phase derivation. */
85
+ export interface ProvenanceProgressRow {
86
+ readonly instance_key: string | null;
87
+ readonly element_id: string | null;
88
+ readonly seq: number;
89
+ readonly at?: string | null;
90
+ }
91
+ /** The furthest point one instance reached: its most-advanced element and the ordering keys. */
92
+ export interface FurthestReached {
93
+ readonly instanceKey: string;
94
+ readonly elementId: string;
95
+ readonly seq: number;
96
+ readonly at?: string;
97
+ }
98
+ /** A single instance's derived phase together with the progress keys that order it against
99
+ * siblings for the Tier-2 lineage rollup. */
100
+ export interface InstancePhase {
101
+ readonly instanceKey: string;
102
+ readonly phase: Phase;
103
+ /** The provenance `seq` of the furthest write — monotonic within an instance. */
104
+ readonly seq?: number;
105
+ /** The provenance `at` timestamp of the furthest write (ISO-8601). */
106
+ readonly at?: string;
107
+ }
108
+ /**
109
+ * Parse one BPMN document into a {@link ScopeIndex}: the element→enclosing-scope containment plus
110
+ * each element's name and optional `nano:phase` override. Pure and deterministic. Namespace
111
+ * prefixes are ignored (matched by local-name), so `bpmn:`/`camunda:`/unprefixed all parse.
112
+ *
113
+ * The scan walks tags maintaining an element-nesting stack, so containment is exact even for
114
+ * deeply nested `subProcess`es. Scope-container frames (see {@link SCOPE_CONTAINERS}) contribute
115
+ * to the `scopeChain`; every id-bearing element is recorded. A `zeebe:property name="nano:phase"`
116
+ * encountered inside an element's `extensionElements` attaches its override to the nearest
117
+ * enclosing id-bearing element (the scope/flow node it decorates).
118
+ *
119
+ * Exception to prefix-agnostic parsing: the `<zeebe:property>` phase-override detection matches the
120
+ * literal `zeebe:` prefix (not by local-name), so a model that binds the Zeebe namespace to a
121
+ * different prefix will not be detected. This is deliberate — it mirrors the rest of the toolkit's
122
+ * `<zeebe:property …>` scans and prevents unrelated `<bpmn:property>` data elements from
123
+ * masquerading as phase overrides. (The `nano:phase` start-tag attribute remains prefix-agnostic.)
124
+ */
125
+ export declare function buildScopeIndex(xml: string): ScopeIndex;
126
+ /**
127
+ * Derive the {@link Phase} for a single element against a {@link ScopeIndex}. Tier 0 (structural)
128
+ * with Tier 1 (`nano:phase`) overrides folded in per crumb. Returns undefined when `elementId` is
129
+ * not in the index (an unknown/foreign element).
130
+ *
131
+ * The breadcrumb is the chain of enclosing scopes (outermost process → innermost subProcess)
132
+ * followed by the element itself. `coarse` is the outermost meaningful scope — the TOP-LEVEL
133
+ * subProcess (the one directly under the process) when the element is inside a subProcess, else
134
+ * the process. `fine` is the leaf element. `label` picks one per {@link PhaseGranularity}.
135
+ */
136
+ export declare function derivePhase(index: ScopeIndex, elementId: string, options?: DerivePhaseOptions): Phase | undefined;
137
+ /**
138
+ * Collapse provenance rows to the furthest point each instance reached — the row with the greatest
139
+ * `seq` per `instance_key` (monotonic within an instance). Rows with a null `instance_key` or
140
+ * `element_id` are ignored (a write outside a job records no element). Deterministic.
141
+ */
142
+ export declare function furthestReached(rows: readonly ProvenanceProgressRow[]): Map<string, FurthestReached>;
143
+ /**
144
+ * The Phase-1 read projection: derive each instance's domain phase from a deployed model's
145
+ * {@link ScopeIndex} and the model's `_urban_write_provenance` rows. Combines {@link furthestReached}
146
+ * (furthest element per instance) with {@link derivePhase}. Instances whose furthest element is not
147
+ * in this model's index are skipped (they belong to another deployed model). Pure and deterministic.
148
+ */
149
+ export declare function deriveInstancePhases(index: ScopeIndex, rows: readonly ProvenanceProgressRow[], options?: DerivePhaseOptions): Map<string, InstancePhase>;
150
+ /** The Tier-2 rollup: the epic/parent phase (the *frontier* of the lineage tree) plus every
151
+ * contributing instance's phase. */
152
+ export interface LineagePhaseRollup {
153
+ readonly rootRequestKey: string;
154
+ /** The frontier — the most-recently-advanced contributor's phase — or undefined when no node in
155
+ * the tree has a derived phase. This is the parent/epic's domain phase. */
156
+ readonly frontier?: InstancePhase;
157
+ /** Every stitched instance that has a derived phase: those reachable from `tree.root` first, in
158
+ * depth-first order, followed by any unattached `tree.nodes` not yet stitched to the tree. */
159
+ readonly contributions: readonly InstancePhase[];
160
+ }
161
+ /**
162
+ * Tier 2 — roll a domain phase up across a lineage tree (#254). Each stitched instance contributes
163
+ * its structurally-derived {@link InstancePhase}; the parent/epic phase is the *frontier* of that
164
+ * tree — the most-recently-advanced contributor (latest `at`, then greatest `seq`). This lets a
165
+ * fan-out epic surface the furthest domain phase any of its children has reached, not just the
166
+ * parent instance's own token position. Pure and deterministic.
167
+ */
168
+ export declare function rollupLineagePhase(tree: LineageTree, phaseByInstance: ReadonlyMap<string, InstancePhase>): LineagePhaseRollup;
@@ -0,0 +1,367 @@
1
+ // phase — the framework-level *domain phase* primitive (issue #266).
2
+ //
3
+ // A running process today exposes only engine status (`ACTIVE`/`TERMINATED`) and whatever
4
+ // terminal status an app reconciles. The *domain* lifecycle — "which phase is this instance
5
+ // in?" — is latent in the model (every BPMN element sits in exactly one enclosing scope:
6
+ // `process ⊃ subProcess ⊃ activity`) but is not reified anywhere. Apps hand-wire it, or don't.
7
+ // This module derives it by convention, with ZERO model declaration required, from two things
8
+ // urban already has: the deployed model's element→scope containment, and the furthest/active
9
+ // `element_id` per instance recorded in `_urban_write_provenance`.
10
+ //
11
+ // A three-tier ladder (most-specific wins):
12
+ //
13
+ // - Tier 0 — structural (default, zero declaration): phase = the breadcrumb of enclosing
14
+ // NAMED scopes down to the current element (`plan-fanout › Implement task`), derived from
15
+ // the containment hierarchy + provenance. Always available.
16
+ // - Tier 1 — optional explicit override: a model MAY annotate `nano:phase` (as a `nano:phase`
17
+ // attribute on the element/scope, or a `zeebe:property name="nano:phase"` in its
18
+ // `extensionElements`) to rename, group, or insert a phase where structure alone is wrong
19
+ // or too fine/coarse. Never required; overrides the structural label for that scope/element.
20
+ // - Tier 2 — cross-instance rollup: lineage (`_urban.lineage` / `buildLineageTree`, #254)
21
+ // stitches child instances; each contributes its structurally-derived phase and the
22
+ // parent/epic phase is the *frontier* of that tree (the most-recently-advanced contributor).
23
+ //
24
+ // This file is intentionally `node:*`-free (the host contract) and pure/deterministic — a read
25
+ // projection, exactly like {@link ./lineage.ts}. It shares nothing with the engine and can run
26
+ // anywhere the model XML + provenance rows are available.
27
+ /** The BPMN local-names urban treats as *scope containers* — the enclosing scopes whose names
28
+ * become coarse phases. Namespace prefixes (e.g. `bpmn:`) are stripped before matching, so any
29
+ * prefix works. A `process` is the root scope; `subProcess`/`adHocSubProcess`/`transaction` are
30
+ * the nestable intermediate scopes a modeller wraps related activities in (and whose name then
31
+ * becomes the phase automatically — "declaring a phase" collapses into *naming a scope you
32
+ * already have"). */
33
+ export const SCOPE_CONTAINERS = [
34
+ "process",
35
+ "subProcess",
36
+ "adHocSubProcess",
37
+ "transaction",
38
+ ];
39
+ // --- BPMN scanning helpers (regex, matching the toolkit derivers' approach) -------------------
40
+ /** Strip a namespace prefix from a tag/attribute local-name (`bpmn:subProcess` → `subProcess`). */
41
+ function localName(name) {
42
+ const i = name.indexOf(":");
43
+ return i === -1 ? name : name.slice(i + 1);
44
+ }
45
+ /** Read the raw (still entity-encoded) value of the first attribute whose name matches `namePattern`
46
+ * (a regex fragment). Accepts either quote style — double (`"…"`) or single (`'…'`) — because both
47
+ * are valid XML; the delimiter is captured and back-referenced so an inner occurrence of the
48
+ * *other* quote (e.g. an apostrophe inside a double-quoted value) does not terminate it early.
49
+ * Returns undefined when no such attribute is present. */
50
+ function readAttrValue(attrsText, namePattern) {
51
+ const m = attrsText.match(new RegExp(`(?:^|\\s)${namePattern}\\s*=\\s*(["'])([\\s\\S]*?)\\1`));
52
+ return m ? m[2] : undefined;
53
+ }
54
+ /** Read an attribute value from a start-tag's attribute text. Matches either an unprefixed name
55
+ * or any-prefixed local-name (so `name=` and `nano:phase`/`x:phase` both resolve), preferring an
56
+ * exact match. Returns undefined when absent. */
57
+ function attr(attrsText, name) {
58
+ const exact = readAttrValue(attrsText, escapeRe(name));
59
+ if (exact != null)
60
+ return decodeXml(exact);
61
+ const local = readAttrValue(attrsText, `[\\w.-]*:${escapeRe(name)}`);
62
+ return local != null ? decodeXml(local) : undefined;
63
+ }
64
+ function escapeRe(s) {
65
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
+ }
67
+ /** Decode the five predefined XML entities so labels read as authored. */
68
+ function decodeXml(s) {
69
+ return s
70
+ .replace(/&lt;/g, "<")
71
+ .replace(/&gt;/g, ">")
72
+ .replace(/&quot;/g, '"')
73
+ .replace(/&apos;/g, "'")
74
+ .replace(/&amp;/g, "&");
75
+ }
76
+ /** Extract a `nano:phase` override from a start-tag's own attributes: a namespace-prefixed
77
+ * `*:phase` attribute (e.g. `nano:phase`, prefix-agnostic). A bare, unprefixed `phase="…"`
78
+ * attribute is intentionally NOT matched — the override contract is a namespaced attribute, and
79
+ * matching bare `phase` would broaden it and risk colliding with unrelated attributes. Returns
80
+ * undefined when absent. */
81
+ function phaseAttr(attrsText) {
82
+ const raw = readAttrValue(attrsText, `[\\w.-]+:phase`);
83
+ if (raw == null)
84
+ return undefined;
85
+ const v = decodeXml(raw);
86
+ return v.length > 0 ? v : undefined;
87
+ }
88
+ /**
89
+ * Parse one BPMN document into a {@link ScopeIndex}: the element→enclosing-scope containment plus
90
+ * each element's name and optional `nano:phase` override. Pure and deterministic. Namespace
91
+ * prefixes are ignored (matched by local-name), so `bpmn:`/`camunda:`/unprefixed all parse.
92
+ *
93
+ * The scan walks tags maintaining an element-nesting stack, so containment is exact even for
94
+ * deeply nested `subProcess`es. Scope-container frames (see {@link SCOPE_CONTAINERS}) contribute
95
+ * to the `scopeChain`; every id-bearing element is recorded. A `zeebe:property name="nano:phase"`
96
+ * encountered inside an element's `extensionElements` attaches its override to the nearest
97
+ * enclosing id-bearing element (the scope/flow node it decorates).
98
+ *
99
+ * Exception to prefix-agnostic parsing: the `<zeebe:property>` phase-override detection matches the
100
+ * literal `zeebe:` prefix (not by local-name), so a model that binds the Zeebe namespace to a
101
+ * different prefix will not be detected. This is deliberate — it mirrors the rest of the toolkit's
102
+ * `<zeebe:property …>` scans and prevents unrelated `<bpmn:property>` data elements from
103
+ * masquerading as phase overrides. (The `nano:phase` start-tag attribute remains prefix-agnostic.)
104
+ */
105
+ export function buildScopeIndex(xml) {
106
+ const elements = new Map();
107
+ let process;
108
+ const stack = [];
109
+ const scopeIds = [];
110
+ // Match every tag: `<name …>`, `<name …/>`, or `</name>`. Comments/CDATA/PIs are skipped. The
111
+ // attribute run consumes whole quoted strings (`"…"`/`'…'`) so a literal `>` inside an attribute
112
+ // value — valid XML, e.g. `name="Wave > 1"` — does not terminate the tag early and truncate it.
113
+ const tagRe = /<(\/)?([\w.:-]+)\b((?:"[^"]*"|'[^']*'|[^>])*?)(\/)?>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>/g;
114
+ const record = (frame) => {
115
+ if (frame.id == null)
116
+ return;
117
+ const el = {
118
+ id: frame.id,
119
+ type: frame.type,
120
+ isScope: frame.isScope,
121
+ scopeChain: [...frame.scopeChain],
122
+ ...(frame.name != null ? { name: frame.name } : {}),
123
+ ...(frame.phaseOverride != null ? { phaseOverride: frame.phaseOverride } : {}),
124
+ };
125
+ elements.set(el.id, el);
126
+ if (el.type === "process" && process === undefined)
127
+ process = el;
128
+ };
129
+ let m;
130
+ while ((m = tagRe.exec(xml)) !== null) {
131
+ // Non-tag matches (comment/CDATA/PI) have no captured name — skip.
132
+ const name = m[2];
133
+ if (name == null)
134
+ continue;
135
+ const isClose = m[1] === "/";
136
+ const attrsText = m[3] ?? "";
137
+ const selfClose = m[4] === "/";
138
+ const type = localName(name);
139
+ if (isClose) {
140
+ // Close the nearest open frame of this type (tolerant of imperfect nesting).
141
+ for (let i = stack.length - 1; i >= 0; i--) {
142
+ if (stack[i].type === type) {
143
+ const closed = stack.splice(i, 1)[0];
144
+ if (closed.isScope && closed.id != null) {
145
+ const idx = scopeIds.lastIndexOf(closed.id);
146
+ if (idx !== -1)
147
+ scopeIds.splice(idx, 1);
148
+ }
149
+ record(closed);
150
+ break;
151
+ }
152
+ }
153
+ continue;
154
+ }
155
+ // A `zeebe:property name="nano:phase" value="…"` inside an `extensionElements` block decorates
156
+ // the nearest open id-bearing element. Match only the `zeebe:` prefix inside `extensionElements`
157
+ // (matching the rest of the toolkit's `<zeebe:property …>` scans) so unrelated `<bpmn:property>`
158
+ // data elements can never masquerade as a phase override.
159
+ if (name.startsWith("zeebe:") &&
160
+ type === "property" &&
161
+ stack.some((f) => f.type === "extensionElements")) {
162
+ const propName = attr(attrsText, "name");
163
+ if (propName === "nano:phase") {
164
+ const value = attr(attrsText, "value");
165
+ if (value != null && value.length > 0) {
166
+ for (let i = stack.length - 1; i >= 0; i--) {
167
+ if (stack[i].id != null) {
168
+ stack[i].phaseOverride = value;
169
+ break;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ // A property tag never opens a scope; if it is a container it still self-closes below.
175
+ }
176
+ const id = attr(attrsText, "id");
177
+ const nameAttr = attr(attrsText, "name");
178
+ const phaseOverride = phaseAttr(attrsText);
179
+ const isScope = SCOPE_CONTAINERS.includes(type);
180
+ const frame = {
181
+ type,
182
+ isScope,
183
+ scopeChain: [...scopeIds],
184
+ ...(id != null ? { id } : {}),
185
+ ...(nameAttr != null ? { name: nameAttr } : {}),
186
+ ...(phaseOverride != null ? { phaseOverride } : {}),
187
+ };
188
+ if (selfClose) {
189
+ // A self-closing element never encloses anything; record it in place.
190
+ record(frame);
191
+ continue;
192
+ }
193
+ if (isScope && frame.id != null)
194
+ scopeIds.push(frame.id);
195
+ stack.push(frame);
196
+ }
197
+ // Record any frames left open by a truncated/malformed document, so nothing is silently lost.
198
+ while (stack.length > 0) {
199
+ const frame = stack.pop();
200
+ if (frame)
201
+ record(frame);
202
+ }
203
+ return { elements, ...(process != null ? { process } : {}) };
204
+ }
205
+ /** Resolve a crumb's label: override wins, else `name`, else an id fallback so it is never
206
+ * blank. Reports the source so a UI can distinguish an authored phase from a structural one. */
207
+ function crumbLabel(el) {
208
+ if (el.phaseOverride != null && el.phaseOverride.length > 0) {
209
+ return { label: el.phaseOverride, source: "override" };
210
+ }
211
+ if (el.name != null && el.name.length > 0)
212
+ return { label: el.name, source: "structural" };
213
+ return { label: el.id, source: "structural" };
214
+ }
215
+ function crumbKind(el) {
216
+ if (el.type === "process")
217
+ return "process";
218
+ return el.isScope ? "subProcess" : "element";
219
+ }
220
+ /**
221
+ * Derive the {@link Phase} for a single element against a {@link ScopeIndex}. Tier 0 (structural)
222
+ * with Tier 1 (`nano:phase`) overrides folded in per crumb. Returns undefined when `elementId` is
223
+ * not in the index (an unknown/foreign element).
224
+ *
225
+ * The breadcrumb is the chain of enclosing scopes (outermost process → innermost subProcess)
226
+ * followed by the element itself. `coarse` is the outermost meaningful scope — the TOP-LEVEL
227
+ * subProcess (the one directly under the process) when the element is inside a subProcess, else
228
+ * the process. `fine` is the leaf element. `label` picks one per {@link PhaseGranularity}.
229
+ */
230
+ export function derivePhase(index, elementId, options = {}) {
231
+ const el = index.elements.get(elementId);
232
+ if (!el)
233
+ return undefined;
234
+ const breadcrumb = [];
235
+ for (const scopeId of el.scopeChain) {
236
+ const scope = index.elements.get(scopeId);
237
+ if (!scope)
238
+ continue;
239
+ const { label, source } = crumbLabel(scope);
240
+ breadcrumb.push({ id: scope.id, type: scope.type, kind: crumbKind(scope), label, source });
241
+ }
242
+ // The element itself is always the leaf crumb. `scopeChain` holds only the ENCLOSING scopes —
243
+ // each frame captures the open scope ids before pushing its own (see buildScopeIndex), so an
244
+ // element is never in its own chain. Thus even when the element is itself a scope/process, it is
245
+ // appended exactly once here as its own leaf; there is no duplicate to guard against.
246
+ {
247
+ const { label, source } = crumbLabel(el);
248
+ breadcrumb.push({ id: el.id, type: el.type, kind: crumbKind(el), label, source });
249
+ }
250
+ const processCrumb = breadcrumb.find((c) => c.kind === "process");
251
+ // The top-level subProcess = the first `subProcess`-family crumb (outermost, directly under the
252
+ // process). That is the "outermost meaningful scope" the coarse phase reports.
253
+ const topSubProcess = breadcrumb.find((c) => c.kind === "subProcess");
254
+ const leaf = breadcrumb[breadcrumb.length - 1];
255
+ const coarse = (topSubProcess ?? processCrumb ?? leaf).label;
256
+ const fine = leaf.label;
257
+ const granularity = options.granularity ?? "auto";
258
+ let label;
259
+ if (granularity === "subProcess")
260
+ label = coarse;
261
+ else if (granularity === "element")
262
+ label = fine;
263
+ else
264
+ label = topSubProcess ? coarse : fine;
265
+ return {
266
+ elementId,
267
+ process: processCrumb?.label ?? el.id,
268
+ coarse,
269
+ fine,
270
+ label,
271
+ breadcrumb,
272
+ };
273
+ }
274
+ /**
275
+ * Collapse provenance rows to the furthest point each instance reached — the row with the greatest
276
+ * `seq` per `instance_key` (monotonic within an instance). Rows with a null `instance_key` or
277
+ * `element_id` are ignored (a write outside a job records no element). Deterministic.
278
+ */
279
+ export function furthestReached(rows) {
280
+ const out = new Map();
281
+ for (const r of rows) {
282
+ if (r.instance_key == null || r.element_id == null)
283
+ continue;
284
+ const prev = out.get(r.instance_key);
285
+ if (!prev || r.seq > prev.seq) {
286
+ out.set(r.instance_key, {
287
+ instanceKey: r.instance_key,
288
+ elementId: r.element_id,
289
+ seq: r.seq,
290
+ ...(r.at != null ? { at: r.at } : {}),
291
+ });
292
+ }
293
+ }
294
+ return out;
295
+ }
296
+ /**
297
+ * The Phase-1 read projection: derive each instance's domain phase from a deployed model's
298
+ * {@link ScopeIndex} and the model's `_urban_write_provenance` rows. Combines {@link furthestReached}
299
+ * (furthest element per instance) with {@link derivePhase}. Instances whose furthest element is not
300
+ * in this model's index are skipped (they belong to another deployed model). Pure and deterministic.
301
+ */
302
+ export function deriveInstancePhases(index, rows, options = {}) {
303
+ const out = new Map();
304
+ for (const f of furthestReached(rows).values()) {
305
+ const phase = derivePhase(index, f.elementId, options);
306
+ if (!phase)
307
+ continue;
308
+ out.set(f.instanceKey, {
309
+ instanceKey: f.instanceKey,
310
+ phase,
311
+ seq: f.seq,
312
+ ...(f.at != null ? { at: f.at } : {}),
313
+ });
314
+ }
315
+ return out;
316
+ }
317
+ /** Order two contributors by progress: later `at` wins; ties break on greater `seq`. A contributor
318
+ * with an `at` beats one without; when neither has an `at`, `seq` decides. */
319
+ function moreAdvanced(a, b) {
320
+ const aAt = a.at ?? "";
321
+ const bAt = b.at ?? "";
322
+ if (aAt !== bAt)
323
+ return aAt > bAt;
324
+ return (a.seq ?? -1) > (b.seq ?? -1);
325
+ }
326
+ /**
327
+ * Tier 2 — roll a domain phase up across a lineage tree (#254). Each stitched instance contributes
328
+ * its structurally-derived {@link InstancePhase}; the parent/epic phase is the *frontier* of that
329
+ * tree — the most-recently-advanced contributor (latest `at`, then greatest `seq`). This lets a
330
+ * fan-out epic surface the furthest domain phase any of its children has reached, not just the
331
+ * parent instance's own token position. Pure and deterministic.
332
+ */
333
+ export function rollupLineagePhase(tree, phaseByInstance) {
334
+ const contributions = [];
335
+ const seen = new Set();
336
+ const visit = (node) => {
337
+ if (seen.has(node.instanceKey))
338
+ return;
339
+ seen.add(node.instanceKey);
340
+ const contribution = phaseByInstance.get(node.instanceKey);
341
+ if (contribution)
342
+ contributions.push(contribution);
343
+ for (const child of node.children)
344
+ visit(child);
345
+ };
346
+ visit(tree.root);
347
+ // `tree.nodes` includes unattached nodes not reachable from root; fold them in too so a
348
+ // contributor whose edge has not yet stitched is not dropped from the epic frontier.
349
+ for (const node of tree.nodes) {
350
+ if (seen.has(node.instanceKey))
351
+ continue;
352
+ seen.add(node.instanceKey);
353
+ const contribution = phaseByInstance.get(node.instanceKey);
354
+ if (contribution)
355
+ contributions.push(contribution);
356
+ }
357
+ let frontier;
358
+ for (const c of contributions) {
359
+ if (!frontier || moreAdvanced(c, frontier))
360
+ frontier = c;
361
+ }
362
+ return {
363
+ rootRequestKey: tree.rootRequestKey,
364
+ contributions,
365
+ ...(frontier != null ? { frontier } : {}),
366
+ };
367
+ }