@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
@@ -20,6 +20,8 @@ import { mountTriggers } from "./modules/triggers.ts";
20
20
  import { mountInstanceTracking } from "./modules/instance-tracking.ts";
21
21
  import type { SchedulerDeps } from "./modules/scheduler.ts";
22
22
  import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
23
+ import { EventBus } from "./events.ts";
24
+ import { createUrbanEvents, mountExtensions, type UrbanEvents, type UrbanExtension } from "./extensions.ts";
23
25
 
24
26
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
25
27
  * error when $PORT is set but not a valid integer in 0..65535. */
@@ -82,6 +84,14 @@ export interface CreateUrbanAppOptions {
82
84
  * so one injected scheduler drives them both (no per-module wiring, no drift).
83
85
  */
84
86
  scheduler?: SchedulerDeps;
87
+ /**
88
+ * Pluggable Urban {@link UrbanExtension}s — the agentic families and connector
89
+ * packs that extend the app lifecycle through the typed extension-event taxonomy
90
+ * (issue #262) instead of editing the runtime. The runtime runs each through the
91
+ * `extension/register` serial checkpoint in a deterministic order and disposes
92
+ * everything they register on `stop()` / HMR. Empty by default.
93
+ */
94
+ extensions?: readonly UrbanExtension[];
85
95
  }
86
96
 
