@nanobpm/urban 0.38.0 → 0.40.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 (53) hide show
  1. package/README.md +37 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/runtime/adapters/deno.js +18 -2
  4. package/dist/runtime/adapters/node.js +17 -5
  5. package/dist/runtime/core/context.d.ts +10 -1
  6. package/dist/runtime/core/host.d.ts +9 -2
  7. package/dist/runtime/core/logger.d.ts +69 -0
  8. package/dist/runtime/core/logger.js +134 -0
  9. package/dist/runtime/core/modules/api.js +7 -1
  10. package/dist/runtime/core/modules/pages.js +52 -4
  11. package/dist/runtime/core/modules/workers.js +16 -1
  12. package/dist/runtime/core/runtime.d.ts +5 -0
  13. package/dist/runtime/core/runtime.js +7 -1
  14. package/dist/runtime/engine/nanosdk.d.ts +1 -1
  15. package/dist/runtime/index.d.ts +1 -0
  16. package/dist/toolkit/artifact.d.ts +11 -0
  17. package/dist/toolkit/artifact.js +18 -0
  18. package/dist/toolkit/gen.d.ts +2 -0
  19. package/dist/toolkit/gen.js +24 -2
  20. package/dist/toolkit/scaffold/operations.js +3 -1
  21. package/dist/toolkit/scaffold/workers.js +2 -0
  22. package/dist/worker.d.ts +1 -0
  23. package/package.json +2 -2
  24. package/src/cli.ts +6 -0
  25. package/src/runtime/adapters/deno.ts +17 -4
  26. package/src/runtime/adapters/node.ts +17 -7
  27. package/src/runtime/core/context.ts +10 -1
  28. package/src/runtime/core/host.ts +9 -2
  29. package/src/runtime/core/logger.test.ts +221 -0
  30. package/src/runtime/core/logger.ts +177 -0
  31. package/src/runtime/core/modules/actions.test.ts +2 -1
  32. package/src/runtime/core/modules/api.test.ts +38 -2
  33. package/src/runtime/core/modules/api.ts +10 -1
  34. package/src/runtime/core/modules/cancel.test.ts +4 -1
  35. package/src/runtime/core/modules/connectors.test.ts +2 -1
  36. package/src/runtime/core/modules/instance-tracking.test.ts +4 -1
  37. package/src/runtime/core/modules/pages.test.ts +31 -0
  38. package/src/runtime/core/modules/pages.ts +52 -4
  39. package/src/runtime/core/modules/surfaces.test.ts +2 -1
  40. package/src/runtime/core/modules/triggers.test.ts +4 -1
  41. package/src/runtime/core/modules/workers.test.ts +59 -2
  42. package/src/runtime/core/modules/workers.ts +15 -1
  43. package/src/runtime/core/runtime.test.ts +22 -0
  44. package/src/runtime/core/runtime.ts +12 -1
  45. package/src/runtime/devserver.test.ts +2 -0
  46. package/src/runtime/engine/nanosdk.ts +1 -1
  47. package/src/runtime/index.ts +2 -0
  48. package/src/toolkit/artifact.ts +19 -0
  49. package/src/toolkit/gen.test.ts +53 -0
  50. package/src/toolkit/gen.ts +27 -2
  51. package/src/toolkit/scaffold/operations.ts +3 -1
  52. package/src/toolkit/scaffold/workers.ts +2 -0
  53. package/src/worker.ts +1 -0
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import type { AppApi, RuntimeContext } from "../context.ts";
4
5
  import type { AppManifest } from "../manifest.ts";
@@ -36,7 +37,7 @@ const engine: EngineClient = {
36
37
  const data = new DataLayer(new Map(), undefined, {});
37
38
 
38
39
  function appFixture(): AppApi {
39
- return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: () => {} };
40
+ return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: createLogger(() => {}) };
40
41
  }
41
42
 
