@nanobpm/urban 0.51.0 → 0.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/execContext.d.ts +5 -0
- package/dist/runtime/core/lineage.d.ts +116 -0
- package/dist/runtime/core/lineage.js +186 -0
- package/dist/runtime/core/modules/datasource.d.ts +6 -0
- package/dist/runtime/core/modules/datasource.js +8 -0
- package/dist/runtime/core/modules/lineage-store.d.ts +81 -0
- package/dist/runtime/core/modules/lineage-store.js +175 -0
- package/dist/runtime/core/modules/workers.js +69 -2
- package/dist/runtime/engine/nanosdk.js +8 -2
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +5 -0
- package/package.json +1 -1
- package/src/runtime/core/execContext.ts +5 -0
- package/src/runtime/core/lineage.test.ts +215 -0
- package/src/runtime/core/lineage.ts +297 -0
- package/src/runtime/core/modules/datasource.ts +9 -0
- package/src/runtime/core/modules/lineage-store.test.ts +177 -0
- package/src/runtime/core/modules/lineage-store.ts +239 -0
- package/src/runtime/core/modules/workers.test.ts +136 -1
- package/src/runtime/core/modules/workers.ts +72 -6
- package/src/runtime/engine/nanosdk.test.ts +97 -3
- package/src/runtime/engine/nanosdk.ts +8 -2
- package/src/runtime/index.ts +35 -0
|
@@ -9,6 +9,11 @@ export interface JobExecContext {
|
|
|
9
9
|
elementId?: string;
|
|
10
10
|
/** The job type (worker) currently executing. */
|
|
11
11
|
jobType?: string;
|
|
12
|
+
/** The lineage `rootRequestKey` the running instance belongs to (its own envelope's root,
|
|
13
|
+
* or — for a root instance with no envelope — its own instance key). Threaded so that when a
|
|
14
|
+
* handler spawns an instance or publishes a message, the SDK propagates the same root and
|
|
15
|
+
* sets `causedByInstanceKey` to {@link instanceKey} (issue #254, see `./lineage.ts`). */
|
|
16
|
+
rootRequestKey?: string;
|
|
12
17
|
}
|
|
13
18
|
/**
|
|
14
19
|
* Install the host-backed ambient store once per process. Idempotent — the first store created
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type JobExecContext } from "./execContext.ts";
|
|
2
|
+
/** The reserved variable namespace the lineage envelope lives under on every
|
|
3
|
+
* instance/message (`_urban.lineage`). Kept as one object so future framework
|
|
4
|
+
* conventions can share the `_urban.*` namespace without colliding. */
|
|
5
|
+
export declare const LINEAGE_NAMESPACE = "_urban";
|
|
6
|
+
/** The key of the lineage envelope within {@link LINEAGE_NAMESPACE}. */
|
|
7
|
+
export declare const LINEAGE_KEY = "lineage";
|
|
8
|
+
/**
|
|
9
|
+
* The lineage envelope, a reserved convention on every instance/message:
|
|
10
|
+
* `_urban.lineage = { rootRequestKey, causedByInstanceKey? }`.
|
|
11
|
+
* - `rootRequestKey` threads every descendant of a single top-level request back to
|
|
12
|
+
* it; it is PRESERVED verbatim across the whole causal tree.
|
|
13
|
+
* - `causedByInstanceKey` is the instance that directly caused this one (the weak
|
|
14
|
+
* parent edge). Absent on a genuine top-level request (a fresh root).
|
|
15
|
+
*/
|
|
16
|
+
export interface LineageEnvelope {
|
|
17
|
+
readonly rootRequestKey: string;
|
|
18
|
+
readonly causedByInstanceKey?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Options for {@link applyAmbientLineage}, injectable for deterministic tests. */
|
|
21
|
+
export interface ApplyLineageOptions {
|
|
22
|
+
/** Override the ambient job context (defaults to {@link currentJobContext}). */
|
|
23
|
+
readonly ambient?: JobExecContext | undefined;
|
|
24
|
+
/** Override the fresh-root minter (defaults to {@link mintRootRequestKey}). */
|
|
25
|
+
readonly mintRootRequestKey?: () => string;
|
|
26
|
+
}
|
|
27
|
+
/** Mint a fresh `rootRequestKey` for a genuine top-level request (no ambient lineage).
|
|
28
|
+
* Uses `crypto.randomUUID` where available (Node ≥ 16.7 / Deno expose it as a global),
|
|
29
|
+
* with a time+random fallback so this stays host-agnostic and never throws. */
|
|
30
|
+
export declare function mintRootRequestKey(): string;
|
|
31
|
+
/**
|
|
32
|
+
* Read the `_urban.lineage` envelope from a variables map, or `undefined` when absent or
|
|
33
|
+
* malformed (no/blank `rootRequestKey`). Tolerant by design: a bad envelope degrades to
|
|
34
|
+
* "no ambient lineage" rather than throwing on the hot instance-creation path.
|
|
35
|
+
*/
|
|
36
|
+
export declare function readLineage(variables: unknown): LineageEnvelope | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Return a COPY of `variables` with `envelope` written under `_urban.lineage`, preserving
|
|
39
|
+
* any other `_urban.*` keys. Never mutates the input.
|
|
40
|
+
*/
|
|
41
|
+
export declare function writeLineage(variables: Record<string, unknown> | undefined, envelope: LineageEnvelope): Record<string, unknown>;
|
|
42
|
+
/**
|
|
43
|
+
* Derive the envelope to attach to a NEW instance/message given the ambient job context.
|
|
44
|
+
* - Ambient lineage present → PROPAGATE it: preserve `rootRequestKey`, set
|
|
45
|
+
* `causedByInstanceKey` to the current instance (the cause).
|
|
46
|
+
* - Ambient job with an instance but no envelope → that instance is itself a root; the
|
|
47
|
+
* child inherits `rootRequestKey = <ambient instance>` and is caused by it.
|
|
48
|
+
* - No ambient job at all (a genuine top-level request) → MINT a fresh `rootRequestKey`.
|
|
49
|
+
*/
|
|
50
|
+
export declare function deriveLineage(ambient: JobExecContext | undefined, mint?: () => string): LineageEnvelope;
|
|
51
|
+
/**
|
|
52
|
+
* The single, adapter-agnostic step that auto-threads lineage onto a `createInstance` /
|
|
53
|
+
* `publishMessage` call's variables. Both the live `SdkEngineClient` and the testkit's
|
|
54
|
+
* `WasmEngineClient` route through this (No Drift Surfaces), so lineage threads identically
|
|
55
|
+
* in production and in-harness.
|
|
56
|
+
*
|
|
57
|
+
* An EXPLICIT caller-supplied `_urban.lineage` always wins (start a new root or re-parent
|
|
58
|
+
* deliberately) — it is returned untouched. Otherwise the envelope is derived from the
|
|
59
|
+
* ambient job context ({@link deriveLineage}) and merged in.
|
|
60
|
+
*/
|
|
61
|
+
export declare function applyAmbientLineage(variables: Record<string, unknown> | undefined, opts?: ApplyLineageOptions): Record<string, unknown>;
|
|
62
|
+
/** How a node connects to its cause. `weak` = the causal envelope edge; `strong` = the
|
|
63
|
+
* engine-native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). */
|
|
64
|
+
export type LineageEdgeType = "weak" | "strong";
|
|
65
|
+
/** One directed edge cause → effect in a lineage tree. `causedByInstanceKey` is absent on a
|
|
66
|
+
* root's own edge. */
|
|
67
|
+
export interface LineageEdge {
|
|
68
|
+
readonly rootRequestKey: string;
|
|
69
|
+
/** The effect / descendant instance this edge lands on. */
|
|
70
|
+
readonly instanceKey: string;
|
|
71
|
+
/** The cause / parent instance, or `undefined` for a root. */
|
|
72
|
+
readonly causedByInstanceKey?: string;
|
|
73
|
+
readonly edgeType: LineageEdgeType;
|
|
74
|
+
}
|
|
75
|
+
/** An app-registered domain row attached to a lineage node (a PR, a task, …). The framework
|
|
76
|
+
* stores it opaquely by `(nodeKey, kind, ref)` without knowing the app's schema. */
|
|
77
|
+
export interface LineageAttachment {
|
|
78
|
+
/** The instance key this row hangs off. */
|
|
79
|
+
readonly nodeKey: string;
|
|
80
|
+
/** App-defined category, e.g. `"pull_request"`, `"task"`. */
|
|
81
|
+
readonly kind: string;
|
|
82
|
+
/** App-defined reference into its own store, e.g. a PR key. */
|
|
83
|
+
readonly ref: string;
|
|
84
|
+
/** Optional human-readable label. */
|
|
85
|
+
readonly label?: string;
|
|
86
|
+
}
|
|
87
|
+
/** A node in the stitched lineage tree. */
|
|
88
|
+
export interface LineageNode {
|
|
89
|
+
readonly instanceKey: string;
|
|
90
|
+
/** How this node connects to its parent; `undefined` for the root. */
|
|
91
|
+
readonly edgeType?: LineageEdgeType;
|
|
92
|
+
readonly causedByInstanceKey?: string;
|
|
93
|
+
readonly attachments: LineageAttachment[];
|
|
94
|
+
readonly children: LineageNode[];
|
|
95
|
+
}
|
|
96
|
+
/** The stitched descendant tree for one `rootRequestKey`. */
|
|
97
|
+
export interface LineageTree {
|
|
98
|
+
readonly rootRequestKey: string;
|
|
99
|
+
/** The root node of the stitched tree, always present. Normally the top-level request's own
|
|
100
|
+
* instance; when `rootRequestKey` is a minted synthetic key, the parentless root instance it
|
|
101
|
+
* threads. Falls back to a synthetic node keyed by `rootRequestKey` when no edges exist. */
|
|
102
|
+
readonly root: LineageNode;
|
|
103
|
+
/** Every node keyed by instance, in insertion order — a convenience flat view. */
|
|
104
|
+
readonly nodes: LineageNode[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Build the stitched descendant tree for `rootRequestKey` by unioning weak and strong edges.
|
|
108
|
+
* When both edge sources connect the SAME node, the STRONG (engine execution) edge wins — it
|
|
109
|
+
* is the authoritative parent — so #808's native edge supersedes the envelope's weak guess
|
|
110
|
+
* without duplicating it. Nodes whose parent chain does not reach the root stay unattached
|
|
111
|
+
* (they still appear in `nodes`). The root is the instance keyed by `rootRequestKey` when that
|
|
112
|
+
* is itself an instance, else the single parentless recorded instance (a minted `rootRequestKey`
|
|
113
|
+
* is a synthetic correlation key, not an instance), else a synthetic node. Pure and
|
|
114
|
+
* deterministic — the store is a thin persistence layer over this.
|
|
115
|
+
*/
|
|
116
|
+
export declare function buildLineageTree(rootRequestKey: string, edges: readonly LineageEdge[], attachments?: readonly LineageAttachment[]): LineageTree;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// lineage — the framework-level lineage primitive (issue #254).
|
|
2
|
+
//
|
|
3
|
+
// Any Urban app wants to stitch *user intent → progress*: a single root request (a
|
|
4
|
+
// button press, an inbound webhook, an API call) fans out into the process instances,
|
|
5
|
+
// PRs, tasks and sub-loops it *causes*, and an operator wants to see that whole thread.
|
|
6
|
+
// Today every app hand-rolls that correlation. This module makes it a framework
|
|
7
|
+
// primitive so the next app gets intent→progress threading for free.
|
|
8
|
+
//
|
|
9
|
+
// Two DISTINCT relationships stitch the thread, and they must stay separate:
|
|
10
|
+
//
|
|
11
|
+
// - the LINEAGE / CAUSATION edge (this module): asynchronous causation — the cause does
|
|
12
|
+
// NOT block the effect, edges fan out (1→N), descendants settle independently and
|
|
13
|
+
// survive their ancestor's termination, and they are created by message-start / API
|
|
14
|
+
// `createInstance` / webhooks as much as by call activities. Carried as a reserved
|
|
15
|
+
// `_urban.lineage` envelope on every instance/message the SDK starts, AUTO-THREADED
|
|
16
|
+
// from the ambient job context (see {@link applyAmbientLineage}).
|
|
17
|
+
// - the EXECUTION edge (`parentProcessInstanceKey`, engine-native — Magikcraft/nano-bpm#808):
|
|
18
|
+
// synchronous delegation where the parent token blocks on the child. This module does
|
|
19
|
+
// NOT duplicate it; the read projection ({@link buildLineageTree}) CONSUMES it as a
|
|
20
|
+
// strong edge, unioned with the weak/causal edges of the envelope.
|
|
21
|
+
//
|
|
22
|
+
// This file is intentionally `node:*`-free (the host contract): the only ambient
|
|
23
|
+
// capability it reaches for is `crypto.randomUUID` (a Web/global, not `node:crypto`),
|
|
24
|
+
// with a portable fallback.
|
|
25
|
+
import { currentJobContext } from "./execContext.js";
|
|
26
|
+
import { isRecord } from "./guards.js";
|
|
27
|
+
/** The reserved variable namespace the lineage envelope lives under on every
|
|
28
|
+
* instance/message (`_urban.lineage`). Kept as one object so future framework
|
|
29
|
+
* conventions can share the `_urban.*` namespace without colliding. */
|
|
30
|
+
export const LINEAGE_NAMESPACE = "_urban";
|
|
31
|
+
/** The key of the lineage envelope within {@link LINEAGE_NAMESPACE}. */
|
|
32
|
+
export const LINEAGE_KEY = "lineage";
|
|
33
|
+
/** Mint a fresh `rootRequestKey` for a genuine top-level request (no ambient lineage).
|
|
34
|
+
* Uses `crypto.randomUUID` where available (Node ≥ 16.7 / Deno expose it as a global),
|
|
35
|
+
* with a time+random fallback so this stays host-agnostic and never throws. */
|
|
36
|
+
export function mintRootRequestKey() {
|
|
37
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
38
|
+
if (uuid)
|
|
39
|
+
return uuid;
|
|
40
|
+
return `root-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
41
|
+
}
|
|
42
|
+
/** Coerce a value to a non-empty string, or `undefined`. */
|
|
43
|
+
function nonEmptyString(value) {
|
|
44
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read the `_urban.lineage` envelope from a variables map, or `undefined` when absent or
|
|
48
|
+
* malformed (no/blank `rootRequestKey`). Tolerant by design: a bad envelope degrades to
|
|
49
|
+
* "no ambient lineage" rather than throwing on the hot instance-creation path.
|
|
50
|
+
*/
|
|
51
|
+
export function readLineage(variables) {
|
|
52
|
+
if (!isRecord(variables))
|
|
53
|
+
return undefined;
|
|
54
|
+
const ns = variables[LINEAGE_NAMESPACE];
|
|
55
|
+
if (!isRecord(ns))
|
|
56
|
+
return undefined;
|
|
57
|
+
const env = ns[LINEAGE_KEY];
|
|
58
|
+
if (!isRecord(env))
|
|
59
|
+
return undefined;
|
|
60
|
+
const rootRequestKey = nonEmptyString(env.rootRequestKey);
|
|
61
|
+
if (!rootRequestKey)
|
|
62
|
+
return undefined;
|
|
63
|
+
return { rootRequestKey, causedByInstanceKey: nonEmptyString(env.causedByInstanceKey) };
|
|
64
|
+
}
|
|
65
|
+
/** Normalise an envelope for storage in variables — drop an absent `causedByInstanceKey`
|
|
66
|
+
* rather than serialising `undefined`, so a fresh root's envelope is `{ rootRequestKey }`. */
|
|
67
|
+
function toEnvelopeRecord(envelope) {
|
|
68
|
+
return envelope.causedByInstanceKey
|
|
69
|
+
? { rootRequestKey: envelope.rootRequestKey, causedByInstanceKey: envelope.causedByInstanceKey }
|
|
70
|
+
: { rootRequestKey: envelope.rootRequestKey };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Return a COPY of `variables` with `envelope` written under `_urban.lineage`, preserving
|
|
74
|
+
* any other `_urban.*` keys. Never mutates the input.
|
|
75
|
+
*/
|
|
76
|
+
export function writeLineage(variables, envelope) {
|
|
77
|
+
const base = isRecord(variables) ? variables : {};
|
|
78
|
+
const ns = isRecord(base[LINEAGE_NAMESPACE]) ? base[LINEAGE_NAMESPACE] : {};
|
|
79
|
+
return { ...base, [LINEAGE_NAMESPACE]: { ...ns, [LINEAGE_KEY]: toEnvelopeRecord(envelope) } };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Derive the envelope to attach to a NEW instance/message given the ambient job context.
|
|
83
|
+
* - Ambient lineage present → PROPAGATE it: preserve `rootRequestKey`, set
|
|
84
|
+
* `causedByInstanceKey` to the current instance (the cause).
|
|
85
|
+
* - Ambient job with an instance but no envelope → that instance is itself a root; the
|
|
86
|
+
* child inherits `rootRequestKey = <ambient instance>` and is caused by it.
|
|
87
|
+
* - No ambient job at all (a genuine top-level request) → MINT a fresh `rootRequestKey`.
|
|
88
|
+
*/
|
|
89
|
+
export function deriveLineage(ambient, mint = mintRootRequestKey) {
|
|
90
|
+
const rootRequestKey = ambient?.rootRequestKey ?? ambient?.instanceKey;
|
|
91
|
+
if (rootRequestKey) {
|
|
92
|
+
return { rootRequestKey, causedByInstanceKey: ambient?.instanceKey };
|
|
93
|
+
}
|
|
94
|
+
return { rootRequestKey: mint() };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The single, adapter-agnostic step that auto-threads lineage onto a `createInstance` /
|
|
98
|
+
* `publishMessage` call's variables. Both the live `SdkEngineClient` and the testkit's
|
|
99
|
+
* `WasmEngineClient` route through this (No Drift Surfaces), so lineage threads identically
|
|
100
|
+
* in production and in-harness.
|
|
101
|
+
*
|
|
102
|
+
* An EXPLICIT caller-supplied `_urban.lineage` always wins (start a new root or re-parent
|
|
103
|
+
* deliberately) — it is returned untouched. Otherwise the envelope is derived from the
|
|
104
|
+
* ambient job context ({@link deriveLineage}) and merged in.
|
|
105
|
+
*/
|
|
106
|
+
export function applyAmbientLineage(variables, opts = {}) {
|
|
107
|
+
const base = isRecord(variables) ? variables : {};
|
|
108
|
+
if (readLineage(base))
|
|
109
|
+
return { ...base };
|
|
110
|
+
const ambient = opts.ambient !== undefined ? opts.ambient : currentJobContext();
|
|
111
|
+
return writeLineage(base, deriveLineage(ambient, opts.mintRootRequestKey));
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build the stitched descendant tree for `rootRequestKey` by unioning weak and strong edges.
|
|
115
|
+
* When both edge sources connect the SAME node, the STRONG (engine execution) edge wins — it
|
|
116
|
+
* is the authoritative parent — so #808's native edge supersedes the envelope's weak guess
|
|
117
|
+
* without duplicating it. Nodes whose parent chain does not reach the root stay unattached
|
|
118
|
+
* (they still appear in `nodes`). The root is the instance keyed by `rootRequestKey` when that
|
|
119
|
+
* is itself an instance, else the single parentless recorded instance (a minted `rootRequestKey`
|
|
120
|
+
* is a synthetic correlation key, not an instance), else a synthetic node. Pure and
|
|
121
|
+
* deterministic — the store is a thin persistence layer over this.
|
|
122
|
+
*/
|
|
123
|
+
export function buildLineageTree(rootRequestKey, edges, attachments = []) {
|
|
124
|
+
// Collapse to one edge per effect instance: strong supersedes weak.
|
|
125
|
+
const byInstance = new Map();
|
|
126
|
+
for (const e of edges) {
|
|
127
|
+
if (e.rootRequestKey !== rootRequestKey)
|
|
128
|
+
continue;
|
|
129
|
+
const prev = byInstance.get(e.instanceKey);
|
|
130
|
+
if (!prev || (prev.edgeType === "weak" && e.edgeType === "strong")) {
|
|
131
|
+
byInstance.set(e.instanceKey, e);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const nodes = new Map();
|
|
135
|
+
const ensure = (instanceKey) => {
|
|
136
|
+
let n = nodes.get(instanceKey);
|
|
137
|
+
if (!n) {
|
|
138
|
+
n = { instanceKey, attachments: [], children: [] };
|
|
139
|
+
nodes.set(instanceKey, n);
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
};
|
|
143
|
+
// Speculatively materialise the `rootRequestKey` node so descendants whose cause IS the root
|
|
144
|
+
// (a child inherits the root's own instance key as `rootRequestKey`) can attach to it below.
|
|
145
|
+
const rootByKey = ensure(rootRequestKey);
|
|
146
|
+
for (const e of byInstance.values()) {
|
|
147
|
+
const node = ensure(e.instanceKey);
|
|
148
|
+
node.edgeType = e.edgeType;
|
|
149
|
+
node.causedByInstanceKey = e.causedByInstanceKey;
|
|
150
|
+
}
|
|
151
|
+
// Attachments are an app extension point keyed by instance. MATERIALISE the target node even
|
|
152
|
+
// when no edge has been recorded for it yet, so an attachment for an as-yet-unstitched instance
|
|
153
|
+
// is preserved in `tree.nodes` (unattached until its edge lands) rather than silently dropped.
|
|
154
|
+
for (const a of attachments) {
|
|
155
|
+
const node = ensure(a.nodeKey);
|
|
156
|
+
node.attachments.push({ nodeKey: a.nodeKey, kind: a.kind, ref: a.ref, ...(a.label ? { label: a.label } : {}) });
|
|
157
|
+
}
|
|
158
|
+
for (const node of nodes.values()) {
|
|
159
|
+
const parentKey = node.causedByInstanceKey;
|
|
160
|
+
if (parentKey && parentKey !== node.instanceKey && nodes.has(parentKey)) {
|
|
161
|
+
ensure(parentKey).children.push(node);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Resolve the root node. Usually `rootRequestKey` IS the root instance's own key, so `rootByKey`
|
|
165
|
+
// carries that instance's edge and descendants. But a genuine top-level request (no ambient job)
|
|
166
|
+
// MINTS a synthetic `rootRequestKey` — a UUID that is not any instance — so `rootByKey` is an
|
|
167
|
+
// empty phantom while the real root is the single recorded instance with no cause. Prefer that
|
|
168
|
+
// parentless instance and drop the phantom, so the tree is never rooted at a childless synthetic
|
|
169
|
+
// node with the real root left floating detached (the bug this guards against).
|
|
170
|
+
let root = rootByKey;
|
|
171
|
+
const rootIsPhantom = rootByKey.edgeType === undefined && rootByKey.children.length === 0 && rootByKey.attachments.length === 0;
|
|
172
|
+
if (rootIsPhantom) {
|
|
173
|
+
// A candidate real root is a parentless node that is genuinely part of the causal structure —
|
|
174
|
+
// it has its own recorded edge or descendants. Attachment-only orphan nodes (materialised above
|
|
175
|
+
// with no edge and no children) are NOT candidates, so a stray attachment can never shadow the
|
|
176
|
+
// real parentless root and re-introduce the phantom-root bug guarded by the minted-root case.
|
|
177
|
+
const parentlessInstances = [...nodes.values()].filter((n) => n !== rootByKey &&
|
|
178
|
+
n.causedByInstanceKey === undefined &&
|
|
179
|
+
(n.edgeType !== undefined || n.children.length > 0));
|
|
180
|
+
if (parentlessInstances.length === 1) {
|
|
181
|
+
root = parentlessInstances[0];
|
|
182
|
+
nodes.delete(rootRequestKey);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { rootRequestKey, root, nodes: [...nodes.values()] };
|
|
186
|
+
}
|
|
@@ -101,6 +101,12 @@ export declare class DataLayer {
|
|
|
101
101
|
private readonly types;
|
|
102
102
|
constructor(sources: Map<string, ProvisionedSource>, defaultSource: string | undefined, types: Record<string, DomainType>);
|
|
103
103
|
source(name?: string): ProvisionedSource;
|
|
104
|
+
/**
|
|
105
|
+
* Whether a default data source is *configured* — not whether it provisions. Lets a caller
|
|
106
|
+
* distinguish the expected "no source at all" case from a genuine misconfiguration (a default is
|
|
107
|
+
* named but its source is missing, so {@link source} throws `no such data source`).
|
|
108
|
+
*/
|
|
109
|
+
hasDefaultSource(): boolean;
|
|
104
110
|
/** A typed accessor for a declared domain type (uses the default source). */
|
|
105
111
|
repo(typeName: string, sourceName?: string): TypeRepo;
|
|
106
112
|
/** The record-oriented `DataSource` gateway for a source (the default when omitted) — the
|
|
@@ -223,6 +223,14 @@ export class DataLayer {
|
|
|
223
223
|
throw new Error(`no such data source "${key}"`);
|
|
224
224
|
return s;
|
|
225
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Whether a default data source is *configured* — not whether it provisions. Lets a caller
|
|
228
|
+
* distinguish the expected "no source at all" case from a genuine misconfiguration (a default is
|
|
229
|
+
* named but its source is missing, so {@link source} throws `no such data source`).
|
|
230
|
+
*/
|
|
231
|
+
hasDefaultSource() {
|
|
232
|
+
return this.defaultSource !== undefined;
|
|
233
|
+
}
|
|
226
234
|
/** A typed accessor for a declared domain type (uses the default source). */
|
|
227
235
|
repo(typeName, sourceName) {
|
|
228
236
|
const def = this.types[typeName];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { SqliteDb } from "../host.ts";
|
|
2
|
+
import { type LineageAttachment, type LineageEdge, type LineageTree } from "../lineage.ts";
|
|
3
|
+
/** The lineage edge table name. Framework bookkeeping, so `_urban_`-prefixed to stay hidden from
|
|
4
|
+
* the domain model / DB Manager (see `SqliteGateway.schema()`), like the migrations ledger. */
|
|
5
|
+
export declare const LINEAGE_EDGES_TABLE = "_urban_lineage_edges";
|
|
6
|
+
/** The lineage attachment table name (app-registered domain rows on a node). `_urban_`-prefixed for
|
|
7
|
+
* the same reason as {@link LINEAGE_EDGES_TABLE}. */
|
|
8
|
+
export declare const LINEAGE_ATTACHMENTS_TABLE = "_urban_lineage_attachments";
|
|
9
|
+
/**
|
|
10
|
+
* The canonical lineage DDL — the single source of truth applied by
|
|
11
|
+
* {@link LineageStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
12
|
+
* `db/migrations/004_urban_lineage.sql` (a drift-guard test asserts they match).
|
|
13
|
+
*
|
|
14
|
+
* Forward-only and additive. `_urban_lineage_edges` is keyed by `(instance_key, edge_type)` so a
|
|
15
|
+
* node can hold both its weak (envelope) and strong (execution) edge at once and a re-record is a
|
|
16
|
+
* no-op; `_urban_lineage_attachments` is keyed by `(node_key, kind, ref)` so re-attaching the same
|
|
17
|
+
* domain row is idempotent. Both carry `root_request_key` with an index so a `getLineage(root)`
|
|
18
|
+
* reads only that thread.
|
|
19
|
+
*/
|
|
20
|
+
export declare const LINEAGE_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS _urban_lineage_edges (\n root_request_key TEXT NOT NULL,\n instance_key TEXT NOT NULL,\n caused_by_instance_key TEXT,\n edge_type TEXT NOT NULL DEFAULT 'weak',\n created_at TEXT NOT NULL,\n PRIMARY KEY (instance_key, edge_type)\n);\nCREATE INDEX IF NOT EXISTS idx__urban_lineage_edges_root ON _urban_lineage_edges (root_request_key);\nCREATE TABLE IF NOT EXISTS _urban_lineage_attachments (\n root_request_key TEXT NOT NULL,\n node_key TEXT NOT NULL,\n kind TEXT NOT NULL,\n ref TEXT NOT NULL,\n label TEXT,\n created_at TEXT NOT NULL,\n PRIMARY KEY (node_key, kind, ref)\n);\nCREATE INDEX IF NOT EXISTS idx__urban_lineage_attachments_root ON _urban_lineage_attachments (root_request_key);";
|
|
21
|
+
/** A monotonic wall clock, injectable for deterministic `created_at` in tests. */
|
|
22
|
+
export interface Clock {
|
|
23
|
+
now(): number;
|
|
24
|
+
}
|
|
25
|
+
/** The default clock: `Date.now()`. */
|
|
26
|
+
export declare const systemClock: Clock;
|
|
27
|
+
/** A minimal job shape {@link LineageStore.recordFromJob} reads — a structural subset of the
|
|
28
|
+
* runtime's `EngineJob` plus the future engine execution edge (`parentProcessInstanceKey`,
|
|
29
|
+
* Magikcraft/nano-bpm#808), consumed as a strong edge where present. */
|
|
30
|
+
export interface LineageJobLike {
|
|
31
|
+
readonly processInstanceKey?: string;
|
|
32
|
+
readonly variables?: Record<string, unknown>;
|
|
33
|
+
/** The engine-native execution parent, when the engine emits it (#808). */
|
|
34
|
+
readonly parentProcessInstanceKey?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface LineageStoreOptions {
|
|
37
|
+
readonly clock?: Clock;
|
|
38
|
+
}
|
|
39
|
+
export declare class LineageStore {
|
|
40
|
+
#private;
|
|
41
|
+
constructor(db: SqliteDb, options?: LineageStoreOptions);
|
|
42
|
+
/** Apply the canonical lineage DDL (idempotent). Callers whose app DataLayer applies the boot
|
|
43
|
+
* migration `db/migrations/004_urban_lineage.sql` do not need this; the runtime calls it so the
|
|
44
|
+
* store is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
45
|
+
ensureSchema(): void;
|
|
46
|
+
/**
|
|
47
|
+
* Record one lineage edge, idempotently. A re-record of the same `(instance_key, edge_type)` is a
|
|
48
|
+
* no-op (an engine may re-activate a job on retry), so recording is safe to drive from any
|
|
49
|
+
* at-least-once observation point. A blank `rootRequestKey` or `instanceKey` is ignored.
|
|
50
|
+
*/
|
|
51
|
+
recordEdge(edge: LineageEdge): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Record the WEAK edge carried by an instance's `_urban.lineage` envelope. `instanceKey` is the
|
|
54
|
+
* effect/descendant node (a created instance's returned key, or a running job's own instance).
|
|
55
|
+
* A no-op when the variables carry no valid envelope.
|
|
56
|
+
*/
|
|
57
|
+
recordEnvelope(instanceKey: string | undefined, variables: unknown): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Record every edge an activated job reveals about its own instance: the weak envelope edge, and
|
|
60
|
+
* — where the engine supplies `parentProcessInstanceKey` (#808) — the strong execution edge under
|
|
61
|
+
* the same root. The natural single observation point for the worker runtime, since every instance
|
|
62
|
+
* that does work activates a job carrying its envelope. Never throws for a malformed job.
|
|
63
|
+
*/
|
|
64
|
+
recordFromJob(job: LineageJobLike): void;
|
|
65
|
+
/**
|
|
66
|
+
* Attach an app-defined domain row to a lineage node, idempotently. The framework stores it
|
|
67
|
+
* opaquely by `(node_key, kind, ref)` — it never interprets the app's schema. This is the
|
|
68
|
+
* extension point that lets an app hang its PRs/tasks off the lineage tree.
|
|
69
|
+
*/
|
|
70
|
+
attach(rootRequestKey: string, attachment: LineageAttachment): boolean;
|
|
71
|
+
/** The persisted edges for one root, in insertion order. */
|
|
72
|
+
edges(rootRequestKey: string): LineageEdge[];
|
|
73
|
+
/** The persisted attachments for one root, in insertion order. */
|
|
74
|
+
attachments(rootRequestKey: string): LineageAttachment[];
|
|
75
|
+
/**
|
|
76
|
+
* The stitched descendant tree for `rootRequestKey`, unioning the weak (envelope) and strong
|
|
77
|
+
* (engine execution) edges and hanging each app attachment off its node. The generic read
|
|
78
|
+
* surface any Urban app queries for intent→progress.
|
|
79
|
+
*/
|
|
80
|
+
getLineage(rootRequestKey: string): LineageTree;
|
|
81
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// lineage-store — the durable read-model projection behind the lineage primitive (issue #254).
|
|
2
|
+
//
|
|
3
|
+
// A framework-level, per-source sidecar (like the write-provenance table) that materialises the
|
|
4
|
+
// lineage edges the SDK auto-threads (`_urban.lineage`) and — where the engine emits it — the
|
|
5
|
+
// native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). `getLineage(root)`
|
|
6
|
+
// unions both and returns the stitched descendant tree for one root request. Apps attach their own
|
|
7
|
+
// domain rows (PRs, tasks) to a node via {@link LineageStore.attach} WITHOUT the framework knowing
|
|
8
|
+
// their schema — the extension point that lets intent→progress visualisation be app-agnostic.
|
|
9
|
+
//
|
|
10
|
+
// The tree assembly itself is the pure {@link buildLineageTree}; this store is only its thin,
|
|
11
|
+
// idempotent persistence + query layer, so recording the same instance twice (an engine job
|
|
12
|
+
// re-activation) collapses to a no-op.
|
|
13
|
+
import { buildLineageTree, readLineage, } from "../lineage.js";
|
|
14
|
+
/** The lineage edge table name. Framework bookkeeping, so `_urban_`-prefixed to stay hidden from
|
|
15
|
+
* the domain model / DB Manager (see `SqliteGateway.schema()`), like the migrations ledger. */
|
|
16
|
+
export const LINEAGE_EDGES_TABLE = "_urban_lineage_edges";
|
|
17
|
+
/** The lineage attachment table name (app-registered domain rows on a node). `_urban_`-prefixed for
|
|
18
|
+
* the same reason as {@link LINEAGE_EDGES_TABLE}. */
|
|
19
|
+
export const LINEAGE_ATTACHMENTS_TABLE = "_urban_lineage_attachments";
|
|
20
|
+
/**
|
|
21
|
+
* The canonical lineage DDL — the single source of truth applied by
|
|
22
|
+
* {@link LineageStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
23
|
+
* `db/migrations/004_urban_lineage.sql` (a drift-guard test asserts they match).
|
|
24
|
+
*
|
|
25
|
+
* Forward-only and additive. `_urban_lineage_edges` is keyed by `(instance_key, edge_type)` so a
|
|
26
|
+
* node can hold both its weak (envelope) and strong (execution) edge at once and a re-record is a
|
|
27
|
+
* no-op; `_urban_lineage_attachments` is keyed by `(node_key, kind, ref)` so re-attaching the same
|
|
28
|
+
* domain row is idempotent. Both carry `root_request_key` with an index so a `getLineage(root)`
|
|
29
|
+
* reads only that thread.
|
|
30
|
+
*/
|
|
31
|
+
export const LINEAGE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${LINEAGE_EDGES_TABLE} (
|
|
32
|
+
root_request_key TEXT NOT NULL,
|
|
33
|
+
instance_key TEXT NOT NULL,
|
|
34
|
+
caused_by_instance_key TEXT,
|
|
35
|
+
edge_type TEXT NOT NULL DEFAULT 'weak',
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
PRIMARY KEY (instance_key, edge_type)
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_${LINEAGE_EDGES_TABLE}_root ON ${LINEAGE_EDGES_TABLE} (root_request_key);
|
|
40
|
+
CREATE TABLE IF NOT EXISTS ${LINEAGE_ATTACHMENTS_TABLE} (
|
|
41
|
+
root_request_key TEXT NOT NULL,
|
|
42
|
+
node_key TEXT NOT NULL,
|
|
43
|
+
kind TEXT NOT NULL,
|
|
44
|
+
ref TEXT NOT NULL,
|
|
45
|
+
label TEXT,
|
|
46
|
+
created_at TEXT NOT NULL,
|
|
47
|
+
PRIMARY KEY (node_key, kind, ref)
|
|
48
|
+
);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS idx_${LINEAGE_ATTACHMENTS_TABLE}_root ON ${LINEAGE_ATTACHMENTS_TABLE} (root_request_key);`;
|
|
50
|
+
/** The default clock: `Date.now()`. */
|
|
51
|
+
export const systemClock = { now: () => Date.now() };
|
|
52
|
+
function normalizeEdgeType(raw) {
|
|
53
|
+
return raw === "strong" ? "strong" : "weak";
|
|
54
|
+
}
|
|
55
|
+
export class LineageStore {
|
|
56
|
+
#db;
|
|
57
|
+
#clock;
|
|
58
|
+
constructor(db, options = {}) {
|
|
59
|
+
this.#db = db;
|
|
60
|
+
this.#clock = options.clock ?? systemClock;
|
|
61
|
+
}
|
|
62
|
+
/** Apply the canonical lineage DDL (idempotent). Callers whose app DataLayer applies the boot
|
|
63
|
+
* migration `db/migrations/004_urban_lineage.sql` do not need this; the runtime calls it so the
|
|
64
|
+
* store is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
65
|
+
ensureSchema() {
|
|
66
|
+
this.#db.exec(LINEAGE_SCHEMA_SQL);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Record one lineage edge, idempotently. A re-record of the same `(instance_key, edge_type)` is a
|
|
70
|
+
* no-op (an engine may re-activate a job on retry), so recording is safe to drive from any
|
|
71
|
+
* at-least-once observation point. A blank `rootRequestKey` or `instanceKey` is ignored.
|
|
72
|
+
*/
|
|
73
|
+
recordEdge(edge) {
|
|
74
|
+
if (!edge.rootRequestKey || !edge.instanceKey)
|
|
75
|
+
return false;
|
|
76
|
+
const { changes } = this.#db.run(`INSERT OR IGNORE INTO ${LINEAGE_EDGES_TABLE}
|
|
77
|
+
(root_request_key, instance_key, caused_by_instance_key, edge_type, created_at)
|
|
78
|
+
VALUES (?, ?, ?, ?, ?)`, [
|
|
79
|
+
edge.rootRequestKey,
|
|
80
|
+
edge.instanceKey,
|
|
81
|
+
edge.causedByInstanceKey ?? null,
|
|
82
|
+
edge.edgeType,
|
|
83
|
+
new Date(this.#clock.now()).toISOString(),
|
|
84
|
+
]);
|
|
85
|
+
return changes > 0;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Record the WEAK edge carried by an instance's `_urban.lineage` envelope. `instanceKey` is the
|
|
89
|
+
* effect/descendant node (a created instance's returned key, or a running job's own instance).
|
|
90
|
+
* A no-op when the variables carry no valid envelope.
|
|
91
|
+
*/
|
|
92
|
+
recordEnvelope(instanceKey, variables) {
|
|
93
|
+
if (!instanceKey)
|
|
94
|
+
return false;
|
|
95
|
+
const env = readLineage(variables);
|
|
96
|
+
if (!env)
|
|
97
|
+
return false;
|
|
98
|
+
return this.recordEdge({
|
|
99
|
+
rootRequestKey: env.rootRequestKey,
|
|
100
|
+
instanceKey,
|
|
101
|
+
causedByInstanceKey: env.causedByInstanceKey,
|
|
102
|
+
edgeType: "weak",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Record every edge an activated job reveals about its own instance: the weak envelope edge, and
|
|
107
|
+
* — where the engine supplies `parentProcessInstanceKey` (#808) — the strong execution edge under
|
|
108
|
+
* the same root. The natural single observation point for the worker runtime, since every instance
|
|
109
|
+
* that does work activates a job carrying its envelope. Never throws for a malformed job.
|
|
110
|
+
*/
|
|
111
|
+
recordFromJob(job) {
|
|
112
|
+
const env = readLineage(job.variables);
|
|
113
|
+
if (!env || !job.processInstanceKey)
|
|
114
|
+
return;
|
|
115
|
+
this.recordEnvelope(job.processInstanceKey, job.variables);
|
|
116
|
+
if (job.parentProcessInstanceKey) {
|
|
117
|
+
this.recordEdge({
|
|
118
|
+
rootRequestKey: env.rootRequestKey,
|
|
119
|
+
instanceKey: job.processInstanceKey,
|
|
120
|
+
causedByInstanceKey: job.parentProcessInstanceKey,
|
|
121
|
+
edgeType: "strong",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Attach an app-defined domain row to a lineage node, idempotently. The framework stores it
|
|
127
|
+
* opaquely by `(node_key, kind, ref)` — it never interprets the app's schema. This is the
|
|
128
|
+
* extension point that lets an app hang its PRs/tasks off the lineage tree.
|
|
129
|
+
*/
|
|
130
|
+
attach(rootRequestKey, attachment) {
|
|
131
|
+
if (!rootRequestKey || !attachment.nodeKey || !attachment.kind || !attachment.ref)
|
|
132
|
+
return false;
|
|
133
|
+
const { changes } = this.#db.run(`INSERT OR IGNORE INTO ${LINEAGE_ATTACHMENTS_TABLE}
|
|
134
|
+
(root_request_key, node_key, kind, ref, label, created_at)
|
|
135
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
136
|
+
rootRequestKey,
|
|
137
|
+
attachment.nodeKey,
|
|
138
|
+
attachment.kind,
|
|
139
|
+
attachment.ref,
|
|
140
|
+
attachment.label ?? null,
|
|
141
|
+
new Date(this.#clock.now()).toISOString(),
|
|
142
|
+
]);
|
|
143
|
+
return changes > 0;
|
|
144
|
+
}
|
|
145
|
+
/** The persisted edges for one root, in insertion order. */
|
|
146
|
+
edges(rootRequestKey) {
|
|
147
|
+
const rows = this.#db.all(`SELECT root_request_key, instance_key, caused_by_instance_key, edge_type
|
|
148
|
+
FROM ${LINEAGE_EDGES_TABLE} WHERE root_request_key = ? ORDER BY rowid`, [rootRequestKey]);
|
|
149
|
+
return rows.map((r) => ({
|
|
150
|
+
rootRequestKey: r.root_request_key,
|
|
151
|
+
instanceKey: r.instance_key,
|
|
152
|
+
causedByInstanceKey: r.caused_by_instance_key ?? undefined,
|
|
153
|
+
edgeType: normalizeEdgeType(r.edge_type),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
/** The persisted attachments for one root, in insertion order. */
|
|
157
|
+
attachments(rootRequestKey) {
|
|
158
|
+
const rows = this.#db.all(`SELECT root_request_key, node_key, kind, ref, label
|
|
159
|
+
FROM ${LINEAGE_ATTACHMENTS_TABLE} WHERE root_request_key = ? ORDER BY rowid`, [rootRequestKey]);
|
|
160
|
+
return rows.map((r) => ({
|
|
161
|
+
nodeKey: r.node_key,
|
|
162
|
+
kind: r.kind,
|
|
163
|
+
ref: r.ref,
|
|
164
|
+
...(r.label ? { label: r.label } : {}),
|
|
165
|
+
}));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The stitched descendant tree for `rootRequestKey`, unioning the weak (envelope) and strong
|
|
169
|
+
* (engine execution) edges and hanging each app attachment off its node. The generic read
|
|
170
|
+
* surface any Urban app queries for intent→progress.
|
|
171
|
+
*/
|
|
172
|
+
getLineage(rootRequestKey) {
|
|
173
|
+
return buildLineageTree(rootRequestKey, this.edges(rootRequestKey), this.attachments(rootRequestKey));
|
|
174
|
+
}
|
|
175
|
+
}
|