@nanobpm/urban 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- package/dist/runtime/core/modules/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- package/src/runtime/core/modules/pages.test.ts +309 -5
- package/src/runtime/core/modules/pages.ts +398 -20
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/index.ts +57 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- package/src/toolkit/models.ts +4 -3
|
@@ -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
|
|
package/src/runtime/index.ts
CHANGED
|
@@ -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,
|
|
@@ -178,6 +209,32 @@ export type {
|
|
|
178
209
|
LineageStoreOptions,
|
|
179
210
|
} from "./core/modules/lineage-store.ts";
|
|
180
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
|
+
|
|
181
238
|
// Model deploy (deploy-by-convention under `resources/`, ADR 0062)
|
|
182
239
|
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
|
|
183
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
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Deriver: BPMN models + manifest → `system-brief.md` / `system-brief.json`, the app's
|
|
2
|
+
// *institutional-memory* brief (ADR 0060). Where `worker-io.ts` emits worker *types* and
|
|
3
|
+
// `meta.ts` emits a *typed accessor*, this deriver folds the SAME scanned structure into
|
|
4
|
+
// agent- and human-readable **system context**: the app's processes, its service-task call
|
|
5
|
+
// graph, the decisions it encodes, and the ownership / "why" carried on
|
|
6
|
+
// the model as reserved `nano:meta` keys. It reuses the sibling scanners (single parse rule
|
|
7
|
+
// per vocabulary) and is pure (models in, artifacts out) so `urban gen --check` keeps it
|
|
8
|
+
// honest — the brief can never rot the way a hand-written README does (ADR 0060 §1).
|
|
9
|
+
//
|
|
10
|
+
// Wired into `runGen` (gen.ts) as a standard artifact and served at runtime via `/app/agent`
|
|
11
|
+
// (agent.ts, ADR 0060 §2): the shape, scanners, fold, and both emitters are real and tested.
|
|
12
|
+
|
|
13
|
+
import type { DerivedArtifact, Deriver } from "../artifact.ts";
|
|
14
|
+
import { GENERATED_DIR } from "../artifact.ts";
|
|
15
|
+
import { byModelPath, type ModelSource, scanModelWorkers, type WorkerIo } from "./worker-io.ts";
|
|
16
|
+
import { scanModelMeta } from "./meta.ts";
|
|
17
|
+
|
|
18
|
+
/** Basenames of the two emitted artifacts. */
|
|
19
|
+
export const SYSTEM_BRIEF_MD = "system-brief.md";
|
|
20
|
+
export const SYSTEM_BRIEF_JSON = "system-brief.json";
|
|
21
|
+
|
|
22
|
+
/** Reserved `nano:meta` keys the brief recognises as ownership / provenance context (ADR 0060
|
|
23
|
+
* §1). All optional and additive — absence degrades the brief to structure-only. `adr` may be
|
|
24
|
+
* declared more than once (repeated `nano:meta`, matching the existing scan) to reference
|
|
25
|
+
* multiple ADRs; the scan collects them into `adrs[]`. */
|
|
26
|
+
export interface Ownership {
|
|
27
|
+
owner?: string;
|
|
28
|
+
team?: string;
|
|
29
|
+
slack?: string;
|
|
30
|
+
runbook?: string;
|
|
31
|
+
since?: string;
|
|
32
|
+
adrs: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One decision the app encodes: a `businessRuleTask` with a `zeebe:calledDecision`. */
|
|
36
|
+
export interface DecisionRef {
|
|
37
|
+
process?: string;
|
|
38
|
+
elementId?: string;
|
|
39
|
+
decisionId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The derived, machine-readable system model — the payload of `system-brief.json` and the
|
|
43
|
+
* input the runtime injects as session context / the retro plane consumes (ADR 0060 §3). */
|
|
44
|
+
export interface SystemBrief {
|
|
45
|
+
/** App id/name from the manifest, if provided. */
|
|
46
|
+
app?: string;
|
|
47
|
+
/** Every process id the models declare, in scan order. */
|
|
48
|
+
processes: string[];
|
|
49
|
+
/** Service-task call graph: each worker binding (task type + in/out envelope + process). The
|
|
50
|
+
* dependency edges are implicit — a process depends on the task types its service tasks invoke. */
|
|
51
|
+
workers: WorkerIo[];
|
|
52
|
+
/** Decisions (`businessRuleTask` → DMN) the models encode. */
|
|
53
|
+
decisions: DecisionRef[];
|
|
54
|
+
/** Ownership / provenance folded from reserved `nano:meta` keys. */
|
|
55
|
+
ownership: Ownership;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attr(tag: string, name: string): string | undefined {
|
|
59
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
60
|
+
return m ? m[1] : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function processId(xml: string): string | undefined {
|
|
64
|
+
const m = xml.match(/<bpmn:process\b[^>]*>/);
|
|
65
|
+
return m ? attr(m[0], "id") : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Scan one BPMN document for `businessRuleTask` → `zeebe:calledDecision` decision refs, in
|
|
69
|
+
* document order, tagged with the enclosing process (mirrors `scanModelWorkers`' shape). */
|
|
70
|
+
export function scanModelDecisions(xml: string): DecisionRef[] {
|
|
71
|
+
const proc = processId(xml);
|
|
72
|
+
const out: DecisionRef[] = [];
|
|
73
|
+
const blockRe = /<bpmn:businessRuleTask\b([^>]*)>([\s\S]*?)<\/bpmn:businessRuleTask>/g;
|
|
74
|
+
let m: RegExpExecArray | null;
|
|
75
|
+
while ((m = blockRe.exec(xml)) !== null) {
|
|
76
|
+
const elementId = attr(`<x ${m[1]}>`, "id");
|
|
77
|
+
const cd = m[2].match(/<zeebe:calledDecision\b[^>]*>/);
|
|
78
|
+
if (!cd) continue;
|
|
79
|
+
const decisionId = attr(cd[0], "decisionId");
|
|
80
|
+
if (!decisionId) continue;
|
|
81
|
+
out.push({ process: proc, elementId, decisionId });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Reserved single-valued ownership keys → their `Ownership` field. */
|
|
87
|
+
const OWNERSHIP_KEYS: Record<string, Exclude<keyof Ownership, "adrs">> = {
|
|
88
|
+
owner: "owner",
|
|
89
|
+
team: "team",
|
|
90
|
+
slack: "slack",
|
|
91
|
+
runbook: "runbook",
|
|
92
|
+
since: "since",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Fold the models' reserved `nano:meta` entries into `Ownership`. Single-valued keys take the
|
|
96
|
+
* last (scan-order) write, matching `foldMeta`; `adr` is multi-valued and accumulates. */
|
|
97
|
+
export function foldOwnership(models: ModelSource[]): Ownership {
|
|
98
|
+
const own: Ownership = { adrs: [] };
|
|
99
|
+
for (const model of [...models].sort(byModelPath)) {
|
|
100
|
+
for (const { key, value } of scanModelMeta(model.xml)) {
|
|
101
|
+
const k = key.trim().toLowerCase();
|
|
102
|
+
if (k === "adr") {
|
|
103
|
+
if (value && !own.adrs.includes(value)) own.adrs.push(value);
|
|
104
|
+
} else if (Object.hasOwn(OWNERSHIP_KEYS, k)) {
|
|
105
|
+
own[OWNERSHIP_KEYS[k]] = value ?? "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return own;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Fold all models + manifest into the machine-readable `SystemBrief`. Deterministic: models
|
|
113
|
+
* are scanned in path order. */
|
|
114
|
+
export function buildSystemBrief(models: ModelSource[], app?: string): SystemBrief {
|
|
115
|
+
const sorted = [...models].sort(byModelPath);
|
|
116
|
+
const workers: WorkerIo[] = [];
|
|
117
|
+
const decisions: DecisionRef[] = [];
|
|
118
|
+
const processes: string[] = [];
|
|
119
|
+
for (const model of sorted) {
|
|
120
|
+
const proc = processId(model.xml);
|
|
121
|
+
if (proc && !processes.includes(proc)) processes.push(proc);
|
|
122
|
+
workers.push(...scanModelWorkers(model.xml));
|
|
123
|
+
decisions.push(...scanModelDecisions(model.xml));
|
|
124
|
+
}
|
|
125
|
+
return { app, processes, workers, decisions, ownership: foldOwnership(models) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Escape a Markdown table cell: neutralise `|` and newlines, which would otherwise break the
|
|
129
|
+
* table. Ownership values come from model-authored `nano:meta`, so this both keeps rendering
|
|
130
|
+
* honest and blunts Markdown/prompt injection into the agent-facing brief. Backticks are left
|
|
131
|
+
* intact — the emitter deliberately wraps ids in code spans. */
|
|
132
|
+
function mdCell(s: string): string {
|
|
133
|
+
return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mdTable(headers: string[], rows: string[][]): string {
|
|
137
|
+
if (rows.length === 0) return "_none_\n";
|
|
138
|
+
const head = `| ${headers.join(" | ")} |\n| ${headers.map(() => "---").join(" | ")} |\n`;
|
|
139
|
+
return head + rows.map((r) => `| ${r.map((c) => mdCell(c || "")).join(" | ")} |`).join("\n") + "\n";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Emit the agent- and human-readable `system-brief.md` from the machine model. Compact by
|
|
143
|
+
* design (call graph + owners, not model XML) to keep the per-session prompt budget small
|
|
144
|
+
* (ADR 0060 §3 / open question 1). */
|
|
145
|
+
export function emitSystemBriefMd(b: SystemBrief): string {
|
|
146
|
+
const o = b.ownership;
|
|
147
|
+
let m = `# ${b.app ?? "App"} — system brief\n\n`;
|
|
148
|
+
m += "_Auto-derived from the process models (ADR 0060). Do not edit — regenerated by " +
|
|
149
|
+
"`urban gen`; drift fails `urban gen --check`._\n\n";
|
|
150
|
+
|
|
151
|
+
m += "## Ownership\n\n";
|
|
152
|
+
const ownRows = [
|
|
153
|
+
["Owner", o.owner ?? ""],
|
|
154
|
+
["Team", o.team ?? ""],
|
|
155
|
+
["Slack", o.slack ?? ""],
|
|
156
|
+
["Runbook", o.runbook ?? ""],
|
|
157
|
+
["Since", o.since ?? ""],
|
|
158
|
+
["ADRs", o.adrs.join(", ")],
|
|
159
|
+
].filter((r) => r[1]);
|
|
160
|
+
m += ownRows.length ? mdTable(["Field", "Value"], ownRows) : "_No ownership metadata declared " +
|
|
161
|
+
"(add reserved `nano:meta` keys: owner, team, slack, runbook, adr, since)._\n";
|
|
162
|
+
m += "\n";
|
|
163
|
+
|
|
164
|
+
m += "## Processes\n\n";
|
|
165
|
+
m += b.processes.length ? b.processes.map((p) => `- \`${p}\``).join("\n") + "\n" : "_none_\n";
|
|
166
|
+
m += "\n";
|
|
167
|
+
|
|
168
|
+
m += "## Service-task call graph (dependency edges)\n\n";
|
|
169
|
+
m += mdTable(
|
|
170
|
+
["Process", "Task type (dependency)", "In", "Out"],
|
|
171
|
+
b.workers.map((w) => [
|
|
172
|
+
w.process ? `\`${w.process}\`` : "",
|
|
173
|
+
`\`${w.taskType}\``,
|
|
174
|
+
w.in ? `\`${w.in}\`` : "",
|
|
175
|
+
w.out ? `\`${w.out}\`` : "",
|
|
176
|
+
]),
|
|
177
|
+
);
|
|
178
|
+
m += "\n";
|
|
179
|
+
|
|
180
|
+
m += "## Decisions (DMN)\n\n";
|
|
181
|
+
m += mdTable(
|
|
182
|
+
["Process", "Element", "Decision"],
|
|
183
|
+
b.decisions.map((d) => [
|
|
184
|
+
d.process ? `\`${d.process}\`` : "",
|
|
185
|
+
d.elementId ? `\`${d.elementId}\`` : "",
|
|
186
|
+
`\`${d.decisionId}\``,
|
|
187
|
+
]),
|
|
188
|
+
);
|
|
189
|
+
return m;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Derive `nano-generated/system-brief.md` + `system-brief.json` from BPMN models (ADR 0060 §1).
|
|
194
|
+
* Pure and deterministic (path-ordered scan), so it is a drop-in drift-checkable artifact like
|
|
195
|
+
* its sibling derivers.
|
|
196
|
+
*/
|
|
197
|
+
export function deriveSystemBrief(models: ModelSource[], app?: string): DerivedArtifact[] {
|
|
198
|
+
const brief = buildSystemBrief(models, app);
|
|
199
|
+
return [
|
|
200
|
+
{ path: `${GENERATED_DIR}/${SYSTEM_BRIEF_MD}`, content: emitSystemBriefMd(brief) },
|
|
201
|
+
{ path: `${GENERATED_DIR}/${SYSTEM_BRIEF_JSON}`, content: JSON.stringify(brief, null, 2) + "\n" },
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The `Deriver` binding, peer to `worker-io` / `meta`, for wiring into `gen.ts`. */
|
|
206
|
+
export const systemBriefDeriver: Deriver<{ models: ModelSource[]; app?: string }> = {
|
|
207
|
+
id: "system-brief",
|
|
208
|
+
describe: "App institutional-memory brief (processes, call graph, decisions, ownership) — ADR 0060",
|
|
209
|
+
derive: ({ models, app }) => deriveSystemBrief(models, app),
|
|
210
|
+
};
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -74,7 +74,7 @@ function fixture(): Record<string, string> {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
test("runGen writes migrations, the worker index, the meta accessor, and the
|
|
77
|
+
test("runGen writes migrations, the worker index, the meta accessor, the message map, and the system brief", async () => {
|
|
78
78
|
const io = memIO(fixture());
|
|
79
79
|
const res = await runGen({ root: "/app", io });
|
|
80
80
|
const paths = res.artifacts.map((a) => a.path).sort();
|
|
@@ -83,6 +83,8 @@ test("runGen writes migrations, the worker index, the meta accessor, and the mes
|
|
|
83
83
|
"nano-generated/domain-rows.d.ts",
|
|
84
84
|
"nano-generated/message-io.d.ts",
|
|
85
85
|
"nano-generated/meta.ts",
|
|
86
|
+
"nano-generated/system-brief.json",
|
|
87
|
+
"nano-generated/system-brief.md",
|
|
86
88
|
"nano-generated/worker-io.d.ts",
|
|
87
89
|
]);
|
|
88
90
|
assert.ok(io.files["/app/nano-generated/app.schema.sql"].includes("CREATE TABLE"));
|
|
@@ -91,6 +93,27 @@ test("runGen writes migrations, the worker index, the meta accessor, and the mes
|
|
|
91
93
|
assert.ok(io.files["/app/nano-generated/message-io.d.ts"].includes("export type MessageName"));
|
|
92
94
|
assert.ok(io.files["/app/nano-generated/domain-rows.d.ts"].includes('"greetings": Greetings;'));
|
|
93
95
|
assert.ok(io.files["/app/nano-generated/domain-rows.d.ts"].includes('"greeting": {'));
|
|
96
|
+
// The system brief threads the app id from the manifest and folds the process + call graph.
|
|
97
|
+
assert.ok(io.files["/app/nano-generated/system-brief.md"].includes("# demo — system brief"));
|
|
98
|
+
assert.ok(io.files["/app/nano-generated/system-brief.md"].includes("demo.do"));
|
|
99
|
+
const brief = JSON.parse(io.files["/app/nano-generated/system-brief.json"]);
|
|
100
|
+
assert.equal(brief.app, "demo");
|
|
101
|
+
assert.deepEqual(brief.processes, ["p"]);
|
|
102
|
+
assert.equal(brief.workers[0].taskType, "demo.do");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("the system brief is drift-checked and swept like its sibling artifacts", async () => {
|
|
106
|
+
const io = memIO(fixture());
|
|
107
|
+
await runGen({ root: "/app", io });
|
|
108
|
+
// Drift gate owns the brief: a hand-edit is reported by --check.
|
|
109
|
+
io.files["/app/nano-generated/system-brief.json"] = "{ tampered: true }";
|
|
110
|
+
const check = await runGen({ root: "/app", io, check: true });
|
|
111
|
+
assert.ok(check.drift.includes("nano-generated/system-brief.json"), "brief drift is reported");
|
|
112
|
+
// Stale sweep owns the brief: a renamed-away brief artifact is removed on the next write.
|
|
113
|
+
io.files["/app/nano-generated/system-brief.old.md"] = "# stale brief";
|
|
114
|
+
const res = await runGen({ root: "/app", io });
|
|
115
|
+
assert.ok(res.swept.includes("nano-generated/system-brief.old.md"), "stale brief swept");
|
|
116
|
+
assert.ok("/app/nano-generated/system-brief.md" in io.files, "fresh brief kept");
|
|
94
117
|
});
|
|
95
118
|
|
|
96
119
|
test("gen --check reports no drift right after a write", async () => {
|
|
@@ -345,13 +368,14 @@ test("readModels: a models block without processes suppresses the resources/ con
|
|
|
345
368
|
const io = memIO({
|
|
346
369
|
"/app/resources/a.bpmn": "<bpmn:definitions/>",
|
|
347
370
|
"/app/resources/sub/b.bpmn": "<bpmn:definitions/>",
|
|
371
|
+
"/app/resources/sub/deeper/c.bpmn": "<bpmn:definitions/>",
|
|
348
372
|
});
|
|
349
|
-
// No models block at all → convention scans resources/ (
|
|
373
|
+
// No models block at all → convention scans resources/ recursively (issue #231).
|
|
350
374
|
const byConvention = await readModels("/app", io, {});
|
|
351
375
|
assert.deepEqual(
|
|
352
376
|
byConvention.map((m) => m.path).sort(),
|
|
353
|
-
["resources/a.bpmn", "resources/sub/b.bpmn"],
|
|
354
|
-
"with no models block, gen discovers under resources/ by convention",
|
|
377
|
+
["resources/a.bpmn", "resources/sub/b.bpmn", "resources/sub/deeper/c.bpmn"],
|
|
378
|
+
"with no models block, gen discovers under resources/ recursively by convention",
|
|
355
379
|
);
|
|
356
380
|
// A declared models block (even one that resolves to zero process files) is an explicit override:
|
|
357
381
|
// the convention scan must be skipped entirely, matching runtime deployModels.
|