@nanobpm/urban 0.52.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/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/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -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/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- 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/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -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/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/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -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/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/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/index.ts +57 -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
|
@@ -0,0 +1,526 @@
|
|
|
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
|
+
|
|
28
|
+
import type { LineageNode, LineageTree } from "./lineage.ts";
|
|
29
|
+
|
|
30
|
+
/** The BPMN local-names urban treats as *scope containers* — the enclosing scopes whose names
|
|
31
|
+
* become coarse phases. Namespace prefixes (e.g. `bpmn:`) are stripped before matching, so any
|
|
32
|
+
* prefix works. A `process` is the root scope; `subProcess`/`adHocSubProcess`/`transaction` are
|
|
33
|
+
* the nestable intermediate scopes a modeller wraps related activities in (and whose name then
|
|
34
|
+
* becomes the phase automatically — "declaring a phase" collapses into *naming a scope you
|
|
35
|
+
* already have"). */
|
|
36
|
+
export const SCOPE_CONTAINERS: readonly string[] = [
|
|
37
|
+
"process",
|
|
38
|
+
"subProcess",
|
|
39
|
+
"adHocSubProcess",
|
|
40
|
+
"transaction",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** The kind of a breadcrumb crumb: the root `process`, an intermediate `subProcess`-family
|
|
44
|
+
* scope, or the leaf `element` (the flow node the token furthest reached). */
|
|
45
|
+
export type CrumbKind = "process" | "subProcess" | "element";
|
|
46
|
+
|
|
47
|
+
/** Where a crumb's label came from: the model's structure (a scope/element `name`, or an
|
|
48
|
+
* id fallback when unnamed), or an explicit `nano:phase` override (Tier 1). */
|
|
49
|
+
export type CrumbSource = "structural" | "override";
|
|
50
|
+
|
|
51
|
+
/** One crumb of a phase breadcrumb — a single enclosing scope, or the leaf element. */
|
|
52
|
+
export interface PhaseCrumb {
|
|
53
|
+
/** The BPMN element id of the scope/element this crumb represents. */
|
|
54
|
+
readonly id: string;
|
|
55
|
+
/** The BPMN local-name (namespace prefix stripped), e.g. `process`, `subProcess`, `serviceTask`. */
|
|
56
|
+
readonly type: string;
|
|
57
|
+
/** Whether this crumb is the root process, an intermediate scope, or the leaf element. */
|
|
58
|
+
readonly kind: CrumbKind;
|
|
59
|
+
/** The display label: the `nano:phase` override if present, else the `name`, else an
|
|
60
|
+
* id fallback so a crumb is never blank. */
|
|
61
|
+
readonly label: string;
|
|
62
|
+
/** Whether {@link label} came from structure or an explicit override. */
|
|
63
|
+
readonly source: CrumbSource;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Chosen granularity for the single-value `label` of a {@link Phase}:
|
|
67
|
+
* - `subProcess` — always the coarse (outermost meaningful scope) label.
|
|
68
|
+
* - `element` — always the fine (leaf element) label.
|
|
69
|
+
* - `auto` (default) — the coarse label when the element is inside a subProcess-family scope,
|
|
70
|
+
* else the fine element label ("subProcess-then-element"). */
|
|
71
|
+
export type PhaseGranularity = "subProcess" | "element" | "auto";
|
|
72
|
+
|
|
73
|
+
/** A derived domain phase for a single point in a process (the element a token reached). */
|
|
74
|
+
export interface Phase {
|
|
75
|
+
/** The element the phase was derived for (the furthest/active flow node). */
|
|
76
|
+
readonly elementId: string;
|
|
77
|
+
/** The enclosing process's id/name (the root crumb's label), for context. */
|
|
78
|
+
readonly process: string;
|
|
79
|
+
/** The coarse phase: the outermost meaningful scope — the top-level `subProcess` label when the
|
|
80
|
+
* element is inside one, else the process label. */
|
|
81
|
+
readonly coarse: string;
|
|
82
|
+
/** The fine phase: the leaf element's label. */
|
|
83
|
+
readonly fine: string;
|
|
84
|
+
/** The single-value phase for the requested {@link PhaseGranularity}. */
|
|
85
|
+
readonly label: string;
|
|
86
|
+
/** The full ordered breadcrumb, outermost process → … → leaf element. A UI can render at any
|
|
87
|
+
* granularity it picks — roll up to a scope, or drill to the task — all derived. */
|
|
88
|
+
readonly breadcrumb: readonly PhaseCrumb[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Options for {@link derivePhase} / {@link deriveInstancePhases}. */
|
|
92
|
+
export interface DerivePhaseOptions {
|
|
93
|
+
/** Which granularity the single-value {@link Phase.label} reports. Default `auto`. */
|
|
94
|
+
readonly granularity?: PhaseGranularity;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The parsed structural view of one deployed model: every element's id/type/name/override plus
|
|
98
|
+
* its chain of enclosing scope ids (outermost → innermost). Build once per deployed model with
|
|
99
|
+
* {@link buildScopeIndex}; derive many instances' phases against it. */
|
|
100
|
+
export interface ScopeIndex {
|
|
101
|
+
/** The process's own element (its id is the `bpmnProcessId`). Undefined for a fragment with no
|
|
102
|
+
* `process` element. */
|
|
103
|
+
readonly process?: ScopeElement;
|
|
104
|
+
/** Every id-bearing element (scopes and flow nodes), keyed by element id. */
|
|
105
|
+
readonly elements: ReadonlyMap<string, ScopeElement>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One id-bearing element parsed from a model: its identity plus its enclosing-scope chain. */
|
|
109
|
+
export interface ScopeElement {
|
|
110
|
+
readonly id: string;
|
|
111
|
+
/** BPMN local-name (prefix stripped), e.g. `process`, `subProcess`, `serviceTask`. */
|
|
112
|
+
readonly type: string;
|
|
113
|
+
/** The element's `name` attribute, if any. */
|
|
114
|
+
readonly name?: string;
|
|
115
|
+
/** An explicit `nano:phase` override for this element/scope, if declared. */
|
|
116
|
+
readonly phaseOverride?: string;
|
|
117
|
+
/** True when {@link type} is one of {@link SCOPE_CONTAINERS}. */
|
|
118
|
+
readonly isScope: boolean;
|
|
119
|
+
/** Ids of the enclosing scope containers, outermost (process) → innermost, NOT including this
|
|
120
|
+
* element itself. */
|
|
121
|
+
readonly scopeChain: readonly string[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A provenance row (subset) as recorded in `_urban_write_provenance`: which element of which
|
|
125
|
+
* instance wrote a row, and the monotonic `seq`/`at` that order progress. Only these fields are
|
|
126
|
+
* read; the join keys (`source`/`table_name`/`pk_value`) are irrelevant to phase derivation. */
|
|
127
|
+
export interface ProvenanceProgressRow {
|
|
128
|
+
readonly instance_key: string | null;
|
|
129
|
+
readonly element_id: string | null;
|
|
130
|
+
readonly seq: number;
|
|
131
|
+
readonly at?: string | null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The furthest point one instance reached: its most-advanced element and the ordering keys. */
|
|
135
|
+
export interface FurthestReached {
|
|
136
|
+
readonly instanceKey: string;
|
|
137
|
+
readonly elementId: string;
|
|
138
|
+
readonly seq: number;
|
|
139
|
+
readonly at?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A single instance's derived phase together with the progress keys that order it against
|
|
143
|
+
* siblings for the Tier-2 lineage rollup. */
|
|
144
|
+
export interface InstancePhase {
|
|
145
|
+
readonly instanceKey: string;
|
|
146
|
+
readonly phase: Phase;
|
|
147
|
+
/** The provenance `seq` of the furthest write — monotonic within an instance. */
|
|
148
|
+
readonly seq?: number;
|
|
149
|
+
/** The provenance `at` timestamp of the furthest write (ISO-8601). */
|
|
150
|
+
readonly at?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- BPMN scanning helpers (regex, matching the toolkit derivers' approach) -------------------
|
|
154
|
+
|
|
155
|
+
/** Strip a namespace prefix from a tag/attribute local-name (`bpmn:subProcess` → `subProcess`). */
|
|
156
|
+
function localName(name: string): string {
|
|
157
|
+
const i = name.indexOf(":");
|
|
158
|
+
return i === -1 ? name : name.slice(i + 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read the raw (still entity-encoded) value of the first attribute whose name matches `namePattern`
|
|
162
|
+
* (a regex fragment). Accepts either quote style — double (`"…"`) or single (`'…'`) — because both
|
|
163
|
+
* are valid XML; the delimiter is captured and back-referenced so an inner occurrence of the
|
|
164
|
+
* *other* quote (e.g. an apostrophe inside a double-quoted value) does not terminate it early.
|
|
165
|
+
* Returns undefined when no such attribute is present. */
|
|
166
|
+
function readAttrValue(attrsText: string, namePattern: string): string | undefined {
|
|
167
|
+
const m = attrsText.match(new RegExp(`(?:^|\\s)${namePattern}\\s*=\\s*(["'])([\\s\\S]*?)\\1`));
|
|
168
|
+
return m ? m[2] : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Read an attribute value from a start-tag's attribute text. Matches either an unprefixed name
|
|
172
|
+
* or any-prefixed local-name (so `name=` and `nano:phase`/`x:phase` both resolve), preferring an
|
|
173
|
+
* exact match. Returns undefined when absent. */
|
|
174
|
+
function attr(attrsText: string, name: string): string | undefined {
|
|
175
|
+
const exact = readAttrValue(attrsText, escapeRe(name));
|
|
176
|
+
if (exact != null) return decodeXml(exact);
|
|
177
|
+
const local = readAttrValue(attrsText, `[\\w.-]*:${escapeRe(name)}`);
|
|
178
|
+
return local != null ? decodeXml(local) : undefined;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function escapeRe(s: string): string {
|
|
182
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Decode the five predefined XML entities so labels read as authored. */
|
|
186
|
+
function decodeXml(s: string): string {
|
|
187
|
+
return s
|
|
188
|
+
.replace(/</g, "<")
|
|
189
|
+
.replace(/>/g, ">")
|
|
190
|
+
.replace(/"/g, '"')
|
|
191
|
+
.replace(/'/g, "'")
|
|
192
|
+
.replace(/&/g, "&");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Extract a `nano:phase` override from a start-tag's own attributes: a namespace-prefixed
|
|
196
|
+
* `*:phase` attribute (e.g. `nano:phase`, prefix-agnostic). A bare, unprefixed `phase="…"`
|
|
197
|
+
* attribute is intentionally NOT matched — the override contract is a namespaced attribute, and
|
|
198
|
+
* matching bare `phase` would broaden it and risk colliding with unrelated attributes. Returns
|
|
199
|
+
* undefined when absent. */
|
|
200
|
+
function phaseAttr(attrsText: string): string | undefined {
|
|
201
|
+
const raw = readAttrValue(attrsText, `[\\w.-]+:phase`);
|
|
202
|
+
if (raw == null) return undefined;
|
|
203
|
+
const v = decodeXml(raw);
|
|
204
|
+
return v.length > 0 ? v : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A mutable frame on the element-nesting stack during {@link buildScopeIndex}. */
|
|
208
|
+
interface Frame {
|
|
209
|
+
id?: string;
|
|
210
|
+
type: string;
|
|
211
|
+
name?: string;
|
|
212
|
+
isScope: boolean;
|
|
213
|
+
phaseOverride?: string;
|
|
214
|
+
/** Ids of enclosing scope containers, outermost → innermost, at the moment this frame opened. */
|
|
215
|
+
scopeChain: string[];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse one BPMN document into a {@link ScopeIndex}: the element→enclosing-scope containment plus
|
|
220
|
+
* each element's name and optional `nano:phase` override. Pure and deterministic. Namespace
|
|
221
|
+
* prefixes are ignored (matched by local-name), so `bpmn:`/`camunda:`/unprefixed all parse.
|
|
222
|
+
*
|
|
223
|
+
* The scan walks tags maintaining an element-nesting stack, so containment is exact even for
|
|
224
|
+
* deeply nested `subProcess`es. Scope-container frames (see {@link SCOPE_CONTAINERS}) contribute
|
|
225
|
+
* to the `scopeChain`; every id-bearing element is recorded. A `zeebe:property name="nano:phase"`
|
|
226
|
+
* encountered inside an element's `extensionElements` attaches its override to the nearest
|
|
227
|
+
* enclosing id-bearing element (the scope/flow node it decorates).
|
|
228
|
+
*
|
|
229
|
+
* Exception to prefix-agnostic parsing: the `<zeebe:property>` phase-override detection matches the
|
|
230
|
+
* literal `zeebe:` prefix (not by local-name), so a model that binds the Zeebe namespace to a
|
|
231
|
+
* different prefix will not be detected. This is deliberate — it mirrors the rest of the toolkit's
|
|
232
|
+
* `<zeebe:property …>` scans and prevents unrelated `<bpmn:property>` data elements from
|
|
233
|
+
* masquerading as phase overrides. (The `nano:phase` start-tag attribute remains prefix-agnostic.)
|
|
234
|
+
*/
|
|
235
|
+
export function buildScopeIndex(xml: string): ScopeIndex {
|
|
236
|
+
const elements = new Map<string, ScopeElement>();
|
|
237
|
+
let process: ScopeElement | undefined;
|
|
238
|
+
|
|
239
|
+
const stack: Frame[] = [];
|
|
240
|
+
const scopeIds: string[] = [];
|
|
241
|
+
|
|
242
|
+
// Match every tag: `<name …>`, `<name …/>`, or `</name>`. Comments/CDATA/PIs are skipped. The
|
|
243
|
+
// attribute run consumes whole quoted strings (`"…"`/`'…'`) so a literal `>` inside an attribute
|
|
244
|
+
// value — valid XML, e.g. `name="Wave > 1"` — does not terminate the tag early and truncate it.
|
|
245
|
+
const tagRe =
|
|
246
|
+
/<(\/)?([\w.:-]+)\b((?:"[^"]*"|'[^']*'|[^>])*?)(\/)?>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>/g;
|
|
247
|
+
|
|
248
|
+
const record = (frame: Frame): void => {
|
|
249
|
+
if (frame.id == null) return;
|
|
250
|
+
const el: ScopeElement = {
|
|
251
|
+
id: frame.id,
|
|
252
|
+
type: frame.type,
|
|
253
|
+
isScope: frame.isScope,
|
|
254
|
+
scopeChain: [...frame.scopeChain],
|
|
255
|
+
...(frame.name != null ? { name: frame.name } : {}),
|
|
256
|
+
...(frame.phaseOverride != null ? { phaseOverride: frame.phaseOverride } : {}),
|
|
257
|
+
};
|
|
258
|
+
elements.set(el.id, el);
|
|
259
|
+
if (el.type === "process" && process === undefined) process = el;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
let m: RegExpExecArray | null;
|
|
263
|
+
while ((m = tagRe.exec(xml)) !== null) {
|
|
264
|
+
// Non-tag matches (comment/CDATA/PI) have no captured name — skip.
|
|
265
|
+
const name = m[2];
|
|
266
|
+
if (name == null) continue;
|
|
267
|
+
const isClose = m[1] === "/";
|
|
268
|
+
const attrsText = m[3] ?? "";
|
|
269
|
+
const selfClose = m[4] === "/";
|
|
270
|
+
const type = localName(name);
|
|
271
|
+
|
|
272
|
+
if (isClose) {
|
|
273
|
+
// Close the nearest open frame of this type (tolerant of imperfect nesting).
|
|
274
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
275
|
+
if (stack[i].type === type) {
|
|
276
|
+
const closed = stack.splice(i, 1)[0];
|
|
277
|
+
if (closed.isScope && closed.id != null) {
|
|
278
|
+
const idx = scopeIds.lastIndexOf(closed.id);
|
|
279
|
+
if (idx !== -1) scopeIds.splice(idx, 1);
|
|
280
|
+
}
|
|
281
|
+
record(closed);
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A `zeebe:property name="nano:phase" value="…"` inside an `extensionElements` block decorates
|
|
289
|
+
// the nearest open id-bearing element. Match only the `zeebe:` prefix inside `extensionElements`
|
|
290
|
+
// (matching the rest of the toolkit's `<zeebe:property …>` scans) so unrelated `<bpmn:property>`
|
|
291
|
+
// data elements can never masquerade as a phase override.
|
|
292
|
+
if (
|
|
293
|
+
name.startsWith("zeebe:") &&
|
|
294
|
+
type === "property" &&
|
|
295
|
+
stack.some((f) => f.type === "extensionElements")
|
|
296
|
+
) {
|
|
297
|
+
const propName = attr(attrsText, "name");
|
|
298
|
+
if (propName === "nano:phase") {
|
|
299
|
+
const value = attr(attrsText, "value");
|
|
300
|
+
if (value != null && value.length > 0) {
|
|
301
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
302
|
+
if (stack[i].id != null) {
|
|
303
|
+
stack[i].phaseOverride = value;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// A property tag never opens a scope; if it is a container it still self-closes below.
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const id = attr(attrsText, "id");
|
|
313
|
+
const nameAttr = attr(attrsText, "name");
|
|
314
|
+
const phaseOverride = phaseAttr(attrsText);
|
|
315
|
+
const isScope = SCOPE_CONTAINERS.includes(type);
|
|
316
|
+
const frame: Frame = {
|
|
317
|
+
type,
|
|
318
|
+
isScope,
|
|
319
|
+
scopeChain: [...scopeIds],
|
|
320
|
+
...(id != null ? { id } : {}),
|
|
321
|
+
...(nameAttr != null ? { name: nameAttr } : {}),
|
|
322
|
+
...(phaseOverride != null ? { phaseOverride } : {}),
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (selfClose) {
|
|
326
|
+
// A self-closing element never encloses anything; record it in place.
|
|
327
|
+
record(frame);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (isScope && frame.id != null) scopeIds.push(frame.id);
|
|
332
|
+
stack.push(frame);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Record any frames left open by a truncated/malformed document, so nothing is silently lost.
|
|
336
|
+
while (stack.length > 0) {
|
|
337
|
+
const frame = stack.pop();
|
|
338
|
+
if (frame) record(frame);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return { elements, ...(process != null ? { process } : {}) };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Resolve a crumb's label: override wins, else `name`, else an id fallback so it is never
|
|
345
|
+
* blank. Reports the source so a UI can distinguish an authored phase from a structural one. */
|
|
346
|
+
function crumbLabel(el: ScopeElement): { label: string; source: CrumbSource } {
|
|
347
|
+
if (el.phaseOverride != null && el.phaseOverride.length > 0) {
|
|
348
|
+
return { label: el.phaseOverride, source: "override" };
|
|
349
|
+
}
|
|
350
|
+
if (el.name != null && el.name.length > 0) return { label: el.name, source: "structural" };
|
|
351
|
+
return { label: el.id, source: "structural" };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function crumbKind(el: ScopeElement): CrumbKind {
|
|
355
|
+
if (el.type === "process") return "process";
|
|
356
|
+
return el.isScope ? "subProcess" : "element";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Derive the {@link Phase} for a single element against a {@link ScopeIndex}. Tier 0 (structural)
|
|
361
|
+
* with Tier 1 (`nano:phase`) overrides folded in per crumb. Returns undefined when `elementId` is
|
|
362
|
+
* not in the index (an unknown/foreign element).
|
|
363
|
+
*
|
|
364
|
+
* The breadcrumb is the chain of enclosing scopes (outermost process → innermost subProcess)
|
|
365
|
+
* followed by the element itself. `coarse` is the outermost meaningful scope — the TOP-LEVEL
|
|
366
|
+
* subProcess (the one directly under the process) when the element is inside a subProcess, else
|
|
367
|
+
* the process. `fine` is the leaf element. `label` picks one per {@link PhaseGranularity}.
|
|
368
|
+
*/
|
|
369
|
+
export function derivePhase(
|
|
370
|
+
index: ScopeIndex,
|
|
371
|
+
elementId: string,
|
|
372
|
+
options: DerivePhaseOptions = {},
|
|
373
|
+
): Phase | undefined {
|
|
374
|
+
const el = index.elements.get(elementId);
|
|
375
|
+
if (!el) return undefined;
|
|
376
|
+
|
|
377
|
+
const breadcrumb: PhaseCrumb[] = [];
|
|
378
|
+
for (const scopeId of el.scopeChain) {
|
|
379
|
+
const scope = index.elements.get(scopeId);
|
|
380
|
+
if (!scope) continue;
|
|
381
|
+
const { label, source } = crumbLabel(scope);
|
|
382
|
+
breadcrumb.push({ id: scope.id, type: scope.type, kind: crumbKind(scope), label, source });
|
|
383
|
+
}
|
|
384
|
+
// The element itself is always the leaf crumb. `scopeChain` holds only the ENCLOSING scopes —
|
|
385
|
+
// each frame captures the open scope ids before pushing its own (see buildScopeIndex), so an
|
|
386
|
+
// element is never in its own chain. Thus even when the element is itself a scope/process, it is
|
|
387
|
+
// appended exactly once here as its own leaf; there is no duplicate to guard against.
|
|
388
|
+
{
|
|
389
|
+
const { label, source } = crumbLabel(el);
|
|
390
|
+
breadcrumb.push({ id: el.id, type: el.type, kind: crumbKind(el), label, source });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const processCrumb = breadcrumb.find((c) => c.kind === "process");
|
|
394
|
+
// The top-level subProcess = the first `subProcess`-family crumb (outermost, directly under the
|
|
395
|
+
// process). That is the "outermost meaningful scope" the coarse phase reports.
|
|
396
|
+
const topSubProcess = breadcrumb.find((c) => c.kind === "subProcess");
|
|
397
|
+
const leaf = breadcrumb[breadcrumb.length - 1];
|
|
398
|
+
|
|
399
|
+
const coarse = (topSubProcess ?? processCrumb ?? leaf).label;
|
|
400
|
+
const fine = leaf.label;
|
|
401
|
+
|
|
402
|
+
const granularity = options.granularity ?? "auto";
|
|
403
|
+
let label: string;
|
|
404
|
+
if (granularity === "subProcess") label = coarse;
|
|
405
|
+
else if (granularity === "element") label = fine;
|
|
406
|
+
else label = topSubProcess ? coarse : fine;
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
elementId,
|
|
410
|
+
process: processCrumb?.label ?? el.id,
|
|
411
|
+
coarse,
|
|
412
|
+
fine,
|
|
413
|
+
label,
|
|
414
|
+
breadcrumb,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Collapse provenance rows to the furthest point each instance reached — the row with the greatest
|
|
420
|
+
* `seq` per `instance_key` (monotonic within an instance). Rows with a null `instance_key` or
|
|
421
|
+
* `element_id` are ignored (a write outside a job records no element). Deterministic.
|
|
422
|
+
*/
|
|
423
|
+
export function furthestReached(rows: readonly ProvenanceProgressRow[]): Map<string, FurthestReached> {
|
|
424
|
+
const out = new Map<string, FurthestReached>();
|
|
425
|
+
for (const r of rows) {
|
|
426
|
+
if (r.instance_key == null || r.element_id == null) continue;
|
|
427
|
+
const prev = out.get(r.instance_key);
|
|
428
|
+
if (!prev || r.seq > prev.seq) {
|
|
429
|
+
out.set(r.instance_key, {
|
|
430
|
+
instanceKey: r.instance_key,
|
|
431
|
+
elementId: r.element_id,
|
|
432
|
+
seq: r.seq,
|
|
433
|
+
...(r.at != null ? { at: r.at } : {}),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* The Phase-1 read projection: derive each instance's domain phase from a deployed model's
|
|
442
|
+
* {@link ScopeIndex} and the model's `_urban_write_provenance` rows. Combines {@link furthestReached}
|
|
443
|
+
* (furthest element per instance) with {@link derivePhase}. Instances whose furthest element is not
|
|
444
|
+
* in this model's index are skipped (they belong to another deployed model). Pure and deterministic.
|
|
445
|
+
*/
|
|
446
|
+
export function deriveInstancePhases(
|
|
447
|
+
index: ScopeIndex,
|
|
448
|
+
rows: readonly ProvenanceProgressRow[],
|
|
449
|
+
options: DerivePhaseOptions = {},
|
|
450
|
+
): Map<string, InstancePhase> {
|
|
451
|
+
const out = new Map<string, InstancePhase>();
|
|
452
|
+
for (const f of furthestReached(rows).values()) {
|
|
453
|
+
const phase = derivePhase(index, f.elementId, options);
|
|
454
|
+
if (!phase) continue;
|
|
455
|
+
out.set(f.instanceKey, {
|
|
456
|
+
instanceKey: f.instanceKey,
|
|
457
|
+
phase,
|
|
458
|
+
seq: f.seq,
|
|
459
|
+
...(f.at != null ? { at: f.at } : {}),
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return out;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The Tier-2 rollup: the epic/parent phase (the *frontier* of the lineage tree) plus every
|
|
466
|
+
* contributing instance's phase. */
|
|
467
|
+
export interface LineagePhaseRollup {
|
|
468
|
+
readonly rootRequestKey: string;
|
|
469
|
+
/** The frontier — the most-recently-advanced contributor's phase — or undefined when no node in
|
|
470
|
+
* the tree has a derived phase. This is the parent/epic's domain phase. */
|
|
471
|
+
readonly frontier?: InstancePhase;
|
|
472
|
+
/** Every stitched instance that has a derived phase: those reachable from `tree.root` first, in
|
|
473
|
+
* depth-first order, followed by any unattached `tree.nodes` not yet stitched to the tree. */
|
|
474
|
+
readonly contributions: readonly InstancePhase[];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Order two contributors by progress: later `at` wins; ties break on greater `seq`. A contributor
|
|
478
|
+
* with an `at` beats one without; when neither has an `at`, `seq` decides. */
|
|
479
|
+
function moreAdvanced(a: InstancePhase, b: InstancePhase): boolean {
|
|
480
|
+
const aAt = a.at ?? "";
|
|
481
|
+
const bAt = b.at ?? "";
|
|
482
|
+
if (aAt !== bAt) return aAt > bAt;
|
|
483
|
+
return (a.seq ?? -1) > (b.seq ?? -1);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Tier 2 — roll a domain phase up across a lineage tree (#254). Each stitched instance contributes
|
|
488
|
+
* its structurally-derived {@link InstancePhase}; the parent/epic phase is the *frontier* of that
|
|
489
|
+
* tree — the most-recently-advanced contributor (latest `at`, then greatest `seq`). This lets a
|
|
490
|
+
* fan-out epic surface the furthest domain phase any of its children has reached, not just the
|
|
491
|
+
* parent instance's own token position. Pure and deterministic.
|
|
492
|
+
*/
|
|
493
|
+
export function rollupLineagePhase(
|
|
494
|
+
tree: LineageTree,
|
|
495
|
+
phaseByInstance: ReadonlyMap<string, InstancePhase>,
|
|
496
|
+
): LineagePhaseRollup {
|
|
497
|
+
const contributions: InstancePhase[] = [];
|
|
498
|
+
const seen = new Set<string>();
|
|
499
|
+
const visit = (node: LineageNode): void => {
|
|
500
|
+
if (seen.has(node.instanceKey)) return;
|
|
501
|
+
seen.add(node.instanceKey);
|
|
502
|
+
const contribution = phaseByInstance.get(node.instanceKey);
|
|
503
|
+
if (contribution) contributions.push(contribution);
|
|
504
|
+
for (const child of node.children) visit(child);
|
|
505
|
+
};
|
|
506
|
+
visit(tree.root);
|
|
507
|
+
// `tree.nodes` includes unattached nodes not reachable from root; fold them in too so a
|
|
508
|
+
// contributor whose edge has not yet stitched is not dropped from the epic frontier.
|
|
509
|
+
for (const node of tree.nodes) {
|
|
510
|
+
if (seen.has(node.instanceKey)) continue;
|
|
511
|
+
seen.add(node.instanceKey);
|
|
512
|
+
const contribution = phaseByInstance.get(node.instanceKey);
|
|
513
|
+
if (contribution) contributions.push(contribution);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let frontier: InstancePhase | undefined;
|
|
517
|
+
for (const c of contributions) {
|
|
518
|
+
if (!frontier || moreAdvanced(c, frontier)) frontier = c;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return {
|
|
522
|
+
rootRequestKey: tree.rootRequestKey,
|
|
523
|
+
contributions,
|
|
524
|
+
...(frontier != null ? { frontier } : {}),
|
|
525
|
+
};
|
|
526
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createNodeHost } from "../adapters/node.ts";
|
|
7
|
+
import { createUrbanApp } from "./runtime.ts";
|
|
8
|
+
import type { EngineClient, WorkerSubscription } from "./host.ts";
|
|
9
|
+
import type { LifecycleEvent, UrbanExtension } from "./extensions.ts";
|
|
10
|
+
|
|
11
|
+
// A no-op engine double: the taxonomy wiring exercises lifecycle + extensions, so
|
|
12
|
+
// nothing here needs to deploy or register.
|
|
13
|
+
class NoopEngine implements EngineClient {
|
|
14
|
+
async deployResources() {
|
|
15
|
+
return { deployed: 0 };
|
|
16
|
+
}
|
|
17
|
+
async createInstance() {
|
|
18
|
+
return { processInstanceKey: "pi-1" };
|
|
19
|
+
}
|
|
20
|
+
async cancelInstance() {}
|
|
21
|
+
async publishMessage() {}
|
|
22
|
+
async searchUserTasks() {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
async getForm() {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
async completeUserTask() {}
|
|
29
|
+
async searchProcessInstances() {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
async registerWorker(jobType: string): Promise<WorkerSubscription> {
|
|
33
|
+
return { jobType, unsubscribe: async () => {} };
|
|
34
|
+
}
|
|
35
|
+
async close() {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A bare app that mounts only the extension taxonomy — no deploy/data/http.
|
|
39
|
+
async function bareApp(extensions: readonly UrbanExtension[]) {
|
|
40
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-ext-"));
|
|
41
|
+
await writeFile(
|
|
42
|
+
join(dir, "nano.app.json"),
|
|
43
|
+
JSON.stringify({ schemaVersion: 1, id: "ext-app", name: "Ext App" }),
|
|
44
|
+
);
|
|
45
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
46
|
+
const app = await createUrbanApp({
|
|
47
|
+
host,
|
|
48
|
+
engine: new NoopEngine(),
|
|
49
|
+
root: ".",
|
|
50
|
+
port: 0,
|
|
51
|
+
extensions,
|
|
52
|
+
mount: {
|
|
53
|
+
deploy: false,
|
|
54
|
+
data: false,
|
|
55
|
+
workers: false,
|
|
56
|
+
surfaces: false,
|
|
57
|
+
triggers: false,
|
|
58
|
+
security: false,
|
|
59
|
+
instanceTracking: false,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return { app, dir };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
test("runtime — lifecycle emits starting/started on start and stopping/stopped on stop", async () => {
|
|
66
|
+
const phases: string[] = [];
|
|
67
|
+
const recorder: UrbanExtension = {
|
|
68
|
+
name: "phase-recorder",
|
|
69
|
+
setup(ctx) {
|
|
70
|
+
ctx.events.lifecycle.on((e: LifecycleEvent) => phases.push(e.phase));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const { app, dir } = await bareApp([recorder]);
|
|
74
|
+
try {
|
|
75
|
+
// The recorder subscribes during start's extension mount, so it catches
|
|
76
|
+
// `started` (emitted after mount) and both stop phases; `starting` fires before
|
|
77
|
+
// any extension exists, by design.
|
|
78
|
+
await app.start();
|
|
79
|
+
await app.stop();
|
|
80
|
+
assert.deepEqual(phases, ["started", "stopping", "stopped"]);
|
|
81
|
+
} finally {
|
|
82
|
+
await rm(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("runtime — a throwing extension does not strand app boot", async () => {
|
|
87
|
+
const good: string[] = [];
|
|
88
|
+
const boom: UrbanExtension = {
|
|
89
|
+
name: "boom",
|
|
90
|
+
order: 1,
|
|
91
|
+
setup() {
|
|
92
|
+
throw new Error("extension setup blew up");
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
const healthy: UrbanExtension = {
|
|
96
|
+
name: "healthy",
|
|
97
|
+
order: 2,
|
|
98
|
+
setup() {
|
|
99
|
+
good.push("healthy");
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
const { app, dir } = await bareApp([boom, healthy]);
|
|
103
|
+
try {
|
|
104
|
+
await app.start(); // must resolve despite `boom` throwing
|
|
105
|
+
assert.deepEqual(good, ["healthy"]);
|
|
106
|
+
const insp = app.inspect();
|
|
107
|
+
assert.ok(insp.extensions, "extensions described in inspect()");
|
|
108
|
+
await app.stop();
|
|
109
|
+
} finally {
|
|
110
|
+
await rm(dir, { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("runtime — extensions dispose cleanly across a start→stop→start cycle (no leaked listeners)", async () => {
|
|
115
|
+
let liveTimers = 0;
|
|
116
|
+
const poller: UrbanExtension = {
|
|
117
|
+
name: "poller",
|
|
118
|
+
setup(ctx) {
|
|
119
|
+
const timer = setInterval(() => {}, 10_000);
|
|
120
|
+
liveTimers += 1;
|
|
121
|
+
ctx.events.lifecycle.on(() => {});
|
|
122
|
+
ctx.effect(() => {
|
|
123
|
+
clearInterval(timer);
|
|
124
|
+
liveTimers -= 1;
|
|
125
|
+
});
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
const { app, dir } = await bareApp([poller]);
|
|
129
|
+
try {
|
|
130
|
+
await app.start();
|
|
131
|
+
assert.equal(liveTimers, 1);
|
|
132
|
+
assert.equal(app.events.lifecycle.size >= 1, true);
|
|
133
|
+
await app.stop();
|
|
134
|
+
// Dispose ladder unwound: the timer effect ran and the lifecycle listener is gone.
|
|
135
|
+
assert.equal(liveTimers, 0);
|
|
136
|
+
assert.equal(app.events.lifecycle.size, 0);
|
|
137
|
+
|
|
138
|
+
// Restart: registrations rebuild without accumulating from the prior cycle.
|
|
139
|
+
await app.start();
|
|
140
|
+
assert.equal(liveTimers, 1);
|
|
141
|
+
await app.stop();
|
|
142
|
+
assert.equal(liveTimers, 0);
|
|
143
|
+
assert.equal(app.events.lifecycle.size, 0);
|
|
144
|
+
} finally {
|
|
145
|
+
// stop() is idempotent (drains an already-empty ladder on the happy path), so
|
|
146
|
+
// calling it here guarantees the interval is cleared even if an assertion above
|
|
147
|
+
// threw before the in-body stop() ran — no leaked timer keeps Node alive.
|
|
148
|
+
await app.stop();
|
|
149
|
+
await rm(dir, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
});
|