@nanobpm/urban 0.77.2 → 0.77.4
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/dist/runtime/core/modules/scheduler.d.ts +8 -0
- package/dist/runtime/core/modules/scheduler.js +48 -2
- package/dist/toolkit/addConnector.js +8 -2
- package/dist/toolkit/scaffold/operations.js +11 -6
- package/dist/toolkit/scaffold/workers.js +12 -7
- package/dist/toolkit/scaffold.d.ts +12 -0
- package/dist/toolkit/scaffold.js +27 -2
- package/package.json +2 -2
- package/src/runtime/core/modules/scheduler.test.ts +105 -0
- package/src/runtime/core/modules/scheduler.ts +56 -2
- package/src/toolkit/addConnector.test.ts +18 -0
- package/src/toolkit/addConnector.ts +8 -2
- package/src/toolkit/derivers/generated-lint.test.ts +2 -2
- package/src/toolkit/scaffold/operations.test.ts +43 -0
- package/src/toolkit/scaffold/operations.ts +11 -6
- package/src/toolkit/scaffold/workers.test.ts +53 -1
- package/src/toolkit/scaffold/workers.ts +12 -7
- package/src/toolkit/scaffold.ts +27 -2
|
@@ -4,6 +4,14 @@ export interface SchedulerDeps {
|
|
|
4
4
|
clearTimer: (handle: unknown) => void;
|
|
5
5
|
/** Current wall-clock time in ms since epoch. */
|
|
6
6
|
now: () => number;
|
|
7
|
+
/** Optional shutdown signal. When it aborts, a pending {@link schedulerClock} `wait()` clears its
|
|
8
|
+
* armed timer and rejects, so a handler parked on the (virtual) clock unwinds at teardown instead
|
|
9
|
+
* of wedging whoever awaits it. Under the test kit this is the engine's shutdown signal: a worker
|
|
10
|
+
* handler parked on `app.wait()` sits on a virtual timer that no `advanceTime` will fire during
|
|
11
|
+
* `engine.close()`, so without this its promise never settles and `close()` hangs (the issue #446
|
|
12
|
+
* follow-up — the virtual-timer sibling of the real-time use-after-free). Absent on the live
|
|
13
|
+
* scheduler, where real timers always fire on wall-clock time, so `wait()` never rejects there. */
|
|
14
|
+
readonly signal?: AbortSignal;
|
|
7
15
|
}
|
|
8
16
|
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
9
17
|
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
// worker handlers. Keeping one definition here — rather than a copy per module — means a
|
|
4
4
|
// test's fake scheduler drives them all the same way, and the "clamp a far-future delay to
|
|
5
5
|
// setTimeout's 32-bit range" rule has a single home.
|
|
6
|
+
/** The rejection surfaced to a `wait()` cancelled by a shutdown {@link SchedulerDeps.signal}: the
|
|
7
|
+
* signal's `reason` when it is an {@link Error}, else a generic teardown error. Reusing the reason
|
|
8
|
+
* keeps a caller's fail-loud message (e.g. "WasmEngineClient closing") intact. */
|
|
9
|
+
function schedulerAbortError(signal) {
|
|
10
|
+
const reason = signal.reason;
|
|
11
|
+
return reason instanceof Error ? reason : new Error("app.wait aborted: scheduler shutting down");
|
|
12
|
+
}
|
|
6
13
|
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
7
14
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
8
15
|
function isTimerHandle(handle) {
|
|
@@ -32,9 +39,48 @@ export function defaultScheduler() {
|
|
|
32
39
|
export function schedulerClock(sched) {
|
|
33
40
|
return {
|
|
34
41
|
now: () => sched.now(),
|
|
35
|
-
wait: (ms) => new Promise((resolve) => {
|
|
42
|
+
wait: (ms) => new Promise((resolve, reject) => {
|
|
43
|
+
const signal = sched.signal;
|
|
44
|
+
// Already shutting down: reject immediately rather than arm a timer that would outlive the
|
|
45
|
+
// teardown (and, under the virtual clock, never fire).
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
reject(schedulerAbortError(signal));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
36
50
|
const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
|
|
37
|
-
|
|
51
|
+
let settled = false;
|
|
52
|
+
let onAbort;
|
|
53
|
+
const handle = sched.setTimer(() => {
|
|
54
|
+
settled = true;
|
|
55
|
+
if (onAbort && signal)
|
|
56
|
+
signal.removeEventListener("abort", onAbort);
|
|
57
|
+
resolve();
|
|
58
|
+
}, delay);
|
|
59
|
+
// When a shutdown signal is present, a mid-wait abort disarms the timer and rejects, so a
|
|
60
|
+
// handler parked here unwinds at teardown instead of wedging its awaiter. Inert (no listener,
|
|
61
|
+
// no behaviour change) on the live scheduler, which supplies no signal.
|
|
62
|
+
//
|
|
63
|
+
// `settled` guards the synchronous-timer case: this seam permits `setTimer` to invoke its
|
|
64
|
+
// callback synchronously (the test `capturingScheduler` does exactly that), which resolves
|
|
65
|
+
// the wait *before* we get here. Installing an abort listener on that already-settled promise
|
|
66
|
+
// would leak its closure until the signal fires (and have shutdown clear an already-fired
|
|
67
|
+
// handle), so skip it entirely when the callback already ran.
|
|
68
|
+
if (signal && !settled) {
|
|
69
|
+
onAbort = () => {
|
|
70
|
+
sched.clearTimer(handle);
|
|
71
|
+
reject(schedulerAbortError(signal));
|
|
72
|
+
};
|
|
73
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
74
|
+
// Re-entrant seam: `setTimer` above is injectable and may abort `signal` *synchronously*
|
|
75
|
+
// without firing the timer callback (so `settled` is still false). An abort event is not
|
|
76
|
+
// replayed to a listener added after the fact, so the listener just registered would never
|
|
77
|
+
// run and the wait would hang until the runner timeout. Re-check and drive the abort path
|
|
78
|
+
// by hand — removing the (now-inert) listener first so it cannot also fire and double-reject.
|
|
79
|
+
if (signal.aborted && !settled) {
|
|
80
|
+
signal.removeEventListener("abort", onAbort);
|
|
81
|
+
onAbort();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
38
84
|
}),
|
|
39
85
|
};
|
|
40
86
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// package (a subprocess) is the CLI's job, so this stays runtime-agnostic and
|
|
13
13
|
// unit-testable.
|
|
14
14
|
import { errorMessage, isRecord } from "../runtime/core/guards.js";
|
|
15
|
+
import { detectJsonIndent } from "./scaffold.js";
|
|
15
16
|
const MANIFEST_FILE = "nano-ide.ext.json";
|
|
16
17
|
function joinPath(a, b) {
|
|
17
18
|
// Trim both slash styles (as toolkit/gen.ts joinPath does) so a Windows-style
|
|
@@ -61,9 +62,11 @@ export async function addConnector(opts) {
|
|
|
61
62
|
if (!(await io.exists(appManifestPath))) {
|
|
62
63
|
throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
|
|
63
64
|
}
|
|
65
|
+
let appRaw;
|
|
64
66
|
let app;
|
|
65
67
|
try {
|
|
66
|
-
|
|
68
|
+
appRaw = await io.readText(appManifestPath);
|
|
69
|
+
app = JSON.parse(appRaw);
|
|
67
70
|
}
|
|
68
71
|
catch (err) {
|
|
69
72
|
throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
|
|
@@ -161,7 +164,10 @@ export async function addConnector(opts) {
|
|
|
161
164
|
workers.push(entry);
|
|
162
165
|
wired.push({ taskType: spec.type, alreadyPresent: false });
|
|
163
166
|
}
|
|
164
|
-
|
|
167
|
+
// Preserve the manifest's own indentation (default tab, matching the scaffold's Biome config)
|
|
168
|
+
// so enabling a connector never reformats nano.app.json out from under `npm run lint` — the same
|
|
169
|
+
// fidelity `urban gen` gives via detectJsonIndent, kept as the single source of truth.
|
|
170
|
+
await io.writeText(appManifestPath, `${JSON.stringify(app, null, detectJsonIndent(appRaw))}\n`);
|
|
165
171
|
return {
|
|
166
172
|
packId: pack.id,
|
|
167
173
|
wired,
|
|
@@ -29,16 +29,21 @@ export function renderOperationStub(operationId, dir = DEFAULT_OPERATIONS_DIR) {
|
|
|
29
29
|
`// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
|
|
30
30
|
`// generated controller (nano-generated/controller.ts) imports this default export and\n` +
|
|
31
31
|
`// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
|
|
32
|
-
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`)
|
|
32
|
+
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`), replacing the\n` +
|
|
33
|
+
`// guard warn + throw.\n` +
|
|
33
34
|
`import { NotImplemented } from "@nanobpm/urban";\n` +
|
|
34
35
|
`import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
|
|
35
36
|
`\n` +
|
|
36
37
|
`export default defineOperation(${key}, async (input, app) => {\n` +
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
`\t// \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
|
|
39
|
+
`\t// through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
|
|
40
|
+
`\t// auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
|
|
41
|
+
`\t// Return \`{ status?, headers?, body? }\` (or nothing → 204). Replace the two lines below.\n` +
|
|
42
|
+
`\tapp.log.warn("operation not implemented", {\n` +
|
|
43
|
+
`\t\toperationId: ${key},\n` +
|
|
44
|
+
`\t\tmethod: input.req.method,\n` +
|
|
45
|
+
`\t});\n` +
|
|
46
|
+
`\tthrow new NotImplemented(${key});\n` +
|
|
42
47
|
`});\n`);
|
|
43
48
|
}
|
|
44
49
|
/**
|
|
@@ -38,21 +38,26 @@ export function renderWorkerStub(taskType, typedIn, typedOut) {
|
|
|
38
38
|
else if (typedOut)
|
|
39
39
|
generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
40
40
|
const varsNote = typedIn
|
|
41
|
-
?
|
|
42
|
-
:
|
|
41
|
+
? `\t// \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
42
|
+
: `\t// \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
43
|
+
// The stub arrives lint-clean under the scaffold's own Biome config: tab-indented, and
|
|
44
|
+
// referencing both `job` and `app` (via the warn line) so `noUnusedFunctionParameters`
|
|
45
|
+
// does not fire before the author has filled in the body.
|
|
43
46
|
return (`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
44
47
|
`// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
|
|
45
|
-
`// below (use \`app.data.table(...)\` for state)
|
|
48
|
+
`// below (use \`app.data.table(...)\` for state), replacing the guard warn + throw so the\n` +
|
|
49
|
+
`// "worker not implemented" warning does not survive into a working handler.\n` +
|
|
46
50
|
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
47
51
|
generatedImport +
|
|
48
52
|
`\n` +
|
|
49
53
|
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
50
54
|
varsNote +
|
|
51
55
|
`\n` +
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
`\t// Reach app state and services through the injected \`app\` API.\n` +
|
|
57
|
+
`\t// Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
|
|
58
|
+
`\t// { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
|
|
59
|
+
`\tapp.log.warn("worker not implemented", { jobKey: job.jobKey });\n` +
|
|
60
|
+
`\tthrow new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
56
61
|
`};\n` +
|
|
57
62
|
`\n` +
|
|
58
63
|
`export default handler;\n`);
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
import type { GenIO } from "./gen.ts";
|
|
2
2
|
import { type SkippedWorker, type StubManifestEntry } from "./scaffold/workers.ts";
|
|
3
|
+
/**
|
|
4
|
+
* Detect the indentation of a JSON document from its first indented line, so a rewrite can
|
|
5
|
+
* reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
|
|
6
|
+
* — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
|
|
7
|
+
*
|
|
8
|
+
* Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
|
|
9
|
+
* truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
|
|
10
|
+
* serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
|
|
11
|
+
* its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
|
|
12
|
+
* unaffected.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectJsonIndent(json: string): string;
|
|
3
15
|
export interface ScaffoldOptions {
|
|
4
16
|
root: string;
|
|
5
17
|
io: GenIO;
|
package/dist/toolkit/scaffold.js
CHANGED
|
@@ -6,13 +6,38 @@ import { joinPath, readModels } from "./gen.js";
|
|
|
6
6
|
import { planWorkerScaffold, } from "./scaffold/workers.js";
|
|
7
7
|
import { planOperationScaffold } from "./scaffold/operations.js";
|
|
8
8
|
import { parseSpec } from "../openapi/spec.js";
|
|
9
|
+
/**
|
|
10
|
+
* Detect the indentation of a JSON document from its first indented line, so a rewrite can
|
|
11
|
+
* reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
|
|
12
|
+
* — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
|
|
13
|
+
*
|
|
14
|
+
* Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
|
|
15
|
+
* truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
|
|
16
|
+
* serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
|
|
17
|
+
* its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
|
|
18
|
+
* unaffected.
|
|
19
|
+
*/
|
|
20
|
+
export function detectJsonIndent(json) {
|
|
21
|
+
// Anchor on any line terminator, not just `\n`, so the width is detected regardless of the
|
|
22
|
+
// manifest's line endings: LF (`\n`), CRLF (`\r\n`, where `\n` already precedes the indent),
|
|
23
|
+
// bare-CR old-Mac (`\r`), and LFCR (`\n\r`) all resolve to the indentation that follows.
|
|
24
|
+
const m = json.match(/[\r\n]([ \t]+)\S/);
|
|
25
|
+
if (!m)
|
|
26
|
+
return "\t";
|
|
27
|
+
const indent = m[1];
|
|
28
|
+
return indent.length > 10 ? indent.slice(0, 10) : indent;
|
|
29
|
+
}
|
|
9
30
|
/** Scaffold write-once worker stubs from the model, wiring them into `manifest.workers[]`. */
|
|
10
31
|
export async function scaffoldWorkers(opts) {
|
|
11
32
|
const { root, io } = opts;
|
|
12
33
|
const write = opts.write ?? false;
|
|
13
34
|
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
14
35
|
const manifestPath = joinPath(root, manifestFile);
|
|
15
|
-
const
|
|
36
|
+
const manifestRaw = await io.readText(manifestPath);
|
|
37
|
+
const manifest = JSON.parse(manifestRaw);
|
|
38
|
+
// Preserve the manifest's own indentation when we patch it back (default tab, matching the
|
|
39
|
+
// scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
|
|
40
|
+
const manifestIndent = detectJsonIndent(manifestRaw);
|
|
16
41
|
const models = await readModels(root, io, manifest);
|
|
17
42
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
18
43
|
const { plans, skipped } = planWorkerScaffold(models, manifest.workers ?? [], declaredTypeIds, manifest.externalTaskTypes ?? []);
|
|
@@ -49,7 +74,7 @@ export async function scaffoldWorkers(opts) {
|
|
|
49
74
|
...manifest,
|
|
50
75
|
workers: [...(manifest.workers ?? []), ...wired],
|
|
51
76
|
};
|
|
52
|
-
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null,
|
|
77
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, manifestIndent)}\n`);
|
|
53
78
|
manifestPatched = true;
|
|
54
79
|
}
|
|
55
80
|
return { outcomes, skipped, wired, manifestPatched, write };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.77.
|
|
3
|
+
"version": "0.77.4",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"@nanobpm/nano-sdk": "^1.2.5",
|
|
104
104
|
"@nanobpm/workflow": "^0.14.0",
|
|
105
105
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
106
|
-
"create-urban-app": "^0.15.
|
|
106
|
+
"create-urban-app": "^0.15.2",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
@@ -42,3 +42,108 @@ test("schedulerClock.wait clamps non-positive / non-finite delays to the current
|
|
|
42
42
|
await schedulerClock(sched).wait(Number.POSITIVE_INFINITY);
|
|
43
43
|
assert.deepEqual(sched.delays, [0, 0, 0]);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
// Regression (issue #446 follow-up): a handler parked on `app.wait()` sits on a (virtual) timer that
|
|
47
|
+
// no clock advance fires during teardown, so whoever awaits it — the test kit's `engine.close()` —
|
|
48
|
+
// hangs forever. `schedulerClock().wait()` now honours an optional shutdown `signal`: a mid-wait
|
|
49
|
+
// abort disarms the armed timer and rejects, so the parked handler unwinds at teardown. The live
|
|
50
|
+
// scheduler supplies no signal, so this is wholly inert in production.
|
|
51
|
+
test("schedulerClock.wait disarms its timer and rejects when the shutdown signal aborts mid-wait", async () => {
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const cleared: unknown[] = [];
|
|
54
|
+
let armed: (() => void) | undefined;
|
|
55
|
+
const sched: SchedulerDeps = {
|
|
56
|
+
now: () => 0,
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
setTimer: (fn) => {
|
|
59
|
+
armed = fn;
|
|
60
|
+
return 42;
|
|
61
|
+
},
|
|
62
|
+
clearTimer: (h) => cleared.push(h),
|
|
63
|
+
};
|
|
64
|
+
const waiting = schedulerClock(sched).wait(10_000);
|
|
65
|
+
assert.equal(typeof armed, "function", "wait must arm a timer while not aborted");
|
|
66
|
+
controller.abort(new Error("engine shutting down"));
|
|
67
|
+
await assert.rejects(waiting, /engine shutting down/, "an aborted wait must reject with the signal's reason");
|
|
68
|
+
assert.deepEqual(cleared, [42], "the armed timer must be cleared on abort so it cannot fire later");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("schedulerClock.wait rejects immediately (arming no timer) when the signal is already aborted", async () => {
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
controller.abort(new Error("already shutting down"));
|
|
74
|
+
let armedCount = 0;
|
|
75
|
+
const sched: SchedulerDeps = {
|
|
76
|
+
now: () => 0,
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
setTimer: () => {
|
|
79
|
+
armedCount += 1;
|
|
80
|
+
return 0;
|
|
81
|
+
},
|
|
82
|
+
clearTimer: () => {},
|
|
83
|
+
};
|
|
84
|
+
await assert.rejects(schedulerClock(sched).wait(1000), /already shutting down/);
|
|
85
|
+
assert.equal(armedCount, 0, "an already-aborted signal must reject without arming a timer");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Regression (PR #447 review, scheduler.ts): this seam permits `setTimer` to fire its callback
|
|
89
|
+
// SYNCHRONOUSLY (the `capturingScheduler` above does). When it does — and a shutdown `signal` is
|
|
90
|
+
// present — the wait resolves before we would install the abort listener, so we must NOT install
|
|
91
|
+
// one: attaching a listener to an already-settled promise leaks its closure until the signal fires
|
|
92
|
+
// and later has shutdown clear an already-fired timer handle. A synchronously-resolved wait must
|
|
93
|
+
// leave zero listeners on the signal and must not clear its (already-fired) handle on a later abort.
|
|
94
|
+
test("schedulerClock.wait installs no abort listener when a signalled wait resolves synchronously", async () => {
|
|
95
|
+
const controller = new AbortController();
|
|
96
|
+
const cleared: unknown[] = [];
|
|
97
|
+
const sched: SchedulerDeps = {
|
|
98
|
+
now: () => 0,
|
|
99
|
+
signal: controller.signal,
|
|
100
|
+
setTimer: (fn) => {
|
|
101
|
+
fn(); // fire synchronously — the wait resolves before any listener could be installed
|
|
102
|
+
return 7;
|
|
103
|
+
},
|
|
104
|
+
clearTimer: (h) => cleared.push(h),
|
|
105
|
+
};
|
|
106
|
+
await schedulerClock(sched).wait(0);
|
|
107
|
+
// A leaked listener would fire here and clear the stale handle; the fix installs none.
|
|
108
|
+
controller.abort(new Error("shutting down"));
|
|
109
|
+
assert.deepEqual(cleared, [], "a synchronously-resolved wait must not install an abort listener");
|
|
110
|
+
});
|
|
111
|
+
// The abort reason is attacker/caller-controlled: guard the case where it is a bare value rather
|
|
112
|
+
// than an Error so `wait()` still rejects with a generic teardown error instead of leaking it.
|
|
113
|
+
test("schedulerClock.wait rejects with a generic teardown error when the abort reason is not an Error", async () => {
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const sched: SchedulerDeps = {
|
|
116
|
+
now: () => 0,
|
|
117
|
+
signal: controller.signal,
|
|
118
|
+
setTimer: () => 0,
|
|
119
|
+
clearTimer: () => {},
|
|
120
|
+
};
|
|
121
|
+
const waiting = schedulerClock(sched).wait(1000);
|
|
122
|
+
controller.abort("not-an-error");
|
|
123
|
+
await assert.rejects(waiting, /scheduler shutting down/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Regression (PR #447 review, scheduler.ts:108): `setTimer` is an injectable seam that may abort the
|
|
127
|
+
// shutdown `signal` SYNCHRONOUSLY while arming, WITHOUT firing its callback (a re-entrant scheduler).
|
|
128
|
+
// An abort event is not replayed to a listener added after the fact, so a wait that merely installed
|
|
129
|
+
// its listener would hang forever — never rejecting, never clearing the armed timer. `wait()` must
|
|
130
|
+
// re-check `signal.aborted` after installing the listener and drive the abort path by hand.
|
|
131
|
+
test("schedulerClock.wait rejects (and clears its timer) when setTimer aborts the signal synchronously", async () => {
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const cleared: unknown[] = [];
|
|
134
|
+
const sched: SchedulerDeps = {
|
|
135
|
+
now: () => 0,
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
setTimer: () => {
|
|
138
|
+
controller.abort(new Error("aborted during setTimer"));
|
|
139
|
+
return 99;
|
|
140
|
+
},
|
|
141
|
+
clearTimer: (h) => cleared.push(h),
|
|
142
|
+
};
|
|
143
|
+
await assert.rejects(
|
|
144
|
+
schedulerClock(sched).wait(1000),
|
|
145
|
+
/aborted during setTimer/,
|
|
146
|
+
"a synchronous mid-arm abort must reject the wait, not leave it hanging",
|
|
147
|
+
);
|
|
148
|
+
assert.deepEqual(cleared, [99], "the armed timer must be cleared when the synchronous abort is handled");
|
|
149
|
+
});
|
|
@@ -10,6 +10,22 @@ export interface SchedulerDeps {
|
|
|
10
10
|
clearTimer: (handle: unknown) => void;
|
|
11
11
|
/** Current wall-clock time in ms since epoch. */
|
|
12
12
|
now: () => number;
|
|
13
|
+
/** Optional shutdown signal. When it aborts, a pending {@link schedulerClock} `wait()` clears its
|
|
14
|
+
* armed timer and rejects, so a handler parked on the (virtual) clock unwinds at teardown instead
|
|
15
|
+
* of wedging whoever awaits it. Under the test kit this is the engine's shutdown signal: a worker
|
|
16
|
+
* handler parked on `app.wait()` sits on a virtual timer that no `advanceTime` will fire during
|
|
17
|
+
* `engine.close()`, so without this its promise never settles and `close()` hangs (the issue #446
|
|
18
|
+
* follow-up — the virtual-timer sibling of the real-time use-after-free). Absent on the live
|
|
19
|
+
* scheduler, where real timers always fire on wall-clock time, so `wait()` never rejects there. */
|
|
20
|
+
readonly signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The rejection surfaced to a `wait()` cancelled by a shutdown {@link SchedulerDeps.signal}: the
|
|
24
|
+
* signal's `reason` when it is an {@link Error}, else a generic teardown error. Reusing the reason
|
|
25
|
+
* keeps a caller's fail-loud message (e.g. "WasmEngineClient closing") intact. */
|
|
26
|
+
function schedulerAbortError(signal: AbortSignal): Error {
|
|
27
|
+
const reason: unknown = signal.reason;
|
|
28
|
+
return reason instanceof Error ? reason : new Error("app.wait aborted: scheduler shutting down");
|
|
13
29
|
}
|
|
14
30
|
|
|
15
31
|
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
@@ -58,9 +74,47 @@ export function schedulerClock(sched: SchedulerDeps): AppClock {
|
|
|
58
74
|
return {
|
|
59
75
|
now: () => sched.now(),
|
|
60
76
|
wait: (ms) =>
|
|
61
|
-
new Promise<void>((resolve) => {
|
|
77
|
+
new Promise<void>((resolve, reject) => {
|
|
78
|
+
const signal = sched.signal;
|
|
79
|
+
// Already shutting down: reject immediately rather than arm a timer that would outlive the
|
|
80
|
+
// teardown (and, under the virtual clock, never fire).
|
|
81
|
+
if (signal?.aborted) {
|
|
82
|
+
reject(schedulerAbortError(signal));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
62
85
|
const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
|
|
63
|
-
|
|
86
|
+
let settled = false;
|
|
87
|
+
let onAbort: (() => void) | undefined;
|
|
88
|
+
const handle = sched.setTimer(() => {
|
|
89
|
+
settled = true;
|
|
90
|
+
if (onAbort && signal) signal.removeEventListener("abort", onAbort);
|
|
91
|
+
resolve();
|
|
92
|
+
}, delay);
|
|
93
|
+
// When a shutdown signal is present, a mid-wait abort disarms the timer and rejects, so a
|
|
94
|
+
// handler parked here unwinds at teardown instead of wedging its awaiter. Inert (no listener,
|
|
95
|
+
// no behaviour change) on the live scheduler, which supplies no signal.
|
|
96
|
+
//
|
|
97
|
+
// `settled` guards the synchronous-timer case: this seam permits `setTimer` to invoke its
|
|
98
|
+
// callback synchronously (the test `capturingScheduler` does exactly that), which resolves
|
|
99
|
+
// the wait *before* we get here. Installing an abort listener on that already-settled promise
|
|
100
|
+
// would leak its closure until the signal fires (and have shutdown clear an already-fired
|
|
101
|
+
// handle), so skip it entirely when the callback already ran.
|
|
102
|
+
if (signal && !settled) {
|
|
103
|
+
onAbort = () => {
|
|
104
|
+
sched.clearTimer(handle);
|
|
105
|
+
reject(schedulerAbortError(signal));
|
|
106
|
+
};
|
|
107
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
108
|
+
// Re-entrant seam: `setTimer` above is injectable and may abort `signal` *synchronously*
|
|
109
|
+
// without firing the timer callback (so `settled` is still false). An abort event is not
|
|
110
|
+
// replayed to a listener added after the fact, so the listener just registered would never
|
|
111
|
+
// run and the wait would hang until the runner timeout. Re-check and drive the abort path
|
|
112
|
+
// by hand — removing the (now-inert) listener first so it cannot also fire and double-reject.
|
|
113
|
+
if (signal.aborted && !settled) {
|
|
114
|
+
signal.removeEventListener("abort", onAbort);
|
|
115
|
+
onAbort();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
64
118
|
}),
|
|
65
119
|
};
|
|
66
120
|
}
|
|
@@ -67,6 +67,24 @@ test("addConnector wires a pack-backed worker + a connection into nano.app.json"
|
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
test("addConnector preserves the manifest's existing indentation (no lint reformat)", async () => {
|
|
71
|
+
// Regression (nano-ide#454): the scaffold's Biome config formats nano.app.json with tabs, so a
|
|
72
|
+
// hard-coded 2-space rewrite here would turn `npm run lint` red the moment a connector is
|
|
73
|
+
// enabled in a fresh app. addConnector must round-trip the manifest's own indentation, like
|
|
74
|
+
// `urban gen` does via detectJsonIndent.
|
|
75
|
+
const tabManifest = `${JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }, null, "\t")}\n`;
|
|
76
|
+
const io = memIO({ ...baseFiles(), "app/nano.app.json": tabManifest });
|
|
77
|
+
await addConnector({ root: "app", pkg: PKG, io });
|
|
78
|
+
const written = io.files["app/nano.app.json"];
|
|
79
|
+
assert.match(written, /\n\t"workers"/, "tab indentation is preserved");
|
|
80
|
+
assert.doesNotMatch(written, /\n {2}"workers"/, "no 2-space indentation was introduced");
|
|
81
|
+
|
|
82
|
+
const twoSpace = `${JSON.stringify({ schemaVersion: 1, id: "t", name: "T" }, null, 2)}\n`;
|
|
83
|
+
const io2 = memIO({ ...baseFiles(), "app/nano.app.json": twoSpace });
|
|
84
|
+
await addConnector({ root: "app", pkg: PKG, io: io2 });
|
|
85
|
+
assert.match(io2.files["app/nano.app.json"], /\n {2}"workers"/, "2-space indentation is preserved");
|
|
86
|
+
});
|
|
87
|
+
|
|
70
88
|
test("addConnector is idempotent — re-running does not duplicate entries", async () => {
|
|
71
89
|
const io = memIO(baseFiles());
|
|
72
90
|
await addConnector({ root: "app", pkg: PKG, io });
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { GenIO } from "./gen.ts";
|
|
16
16
|
import { errorMessage, isRecord } from "../runtime/core/guards.ts";
|
|
17
|
+
import { detectJsonIndent } from "./scaffold.ts";
|
|
17
18
|
|
|
18
19
|
/** A config field a pack declares (subset of ext-types `ConfigField`). */
|
|
19
20
|
interface PackConfigField {
|
|
@@ -116,13 +117,15 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
|
|
|
116
117
|
if (!(await io.exists(appManifestPath))) {
|
|
117
118
|
throw new Error(`no ${manifestFile} at ${appManifestPath} — run this inside an Urban app.`);
|
|
118
119
|
}
|
|
120
|
+
let appRaw: string;
|
|
119
121
|
let app: {
|
|
120
122
|
workers?: { taskType?: string; connector?: string; connection?: string; handler?: string; llm?: string }[];
|
|
121
123
|
connections?: Record<string, Record<string, unknown>>;
|
|
122
124
|
[k: string]: unknown;
|
|
123
125
|
};
|
|
124
126
|
try {
|
|
125
|
-
|
|
127
|
+
appRaw = await io.readText(appManifestPath);
|
|
128
|
+
app = JSON.parse(appRaw);
|
|
126
129
|
} catch (err) {
|
|
127
130
|
throw new Error(`failed to parse ${appManifestPath}: ${errorMessage(err)}`);
|
|
128
131
|
}
|
|
@@ -223,7 +226,10 @@ export async function addConnector(opts: AddConnectorOptions): Promise<AddConnec
|
|
|
223
226
|
wired.push({ taskType: spec.type, alreadyPresent: false });
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
|
|
229
|
+
// Preserve the manifest's own indentation (default tab, matching the scaffold's Biome config)
|
|
230
|
+
// so enabling a connector never reformats nano.app.json out from under `npm run lint` — the same
|
|
231
|
+
// fidelity `urban gen` gives via detectJsonIndent, kept as the single source of truth.
|
|
232
|
+
await io.writeText(appManifestPath, `${JSON.stringify(app, null, detectJsonIndent(appRaw))}\n`);
|
|
227
233
|
|
|
228
234
|
return {
|
|
229
235
|
packId: pack.id,
|
|
@@ -149,7 +149,7 @@ function lintUnderScaffold(
|
|
|
149
149
|
biomeBin: string,
|
|
150
150
|
dirs: string[],
|
|
151
151
|
): { status: number | null; stdout: string; stderr: string } {
|
|
152
|
-
const templateBiome = JSON.parse(readFileSync(join(templateDir, "
|
|
152
|
+
const templateBiome = JSON.parse(readFileSync(join(templateDir, "_biome.json"), "utf8"));
|
|
153
153
|
// Copy the scaffold's GritQL plugin (bans `as` assertions) next to the config so its relative
|
|
154
154
|
// path resolves, then reuse the template's exact ruleset — force-scoped to the generated dirs so
|
|
155
155
|
// the guard tracks the scaffold standard without depending on the template's own `includes`.
|
|
@@ -320,7 +320,7 @@ function lintWithTemplateConfigVerbatim(
|
|
|
320
320
|
templateDir: string,
|
|
321
321
|
biomeBin: string,
|
|
322
322
|
): { status: number | null; stdout: string; stderr: string } {
|
|
323
|
-
const templateBiome = JSON.parse(readFileSync(join(templateDir, "
|
|
323
|
+
const templateBiome = JSON.parse(readFileSync(join(templateDir, "_biome.json"), "utf8"));
|
|
324
324
|
cpSync(join(templateDir, "plugins"), join(work, "plugins"), { recursive: true });
|
|
325
325
|
const config = { ...templateBiome, $schema: undefined, root: true };
|
|
326
326
|
writeFileSync(join(work, "biome.json"), `${JSON.stringify(config, null, "\t")}\n`);
|
|
@@ -34,6 +34,49 @@ test("renderOperationStub adjusts the generated-import depth for a nested api.di
|
|
|
34
34
|
assert.match(stub, /import \{ defineOperation \} from "\.\.\/\.\.\/nano-generated\/operations\.ts";/);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
+
test("operation stub is lint-clean under the scaffold config: tab-indented, both params referenced", () => {
|
|
38
|
+
// Guards the same two failure modes as the worker stub (nano-ide#454): the scaffold's Biome
|
|
39
|
+
// config formats with tabs and enables correctness/noUnusedFunctionParameters, so a
|
|
40
|
+
// space-indented body or an unused `input`/`app` makes a freshly generated operation fail the
|
|
41
|
+
// app's own `biome check`. Body lines must be tab-indented and both params read before the
|
|
42
|
+
// author fills in the body.
|
|
43
|
+
const stub = renderOperationStub("getInvoice");
|
|
44
|
+
const bodyLines = stub
|
|
45
|
+
.split("\n")
|
|
46
|
+
.filter((l) => l.startsWith(" ") || l.startsWith("\t"));
|
|
47
|
+
assert.ok(bodyLines.length > 0, "the stub has an indented body");
|
|
48
|
+
for (const line of bodyLines) {
|
|
49
|
+
assert.ok(line.startsWith("\t"), `body line is tab-indented, not space-indented: ${line}`);
|
|
50
|
+
}
|
|
51
|
+
assert.match(
|
|
52
|
+
stub,
|
|
53
|
+
/app\.log\.warn\("operation not implemented", \{\n\t\toperationId: "getInvoice",\n\t\tmethod: input\.req\.method,\n\t\}\);/,
|
|
54
|
+
);
|
|
55
|
+
// Guard the same Biome-width defect class the reviewer flagged (nano-ide#454): the generated
|
|
56
|
+
// stub is linted by the scaffold's `operations/**/*.ts` include at Biome's default 80-column
|
|
57
|
+
// width (tab = 2 columns), so a single-line log call that overruns 80 makes `urban gen` emit a
|
|
58
|
+
// file `biome check` immediately reports as needing formatting. Assert every non-comment code
|
|
59
|
+
// line fits, so any future template change that reintroduces an over-wide line fails here.
|
|
60
|
+
for (const line of stub.split("\n")) {
|
|
61
|
+
if (line.trim().startsWith("//")) continue;
|
|
62
|
+
const leadingTabs = line.length - line.replace(/^\t+/, "").length;
|
|
63
|
+
const width = leadingTabs * 2 + (line.length - leadingTabs);
|
|
64
|
+
assert.ok(width <= 80, `generated code line exceeds Biome's 80-column width (${width}): ${line}`);
|
|
65
|
+
}
|
|
66
|
+
// Guard the PII/credential-leak defect class (nano-ide#454): the stub must reference a
|
|
67
|
+
// non-sensitive identifier, never serialize the whole validated `input` (which carries
|
|
68
|
+
// params/query/body/req) into the NDJSON log line.
|
|
69
|
+
assert.doesNotMatch(stub, /app\.log\.warn\([^)]*,\s*input\s*\}/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("operation stub logs a non-sensitive identifier, not the raw input payload", () => {
|
|
73
|
+
// Red/Green guard for the operation-stub log-leak: an unedited 501 handler must not emit the
|
|
74
|
+
// validated request body/params/query (potential credentials or PII) to structured logs.
|
|
75
|
+
const stub = renderOperationStub("createInvoice");
|
|
76
|
+
assert.doesNotMatch(stub, /,\s*input\s*\}\)/);
|
|
77
|
+
assert.match(stub, /method: input\.req\.method/);
|
|
78
|
+
});
|
|
79
|
+
|
|
37
80
|
test("planOperationScaffold plans one stub per declared operationId", () => {
|
|
38
81
|
const plans = planOperationScaffold(doc);
|
|
39
82
|
const byId = Object.fromEntries(plans.map((p) => [p.operationId, p.handlerPath]));
|
|
@@ -44,16 +44,21 @@ export function renderOperationStub(operationId: string, dir: string = DEFAULT_O
|
|
|
44
44
|
`// (ADR 0059). This file is yours to edit — \`urban gen\` will never overwrite it. The\n` +
|
|
45
45
|
`// generated controller (nano-generated/controller.ts) imports this default export and\n` +
|
|
46
46
|
`// type-checks it against the spec, so the signature cannot drift. Implement the body below\n` +
|
|
47
|
-
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`)
|
|
47
|
+
`// (the validated \`{ req, params, query, body }\` input + the injected \`app\`), replacing the\n` +
|
|
48
|
+
`// guard warn + throw.\n` +
|
|
48
49
|
`import { NotImplemented } from "@nanobpm/urban";\n` +
|
|
49
50
|
`import { defineOperation } from ${JSON.stringify(`${up}nano-generated/operations.ts`)};\n` +
|
|
50
51
|
`\n` +
|
|
51
52
|
`export default defineOperation(${key}, async (input, app) => {\n` +
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
`\t// \`input\` is the validated request (params/query/body/req); reach app state + services\n` +
|
|
54
|
+
`\t// through the injected \`app\` API. Structured logs via \`app.log.info("…", { … })\` are\n` +
|
|
55
|
+
`\t// auto-tagged with this request's { method, path, operationId } (ADR 0061).\n` +
|
|
56
|
+
`\t// Return \`{ status?, headers?, body? }\` (or nothing → 204). Replace the two lines below.\n` +
|
|
57
|
+
`\tapp.log.warn("operation not implemented", {\n` +
|
|
58
|
+
`\t\toperationId: ${key},\n` +
|
|
59
|
+
`\t\tmethod: input.req.method,\n` +
|
|
60
|
+
`\t});\n` +
|
|
61
|
+
`\tthrow new NotImplemented(${key});\n` +
|
|
57
62
|
`});\n`
|
|
58
63
|
);
|
|
59
64
|
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type ScaffoldWorker,
|
|
8
8
|
} from "./workers.ts";
|
|
9
9
|
import type { ModelSource } from "../derivers/worker-io.ts";
|
|
10
|
-
import { scaffoldWorkers } from "../scaffold.ts";
|
|
10
|
+
import { detectJsonIndent, scaffoldWorkers } from "../scaffold.ts";
|
|
11
11
|
import type { GenIO } from "../gen.ts";
|
|
12
12
|
|
|
13
13
|
// A service task with a data-envelope in/out contract; `type` is the taskType, and the
|
|
@@ -53,6 +53,43 @@ test("untyped stub imports no generated types and uses bare AppJobHandler", () =
|
|
|
53
53
|
assert.match(stub, /const handler: AppJobHandler = async \(job, app\) =>/);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
test("stub is lint-clean under the scaffold config: tab-indented, both params referenced", () => {
|
|
57
|
+
// Guards the two ways `urban gen`'s stub used to fail a fresh scaffold's own `biome check`
|
|
58
|
+
// (nano-ide#451): space indentation (Biome defaults to tabs) and unused `job`/`app` params
|
|
59
|
+
// (correctness/noUnusedFunctionParameters). Body lines must be tab-indented and both params
|
|
60
|
+
// must be read before the author fills in the body.
|
|
61
|
+
const stub = renderWorkerStub("greet.hello", false, false);
|
|
62
|
+
const bodyLines = stub.split("\n").filter((l) => l.startsWith(" ") || l.startsWith("\t"));
|
|
63
|
+
assert.ok(bodyLines.length > 0, "the stub has an indented body");
|
|
64
|
+
for (const line of bodyLines) {
|
|
65
|
+
assert.ok(line.startsWith("\t"), `body line is tab-indented, not space-indented: ${line}`);
|
|
66
|
+
}
|
|
67
|
+
assert.match(stub, /app\.log\.warn\("worker not implemented", \{ jobKey: job\.jobKey \}\)/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("detectJsonIndent reuses tab/space width but clamps to JSON.stringify's 10-char cap", () => {
|
|
71
|
+
assert.equal(detectJsonIndent('{\n\t"a": 1\n}'), "\t");
|
|
72
|
+
assert.equal(detectJsonIndent('{\n "a": 1\n}'), " ");
|
|
73
|
+
assert.equal(detectJsonIndent("{}"), "\t", "no indentation falls back to a tab");
|
|
74
|
+
// JSON.stringify silently truncates a string `space` to 10 chars, so a wider space indent must
|
|
75
|
+
// be clamped rather than promising a fidelity the serializer cannot honor.
|
|
76
|
+
const wide = `{\n${" ".repeat(12)}"a": 1\n}`;
|
|
77
|
+
const indent = detectJsonIndent(wide);
|
|
78
|
+
assert.equal(indent, " ".repeat(10));
|
|
79
|
+
assert.equal(JSON.stringify({ a: 1 }, null, indent), JSON.stringify({ a: 1 }, null, wide.match(/\n( +)/)?.[1]));
|
|
80
|
+
// The 10-char cap is not space-only: an overlong *tab* indent must clamp too, or the rewrite
|
|
81
|
+
// would silently differ from an 11+-tab input (JSON.stringify truncates any string `space`).
|
|
82
|
+
const wideTabs = `{\n${"\t".repeat(12)}"a": 1\n}`;
|
|
83
|
+
const tabIndent = detectJsonIndent(wideTabs);
|
|
84
|
+
assert.equal(tabIndent, "\t".repeat(10));
|
|
85
|
+
assert.equal(JSON.stringify({ a: 1 }, null, tabIndent), JSON.stringify({ a: 1 }, null, wideTabs.match(/\n(\t+)/)?.[1]));
|
|
86
|
+
// Line-ending agnostic: the width must be detected regardless of terminator, or a space-indented
|
|
87
|
+
// Windows/old-Mac manifest would be rewritten at the tab fallback width instead of preserved.
|
|
88
|
+
assert.equal(detectJsonIndent('{\r\n "a": 1\r\n}'), " ", "CRLF space indent preserved");
|
|
89
|
+
assert.equal(detectJsonIndent('{\r "a": 1\r}'), " ", "bare-CR space indent preserved");
|
|
90
|
+
assert.equal(detectJsonIndent('{\n\r "a": 1\n\r}'), " ", "LFCR space indent preserved");
|
|
91
|
+
});
|
|
92
|
+
|
|
56
93
|
test("typed-out-only stub fills In with the open default", () => {
|
|
57
94
|
const stub = renderWorkerStub("demo.out", false, true);
|
|
58
95
|
assert.match(stub, /import type \{ WorkerOutputs \} from/);
|
|
@@ -183,6 +220,21 @@ test("scaffoldWorkers --write creates the stub and patches the manifest once", a
|
|
|
183
220
|
assert.ok(io.files["/app/nano.app.json"].endsWith("\n"), "manifest ends with newline");
|
|
184
221
|
});
|
|
185
222
|
|
|
223
|
+
test("scaffoldWorkers preserves the manifest's own indentation when wiring (nano-ide#451)", async () => {
|
|
224
|
+
// `urban gen` used to reserialize nano.app.json with a hard-coded 2-space indent, clobbering
|
|
225
|
+
// the tab-indented scaffold and breaking its own `biome check`. The patch must round-trip the
|
|
226
|
+
// file's existing indentation instead.
|
|
227
|
+
for (const indent of ["\t", " "]) {
|
|
228
|
+
const files = appFixture();
|
|
229
|
+
files["/app/nano.app.json"] = `${JSON.stringify(JSON.parse(files["/app/nano.app.json"]), null, indent)}\n`;
|
|
230
|
+
const io = memIO(files);
|
|
231
|
+
await scaffoldWorkers({ root: "/app", io, write: true });
|
|
232
|
+
const patched = io.files["/app/nano.app.json"];
|
|
233
|
+
const firstIndent = patched.match(/\n([ \t]+)\S/)?.[1];
|
|
234
|
+
assert.equal(firstIndent, indent, `indentation preserved (${JSON.stringify(indent)})`);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
186
238
|
test("scaffoldWorkers never clobbers an existing stub (keeps it), still wires it", async () => {
|
|
187
239
|
const files = appFixture();
|
|
188
240
|
files["/app/workers/t-new/worker.ts"] = "// hand-edited, do not touch\n";
|
|
@@ -87,23 +87,28 @@ export function renderWorkerStub(
|
|
|
87
87
|
else if (typedOut) generic = `<Record<string, unknown>, WorkerOutputs[${key}]>`;
|
|
88
88
|
|
|
89
89
|
const varsNote = typedIn
|
|
90
|
-
?
|
|
91
|
-
:
|
|
90
|
+
? `\t// \`job.variables\` is typed as WorkerInputs[${key}].`
|
|
91
|
+
: `\t// \`job.variables\` is an open Record<string, unknown> (no declared input type).`;
|
|
92
92
|
|
|
93
|
+
// The stub arrives lint-clean under the scaffold's own Biome config: tab-indented, and
|
|
94
|
+
// referencing both `job` and `app` (via the warn line) so `noUnusedFunctionParameters`
|
|
95
|
+
// does not fire before the author has filled in the body.
|
|
93
96
|
return (
|
|
94
97
|
`// Handler stub for the \`${taskType}\` service task, scaffolded from the model (ADR 0056).\n` +
|
|
95
98
|
`// This file is yours to edit — \`urban gen\` will never overwrite it. Implement the body\n` +
|
|
96
|
-
`// below (use \`app.data.table(...)\` for state)
|
|
99
|
+
`// below (use \`app.data.table(...)\` for state), replacing the guard warn + throw so the\n` +
|
|
100
|
+
`// "worker not implemented" warning does not survive into a working handler.\n` +
|
|
97
101
|
`import type { AppJobHandler } from "@nanobpm/urban/worker";\n` +
|
|
98
102
|
generatedImport +
|
|
99
103
|
`\n` +
|
|
100
104
|
`const handler: AppJobHandler${generic} = async (job, app) => {\n` +
|
|
101
105
|
varsNote +
|
|
102
106
|
`\n` +
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
+
`\t// Reach app state and services through the injected \`app\` API.\n` +
|
|
108
|
+
`\t// Structured logs: \`app.log.info("done", { … })\` — auto-tagged with this job's\n` +
|
|
109
|
+
`\t// { jobKey, jobType, processInstanceKey, elementId } (ADR 0061).\n` +
|
|
110
|
+
`\tapp.log.warn("worker not implemented", { jobKey: job.jobKey });\n` +
|
|
111
|
+
`\tthrow new Error(${JSON.stringify(`worker not implemented: ${taskType}`)});\n` +
|
|
107
112
|
`};\n` +
|
|
108
113
|
`\n` +
|
|
109
114
|
`export default handler;\n`
|
package/src/toolkit/scaffold.ts
CHANGED
|
@@ -14,6 +14,27 @@ import {
|
|
|
14
14
|
import { planOperationScaffold } from "./scaffold/operations.ts";
|
|
15
15
|
import { parseSpec } from "../openapi/spec.ts";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Detect the indentation of a JSON document from its first indented line, so a rewrite can
|
|
19
|
+
* reuse it (`"\t"` or an N-space string) instead of hard-coding one style. Falls back to a tab
|
|
20
|
+
* — the scaffold's Biome default — when the file is empty/single-line or uses no indentation.
|
|
21
|
+
*
|
|
22
|
+
* Any indent — spaces or tabs — is clamped to 10 characters because `JSON.stringify` silently
|
|
23
|
+
* truncates a string `space` argument to 10; returning a wider prefix would promise a fidelity the
|
|
24
|
+
* serializer cannot honor (e.g. an 11+-tab manifest would be rewritten at a different width than
|
|
25
|
+
* its input). Tab indentation (the scaffold default) and any realistic width (2/4 spaces) are
|
|
26
|
+
* unaffected.
|
|
27
|
+
*/
|
|
28
|
+
export function detectJsonIndent(json: string): string {
|
|
29
|
+
// Anchor on any line terminator, not just `\n`, so the width is detected regardless of the
|
|
30
|
+
// manifest's line endings: LF (`\n`), CRLF (`\r\n`, where `\n` already precedes the indent),
|
|
31
|
+
// bare-CR old-Mac (`\r`), and LFCR (`\n\r`) all resolve to the indentation that follows.
|
|
32
|
+
const m = json.match(/[\r\n]([ \t]+)\S/);
|
|
33
|
+
if (!m) return "\t";
|
|
34
|
+
const indent = m[1];
|
|
35
|
+
return indent.length > 10 ? indent.slice(0, 10) : indent;
|
|
36
|
+
}
|
|
37
|
+
|
|
17
38
|
/** The manifest fields the scaffolder reads. */
|
|
18
39
|
interface ScaffoldManifest {
|
|
19
40
|
types?: Record<string, unknown>;
|
|
@@ -61,7 +82,11 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
|
|
|
61
82
|
const manifestFile = opts.manifestFile ?? "nano.app.json";
|
|
62
83
|
const manifestPath = joinPath(root, manifestFile);
|
|
63
84
|
|
|
64
|
-
const
|
|
85
|
+
const manifestRaw = await io.readText(manifestPath);
|
|
86
|
+
const manifest: ScaffoldManifest = JSON.parse(manifestRaw);
|
|
87
|
+
// Preserve the manifest's own indentation when we patch it back (default tab, matching the
|
|
88
|
+
// scaffold's Biome config), so wiring a worker never reformats the file out from under lint.
|
|
89
|
+
const manifestIndent = detectJsonIndent(manifestRaw);
|
|
65
90
|
const models = await readModels(root, io, manifest);
|
|
66
91
|
const declaredTypeIds = Object.keys(manifest.types ?? {});
|
|
67
92
|
|
|
@@ -105,7 +130,7 @@ export async function scaffoldWorkers(opts: ScaffoldOptions): Promise<ScaffoldRu
|
|
|
105
130
|
...manifest,
|
|
106
131
|
workers: [...(manifest.workers ?? []), ...wired],
|
|
107
132
|
};
|
|
108
|
-
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null,
|
|
133
|
+
await io.writeText(manifestPath, `${JSON.stringify(withWorkers, null, manifestIndent)}\n`);
|
|
109
134
|
manifestPatched = true;
|
|
110
135
|
}
|
|
111
136
|
|