@nanobpm/urban 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/execContext.d.ts +5 -0
  5. package/dist/runtime/core/extensions.d.ts +131 -0
  6. package/dist/runtime/core/extensions.js +137 -0
  7. package/dist/runtime/core/glob.d.ts +7 -6
  8. package/dist/runtime/core/glob.js +16 -14
  9. package/dist/runtime/core/host.d.ts +4 -1
  10. package/dist/runtime/core/lineage.d.ts +116 -0
  11. package/dist/runtime/core/lineage.js +186 -0
  12. package/dist/runtime/core/modules/agent.d.ts +17 -0
  13. package/dist/runtime/core/modules/agent.js +63 -0
  14. package/dist/runtime/core/modules/datasource.d.ts +6 -0
  15. package/dist/runtime/core/modules/datasource.js +8 -0
  16. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  17. package/dist/runtime/core/modules/deploy.js +62 -16
  18. package/dist/runtime/core/modules/lineage-store.d.ts +81 -0
  19. package/dist/runtime/core/modules/lineage-store.js +175 -0
  20. package/dist/runtime/core/modules/pages.js +397 -20
  21. package/dist/runtime/core/modules/surfaces.js +8 -0
  22. package/dist/runtime/core/modules/workers.js +69 -2
  23. package/dist/runtime/core/phase.d.ts +168 -0
  24. package/dist/runtime/core/phase.js +367 -0
  25. package/dist/runtime/core/runtime.d.ts +17 -0
  26. package/dist/runtime/core/runtime.js +30 -0
  27. package/dist/runtime/engine/nanosdk.js +8 -2
  28. package/dist/runtime/index.d.ts +10 -0
  29. package/dist/runtime/index.js +12 -0
  30. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  31. package/dist/toolkit/derivers/system-brief.js +156 -0
  32. package/dist/toolkit/gen.d.ts +5 -5
  33. package/dist/toolkit/gen.js +20 -14
  34. package/dist/toolkit/index.d.ts +5 -0
  35. package/dist/toolkit/index.js +3 -0
  36. package/dist/toolkit/models.d.ts +4 -3
  37. package/dist/toolkit/models.js +4 -3
  38. package/package.json +1 -1
  39. package/src/runtime/core/events.test.ts +382 -0
  40. package/src/runtime/core/events.ts +370 -0
  41. package/src/runtime/core/execContext.ts +5 -0
  42. package/src/runtime/core/extensions.test.ts +378 -0
  43. package/src/runtime/core/extensions.ts +276 -0
  44. package/src/runtime/core/glob.ts +15 -12
  45. package/src/runtime/core/host.ts +4 -1
  46. package/src/runtime/core/lineage.test.ts +215 -0
  47. package/src/runtime/core/lineage.ts +297 -0
  48. package/src/runtime/core/modules/agent.test.ts +104 -0
  49. package/src/runtime/core/modules/agent.ts +76 -0
  50. package/src/runtime/core/modules/datasource.ts +9 -0
  51. package/src/runtime/core/modules/deploy.test.ts +84 -16
  52. package/src/runtime/core/modules/deploy.ts +59 -16
  53. package/src/runtime/core/modules/lineage-store.test.ts +177 -0
  54. package/src/runtime/core/modules/lineage-store.ts +239 -0
  55. package/src/runtime/core/modules/pages.test.ts +309 -5
  56. package/src/runtime/core/modules/pages.ts +398 -20
  57. package/src/runtime/core/modules/surfaces.ts +9 -0
  58. package/src/runtime/core/modules/workers.test.ts +136 -1
  59. package/src/runtime/core/modules/workers.ts +72 -6
  60. package/src/runtime/core/phase.test.ts +350 -0
  61. package/src/runtime/core/phase.ts +526 -0
  62. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  63. package/src/runtime/core/runtime.ts +49 -0
  64. package/src/runtime/devserver.test.ts +3 -0
  65. package/src/runtime/engine/nanosdk.test.ts +97 -3
  66. package/src/runtime/engine/nanosdk.ts +8 -2
  67. package/src/runtime/index.ts +92 -0
  68. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  69. package/src/toolkit/derivers/system-brief.ts +210 -0
  70. package/src/toolkit/gen.test.ts +28 -4
  71. package/src/toolkit/gen.ts +20 -13
  72. package/src/toolkit/index.ts +14 -0
  73. package/src/toolkit/models.ts +4 -3