42
43
  const spec = JSON.stringify({
@@ -88,6 +89,7 @@ function build(
88
89
  specText = spec,
89
90
  env: (v: string) => string | undefined = () => undefined,
90
91
  existsPaths: string[] = [],
92
+ app: AppApi = appFixture(),
91
93
  ): Built {
92
94
  const imported: string[] = [];
93
95
  const logs: Array<{ level: string; msg: string }> = [];
@@ -114,7 +116,7 @@ function build(
114
116
  const manifest: AppManifest = { schemaVersion: 1, id: "t", name: "T" };
115
117
  if (api) Reflect.set(manifest, "api", api);
116
118
  const ctx: RuntimeContext = { root: "/app", manifest, engine, host };
117
- const handle = mountApi(ctx, appFixture());
119
+ const handle = mountApi(ctx, app);
118
120
  const router = makeRouter(handle.routes);
119
121
  return { router: (r) => Promise.resolve(router(r)), imported, logs, handle };
120
122
  }
@@ -137,6 +139,40 @@ test("no api binding → no routes (no-op for actions-only apps)", () => {
137
139
  return router(req("GET", "/app/api/invoices/1")).then((res) => assert.equal(res.status, 404));
138
140
  });
139
141
 
142
+ test("a delegate's app.log is bound to the request's correlation context", async () => {
143
+ const captured: Array<{ level: string; msg: string; fields?: Record<string, unknown> }> = [];
144
+ const capturingApp: AppApi = {
145
+ manifest: { schemaVersion: 1, id: "t", name: "T" },
146
+ data,
147
+ engine,
148
+ env: () => undefined,
149
+ log: createLogger((level, msg, fields) => {
150
+ captured.push({ level, msg, fields });
151
+ }),
152
+ };
153
+ const handler: OperationHandler = (_input, app) => {
154
+ app.log.info("creating invoice", { amount: 5 });
155
+ return { status: 201, body: { id: "i1", amount: 5 } };
156
+ };
157
+ const { router } = build(
158
+ { spec: "openapi.json", dir: "operations" },
159
+ { "/app/operations/createInvoice": { default: handler } },
160
+ spec,
161
+ () => undefined,
162
+ [],
163
+ capturingApp,
164
+ );
165
+ const res = await router(req("POST", "/app/api/invoices", { body: JSON.stringify({ id: "i1", amount: 5 }) }));
166
+ assert.equal(res.status, 201);
167
+ assert.equal(captured.length, 1);
168
+ assert.equal(captured[0].level, "info");
169
+ assert.equal(captured[0].msg, "creating invoice");
170
+ assert.deepEqual(
171
+ { ...captured[0].fields },
172
+ { method: "POST", path: "/invoices", operationId: "createInvoice", amount: 5 },
173
+ );
174
+ });
175
+
140
176
  test("routes an operation to its delegate with validated params + body", async () => {
141
177
  const seen: unknown[] = [];
142
178
  const handler: OperationHandler = (input) => {
@@ -640,8 +640,17 @@ export function mountApi(ctx: RuntimeContext, app: AppApi): ApiHandle {
640
640
  // required), so pass `undefined` to those handlers rather than leaking a parsed value through
641
641
  // a body-less type.
642
642
  const handlerBody = op.requestBodySchema !== undefined || op.requestBodyRequired ? body : undefined;
643
+ // Hand the delegate an AppApi whose `log` is bound to this request's correlation context, so its
644
+ // lines self-tag with `{ method, path, operationId }` without the author threading it.
645
+ const requestApp: AppApi = {
646
+ ...app,
647
+ log: app.log.child({ method, path: op.path, operationId: op.operationId }),
648
+ };
643
649
  try {
644
- const result = await handler({ req, params: coercedParams, query: coercedQuery, body: handlerBody }, app);
650
+ const result = await handler(
651
+ { req, params: coercedParams, query: coercedQuery, body: handlerBody },
652
+ requestApp,
653
+ );
645
654
  if (!result) return { status: 204 };
646
655
  const status = result.status ?? 200;
647
656
  // Optional response validation (dev by default): warn-only, never blocks the response.
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import { mkdtemp, rm } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
@@ -108,7 +109,9 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
108
109
  data,
109
110
  engine,
110
111
  env: () => undefined,
111
- log: (level, msg) => logs.push({ level, msg }),
112
+ log: createLogger((level, msg) => {
113
+ logs.push({ level, msg });
114
+ }),
112
115
  };
113
116
  return {
114
117
  api,
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import type { AppApi, RuntimeContext } from "../context.ts";
4
5
  import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscription } from "../host.ts";
@@ -83,7 +84,7 @@ function makeApp(env: Record<string, string> = {}): AppApi {
83
84
  data: new DataLayer(new Map(), undefined, {}),
84
85
  engine: new MiniEngine(),
85
86
  env: (n: string) => env[n],
86
- log: () => {},
87
+ log: createLogger(() => {}),
87
88
  };
88
89
  }
89
90
 
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import { mkdtemp, rm } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
@@ -148,7 +149,9 @@ async function withHarness(
148
149
  data,
149
150
  engine,
150
151
  env: () => undefined,
151
- log: (level, msg) => logs.push({ level, msg }),
152
+ log: createLogger((level, msg) => {
153
+ logs.push({ level, msg });
154
+ }),
152
155
  };
153
156
  return {
154
157
  api,
@@ -262,6 +262,37 @@ test("renderer wires a column's processExplorer link to the console explorer", a
262
262
  assert.match(js, /"\/console\/explorer\?instance="\s*\+\s*encodeURIComponent\(keyStr\)/);
263
263
  assert.match(js, /target:\s*"_blank"/);
264
264
  assert.match(js, /rel:\s*"noopener noreferrer"/);
265
+ // Embedded in the console, a plain click routes the host explorer in place via
266
+ // hostNavigate("processExplorer", {instance}) + preventDefault; a modified
267
+ // click (new-tab intent) short-circuits so the native _blank anchor wins.
268
+ assert.match(js, /hostNavigate\("processExplorer",\s*\{\s*instance:\s*keyStr\s*\}\)/);
269
+ // Non-primary buttons (middle/right) and modified clicks keep the native
270
+ // new-tab behavior; only a plain primary click routes in-host.
271
+ assert.match(js, /ev\.button !== 0/);
272
+ assert.match(js, /ev\.metaKey \|\| ev\.ctrlKey \|\| ev\.shiftKey \|\| ev\.altKey/);
273
+ assert.match(js, /ev\.preventDefault\(\)/);
274
+ });
275
+
276
+ test("renderer exposes an embed-gated host-navigation bridge", async () => {
277
+ const res = await dispatch("GET", "/app/runtime.js");
278
+ const js = res.body ?? "";
279
+ // In-host navigation is gated on being embedded in a SAME-ORIGIN console
280
+ // iframe: standalone (or a cross-origin framer, where postMessage to our own
281
+ // origin could never be delivered) hostNavigate returns false so callers keep
282
+ // the native new-window anchor. The payload is structured (target + params)
283
+ // and posted to the console origin — the host builds its own route, so row
284
+ // data can't smuggle a path. The same-origin gate reads parent.location.origin
285
+ // in a try/catch (a cross-origin parent throws under the same-origin policy).
286
+ assert.match(js, /window\.parent\.location\.origin === window\.location\.origin/);
287
+ assert.match(js, /const NANO_EMBEDDED =/);
288
+ assert.match(js, /function hostNavigate\(target, params\)/);
289
+ assert.match(js, /if \(!NANO_EMBEDDED\) return false;/);
290
+ assert.match(
291
+ js,
292
+ /window\.parent\.postMessage\(\s*\{\s*type:\s*"nano-navigate",\s*target:\s*target,\s*params:\s*params\s*\}\s*,\s*window\.location\.origin\s*\)/,
293
+ );
294
+ // The theme bridge reuses the same embed flag (no second window.parent probe).
295
+ assert.match(js, /if \(NANO_EMBEDDED\) \{/);
265
296
  });
266
297
 
267
298
  test("the renderer honours numeric actionForm fields", async () => {
@@ -608,7 +608,36 @@ function applyTheme(msg) {
608
608
  }
609
609
  }
610
610
  }
611
- if (window.parent && window.parent !== window) {
611
+ // True when this app runs inside the Nano console's same-origin iframe (vs a
612
+ // standalone CLI run). Gates the theme bridge below AND in-host link navigation:
613
+ // embedded, a processExplorer link routes the console in place; standalone, the
614
+ // link keeps its native new-window behavior. The same-origin probe matters: a
615
+ // cross-origin framer is also window.parent, but postMessage to our own origin
616
+ // would never reach it and hostNavigate would still preventDefault the link —
617
+ // so a cross-origin embed must fall through to the native _blank anchor. Reading
618
+ // parent.location.origin throws under the same-origin policy for a cross-origin
619
+ // parent, so the try/catch is the origin gate.
620
+ const NANO_EMBEDDED = (() => {
621
+ if (!window.parent || window.parent === window) return false;
622
+ try {
623
+ return window.parent.location.origin === window.location.origin;
624
+ } catch {
625
+ return false;
626
+ }
627
+ })();
628
+
629
+ // Ask the framing console to navigate in-host. Returns true when a message was
630
+ // posted (embedded) so the caller can suppress the native anchor; false
631
+ // standalone so it falls through to the new-window <a>. The payload is
632
+ // structured (target + params), never a raw href — the console constructs its
633
+ // own route, so app/row data can't smuggle a path or scheme across the boundary.
634
+ function hostNavigate(target, params) {
635
+ if (!NANO_EMBEDDED) return false;
636
+ window.parent.postMessage({ type: "nano-navigate", target: target, params: params }, window.location.origin);
637
+ return true;
638
+ }
639
+
640
+ if (NANO_EMBEDDED) {
612
641
  window.addEventListener("message", (ev) => {
613
642
  // Same-origin proxy (posture A): only trust a message from the framing
614
643
  // parent AND from our own origin. ev.source alone is insufficient — a
@@ -723,8 +752,12 @@ function renderText(node) {
723
752
  // non-empty key (after trimming surrounding whitespace) produces a link.
724
753
  // Unknown link kinds fall back to plain text so an unrecognised schema
725
754
  // can't render a broken anchor.
726
- // Both open in a new tab with rel=noopener noreferrer so the opened page can't
727
- // reach window.opener. Shared by the top-level grid and child grids.
755
+ // The external linkField anchor always opens in a new tab (rel=noopener
756
+ // noreferrer). The processExplorer anchor does too when the app runs
757
+ // standalone, but when embedded in the console a plain click instead routes the
758
+ // host's own explorer view in place via the nano-navigate postMessage bridge
759
+ // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
760
+ // grid and child grids.
728
761
  function gridCell(col, row) {
729
762
  const text = row[col.field] == null ? "" : String(row[col.field]);
730
763
  if (col.linkField) {
@@ -745,7 +778,22 @@ function gridCell(col, row) {
745
778
  return el(
746
779
  "td",
747
780
  {},
748
- el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text),
781
+ el("a", {
782
+ class: "pc-link",
783
+ href,
784
+ target: "_blank",
785
+ rel: "noopener noreferrer",
786
+ // Embedded in the console a plain primary click routes the host's
787
+ // explorer in place (via the nano-navigate bridge) instead of opening
788
+ // a new window. A non-primary button (middle/right = new-tab/context
789
+ // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
790
+ // any standalone run fall through to the native anchor above.
791
+ onclick: (ev) => {
792
+ if (ev.button !== 0) return;
793
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
794
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
795
+ },
796
+ }, text),
749
797
  );
750
798
  }
751
799
  }
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
6
6
  import { mountSurfaces } from "./surfaces.ts";
7
7
  import type { EngineClient, HostContext, HttpRequest, HttpResponse } from "../host.ts";
8
8
  import { DataLayer } from "./datasource.ts";
9
+ import { createLogger } from "../logger.ts";
9
10
 
10
11
  function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurfaces>[0] {
11
12
  const host: HostContext = {
@@ -53,7 +54,7 @@ const fakeApp: Parameters<typeof mountSurfaces>[1] = {
53
54
  data: new DataLayer(new Map(), undefined, {}),
54
55
  engine: fakeEngine,
55
56
  env: () => undefined,
56
- log: () => {},
57
+ log: createLogger(() => {}),
57
58
  };
58
59
 
59
60
  async function render(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<string> {
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import type { AppApi, RuntimeContext } from "../context.ts";
4
5
  import type { EngineClient, HostContext, HttpRequest } from "../host.ts";
@@ -46,7 +47,9 @@ function fakeApp(): { app: AppApi; calls: EngineCall[]; logs: Array<{ level: str
46
47
  data: new DataLayer(new Map(), undefined, {}),
47
48
  engine,
48
49
  env: () => undefined,
49
- log: (level: string, msg: string) => logs.push({ level, msg }),
50
+ log: createLogger((level: string, msg: string) => {
51
+ logs.push({ level, msg });
52
+ }),
50
53
  };
51
54
  return { app, calls, logs };
52
55
  }
@@ -1,4 +1,5 @@
1
1
  import { test } from "node:test";
2
+ import { createLogger } from "../logger.ts";
2
3
  import assert from "node:assert/strict";
3
4
  import type { AppApi, RuntimeContext } from "../context.ts";
4
5
  import type { EngineClient, EngineJob, HostContext, JobHandler, WorkerSubscription } from "../host.ts";
@@ -63,7 +64,7 @@ function makeApp(over: Partial<AppApi> = {}): AppApi {
63
64
  data: new DataLayer(new Map(), undefined, {}),
64
65
  engine: new MiniEngine(),
65
66
  env: (n) => env[n],
66
- log: () => {},
67
+ log: createLogger(() => {}),
67
68
  ...over,
68
69
  };
69
70
  }
@@ -194,7 +195,63 @@ test("mountWorkers silently skips a connector-backed worker (mountConnectors own
194
195
  assert.ok(!logs.some((l) => l.msg.includes("neither a handler nor an llm")));
195
196
  });
196
197
 
197
- // ── AppJobHandler generics: optional In/Out type parameters ──────────────────────────────
198
+ test("mountWorkers binds the job's correlation context onto the handler's app.log", async () => {
199
+ const engine = new MiniEngine();
200
+ const { ctx } = makeCtx({ workers: [{ taskType: "charge", handler: "workers/charge.ts" }] }, engine);
201
+ const captured: Array<{ level: string; msg: string; fields?: Record<string, unknown> }> = [];
202
+ // The handler logs through the injected app; assert the runtime pre-bound the job context.
203
+ const handler: AppJobHandler = (_job, app) => {
204
+ app.log.info("charging", { amount: 100 });
205
+ return { ok: true };
206
+ };
207
+ ctx.host.importModule = async () => ({ default: handler });
208
+ const app = makeApp({
209
+ log: createLogger((level, msg, fields) => {
210
+ captured.push({ level, msg, fields });
211
+ }),
212
+ });
213
+ await mountWorkers(ctx, app);
214
+ await engine.deliver("charge", {
215
+ jobKey: "j-7",
216
+ jobType: "charge",
217
+ processInstanceKey: "pi-42",
218
+ elementId: "Task_Charge",
219
+ variables: {},
220
+ });
221
+ assert.equal(captured.length, 1);
222
+ assert.equal(captured[0].level, "info");
223
+ assert.equal(captured[0].msg, "charging");
224
+ assert.deepEqual(
225
+ { ...captured[0].fields },
226
+ {
227
+ jobKey: "j-7",
228
+ jobType: "charge",
229
+ processInstanceKey: "pi-42",
230
+ elementId: "Task_Charge",
231
+ amount: 100,
232
+ },
233
+ );
234
+ });
235
+
236
+ test("mountWorkers omits absent instance/element from the bound log context", async () => {
237
+ const engine = new MiniEngine();
238
+ const { ctx } = makeCtx({ workers: [{ taskType: "ping", handler: "workers/ping.ts" }] }, engine);
239
+ const captured: Array<Record<string, unknown> | undefined> = [];
240
+ const handler: AppJobHandler = (_job, app) => {
241
+ app.log.warn("no instance");
242
+ };
243
+ ctx.host.importModule = async () => ({ default: handler });
244
+ const app = makeApp({
245
+ log: createLogger((_level, _msg, fields) => {
246
+ captured.push(fields);
247
+ }),
248
+ });
249
+ await mountWorkers(ctx, app);
250
+ await engine.deliver("ping", { jobKey: "j-1", jobType: "ping", variables: {} });
251
+ assert.equal(captured.length, 1);
252
+ assert.deepEqual({ ...captured[0] }, { jobKey: "j-1", jobType: "ping" });
253
+ });
254
+
198
255
  // These are primarily compile-time assertions (the suite is typechecked by `tsc --noEmit`),
199
256
  // with a runtime smoke that a typed handler still returns its output when delivered a job.
200
257
  test("AppJobHandler carries optional In/Out variable types", async () => {
@@ -72,6 +72,20 @@ function isAppJobHandler(value: unknown): value is AppJobHandler {
72
72
  return typeof value === "function";
73
73
  }
74
74
 
75
+ /**
76
+ * Hand a handler an {@link AppApi} whose `log` is bound to the job's correlation context, so every
77
+ * line the handler emits self-tags with `{ jobKey, jobType, processInstanceKey, elementId }` without
78
+ * the author threading it. Undefined fields (e.g. a job dispatched without an instance) are omitted
79
+ * so the bound context stays clean. The rest of the API is shared by reference — only `log` is
80
+ * per-job.
81
+ */
82
+ function withJobLog(app: AppApi, job: EngineJob): AppApi {
83
+ const bindings: Record<string, unknown> = { jobKey: job.jobKey, jobType: job.jobType };
84
+ if (job.processInstanceKey !== undefined) bindings.processInstanceKey = job.processInstanceKey;
85
+ if (job.elementId !== undefined) bindings.elementId = job.elementId;
86
+ return { ...app, log: app.log.child(bindings) };
87
+ }
88
+
75
89
  /**
76
90
  * Resolve a handler for `jobType` from a loaded module, in priority order:
77
91
  * 1. `handlers[jobType]` (a map keyed by job type — the multi-type module case)
@@ -167,7 +181,7 @@ export async function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<Wo
167
181
  const wrapped: JobHandler = (job) =>
168
182
  runInJobContext(
169
183
  { instanceKey: job.processInstanceKey, elementId: job.elementId, jobType },
170
- () => handler(job, app),
184
+ () => handler(job, withJobLog(app, job)),
171
185
  );
172
186
  const sub = await ctx.engine.registerWorker(jobType, wrapped, {
173
187
  workerName: `${ctx.manifest.id}:${jobType}`,
@@ -304,6 +304,28 @@ test("task-inbox /api/complete returns 400 on a malformed JSON body", async () =
304
304
  }
305
305
  });
306
306
 
307
+ test("app.log is an app-level structured logger routed to the host sink", async () => {
308
+ const dir = await makeFixture();
309
+ const logs: Array<{ level: string; msg: string; fields?: Record<string, unknown> }> = [];
310
+ const host = createNodeHost({ cwd: dir, log: (level, msg, fields) => logs.push({ level, msg, fields }) });
311
+ const engine = new FakeEngine();
312
+ const app = await createUrbanApp({ host, engine, root: ".", port: 0 });
313
+ app.log.info("booting", { pid: 1 });
314
+ assert.deepEqual(
315
+ // `fields` is the merged bag, deliberately a null-prototype object (untrusted-key hardening);
316
+ // spread it so the assertion compares contents, not prototype.
317
+ logs.filter((l) => l.msg === "booting").map((l) => ({ ...l, fields: { ...l.fields } })),
318
+ [{ level: "info", msg: "booting", fields: { pid: 1 } }],
319
+ );
320
+ // ergonomic methods + child binding are present on the handle logger
321
+ const bound = app.log.child({ region: "eu" });
322
+ bound.warn("slow");
323
+ assert.deepEqual(
324
+ logs.filter((l) => l.msg === "slow").map((l) => ({ ...l, fields: { ...l.fields } })),
325
+ [{ level: "warn", msg: "slow", fields: { region: "eu" } }],
326
+ );
327
+ });
328
+
307
329
  test("resolvePort prefers explicit, then $PORT, then 8090; rejects bad $PORT", () => {
308
330
  assert.equal(resolvePort(3000, "9999"), 3000);
309
331
  assert.equal(resolvePort(undefined, "9999"), 9999);
@@ -13,6 +13,7 @@ import { deployModels } from "./modules/deploy.ts";
13
13
  import type { TemplateSource } from "./modules/templates.ts";
14
14
  import { provisionData, DataLayer } from "./modules/datasource.ts";
15
15
  import { installExecStore, type JobExecContext } from "./execContext.ts";
16
+ import { createLogger, type Logger } from "./logger.ts";
16
17
  import { mountWorkers } from "./modules/workers.ts";
17
18
  import { mountConnectors } from "./modules/connectors.ts";
18
19
  import { mountSurfaces } from "./modules/surfaces.ts";
@@ -99,6 +100,10 @@ export interface UrbanApp {
99
100
  stop(): Promise<void>;
100
101
  /** A structured snapshot of what is mounted. */
101
102
  inspect(): Record<string, unknown>;
103
+ /** An app-level structured {@link Logger} for the entrypoint (`main.ts`). It carries no
104
+ * per-request/per-job correlation (worker handlers and route delegates get a child logger bound
105
+ * to their job/request context via `AppApi.log`); use it for boot/shutdown lifecycle lines. */
106
+ readonly log: Logger;
102
107
  /** The provisioned data layer (available after start when `data` is mounted). */
103
108
  readonly data: DataLayer | undefined;
104
109
  readonly security: SecurityPolicy | undefined;
@@ -123,6 +128,11 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
123
128
  };
124
129
  const ctx = { manifest, host, engine, root, templates: opts.templates };
125
130
 
131
+ // One app-level structured logger over the host sink, shared by the entrypoint (exposed as
132
+ // `app.log`) and the per-invocation `AppApi.log` (workers/route delegates `child()` it with their
133
+ // correlation context).
134
+ const appLog = createLogger((l, m, f) => host.log(l, m, f));
135
+
126
136
  // Install the ambient job-execution store once per process (idempotent) so worker dispatch can
127
137
  // stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
128
138
  // leaves provenance capture disabled.
@@ -173,6 +183,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
173
183
  const app: UrbanApp = {
174
184
  manifest,
175
185
  root,
186
+ log: appLog,
176
187
  get data() {
177
188
  return data;
178
189
  },
@@ -197,7 +208,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
197
208
  engine,
198
209
  sdk: engineSdk(engine),
199
210
  env: (n) => host.env(n),
200
- log: (l, m, f) => host.log(l, m, f),
211
+ log: appLog,
201
212
  };
202
213
 
203
214
  if (flags.workers) {
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  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
+ import { createLogger } from "./core/logger.ts";
6
7
 
7
8
  const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
8
9
 
@@ -226,6 +227,7 @@ function fakeApp(stops: string[], id: string): UrbanApp {
226
227
  stops.push(id);
227
228
  },
228
229
  inspect: () => ({ app: id }),
230
+ log: createLogger(() => {}),
229
231
  data: undefined,
230
232
  security: undefined,
231
233
  httpPort: undefined,
@@ -138,7 +138,7 @@ export interface NanoSdkClient {
138
138
  close?(): void | Promise<void>;
139
139
  }
140
140
 
141
- type Log = (level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
141
+ type Log = (level: "debug" | "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>) => void;
142
142
 
143
143
  /**
144
144
  * An `EngineClient` backed entirely by a `@nanobpm/nano-sdk` client. Deploy, create,
@@ -54,6 +54,8 @@ export type {
54
54
  WorkerSubscription,
55
55
  } from "./core/host.ts";
56
56
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
57
+ // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`.
58
+ export type { Logger, LogLevel, LogFields } from "./core/logger.ts";
57
59
 
58
60
  // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
59
61
  // error routed to an error boundary (ADR 0050), instead of a retryable failure.
@@ -23,6 +23,25 @@ export interface Deriver<I> {
23
23
  * drop-in for the IDE's own codegen (ADR 0053). One drift domain, gitignored, never committed. */
24
24
  export const GENERATED_DIR = "nano-generated";
25
25
 
26
+ /**
27
+ * Files that live in `GENERATED_DIR` but are NOT produced by `urban gen`. These are materialized by
28
+ * a different writer — the runtime SDK shims and the `urban data` / console-host dataops path, which
29
+ * derives the typed wrappers from the *live* DB (something `urban gen` can't do without a running
30
+ * datasource). `urban gen`'s stale-file sweep must own only what it emits, so it protects these by
31
+ * name: it never deletes them even though it didn't (re)write them this run. Basenames, relative to
32
+ * `GENERATED_DIR`. Keep in sync with the emitters' filename constants (DOMAIN_BINDINGS,
33
+ * WORKER_BINDINGS_TS, MESSAGE_BINDINGS_TS, DOMAIN_MODEL_JSON) + the `./data-sdk.ts`/`./worker-sdk.ts`
34
+ * shim imports the generated wrappers reference.
35
+ */
36
+ export const RUNTIME_MATERIALIZED_ARTIFACTS: readonly string[] = [
37
+ "domain.ts",
38
+ "workers.ts",
39
+ "messages.ts",
40
+ "domain.json",
41
+ "data-sdk.ts",
42
+ "worker-sdk.ts",
43
+ ];
44
+
26
45
  /** Stable ordering so artifact lists compare deterministically. */
27
46
  export function sortArtifacts(a: DerivedArtifact[]): DerivedArtifact[] {
28
47
  return [...a].sort((x, y) => (x.path < y.path ? -1 : x.path > y.path ? 1 : 0));
@@ -103,6 +103,59 @@ test("collectArtifacts touches no writes", async () => {
103
103
  assert.equal(Object.keys(io.files).length, before);
104
104
  });
105
105
 
106
+ test("runGen sweeps stale files from nano-generated/ (write mode)", async () => {
107
+ const io = memIO(fixture());
108
+ // A stale artifact left by a prior gen (e.g. a renamed `data_sdk.ts` orphan) that this run does
109
+ // not re-emit. It must be removed so the app can't import a dead generated module.
110
+ io.files["/app/nano-generated/data_sdk.ts"] = "// stale, renamed away";
111
+ io.files["/app/nano-generated/old-worker-io.d.ts"] = "// stale";
112
+ const res = await runGen({ root: "/app", io });
113
+ assert.deepEqual(res.swept, [
114
+ "nano-generated/data_sdk.ts",
115
+ "nano-generated/old-worker-io.d.ts",
116
+ ]);
117
+ assert.ok(!("/app/nano-generated/data_sdk.ts" in io.files), "stale file removed");
118
+ assert.ok(!("/app/nano-generated/old-worker-io.d.ts" in io.files), "stale file removed");
119
+ // Current artifacts survive.
120
+ assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "fresh artifact kept");
121
+ });
122
+
123
+ test("runGen never sweeps runtime-materialized wrappers gen does not emit", async () => {
124
+ const io = memIO(fixture());
125
+ // The SDK shims + `urban data`/console dataops wrappers live in nano-generated/ but are not
126
+ // produced by `urban gen`; the app needs them at runtime, so the sweep must protect them.
127
+ for (const name of ["domain.ts", "workers.ts", "messages.ts", "domain.json", "data-sdk.ts", "worker-sdk.ts"]) {
128
+ io.files[`/app/nano-generated/${name}`] = `// runtime-materialized ${name}`;
129
+ }
130
+ const res = await runGen({ root: "/app", io });
131
+ assert.deepEqual(res.swept, [], "no runtime-materialized file swept");
132
+ for (const name of ["domain.ts", "workers.ts", "messages.ts", "domain.json", "data-sdk.ts", "worker-sdk.ts"]) {
133
+ assert.ok(`/app/nano-generated/${name}` in io.files, `${name} preserved`);
134
+ }
135
+ });
136
+
137
+ test("gen --check never sweeps (read-only)", async () => {
138
+ const io = memIO(fixture());
139
+ await runGen({ root: "/app", io });
140
+ io.files["/app/nano-generated/stale.ts"] = "// stale";
141
+ const res = await runGen({ root: "/app", io, check: true });
142
+ assert.deepEqual(res.swept, []);
143
+ assert.ok("/app/nano-generated/stale.ts" in io.files, "check mode leaves stale files untouched");
144
+ });
145
+
146
+ test("modelsOnly (urban derive) never sweeps nano-generated type-contract outputs", async () => {
147
+ const io = memIO(fixture());
148
+ // A prior `urban gen` wrote the type-contract outputs.
149
+ await runGen({ root: "/app", io });
150
+ assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "precondition: gen wrote outputs");
151
+ // `urban derive` (modelsOnly) derives only models — its artifacts carry no nano-generated/*, so
152
+ // the sweep must be a no-op here; otherwise it would wipe the type contracts above.
153
+ const res = await runGen({ root: "/app", io, modelsOnly: true });
154
+ assert.deepEqual(res.swept, []);
155
+ assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "derive preserved the type contracts");
156
+ assert.ok("/app/nano-generated/app.schema.sql" in io.files, "derive preserved the migrations");
157
+ });
158
+
106
159
  // --- code-first model derivation (urban gen / urban derive) ---
107
160
 
108
161
  const GreetIn = envelope("GreetIn", { who: "string" });