@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.
- package/README.md +37 -0
- package/dist/cli.js +6 -0
- package/dist/runtime/adapters/deno.js +18 -2
- package/dist/runtime/adapters/node.js +17 -5
- package/dist/runtime/core/context.d.ts +10 -1
- package/dist/runtime/core/host.d.ts +9 -2
- package/dist/runtime/core/logger.d.ts +69 -0
- package/dist/runtime/core/logger.js +134 -0
- package/dist/runtime/core/modules/api.js +7 -1
- package/dist/runtime/core/modules/pages.js +52 -4
- package/dist/runtime/core/modules/workers.js +16 -1
- package/dist/runtime/core/runtime.d.ts +5 -0
- package/dist/runtime/core/runtime.js +7 -1
- package/dist/runtime/engine/nanosdk.d.ts +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/toolkit/artifact.d.ts +11 -0
- package/dist/toolkit/artifact.js +18 -0
- package/dist/toolkit/gen.d.ts +2 -0
- package/dist/toolkit/gen.js +24 -2
- package/dist/toolkit/scaffold/operations.js +3 -1
- package/dist/toolkit/scaffold/workers.js +2 -0
- package/dist/worker.d.ts +1 -0
- package/package.json +2 -2
- package/src/cli.ts +6 -0
- package/src/runtime/adapters/deno.ts +17 -4
- package/src/runtime/adapters/node.ts +17 -7
- package/src/runtime/core/context.ts +10 -1
- package/src/runtime/core/host.ts +9 -2
- package/src/runtime/core/logger.test.ts +221 -0
- package/src/runtime/core/logger.ts +177 -0
- package/src/runtime/core/modules/actions.test.ts +2 -1
- package/src/runtime/core/modules/api.test.ts +38 -2
- package/src/runtime/core/modules/api.ts +10 -1
- package/src/runtime/core/modules/cancel.test.ts +4 -1
- package/src/runtime/core/modules/connectors.test.ts +2 -1
- package/src/runtime/core/modules/instance-tracking.test.ts +4 -1
- package/src/runtime/core/modules/pages.test.ts +31 -0
- package/src/runtime/core/modules/pages.ts +52 -4
- package/src/runtime/core/modules/surfaces.test.ts +2 -1
- package/src/runtime/core/modules/triggers.test.ts +4 -1
- package/src/runtime/core/modules/workers.test.ts +59 -2
- package/src/runtime/core/modules/workers.ts +15 -1
- package/src/runtime/core/runtime.test.ts +22 -0
- package/src/runtime/core/runtime.ts +12 -1
- package/src/runtime/devserver.test.ts +2 -0
- package/src/runtime/engine/nanosdk.ts +1 -1
- package/src/runtime/index.ts +2 -0
- package/src/toolkit/artifact.ts +19 -0
- package/src/toolkit/gen.test.ts +53 -0
- package/src/toolkit/gen.ts +27 -2
- package/src/toolkit/scaffold/operations.ts +3 -1
- package/src/toolkit/scaffold/workers.ts +2 -0
- 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,
|
|
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(
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
//
|
|
727
|
-
//
|
|
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", {
|
|
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) =>
|
|
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
|
-
|
|
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:
|
|
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,
|
package/src/runtime/index.ts
CHANGED
|
@@ -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.
|
package/src/toolkit/artifact.ts
CHANGED
|
@@ -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));
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -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" });
|