@@ -0,0 +1,17 @@
1
+ import type { RuntimeContext } from "../context.ts";
2
+ import { type Route } from "../router.ts";
3
+ export interface AgentHandle {
4
+ readonly name: string;
5
+ routes: Route[];
6
+ describe(): Record<string, unknown>;
7
+ }
8
+ /**
9
+ * Mount the `/app/agent` (+ `/app/agent.json`) institutional-memory brief routes. Reads the
10
+ * `urban gen`-derived artifacts through the host file port, resolving each under `ctx.root` via
11
+ * the shared `resolveAppPath` (like every other runtime module) so serving never depends on the
12
+ * host's current working directory. A genuinely absent artifact — checked with `host.exists`
13
+ * rather than a blanket catch — yields a 404 (gen never ran, or the app has no models); any other
14
+ * read failure (permissions, transient I/O) is left to surface as a 500 rather than being masked
15
+ * as "not generated".
16
+ */
17
+ export declare function mountAgent(ctx: RuntimeContext): AgentHandle;
@@ -0,0 +1,63 @@
1
+ // agent — the app-scoped institutional-memory brief surface (ADR 0060 §2). The app-level mirror
2
+ // of the node `/agent` brief (ADR 0051): where the node brief describes the whole runtime, this
3
+ // serves ONE app's derived `system-brief.md` / `system-brief.json` so an agent (or human) opening
4
+ // work on the app starts grounded in the app's own system model — its processes, service-task call
5
+ // graph, decisions, and ownership.
6
+ //
7
+ // GET /app/agent → nano-generated/system-brief.md (text/markdown)
8
+ // GET /app/agent.json → nano-generated/system-brief.json (application/json)
9
+ //
10
+ // The brief is derived by `urban gen` (systemBriefDeriver, wired in gen.ts) into the app's
11
+ // `nano-generated/` dir, so this route is a thin read-through of that artifact — no re-derivation
12
+ // at request time (the derived file is the single source of truth, kept honest by
13
+ // `urban gen --check`). Mounted unconditionally: the brief is a property of the app's models, not
14
+ // of any opt-in surface, so it is always addressable when a brief has been generated. An app whose
15
+ // models produced no brief (never ran gen) returns 404.
16
+ import { GENERATED_DIR } from "../../../toolkit/artifact.js";
17
+ import { SYSTEM_BRIEF_JSON, SYSTEM_BRIEF_MD } from "../../../toolkit/derivers/system-brief.js";
18
+ import { json } from "../router.js";
19
+ import { resolveAppPath } from "./datasource.js";
20
+ /** The app-relative path of a generated brief artifact. */
21
+ const briefPath = (name) => `${GENERATED_DIR}/${name}`;
22
+ function markdown(body) {
23
+ return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body };
24
+ }
25
+ /**
26
+ * Mount the `/app/agent` (+ `/app/agent.json`) institutional-memory brief routes. Reads the
27
+ * `urban gen`-derived artifacts through the host file port, resolving each under `ctx.root` via
28
+ * the shared `resolveAppPath` (like every other runtime module) so serving never depends on the
29
+ * host's current working directory. A genuinely absent artifact — checked with `host.exists`
30
+ * rather than a blanket catch — yields a 404 (gen never ran, or the app has no models); any other
31
+ * read failure (permissions, transient I/O) is left to surface as a 500 rather than being masked
32
+ * as "not generated".
33
+ */
34
+ export function mountAgent(ctx) {
35
+ const artifactPath = (name) => resolveAppPath(ctx.root, briefPath(name));
36
+ const notGenerated = () => json({ error: "system brief not generated — run `urban gen`" }, 404);
37
+ const serve = async (name, toResponse) => {
38
+ const path = artifactPath(name);
39
+ if (!(await ctx.host.exists(path)))
40
+ return notGenerated();
41
+ return toResponse(await ctx.host.readTextFile(path));
42
+ };
43
+ const routes = [
44
+ {
45
+ method: "GET",
46
+ path: "/app/agent",
47
+ source: "surface:agent",
48
+ handler: () => serve(SYSTEM_BRIEF_MD, markdown),
49
+ },
50
+ {
51
+ method: "GET",
52
+ path: "/app/agent.json",
53
+ source: "surface:agent",
54
+ handler: () => serve(SYSTEM_BRIEF_JSON, (body) => ({
55
+ status: 200,
56
+ headers: { "content-type": "application/json" },
57
+ body,
58
+ })),
59
+ },
60
+ ];
61
+ ctx.host.log("info", "agent brief surface mounted", { routes: routes.length });
62
+ return { name: "agent", routes, describe: () => ({ routes: routes.map((r) => r.path) }) };
63
+ }
@@ -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];
@@ -1,12 +1,15 @@
1
1
  import type { RuntimeContext } from "../context.ts";
