@nanobpm/urban 0.50.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/pages.js +165 -33
- 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/pages.test.ts +85 -0
- package/src/runtime/core/modules/pages.ts +165 -33
- 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
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { createNodeHost } from "../../adapters/node.ts";
|
|
9
|
+
import type { SqliteDb } from "../host.ts";
|
|
10
|
+
import { makeGateway } from "./gateway.ts";
|
|
11
|
+
import {
|
|
12
|
+
LINEAGE_ATTACHMENTS_TABLE,
|
|
13
|
+
LINEAGE_EDGES_TABLE,
|
|
14
|
+
LINEAGE_SCHEMA_SQL,
|
|
15
|
+
LineageStore,
|
|
16
|
+
} from "./lineage-store.ts";
|
|
17
|
+
|
|
18
|
+
async function withStore(fn: (store: LineageStore, db: SqliteDb) => void | Promise<void>): Promise<void> {
|
|
19
|
+
const dir = await mkdtemp(join(tmpdir(), "urban-lineage-"));
|
|
20
|
+
const host = createNodeHost({ cwd: dir, log: () => {} });
|
|
21
|
+
const db = host.openSqlite(join(dir, "test.db"));
|
|
22
|
+
const store = new LineageStore(db, { clock: { now: () => 0 } });
|
|
23
|
+
store.ensureSchema();
|
|
24
|
+
try {
|
|
25
|
+
await fn(store, db);
|
|
26
|
+
} finally {
|
|
27
|
+
db.close();
|
|
28
|
+
await rm(dir, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normaliseSql(sql: string): string {
|
|
33
|
+
return sql
|
|
34
|
+
.split("\n")
|
|
35
|
+
.map((line) => line.replace(/--.*$/, ""))
|
|
36
|
+
.join("\n")
|
|
37
|
+
.replace(/\s+/g, " ")
|
|
38
|
+
.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const migrationPath = fileURLToPath(new URL("../../../../../../db/migrations/004_urban_lineage.sql", import.meta.url));
|
|
42
|
+
|
|
43
|
+
test("the lineage projection tables are framework bookkeeping — `_urban_`-prefixed and hidden from the domain model", async () => {
|
|
44
|
+
// The gateway hides `_urban_%` / `_nano_%` tables from the domain model / DB Manager. These
|
|
45
|
+
// projection tables are framework sidecars (not user/domain tables), so their names MUST carry
|
|
46
|
+
// that prefix or they would leak into apps' schemas.
|
|
47
|
+
assert.match(LINEAGE_EDGES_TABLE, /^_urban_/, "lineage edge table must be `_urban_`-prefixed to stay hidden");
|
|
48
|
+
assert.match(LINEAGE_ATTACHMENTS_TABLE, /^_urban_/, "lineage attachment table must be `_urban_`-prefixed to stay hidden");
|
|
49
|
+
await withStore(async (_store, db) => {
|
|
50
|
+
const surfaced = (await makeGateway(db).schema()).map((t) => t.name);
|
|
51
|
+
assert.ok(!surfaced.includes(LINEAGE_EDGES_TABLE), "lineage edge table must not surface in the domain model");
|
|
52
|
+
assert.ok(!surfaced.includes(LINEAGE_ATTACHMENTS_TABLE), "lineage attachment table must not surface in the domain model");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the boot migration and LineageStore's DDL do not drift", () => {
|
|
57
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
58
|
+
assert.equal(
|
|
59
|
+
normaliseSql(migrationSql),
|
|
60
|
+
normaliseSql(LINEAGE_SCHEMA_SQL),
|
|
61
|
+
"db/migrations/004_urban_lineage.sql must match LINEAGE_SCHEMA_SQL — update both together",
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the boot migration is forward-only and additive (IF NOT EXISTS, no drops/alters)", () => {
|
|
66
|
+
const migrationSql = readFileSync(migrationPath, "utf8");
|
|
67
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS _urban_lineage_edges/);
|
|
68
|
+
assert.match(migrationSql, /CREATE TABLE IF NOT EXISTS _urban_lineage_attachments/);
|
|
69
|
+
assert.doesNotMatch(migrationSql, /\bDROP\b/i);
|
|
70
|
+
assert.doesNotMatch(migrationSql, /\bALTER\b/i);
|
|
71
|
+
// Numbering is a shared surface; guard the prefix so a rebase renumber is caught here.
|
|
72
|
+
assert.match(migrationPath, /[/\\]004_urban_lineage\.sql$/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("ensureSchema creates both lineage tables idempotently", async () => {
|
|
76
|
+
await withStore((store, db) => {
|
|
77
|
+
store.ensureSchema();
|
|
78
|
+
store.ensureSchema();
|
|
79
|
+
const tables = db
|
|
80
|
+
.all<{ name: string }>("SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?)", [
|
|
81
|
+
LINEAGE_EDGES_TABLE,
|
|
82
|
+
LINEAGE_ATTACHMENTS_TABLE,
|
|
83
|
+
])
|
|
84
|
+
.map((r) => r.name)
|
|
85
|
+
.sort();
|
|
86
|
+
assert.deepEqual(tables, [LINEAGE_ATTACHMENTS_TABLE, LINEAGE_EDGES_TABLE].sort());
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("recordEdge is idempotent (a re-record of the same node/edge-type is a no-op)", async () => {
|
|
91
|
+
await withStore((store) => {
|
|
92
|
+
const edge = {
|
|
93
|
+
rootRequestKey: "root",
|
|
94
|
+
instanceKey: "child",
|
|
95
|
+
causedByInstanceKey: "root",
|
|
96
|
+
edgeType: "weak" as const,
|
|
97
|
+
};
|
|
98
|
+
assert.equal(store.recordEdge(edge), true);
|
|
99
|
+
assert.equal(store.recordEdge(edge), false, "re-record collapses to a no-op");
|
|
100
|
+
assert.equal(store.edges("root").length, 1);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("recordEnvelope records the weak edge carried by an instance's _urban.lineage", async () => {
|
|
105
|
+
await withStore((store) => {
|
|
106
|
+
const recorded = store.recordEnvelope("child", {
|
|
107
|
+
_urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } },
|
|
108
|
+
});
|
|
109
|
+
assert.equal(recorded, true);
|
|
110
|
+
assert.deepEqual(store.edges("root"), [
|
|
111
|
+
{ rootRequestKey: "root", instanceKey: "child", causedByInstanceKey: "root", edgeType: "weak" },
|
|
112
|
+
]);
|
|
113
|
+
// No envelope → nothing recorded.
|
|
114
|
+
assert.equal(store.recordEnvelope("x", { plain: 1 }), false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("recordFromJob records both the weak envelope edge and the strong execution edge (#808)", async () => {
|
|
119
|
+
await withStore((store) => {
|
|
120
|
+
store.recordFromJob({
|
|
121
|
+
processInstanceKey: "child",
|
|
122
|
+
parentProcessInstanceKey: "parent",
|
|
123
|
+
variables: { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "cause" } } },
|
|
124
|
+
});
|
|
125
|
+
const edges = store.edges("root");
|
|
126
|
+
assert.equal(edges.length, 2);
|
|
127
|
+
const strong = edges.find((e) => e.edgeType === "strong");
|
|
128
|
+
assert.deepEqual(strong, {
|
|
129
|
+
rootRequestKey: "root",
|
|
130
|
+
instanceKey: "child",
|
|
131
|
+
causedByInstanceKey: "parent",
|
|
132
|
+
edgeType: "strong",
|
|
133
|
+
});
|
|
134
|
+
// getLineage unions them: the strong execution edge wins for the node.
|
|
135
|
+
const tree = store.getLineage("root");
|
|
136
|
+
const node = tree.nodes.find((n) => n.instanceKey === "child");
|
|
137
|
+
assert.equal(node?.edgeType, "strong");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("attach hangs an app domain row off a node, idempotently, and getLineage returns it", async () => {
|
|
142
|
+
await withStore((store) => {
|
|
143
|
+
store.recordEnvelope("child", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } } });
|
|
144
|
+
assert.equal(store.attach("root", { nodeKey: "child", kind: "pull_request", ref: "o/r#7", label: "PR 7" }), true);
|
|
145
|
+
assert.equal(store.attach("root", { nodeKey: "child", kind: "pull_request", ref: "o/r#7", label: "PR 7" }), false);
|
|
146
|
+
const tree = store.getLineage("root");
|
|
147
|
+
const child = tree.root.children[0];
|
|
148
|
+
assert.equal(child.instanceKey, "child");
|
|
149
|
+
assert.deepEqual(child.attachments, [{ nodeKey: "child", kind: "pull_request", ref: "o/r#7", label: "PR 7" }]);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("getLineage returns the full descendant tree keyed by rootRequestKey", async () => {
|
|
154
|
+
await withStore((store) => {
|
|
155
|
+
// A root fans out to two children; one child spawns a grandchild.
|
|
156
|
+
store.recordEdge({ rootRequestKey: "root", instanceKey: "root", edgeType: "weak" });
|
|
157
|
+
store.recordEnvelope("c1", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } } });
|
|
158
|
+
store.recordEnvelope("c2", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "root" } } });
|
|
159
|
+
store.recordEnvelope("g1", { _urban: { lineage: { rootRequestKey: "root", causedByInstanceKey: "c1" } } });
|
|
160
|
+
|
|
161
|
+
const tree = store.getLineage("root");
|
|
162
|
+
assert.equal(tree.rootRequestKey, "root");
|
|
163
|
+
assert.equal(tree.root.instanceKey, "root");
|
|
164
|
+
assert.deepEqual(tree.root.children.map((c) => c.instanceKey).sort(), ["c1", "c2"]);
|
|
165
|
+
const c1 = tree.root.children.find((c) => c.instanceKey === "c1");
|
|
166
|
+
assert.deepEqual(c1?.children.map((c) => c.instanceKey), ["g1"]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("getLineage returns a lone-root tree for a root with no recorded descendants", async () => {
|
|
171
|
+
await withStore((store) => {
|
|
172
|
+
const tree = store.getLineage("unknown-root");
|
|
173
|
+
assert.equal(tree.root.instanceKey, "unknown-root");
|
|
174
|
+
assert.deepEqual(tree.root.children, []);
|
|
175
|
+
assert.equal(tree.nodes.length, 1);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
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
|
+
|
|
14
|
+
import type { SqliteDb } from "../host.ts";
|
|
15
|
+
import {
|
|
16
|
+
buildLineageTree,
|
|
17
|
+
type LineageAttachment,
|
|
18
|
+
type LineageEdge,
|
|
19
|
+
type LineageEdgeType,
|
|
20
|
+
type LineageTree,
|
|
21
|
+
readLineage,
|
|
22
|
+
} from "../lineage.ts";
|
|
23
|
+
|
|
24
|
+
/** The lineage edge table name. Framework bookkeeping, so `_urban_`-prefixed to stay hidden from
|
|
25
|
+
* the domain model / DB Manager (see `SqliteGateway.schema()`), like the migrations ledger. */
|
|
26
|
+
export const LINEAGE_EDGES_TABLE = "_urban_lineage_edges";
|
|
27
|
+
/** The lineage attachment table name (app-registered domain rows on a node). `_urban_`-prefixed for
|
|
28
|
+
* the same reason as {@link LINEAGE_EDGES_TABLE}. */
|
|
29
|
+
export const LINEAGE_ATTACHMENTS_TABLE = "_urban_lineage_attachments";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The canonical lineage DDL — the single source of truth applied by
|
|
33
|
+
* {@link LineageStore.ensureSchema}, mirrored verbatim by the boot migration
|
|
34
|
+
* `db/migrations/004_urban_lineage.sql` (a drift-guard test asserts they match).
|
|
35
|
+
*
|
|
36
|
+
* Forward-only and additive. `_urban_lineage_edges` is keyed by `(instance_key, edge_type)` so a
|
|
37
|
+
* node can hold both its weak (envelope) and strong (execution) edge at once and a re-record is a
|
|
38
|
+
* no-op; `_urban_lineage_attachments` is keyed by `(node_key, kind, ref)` so re-attaching the same
|
|
39
|
+
* domain row is idempotent. Both carry `root_request_key` with an index so a `getLineage(root)`
|
|
40
|
+
* reads only that thread.
|
|
41
|
+
*/
|
|
42
|
+
export const LINEAGE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${LINEAGE_EDGES_TABLE} (
|
|
43
|
+
root_request_key TEXT NOT NULL,
|
|
44
|
+
instance_key TEXT NOT NULL,
|
|
45
|
+
caused_by_instance_key TEXT,
|
|
46
|
+
edge_type TEXT NOT NULL DEFAULT 'weak',
|
|
47
|
+
created_at TEXT NOT NULL,
|
|
48
|
+
PRIMARY KEY (instance_key, edge_type)
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_${LINEAGE_EDGES_TABLE}_root ON ${LINEAGE_EDGES_TABLE} (root_request_key);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS ${LINEAGE_ATTACHMENTS_TABLE} (
|
|
52
|
+
root_request_key TEXT NOT NULL,
|
|
53
|
+
node_key TEXT NOT NULL,
|
|
54
|
+
kind TEXT NOT NULL,
|
|
55
|
+
ref TEXT NOT NULL,
|
|
56
|
+
label TEXT,
|
|
57
|
+
created_at TEXT NOT NULL,
|
|
58
|
+
PRIMARY KEY (node_key, kind, ref)
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_${LINEAGE_ATTACHMENTS_TABLE}_root ON ${LINEAGE_ATTACHMENTS_TABLE} (root_request_key);`;
|
|
61
|
+
|
|
62
|
+
/** A monotonic wall clock, injectable for deterministic `created_at` in tests. */
|
|
63
|
+
export interface Clock {
|
|
64
|
+
now(): number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The default clock: `Date.now()`. */
|
|
68
|
+
export const systemClock: Clock = { now: () => Date.now() };
|
|
69
|
+
|
|
70
|
+
/** A minimal job shape {@link LineageStore.recordFromJob} reads — a structural subset of the
|
|
71
|
+
* runtime's `EngineJob` plus the future engine execution edge (`parentProcessInstanceKey`,
|
|
72
|
+
* Magikcraft/nano-bpm#808), consumed as a strong edge where present. */
|
|
73
|
+
export interface LineageJobLike {
|
|
74
|
+
readonly processInstanceKey?: string;
|
|
75
|
+
readonly variables?: Record<string, unknown>;
|
|
76
|
+
/** The engine-native execution parent, when the engine emits it (#808). */
|
|
77
|
+
readonly parentProcessInstanceKey?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface LineageStoreOptions {
|
|
81
|
+
readonly clock?: Clock;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface EdgeRow {
|
|
85
|
+
root_request_key: string;
|
|
86
|
+
instance_key: string;
|
|
87
|
+
caused_by_instance_key: string | null;
|
|
88
|
+
edge_type: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface AttachmentRow {
|
|
92
|
+
root_request_key: string;
|
|
93
|
+
node_key: string;
|
|
94
|
+
kind: string;
|
|
95
|
+
ref: string;
|
|
96
|
+
label: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeEdgeType(raw: string): LineageEdgeType {
|
|
100
|
+
return raw === "strong" ? "strong" : "weak";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class LineageStore {
|
|
104
|
+
readonly #db: SqliteDb;
|
|
105
|
+
readonly #clock: Clock;
|
|
106
|
+
|
|
107
|
+
constructor(db: SqliteDb, options: LineageStoreOptions = {}) {
|
|
108
|
+
this.#db = db;
|
|
109
|
+
this.#clock = options.clock ?? systemClock;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Apply the canonical lineage DDL (idempotent). Callers whose app DataLayer applies the boot
|
|
113
|
+
* migration `db/migrations/004_urban_lineage.sql` do not need this; the runtime calls it so the
|
|
114
|
+
* store is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
|
|
115
|
+
ensureSchema(): void {
|
|
116
|
+
this.#db.exec(LINEAGE_SCHEMA_SQL);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Record one lineage edge, idempotently. A re-record of the same `(instance_key, edge_type)` is a
|
|
121
|
+
* no-op (an engine may re-activate a job on retry), so recording is safe to drive from any
|
|
122
|
+
* at-least-once observation point. A blank `rootRequestKey` or `instanceKey` is ignored.
|
|
123
|
+
*/
|
|
124
|
+
recordEdge(edge: LineageEdge): boolean {
|
|
125
|
+
if (!edge.rootRequestKey || !edge.instanceKey) return false;
|
|
126
|
+
const { changes } = this.#db.run(
|
|
127
|
+
`INSERT OR IGNORE INTO ${LINEAGE_EDGES_TABLE}
|
|
128
|
+
(root_request_key, instance_key, caused_by_instance_key, edge_type, created_at)
|
|
129
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
130
|
+
[
|
|
131
|
+
edge.rootRequestKey,
|
|
132
|
+
edge.instanceKey,
|
|
133
|
+
edge.causedByInstanceKey ?? null,
|
|
134
|
+
edge.edgeType,
|
|
135
|
+
new Date(this.#clock.now()).toISOString(),
|
|
136
|
+
],
|
|
137
|
+
);
|
|
138
|
+
return changes > 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Record the WEAK edge carried by an instance's `_urban.lineage` envelope. `instanceKey` is the
|
|
143
|
+
* effect/descendant node (a created instance's returned key, or a running job's own instance).
|
|
144
|
+
* A no-op when the variables carry no valid envelope.
|
|
145
|
+
*/
|
|
146
|
+
recordEnvelope(instanceKey: string | undefined, variables: unknown): boolean {
|
|
147
|
+
if (!instanceKey) return false;
|
|
148
|
+
const env = readLineage(variables);
|
|
149
|
+
if (!env) return false;
|
|
150
|
+
return this.recordEdge({
|
|
151
|
+
rootRequestKey: env.rootRequestKey,
|
|
152
|
+
instanceKey,
|
|
153
|
+
causedByInstanceKey: env.causedByInstanceKey,
|
|
154
|
+
edgeType: "weak",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Record every edge an activated job reveals about its own instance: the weak envelope edge, and
|
|
160
|
+
* — where the engine supplies `parentProcessInstanceKey` (#808) — the strong execution edge under
|
|
161
|
+
* the same root. The natural single observation point for the worker runtime, since every instance
|
|
162
|
+
* that does work activates a job carrying its envelope. Never throws for a malformed job.
|
|
163
|
+
*/
|
|
164
|
+
recordFromJob(job: LineageJobLike): void {
|
|
165
|
+
const env = readLineage(job.variables);
|
|
166
|
+
if (!env || !job.processInstanceKey) return;
|
|
167
|
+
this.recordEnvelope(job.processInstanceKey, job.variables);
|
|
168
|
+
if (job.parentProcessInstanceKey) {
|
|
169
|
+
this.recordEdge({
|
|
170
|
+
rootRequestKey: env.rootRequestKey,
|
|
171
|
+
instanceKey: job.processInstanceKey,
|
|
172
|
+
causedByInstanceKey: job.parentProcessInstanceKey,
|
|
173
|
+
edgeType: "strong",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Attach an app-defined domain row to a lineage node, idempotently. The framework stores it
|
|
180
|
+
* opaquely by `(node_key, kind, ref)` — it never interprets the app's schema. This is the
|
|
181
|
+
* extension point that lets an app hang its PRs/tasks off the lineage tree.
|
|
182
|
+
*/
|
|
183
|
+
attach(rootRequestKey: string, attachment: LineageAttachment): boolean {
|
|
184
|
+
if (!rootRequestKey || !attachment.nodeKey || !attachment.kind || !attachment.ref) return false;
|
|
185
|
+
const { changes } = this.#db.run(
|
|
186
|
+
`INSERT OR IGNORE INTO ${LINEAGE_ATTACHMENTS_TABLE}
|
|
187
|
+
(root_request_key, node_key, kind, ref, label, created_at)
|
|
188
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
189
|
+
[
|
|
190
|
+
rootRequestKey,
|
|
191
|
+
attachment.nodeKey,
|
|
192
|
+
attachment.kind,
|
|
193
|
+
attachment.ref,
|
|
194
|
+
attachment.label ?? null,
|
|
195
|
+
new Date(this.#clock.now()).toISOString(),
|
|
196
|
+
],
|
|
197
|
+
);
|
|
198
|
+
return changes > 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The persisted edges for one root, in insertion order. */
|
|
202
|
+
edges(rootRequestKey: string): LineageEdge[] {
|
|
203
|
+
const rows = this.#db.all<EdgeRow>(
|
|
204
|
+
`SELECT root_request_key, instance_key, caused_by_instance_key, edge_type
|
|
205
|
+
FROM ${LINEAGE_EDGES_TABLE} WHERE root_request_key = ? ORDER BY rowid`,
|
|
206
|
+
[rootRequestKey],
|
|
207
|
+
);
|
|
208
|
+
return rows.map((r) => ({
|
|
209
|
+
rootRequestKey: r.root_request_key,
|
|
210
|
+
instanceKey: r.instance_key,
|
|
211
|
+
causedByInstanceKey: r.caused_by_instance_key ?? undefined,
|
|
212
|
+
edgeType: normalizeEdgeType(r.edge_type),
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The persisted attachments for one root, in insertion order. */
|
|
217
|
+
attachments(rootRequestKey: string): LineageAttachment[] {
|
|
218
|
+
const rows = this.#db.all<AttachmentRow>(
|
|
219
|
+
`SELECT root_request_key, node_key, kind, ref, label
|
|
220
|
+
FROM ${LINEAGE_ATTACHMENTS_TABLE} WHERE root_request_key = ? ORDER BY rowid`,
|
|
221
|
+
[rootRequestKey],
|
|
222
|
+
);
|
|
223
|
+
return rows.map((r) => ({
|
|
224
|
+
nodeKey: r.node_key,
|
|
225
|
+
kind: r.kind,
|
|
226
|
+
ref: r.ref,
|
|
227
|
+
...(r.label ? { label: r.label } : {}),
|
|
228
|
+
}));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The stitched descendant tree for `rootRequestKey`, unioning the weak (envelope) and strong
|
|
233
|
+
* (engine execution) edges and hanging each app attachment off its node. The generic read
|
|
234
|
+
* surface any Urban app queries for intent→progress.
|
|
235
|
+
*/
|
|
236
|
+
getLineage(rootRequestKey: string): LineageTree {
|
|
237
|
+
return buildLineageTree(rootRequestKey, this.edges(rootRequestKey), this.attachments(rootRequestKey));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -371,6 +371,91 @@ test("renderer interpolates a column's per-cell template into DOM text (#214)",
|
|
|
371
371
|
assert.match(js, /const text = typeof col\.template === "string" \? interpTemplate\(col\.template, row\) : rawText;/);
|
|
372
372
|
});
|
|
373
373
|
|
|
374
|
+
test("renderer renders a column's subtitle as a muted second line, never an href (#257)", async () => {
|
|
375
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
376
|
+
const js = res.body ?? "";
|
|
377
|
+
// A column may render an optional muted second line beneath the primary text
|
|
378
|
+
// (the identity key under a title, GitHub/Linear style). The value comes from
|
|
379
|
+
// col.subtitleField (raw) or col.subtitleTemplate (interpolated), null/absent →
|
|
380
|
+
// "" → no extra line. Guard the shape: subtitleTemplate wins via interpTemplate,
|
|
381
|
+
// the subtitle is emitted as a .pc-cell-sub DOM-text line, and — crucially for
|
|
382
|
+
// the security posture — it is NEVER an href source (only linkField/keyField
|
|
383
|
+
// drive anchors; the subtitle stays plain text).
|
|
384
|
+
assert.match(js, /col\.subtitleField != null && row\[col\.subtitleField\] != null \? String\(row\[col\.subtitleField\]\) : ""/);
|
|
385
|
+
assert.match(js, /const subText = typeof col\.subtitleTemplate === "string" \? interpTemplate\(col\.subtitleTemplate, row\) : subRaw;/);
|
|
386
|
+
assert.match(js, /class: "pc-cell-sub"/);
|
|
387
|
+
// Absent subtitle (empty) and no truncation collapses back to the single inline
|
|
388
|
+
// node, so existing grids render exactly as before (backward compatible).
|
|
389
|
+
assert.match(js, /if \(subText === "" && !truncate\) return el\("td", \{\}, primary\);/);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
test("renderer truncates a column to one line with an ellipsis + full-text tooltip (#257)", async () => {
|
|
393
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
394
|
+
const js = res.body ?? "";
|
|
395
|
+
// col.truncate:true clamps the primary (and any subtitle) line to a single row
|
|
396
|
+
// with an ellipsis, carrying the full value in a native title= tooltip and
|
|
397
|
+
// aria-label so the clipped text stays recoverable on hover and to assistive
|
|
398
|
+
// tech. Guard the shape: opt-in on `=== true`, a .pc-truncate class is added to
|
|
399
|
+
// the line, and the full text is mirrored into title/aria-label.
|
|
400
|
+
assert.match(js, /const truncate = col\.truncate === true;/);
|
|
401
|
+
assert.match(js, /"pc-cell-main"\s*\+\s*\(truncate \? " pc-truncate" : ""\)/);
|
|
402
|
+
assert.match(js, /mainAttrs\.title = primaryText; mainAttrs\["aria-label"\] = primaryText;/);
|
|
403
|
+
// title/aria-label are NOT inherited by descendants in HTML, so a link cell's
|
|
404
|
+
// <a> primary would miss the full-text tooltip/accessible name the wrapper div
|
|
405
|
+
// carries. Guard that they're also mirrored onto the primary element itself when
|
|
406
|
+
// it's a node (an anchor), so link cells get the same full-text recovery as text.
|
|
407
|
+
assert.match(js, /if \(primary && typeof primary\.setAttribute === "function"\) \{/);
|
|
408
|
+
assert.match(js, /primary\.setAttribute\("title", primaryText\); primary\.setAttribute\("aria-label", primaryText\);/);
|
|
409
|
+
assert.match(js, /"pc-cell-sub"\s*\+\s*\(truncate \? " pc-truncate" : ""\)/);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("renderer emits a <colgroup> sizing columns by width/weight (#257)", async () => {
|
|
413
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
414
|
+
const js = res.body ?? "";
|
|
415
|
+
// A column may declare an explicit `width` ("40%", "22rem") or a relative
|
|
416
|
+
// integer `weight`, so table-layout:fixed stops splitting width equally. This
|
|
417
|
+
// emits a <colgroup> of <col style="width:…"> (fixed layout honours <col>
|
|
418
|
+
// widths). Guard the shape: an explicit width string wins verbatim, a positive
|
|
419
|
+
// weight is normalised across weighted columns into a percentage of the width
|
|
420
|
+
// the explicit columns leave (the remainder), unsized columns get no width
|
|
421
|
+
// (share the remainder), and no colgroup is emitted at all when no column
|
|
422
|
+
// declares sizing (so unsized grids are unchanged).
|
|
423
|
+
assert.match(js, /function buildColgroup\(cols, extraCount\)/);
|
|
424
|
+
assert.match(js, /if \(!hasSizing\) return null;/);
|
|
425
|
+
assert.match(js, /function hasExplicitWidth\(col\)/);
|
|
426
|
+
assert.match(js, /if \(hasExplicitWidth\(col\)\) return col\.width;/);
|
|
427
|
+
assert.match(js, /Math\.round\(\(col\.weight \/ weightTotal\) \* remainderPct \* 100\) \/ 100 \+ "%"/);
|
|
428
|
+
assert.match(js, /el\("col", w \? \{ style: "width:" \+ w \} : \{\}\)/);
|
|
429
|
+
// Regression (#258 review): mixing explicit % widths with weighted columns
|
|
430
|
+
// must not oversubscribe past 100% — weighted columns share only the remainder
|
|
431
|
+
// (100 - sum of explicit %), and only when every explicit width is a
|
|
432
|
+
// percentage (rem/px explicit widths fall back to a share of the full width).
|
|
433
|
+
assert.match(js, /const remainderPct = allExplicitArePct \? Math\.max\(0, 100 - explicitPctTotal\) : 100;/);
|
|
434
|
+
// Regression (#258 review): a column carrying both an explicit width and a
|
|
435
|
+
// weight uses its width verbatim and never consumes its weight, so it must be
|
|
436
|
+
// excluded from weightTotal or the truly-weighted columns' share is shrunk.
|
|
437
|
+
assert.match(js, /if \(!hasExplicitWidth\(c\) && typeof c\.weight === "number" && c\.weight > 0\) weightTotal \+= c\.weight;/);
|
|
438
|
+
assert.match(js, /function colWidthPct\(width\)/);
|
|
439
|
+
// The colgroup precedes <thead> in the table, and only when sizing is present.
|
|
440
|
+
assert.match(js, /\? el\("table", \{ class: "pc-grid" \}, colgroup, thead, tbody\)/);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("the shell styles the subtitle line muted and the truncate clamp to one line (#257)", async () => {
|
|
444
|
+
const res = await dispatch("GET", "/");
|
|
445
|
+
const html = res.body ?? "";
|
|
446
|
+
// The muted subtitle line and the one-line clamp are styled in the shell CSS
|
|
447
|
+
// (served with the shell HTML, like the other pc-grid rules). Guard both rules.
|
|
448
|
+
const subRule = (html.match(/\.pc-cell-sub\s*\{[^}]*\}/) ?? [""])[0];
|
|
449
|
+
assert.ok(subRule, ".pc-cell-sub rule must be present");
|
|
450
|
+
assert.match(subRule, /color:var\(--nano-text-muted\)/);
|
|
451
|
+
assert.match(subRule, /font-size:\.8rem/);
|
|
452
|
+
const truncRule = (html.match(/\.pc-truncate\s*\{[^}]*\}/) ?? [""])[0];
|
|
453
|
+
assert.ok(truncRule, ".pc-truncate rule must be present");
|
|
454
|
+
assert.match(truncRule, /white-space:nowrap/);
|
|
455
|
+
assert.match(truncRule, /overflow:hidden/);
|
|
456
|
+
assert.match(truncRule, /text-overflow:ellipsis/);
|
|
457
|
+
});
|
|
458
|
+
|
|
374
459
|
test("renderer wires a column's processExplorer link to the console explorer", async () => {
|
|
375
460
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
376
461
|
const js = res.body ?? "";
|