@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.
- package/README.md +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/execContext.d.ts +5 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/lineage.d.ts +116 -0
- package/dist/runtime/core/lineage.js +186 -0
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -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/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- 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/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/modules/workers.js +69 -2
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/engine/nanosdk.js +8 -2
- package/dist/runtime/index.d.ts +10 -0
- package/dist/runtime/index.js +12 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/execContext.ts +5 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/lineage.test.ts +215 -0
- package/src/runtime/core/lineage.ts +297 -0
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/datasource.ts +9 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- 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/pages.test.ts +309 -5
- package/src/runtime/core/modules/pages.ts +398 -20
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/modules/workers.test.ts +136 -1
- package/src/runtime/core/modules/workers.ts +72 -6
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/engine/nanosdk.test.ts +97 -3
- package/src/runtime/engine/nanosdk.ts +8 -2
- package/src/runtime/index.ts +92 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- 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) =>
|
|
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) =>
|
|
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(/</g, "<")
|
|
71
|
+
.replace(/>/g, ">")
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/'/g, "'")
|
|
74
|
+
.replace(/&/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
|
+
}
|