2
- /** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
2
+ /** The convention directory: deploy-only, walked recursively when no `models` are declared. */
3
3
  export declare const RESOURCES_DIR = "resources";
4
4
  /**
5
- * Deploy the app's processes, decisions and forms.
5
+ * Deploy the app's resources.
6
6
  *
7
- * With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
8
- * one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
9
- * hard error in either mode.
7
+ * With no manifest `models`, deployables are discovered by convention under `resources/`
8
+ * (recursively — every file at any depth) and each is keyed by its path relative to `resources/`
9
+ * (POSIX). With `models` globs declared, those are used verbatim and keyed by basename (a basename
10
+ * collision is a hard error). Content is deployed verbatim (no template substitution); each file is
11
+ * content-typed by extension so a generic resource (e.g. a prompt `.md`) deploys as a versioned
12
+ * generic resource the engine resolves via `zeebe:linkedResource bindingType:latest`.
10
13
  */
11
14
  export declare function deployModels(ctx: RuntimeContext): Promise<{
12
15
  deployed: number;
@@ -2,18 +2,42 @@
2
2
  //
3
3
  // Resources are found by ONE of two paths:
4
4
  // 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
5
- // discovered by walking `resources/` — shallow, one level deep. `resources/` is deploy-only:
6
- // everything under it (and nothing outside it) is deployed. Docs (`docs/`, `AGENTS.md`,
7
- // top-level `*.md`) live outside `resources/` and are never swept in.
5
+ // discovered by walking `resources/` **recursively** — every file under it, at any depth
6
+ // (issue #231). `resources/` is deploy-only: everything under it (and nothing outside it) is
7
+ // deployed. Docs (`docs/`, `AGENTS.md`, top-level `*.md`) live outside `resources/` and are
8
+ // never swept in. A convention resource's deploy key (`resourceId`) is its path **relative to
9
+ // `resources/`** (POSIX-normalised), e.g. `resources/prompts/plan.md` → `prompts/plan.md`, so
10
+ // a linked service task references it with `zeebe:linkedResource resourceId="prompts/plan.md"`.
11
+ // The relative path (not the bare basename) preserves sub-directory structure and lets two
12
+ // files sharing a filename in different sub-dirs (`a/x.md`, `b/x.md`) deploy as distinct
13
+ // resources — there is no basename-collision hazard.
8
14
  // 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
9
- // convention walk is skipped) — the escape hatch for a non-convention layout.
15
+ // convention walk is skipped) — the escape hatch for a non-convention layout. Override
16
+ // resources are keyed by basename (filename only), so a basename collision across the declared
17
+ // globs is a hard error, detected up front.
10
18
  //
11
- // Either way the deploy dedupe key is the resource's basename (filename only), so a basename
12
- // collision — two files with the same name in different directories — would silently clobber one
13
- // resource with another at the engine. We detect that up front and fail loudly instead.
19
+ // Content is deployed **verbatim** — there is no deploy-time `{{token}}` substitution (removed in
20
+ // ADR 0062) — so a generic resource (a prompt `.md`, an RPA/script file) is deployed as-is.
21
+ // The deploy pipeline is **UTF-8 text only**, end-to-end: `host.readTextFile()` yields a `string`
22
+ // and the engine's `deployResources` takes `content: string`, so there is no byte channel and
23
+ // arbitrary binary is out of scope by design. "Verbatim" therefore means the UTF-8 text is passed
24
+ // through unchanged; a non-UTF-8/binary file swept into `resources/` is not a supported input (see
25
+ // `contentTypeFor`'s `application/octet-stream` note).
14
26
  import { discoverResources, expandPatterns } from "../glob.js";
15
- /** The convention directory: deploy-only, walked one level deep when no `models` are declared. */
27
+ /** The convention directory: deploy-only, walked recursively when no `models` are declared. */
16
28
  export const RESOURCES_DIR = "resources";
29
+ /**
30
+ * Classify a file into an engine content type by extension. Models get their real XML/JSON types;
31
+ * generic resources get a real text content type where the extension is known (`.md`, `.json`,
32
+ * `.txt`), falling back to `application/octet-stream` for anything unrecognised (so an unknown
33
+ * extension is never mislabelled as a specific text type). Note the deploy pipeline is UTF-8 text
34
+ * only (`readTextFile` → engine `content: string`): the `octet-stream` fallback is a conservative
35
+ * MIME label for an unrecognised *text* resource (e.g. a script with an uncommon extension), not a
36
+ * promise of binary fidelity — a genuinely binary file cannot be preserved byte-for-byte and is
37
+ * out of scope. The engine deploys a non-model content type as a generic resource
38
+ * (versioned per `resourceId`); a service task resolves it via
39
+ * `zeebe:linkedResource resourceId="<id>" bindingType:latest`.
40
+ */
17
41
  function contentTypeFor(path) {
18
42
  if (path.endsWith(".bpmn"))
19
43
  return "text/xml";
@@ -21,14 +45,30 @@ function contentTypeFor(path) {
21
45
  return "text/xml";
22
46
  if (path.endsWith(".form"))
23
47
  return "application/json";
48
+ if (path.endsWith(".md"))
49
+ return "text/markdown";
50
+ if (path.endsWith(".json"))
51
+ return "application/json";
52
+ if (path.endsWith(".txt"))
53
+ return "text/plain";
24
54
  return "application/octet-stream";
25
55
  }
26
56
  function baseName(path) {
27
57
  const i = path.lastIndexOf("/");
28
58
  return i >= 0 ? path.slice(i + 1) : path;
29
59
  }
30
- /** Fail loudly when two resolved files share a basename (the deploy dedupe key), naming the
31
- * colliding paths so the author can rename or relocate one. */
60
+ /**
61
+ * A convention resource's `resourceId`: its path relative to `<root>/resources/` (POSIX). Given a
62
+ * root-prefixed discovered path (`/app/resources/prompts/plan.md`) it returns `prompts/plan.md`.
63
+ * Falls back to the basename if — defensively — the path is not under the resources prefix.
64
+ */
65
+ function relativeResourceId(path, root) {
66
+ const prefix = `${root.replace(/\/+$/, "")}/${RESOURCES_DIR}/`;
67
+ return path.startsWith(prefix) ? path.slice(prefix.length) : baseName(path);
68
+ }
69
+ /** Fail loudly when two resolved files share a basename (the override dedupe key), naming the
70
+ * colliding paths so the author can rename or relocate one. Only relevant for the `models`
71
+ * override path; convention resources are keyed by relative path and cannot collide. */
32
72
  function assertNoBasenameCollisions(files) {
33
73
  const byName = new Map();
34
74
  for (const path of files) {
@@ -49,11 +89,14 @@ function assertNoBasenameCollisions(files) {
49
89
  `the same name would clobber each other. Rename or relocate one of:\n${detail}`);
50
90
  }
51
91
  /**
52
- * Deploy the app's processes, decisions and forms.
92
+ * Deploy the app's resources.
53
93
  *
54
- * With no manifest `models`, deployables are discovered by convention under `resources/` (shallow,
55
- * one level deep). With `models` globs declared, those are used verbatim. Basename collisions are a
56
- * hard error in either mode.
94
+ * With no manifest `models`, deployables are discovered by convention under `resources/`
95
+ * (recursively — every file at any depth) and each is keyed by its path relative to `resources/`
96
+ * (POSIX). With `models` globs declared, those are used verbatim and keyed by basename (a basename
97
+ * collision is a hard error). Content is deployed verbatim (no template substitution); each file is
98
+ * content-typed by extension so a generic resource (e.g. a prompt `.md`) deploys as a versioned
99
+ * generic resource the engine resolves via `zeebe:linkedResource bindingType:latest`.
57
100
  */
58
101
  export async function deployModels(ctx) {
59
102
  const models = ctx.manifest.models;
@@ -79,9 +122,12 @@ export async function deployModels(ctx) {
79
122
  }
80
123
  return { deployed: 0, files: [] };
81
124
  }
82
- assertNoBasenameCollisions(files);
125
+ // Convention resources are keyed by their (unique) relative path; only the override path, whose
126
+ // basename key can collide across globs, needs the collision guard.
127
+ if (!byConvention)
128
+ assertNoBasenameCollisions(files);
83
129
  const resources = await Promise.all(files.map(async (path) => ({
84
- name: baseName(path),
130
+ name: byConvention ? relativeResourceId(path, ctx.root) : baseName(path),
85
131
  content: await ctx.host.readTextFile(path),
86
132
  contentType: contentTypeFor(path),
87
133
  })));
@@ -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
+ }