87
97
  export interface UrbanApp {
@@ -112,6 +122,14 @@ export interface UrbanApp {
112
122
  * `undefined` before `start()`, after `stop()`, and on hosts that don't surface one (Deno).
113
123
  */
114
124
  readonly httpServer: object | undefined;
125
+ /**
126
+ * The app's typed extension-event taxonomy (issue #262): lifecycle notifications,
127
+ * the extension-registration checkpoint, request/security waterfall gates, and
128
+ * the reconcile fan-out — each a channel with a declared dispatch mode. Modules
129
+ * and {@link UrbanExtension}s hook onto these seams instead of wiring directly;
130
+ * every registration is an effect on a dispose ladder that `stop()` unwinds.
131
+ */
132
+ readonly events: UrbanEvents;
115
133
  }
116
134
 
117
135
  export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<UrbanApp> {
@@ -137,6 +155,20 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
137
155
  // correlation context).
138
156
  const appLog = createLogger((l, m, f) => host.log(l, m, f));
139
157
 
158
+ // The app-wide extension-event microkernel (issue #262): one typed taxonomy over
159
+ // one dispose ladder. Contained listener throws land in the structured log rather
160
+ // than stranding the pipeline. Modules and extensions hook onto `events`; the
161
+ // whole ladder unwinds on teardown.
162
+ const bus = new EventBus({
163
+ onError: (err, info) =>
164
+ appLog.warn("extension listener threw and was contained", {
165
+ event: info.event,
166
+ mode: info.mode,
167
+ error: String(err),
168
+ }),
169
+ });
170
+ const events = createUrbanEvents(bus);
171
+
140
172
  // Install the ambient job-execution store once per process (idempotent) so worker dispatch can
141
173
  // stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
142
174
  // leaves provenance capture disabled.
@@ -162,6 +194,8 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
162
194
  // Release every mounted resource and reset internal state so a subsequent
163
195
  // start() begins clean. Used by stop() and by start()'s failure path.
164
196
  const teardown = async () => {
197
+ // Notify lifecycle listeners before we start tearing seams down (issue #262).
198
+ events.lifecycle.emit({ app: manifest.id, phase: "stopping" });
165
199
  if (server) {
166
200
  try {
167
201
  await server.stop();
@@ -189,12 +223,18 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
189
223
  data = undefined;
190
224
  security = undefined;
191
225
  started = false;
226
+ // Terminal lifecycle notification, then unwind the whole dispose ladder LIFO:
227
+ // every extension listener/effect (and the lifecycle channel itself) is torn
228
+ // down, so a subsequent start() — or a dev-server HMR reload — leaks nothing.
229
+ events.lifecycle.emit({ app: manifest.id, phase: "stopped" });
230
+ bus.dispose();
192
231
  };
193
232
 
194
233
  const app: UrbanApp = {
195
234
  manifest,
196
235
  root,
197
236
  log: appLog,
237
+ events,
198
238
  get data() {
199
239
  return data;
200
240
  },
@@ -212,6 +252,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
212
252
  async start() {
213
253
  if (started) throw new Error("app already started");
214
254
  started = true;
255
+ events.lifecycle.emit({ app: manifest.id, phase: "starting" });
215
256
  try {
216
257
  if (flags.security) security = mountSecurity(ctx);
217
258
  if (flags.deploy) describe.deploy = await deployModels(ctx);
@@ -237,6 +278,13 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
237
278
  if (c.jobTypes.length > 0) describe.connectors = c.describe?.();
238
279
  }
239
280
 
281
+ // Run the agentic-family / connector-pack extension surface through the
282
+ // typed taxonomy (issue #262). Everything an extension registers rides the
283
+ // shared dispose ladder, so teardown's single `bus.dispose()` unwinds it —
284
+ // the extension host is deliberately NOT pushed onto `mounted`.
285
+ const extHost = await mountExtensions(api, opts.extensions ?? [], { bus, events });
286
+ describe.extensions = extHost.describe?.();
287
+
240
288
  const routes: Route[] = [];
241
289
  let hasUiSurfaces = false;
242
290
  if (flags.surfaces) {
@@ -308,6 +356,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
308
356
  }
309
357
  }
310
358
  host.log("info", `urban app "${manifest.id}" started`, {});
359
+ events.lifecycle.emit({ app: manifest.id, phase: "started" });
311
360
  } catch (err) {
312
361
  // A failed start must not leave the app half-mounted (leaked workers,
313
362
  // a bound port) or wedged in the "already started" state.
@@ -4,6 +4,8 @@ import { runDev, shouldReload, type DevDeps } from "./devserver.ts";
4
4
  import type { HostContext } from "./core/host.ts";
5
5
  import type { UrbanApp } from "./core/runtime.ts";
6
6
  import { createLogger } from "./core/logger.ts";
7
+ import { createUrbanEvents } from "./core/extensions.ts";
8
+ import { EventBus } from "./core/events.ts";
7
9
 
8
10
  const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
9
11
 
@@ -232,6 +234,7 @@ function fakeApp(stops: string[], id: string): UrbanApp {
232
234
  security: undefined,
233
235
  httpPort: undefined,
234
236
  httpServer: undefined,
237
+ events: createUrbanEvents(new EventBus()),
235
238
  };
236
239
  }
237
240
 
@@ -11,6 +11,9 @@ import {
11
11
  type NanoSdkJobWorkerConfig,
12
12
  } from "./nanosdk.ts";
13
13
  import { BpmnError } from "../core/host.ts";
14
+ import { __resetExecStoreForTests, installExecStore, runInJobContext } from "../core/execContext.ts";
15
+ import { readLineage } from "../core/lineage.ts";
16
+ import { createNodeHost } from "../adapters/node.ts";
14
17
 
15
18
  /** A fake nano-sdk client that records calls and lets a test drive its job worker. */
16
19
  function fakeSdkClient(overrides: Partial<NanoSdkClient> = {}): NanoSdkClient & {
@@ -133,7 +136,8 @@ test("createInstance routes through the SDK and coerces the key", async () => {
133
136
  const engine = new SdkEngineClient(client);
134
137
  const res = await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
135
138
  assert.equal(res.processInstanceKey, "99");
136
- assert.deepEqual(res.variables, { a: 1 });
139
+ // Caller variables are preserved (the SDK also auto-threads a `_urban.lineage` envelope, tested below).
140
+ assert.equal(res.variables?.a, 1);
137
141
  assert.ok(client.calls.includes("createProcessInstance"));
138
142
  });
139
143
 
@@ -162,7 +166,7 @@ test("cancelInstance routes through the SDK", async () => {
162
166
  assert.ok(client.calls.includes("cancelProcessInstance") || seen !== undefined);
163
167
  });
164
168
 
165
- test("publishMessage defaults correlationKey/variables", async () => {
169
+ test("publishMessage defaults correlationKey and auto-threads lineage onto variables", async () => {
166
170
  let seen: Record<string, unknown> | undefined;
167
171
  const client = fakeSdkClient({
168
172
  publishMessage: async (input) => {
@@ -172,7 +176,12 @@ test("publishMessage defaults correlationKey/variables", async () => {
172
176
  });
173
177
  const engine = new SdkEngineClient(client);
174
178
  await engine.publishMessage({ name: "m" });
175
- assert.deepEqual(seen, { name: "m", correlationKey: "", variables: {} });
179
+ assert.equal(seen?.name, "m");
180
+ assert.equal(seen?.correlationKey, "");
181
+ // With no ambient job context this is a top-level publish: a fresh root envelope is minted.
182
+ const env = readLineage(seen?.variables);
183
+ assert.ok(env, "a lineage envelope is threaded onto the message variables");
184
+ assert.equal(env?.causedByInstanceKey, undefined);
176
185
  });
177
186
 
178
187
  test("searchUserTasks passes zero-wait consistency and maps items", async () => {
@@ -670,3 +679,88 @@ test("createNanoSdkEngineClient uses an injected client factory with the resolve
670
679
  assert.deepEqual(seen, { restAddress: "http://x/v2", token: "t", transport: "falcon" });
671
680
  assert.ok(engine);
672
681
  });
682
+
683
+ // --- Lineage auto-threading (issue #254) ---------------------------------------
684
+
685
+ /** A fake client that captures the variables handed to createProcessInstance/publishMessage. */
686
+ function capturingClient(): NanoSdkClient & { createVars?: Record<string, unknown>; msgVars?: Record<string, unknown> } {
687
+ const base = fakeSdkClient();
688
+ const cap: NanoSdkClient & { createVars?: Record<string, unknown>; msgVars?: Record<string, unknown> } = {
689
+ ...base,
690
+ async createProcessInstance(input) {
691
+ cap.createVars = input.variables;
692
+ return { processInstanceKey: 99, variables: input.variables };
693
+ },
694
+ async publishMessage(input) {
695
+ cap.msgVars = input.variables;
696
+ return { key: 1 };
697
+ },
698
+ };
699
+ return cap;
700
+ }
701
+
702
+ test("createInstance mints a fresh root envelope for a top-level request (no ambient)", async () => {
703
+ __resetExecStoreForTests();
704
+ const client = capturingClient();
705
+ const engine = new SdkEngineClient(client);
706
+ await engine.createInstance({ processDefinitionId: "p", variables: { a: 1 } });
707
+ const env = readLineage(client.createVars);
708
+ assert.ok(env, "an envelope was threaded onto the variables");
709
+ assert.notEqual(env?.rootRequestKey, "");
710
+ assert.equal(env?.causedByInstanceKey, undefined, "a fresh root has no cause");
711
+ assert.equal(client.createVars?.a, 1, "caller variables are preserved");
712
+ });
713
+
714
+ test("createInstance propagates the ambient lineage and stamps causedByInstanceKey", async () => {
715
+ __resetExecStoreForTests();
716
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
717
+ installExecStore(() => host.createAsyncStore?.());
718
+ const client = capturingClient();
719
+ const engine = new SdkEngineClient(client);
720
+ try {
721
+ await runInJobContext({ instanceKey: "pi-parent", rootRequestKey: "root-1", jobType: "w" }, () =>
722
+ engine.createInstance({ processDefinitionId: "child" }),
723
+ );
724
+ } finally {
725
+ __resetExecStoreForTests();
726
+ }
727
+ assert.deepEqual(readLineage(client.createVars), {
728
+ rootRequestKey: "root-1",
729
+ causedByInstanceKey: "pi-parent",
730
+ });
731
+ });
732
+
733
+ test("createInstance leaves an explicit caller-supplied envelope untouched (override wins)", async () => {
734
+ __resetExecStoreForTests();
735
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
736
+ installExecStore(() => host.createAsyncStore?.());
737
+ const client = capturingClient();
738
+ const engine = new SdkEngineClient(client);
739
+ try {
740
+ await runInJobContext({ instanceKey: "pi-parent", rootRequestKey: "root-1", jobType: "w" }, () =>
741
+ engine.createInstance({
742
+ processDefinitionId: "child",
743
+ variables: { _urban: { lineage: { rootRequestKey: "explicit" } } },
744
+ }),
745
+ );
746
+ } finally {
747
+ __resetExecStoreForTests();
748
+ }
749
+ assert.deepEqual(readLineage(client.createVars), { rootRequestKey: "explicit", causedByInstanceKey: undefined });
750
+ });
751
+
752
+ test("publishMessage threads lineage so message-started instances inherit it", async () => {
753
+ __resetExecStoreForTests();
754
+ const host = createNodeHost({ cwd: process.cwd(), log: () => {} });
755
+ installExecStore(() => host.createAsyncStore?.());
756
+ const client = capturingClient();
757
+ const engine = new SdkEngineClient(client);
758
+ try {
759
+ await runInJobContext({ instanceKey: "pi-9", rootRequestKey: "root-9", jobType: "w" }, () =>
760
+ engine.publishMessage({ name: "start" }),
761
+ );
762
+ } finally {
763
+ __resetExecStoreForTests();
764
+ }
765
+ assert.deepEqual(readLineage(client.msgVars), { rootRequestKey: "root-9", causedByInstanceKey: "pi-9" });
766
+ });
@@ -31,6 +31,7 @@ import {
31
31
  } from "../core/form-contract.ts";
32
32
  import type { EngineSdkClient } from "./sdk.ts";
33
33
  import { isRecord } from "../core/guards.ts";
34
+ import { applyAmbientLineage } from "../core/lineage.ts";
34
35
 
35
36
  /** Coerce an engine response's process-instance key to a non-empty string, or
36
37
  * throw — a missing key means a malformed/partial response, not a real instance. */
@@ -193,9 +194,12 @@ export class SdkEngineClient implements EngineClient {
193
194
  variables?: Record<string, unknown>;
194
195
  awaitCompletion?: boolean;
195
196
  }): Promise<{ processInstanceKey: string; variables?: Record<string, unknown> }> {
197
+ // Auto-thread the `_urban.lineage` envelope (issue #254): a handler that spawns an
198
+ // instance propagates its `rootRequestKey` and stamps `causedByInstanceKey`; a genuine
199
+ // top-level request mints a fresh root. An explicit caller-supplied envelope wins.
196
200
  const body = await this.client.createProcessInstance({
197
201
  processDefinitionId: input.processDefinitionId,
198
- variables: input.variables ?? {},
202
+ variables: applyAmbientLineage(input.variables),
199
203
  awaitCompletion: input.awaitCompletion ?? false,
200
204
  });
201
205
  const rawKey = body.processInstanceKey ?? body.key;
@@ -217,10 +221,12 @@ export class SdkEngineClient implements EngineClient {
217
221
  correlationKey?: string;
218
222
  variables?: Record<string, unknown>;
219
223
  }): Promise<void> {
224
+ // Message-started instances inherit lineage too (issue #254): thread the envelope onto the
225
+ // message variables so the instance the message starts carries `_urban.lineage`.
220
226
  await this.client.publishMessage({
221
227
  name: input.name,
222
228
  correlationKey: input.correlationKey ?? "",
223
- variables: input.variables ?? {},
229
+ variables: applyAmbientLineage(input.variables),
224
230
  });
225
231
  }
226
232
 
@@ -8,6 +8,37 @@ export type {
8
8
  UrbanApp,
9
9
  } from "./core/runtime.ts";
10
10
 
11
+ // Extension-event taxonomy + microkernel (issue #262)
12
+ export { EventBus } from "./core/events.ts";
13
+ export type {
14
+ DispatchMode,
15
+ Disposer,
16
+ EmitChannel,
17
+ ErrorSink,
18
+ EventBusOptions,
19
+ Listener,
20
+ Middleware,
21
+ ParallelChannel,
22
+ SerialChannel,
23
+ WaterfallChannel,
24
+ } from "./core/events.ts";
25
+ export { URBAN_EVENT_MODES, createUrbanEvents, mountExtensions, urbanEventMode } from "./core/extensions.ts";
26
+ export type {
27
+ DispatchRequest,
28
+ DispatchResponse,
29
+ ExtensionHost,
30
+ ExtensionRegistered,
31
+ ExtensionSetupContext,
32
+ GateDecision,
33
+ GateRequest,
34
+ LifecycleEvent,
35
+ MountExtensionsOptions,
36
+ ReconcileEvent,
37
+ UrbanEventName,
38
+ UrbanEvents,
39
+ UrbanExtension,
40
+ } from "./core/extensions.ts";
41
+
11
42
  // Manifest
12
43
  export {
13
44
  bindModeToHost,
@@ -143,6 +174,67 @@ export type {
143
174
  } from "./core/modules/llm.ts";
144
175
  export { evalCorrelation } from "./core/modules/triggers.ts";
145
176
 
177
+ // Lineage — the framework-level lineage primitive (issue #254): the `_urban.lineage`
178
+ // envelope + auto-threading, and the generic read projection that stitches user intent →
179
+ // progress (a root request → the instances/PRs/tasks it causes).
180
+ export {
181
+ applyAmbientLineage,
182
+ buildLineageTree,
183
+ deriveLineage,
184
+ LINEAGE_KEY,
185
+ LINEAGE_NAMESPACE,
186
+ mintRootRequestKey,
187
+ readLineage,
188
+ writeLineage,
189
+ } from "./core/lineage.ts";
190
+ export type {
191
+ ApplyLineageOptions,
192
+ LineageAttachment,
193
+ LineageEdge,
194
+ LineageEdgeType,
195
+ LineageEnvelope,
196
+ LineageNode,
197
+ LineageTree,
198
+ } from "./core/lineage.ts";
199
+ export {
200
+ LINEAGE_ATTACHMENTS_TABLE,
201
+ LINEAGE_EDGES_TABLE,
202
+ LINEAGE_SCHEMA_SQL,
203
+ LineageStore,
204
+ systemClock as lineageSystemClock,
205
+ } from "./core/modules/lineage-store.ts";
206
+ export type {
207
+ Clock as LineageClock,
208
+ LineageJobLike,
209
+ LineageStoreOptions,
210
+ } from "./core/modules/lineage-store.ts";
211
+
212
+ // Phase — the framework-level *domain phase* primitive (issue #266): derive "which phase is this
213
+ // instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
214
+ // optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
215
+ export {
216
+ buildScopeIndex,
217
+ derivePhase,
218
+ deriveInstancePhases,
219
+ furthestReached,
220
+ rollupLineagePhase,
221
+ SCOPE_CONTAINERS,
222
+ } from "./core/phase.ts";
223
+ export type {
224
+ CrumbKind,
225
+ CrumbSource,
226
+ DerivePhaseOptions,
227
+ FurthestReached,
228
+ InstancePhase,
229
+ LineagePhaseRollup,
230
+ Phase,
231
+ PhaseCrumb,
232
+ PhaseGranularity,
233
+ ProvenanceProgressRow,
234
+ ScopeElement,
235
+ ScopeIndex,
236
+ } from "./core/phase.ts";
237
+
146
238
  // Model deploy (deploy-by-convention under `resources/`, ADR 0062)
147
239
  export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
148
240
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
@@ -0,0 +1,150 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildSystemBrief,
5
+ deriveSystemBrief,
6
+ emitSystemBriefMd,
7
+ foldOwnership,
8
+ scanModelDecisions,
9
+ SYSTEM_BRIEF_JSON,
10
+ SYSTEM_BRIEF_MD,
11
+ type SystemBrief,
12
+ } from "./system-brief.ts";
13
+ import type { ModelSource } from "./worker-io.ts";
14
+
15
+ // A model with a service task (worker), a business-rule task (decision), and ownership
16
+ // `nano:meta` (single-valued + two repeated `adr`s).
17
+ const ORDER_XML = `
18
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
19
+ xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
20
+ <bpmn:process id="order-fulfilment">
21
+ <bpmn:extensionElements>
22
+ <nano:meta xmlns:nano="urn:nano" key="owner" value="payments-team" />
23
+ <nano:meta xmlns:nano="urn:nano" key="slack" value="#payments" />
24
+ <nano:meta xmlns:nano="urn:nano" key="adr" value="ADR 0040" />
25
+ <nano:meta xmlns:nano="urn:nano" key="adr" value="ADR 0060" />
26
+ </bpmn:extensionElements>
27
+ <bpmn:serviceTask id="charge">
28
+ <bpmn:extensionElements>
29
+ <zeebe:taskDefinition type="charge-card" />
30
+ <zeebe:ioMapping>
31
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="io.acme.ChargeReq" />
32
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="io.acme.ChargeRes" />
33
+ </zeebe:ioMapping>
34
+ </bpmn:extensionElements>
35
+ </bpmn:serviceTask>
36
+ <bpmn:businessRuleTask id="risk">
37
+ <bpmn:extensionElements>
38
+ <zeebe:calledDecision decisionId="fraud-score" resultVariable="score" />
39
+ </bpmn:extensionElements>
40
+ </bpmn:businessRuleTask>
41
+ </bpmn:process>
42
+ </bpmn:definitions>`;
43
+
44
+ const models: ModelSource[] = [{ path: "order.bpmn", xml: ORDER_XML }];
45
+
46
+ test("scanModelDecisions extracts businessRuleTask → calledDecision", () => {
47
+ const decisions = scanModelDecisions(ORDER_XML);
48
+ assert.deepEqual(decisions, [
49
+ { process: "order-fulfilment", elementId: "risk", decisionId: "fraud-score" },
50
+ ]);
51
+ });
52
+
53
+ test("foldOwnership folds reserved meta keys, accumulating repeated `adr`", () => {
54
+ const own = foldOwnership(models);
55
+ assert.equal(own.owner, "payments-team");
56
+ assert.equal(own.slack, "#payments");
57
+ assert.deepEqual(own.adrs, ["ADR 0040", "ADR 0060"]);
58
+ assert.equal(own.team, undefined);
59
+ });
60
+
61
+ // `nano:meta` keys are user-authored, so a crafted key that collides with an
62
+ // `Object.prototype` member (e.g. `toString`) must NOT be treated as a reserved
63
+ // ownership key. Guards against prototype-chain membership checks (`k in OWNERSHIP_KEYS`).
64
+ test("foldOwnership ignores Object.prototype keys in crafted meta", () => {
65
+ const xml = `
66
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
67
+ <bpmn:process id="hostile">
68
+ <bpmn:extensionElements>
69
+ <nano:meta xmlns:nano="urn:nano" key="toString" value="pwned" />
70
+ <nano:meta xmlns:nano="urn:nano" key="constructor" value="pwned" />
71
+ <nano:meta xmlns:nano="urn:nano" key="hasOwnProperty" value="pwned" />
72
+ </bpmn:extensionElements>
73
+ </bpmn:process>
74
+ </bpmn:definitions>`;
75
+ const own = foldOwnership([{ path: "hostile.bpmn", xml }]);
76
+ assert.deepEqual(own, { adrs: [] });
77
+ });
78
+
79
+ test("buildSystemBrief folds processes, call graph, decisions, ownership", () => {
80
+ const b: SystemBrief = buildSystemBrief(models, "acme-orders");
81
+ assert.equal(b.app, "acme-orders");
82
+ assert.deepEqual(b.processes, ["order-fulfilment"]);
83
+ assert.equal(b.workers.length, 1);
84
+ assert.equal(b.workers[0].taskType, "charge-card");
85
+ assert.equal(b.workers[0].in, "io.acme.ChargeReq");
86
+ assert.equal(b.decisions[0].decisionId, "fraud-score");
87
+ assert.equal(b.ownership.owner, "payments-team");
88
+ });
89
+
90
+ test("emitSystemBriefMd renders owner, call graph, and decisions", () => {
91
+ const md = emitSystemBriefMd(buildSystemBrief(models, "acme-orders"));
92
+ assert.match(md, /# acme-orders — system brief/);
93
+ assert.match(md, /payments-team/);
94
+ assert.match(md, /ADR 0040, ADR 0060/);
95
+ assert.match(md, /charge-card/);
96
+ assert.match(md, /fraud-score/);
97
+ });
98
+
99
+ test("emitSystemBriefMd degrades gracefully with no ownership meta", () => {
100
+ const bare = `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
101
+ <bpmn:process id="p1"></bpmn:process></bpmn:definitions>`;
102
+ const md = emitSystemBriefMd(buildSystemBrief([{ path: "p.bpmn", xml: bare }]));
103
+ assert.match(md, /No ownership metadata declared/);
104
+ assert.match(md, /## Processes/);
105
+ });
106
+
107
+ test("emitSystemBriefMd escapes `|` and newlines in model-authored ownership cells", () => {
108
+ const evil = `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
109
+ <bpmn:process id="p1">
110
+ <bpmn:extensionElements>
111
+ <nano:meta xmlns:nano="urn:nano" key="owner" value="a | b" />
112
+ </bpmn:extensionElements>
113
+ </bpmn:process></bpmn:definitions>`;
114
+ const md = emitSystemBriefMd(buildSystemBrief([{ path: "p.bpmn", xml: evil }]));
115
+ const ownerLine = md.split("\n").find((l) => l.startsWith("| Owner |"));
116
+ assert.ok(ownerLine, "owner row rendered");
117
+ // The literal `|` from the meta value must be escaped so it can't add a phantom column.
118
+ assert.match(ownerLine!, /a \\\| b/);
119
+ });
120
+
121
+ test("processId only recognises `bpmn:process` (consistent with scanModelWorkers)", () => {
122
+ // A non-`bpmn:`-prefixed process must not be listed, otherwise the brief would show a process
123
+ // id while every worker/decision row carried an empty Process column.
124
+ const other = `<definitions xmlns:foo="urn:foo">
125
+ <foo:process id="not-bpmn"></foo:process></definitions>`;
126
+ const b = buildSystemBrief([{ path: "o.bpmn", xml: other }]);
127
+ assert.deepEqual(b.processes, []);
128
+ });
129
+
130
+ test("deriveSystemBrief emits both artifacts with valid JSON", () => {
131
+ const arts = deriveSystemBrief(models, "acme-orders");
132
+ const md = arts.find((a) => a.path.endsWith(SYSTEM_BRIEF_MD));
133
+ const json = arts.find((a) => a.path.endsWith(SYSTEM_BRIEF_JSON));
134
+ assert.ok(md && json);
135
+ const parsed: unknown = JSON.parse(json!.content);
136
+ assertSystemBriefShape(parsed);
137
+ assert.equal(parsed.app, "acme-orders");
138
+ assert.equal(parsed.workers[0].taskType, "charge-card");
139
+ });
140
+
141
+ /** Runtime-validate the parsed JSON shape (no `as` cast — banned in this repo) and narrow it to
142
+ * `SystemBrief` for the assertions that follow. */
143
+ function assertSystemBriefShape(v: unknown): asserts v is SystemBrief {
144
+ assert.ok(typeof v === "object" && v !== null, "brief is an object");
145
+ assert.ok(!("app" in v) || typeof v.app === "string", "app is absent or a string");
146
+ assert.ok("workers" in v && Array.isArray(v.workers), "workers is an array");
147
+ assert.ok("processes" in v && Array.isArray(v.processes), "processes is an array");
148
+ assert.ok("decisions" in v && Array.isArray(v.decisions), "decisions is an array");
149
+ assert.ok("ownership" in v && typeof v.ownership === "object" && v.ownership !== null, "ownership is an object");
150
+ }