@nanobpm/urban 0.48.0 → 0.49.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 +80 -21
- package/dist/runtime/adapters/deno.js +22 -4
- package/dist/runtime/adapters/node.js +34 -4
- package/dist/runtime/core/context.d.ts +0 -4
- package/dist/runtime/core/glob.d.ts +9 -0
- package/dist/runtime/core/glob.js +21 -0
- package/dist/runtime/core/host.d.ts +71 -8
- package/dist/runtime/core/host.js +13 -0
- package/dist/runtime/core/manifest.d.ts +89 -2
- package/dist/runtime/core/manifest.js +72 -0
- package/dist/runtime/core/modules/deploy.d.ts +9 -3
- package/dist/runtime/core/modules/deploy.js +69 -37
- package/dist/runtime/core/modules/instance-tracking.d.ts +1 -1
- package/dist/runtime/core/modules/instance-tracking.js +20 -5
- package/dist/runtime/core/modules/pages.js +34 -2
- package/dist/runtime/core/modules/surfaces.js +192 -18
- package/dist/runtime/core/router.d.ts +7 -0
- package/dist/runtime/core/router.js +9 -0
- package/dist/runtime/core/runtime.d.ts +0 -5
- package/dist/runtime/core/runtime.js +27 -4
- package/dist/runtime/core/validate.js +65 -1
- package/dist/runtime/engine/nanosdk.d.ts +9 -6
- package/dist/runtime/engine/nanosdk.js +65 -1
- package/dist/runtime/index.d.ts +3 -5
- package/dist/runtime/index.js +3 -4
- package/dist/runtime/run.js +0 -1
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +54 -6
- package/dist/toolkit/models.d.ts +6 -4
- package/dist/toolkit/models.js +7 -4
- package/package.json +1 -1
- package/src/runtime/adapters/deno.ts +26 -6
- package/src/runtime/adapters/node.test.ts +67 -0
- package/src/runtime/adapters/node.ts +37 -4
- package/src/runtime/core/context.ts +0 -4
- package/src/runtime/core/glob.ts +24 -0
- package/src/runtime/core/host.ts +74 -4
- package/src/runtime/core/manifest.test.ts +61 -1
- package/src/runtime/core/manifest.ts +136 -2
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/deploy.test.ts +97 -161
- package/src/runtime/core/modules/deploy.ts +69 -37
- package/src/runtime/core/modules/instance-tracking.test.ts +90 -0
- package/src/runtime/core/modules/instance-tracking.ts +19 -6
- package/src/runtime/core/modules/pages.test.ts +43 -0
- package/src/runtime/core/modules/pages.ts +34 -2
- package/src/runtime/core/modules/surfaces.test.ts +198 -5
- package/src/runtime/core/modules/surfaces.ts +191 -18
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/router.test.ts +10 -1
- package/src/runtime/core/router.ts +10 -0
- package/src/runtime/core/runtime.test.ts +67 -1
- package/src/runtime/core/runtime.ts +28 -9
- package/src/runtime/core/validate.test.ts +145 -0
- package/src/runtime/core/validate.ts +64 -1
- package/src/runtime/engine/nanosdk.test.ts +122 -0
- package/src/runtime/engine/nanosdk.ts +72 -2
- package/src/runtime/index.ts +11 -4
- package/src/runtime/run.ts +0 -1
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +46 -10
- package/src/toolkit/gen.ts +59 -6
- package/src/toolkit/models.test.ts +5 -5
- package/src/toolkit/models.ts +8 -5
- package/dist/runtime/core/modules/templates.d.ts +0 -18
- package/dist/runtime/core/modules/templates.js +0 -133
- package/src/runtime/core/modules/templates.ts +0 -153
|
@@ -14,12 +14,14 @@
|
|
|
14
14
|
import type {
|
|
15
15
|
EngineClient,
|
|
16
16
|
EngineJob,
|
|
17
|
+
FormSchema,
|
|
17
18
|
JobHandler,
|
|
18
19
|
ProcessInstanceSnapshot,
|
|
19
20
|
ProcessInstanceState,
|
|
21
|
+
UserTaskSummary,
|
|
20
22
|
WorkerSubscription,
|
|
21
23
|
} from "../core/host.ts";
|
|
22
|
-
import { isBpmnError } from "../core/host.ts";
|
|
24
|
+
import { isBpmnError, presentFormIdentifier } from "../core/host.ts";
|
|
23
25
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
24
26
|
import { isRecord } from "../core/guards.ts";
|
|
25
27
|
|
|
@@ -125,6 +127,11 @@ export interface NanoSdkClient {
|
|
|
125
127
|
input: { userTaskKey: string; variables?: Record<string, unknown> },
|
|
126
128
|
options?: unknown,
|
|
127
129
|
): Promise<unknown>;
|
|
130
|
+
getFormByKey(
|
|
131
|
+
input: { formKey: string },
|
|
132
|
+
consistency?: unknown,
|
|
133
|
+
options?: unknown,
|
|
134
|
+
): Promise<Record<string, unknown>>;
|
|
128
135
|
searchProcessInstances(
|
|
129
136
|
input: { filter?: Record<string, unknown>; page?: Record<string, unknown> },
|
|
130
137
|
consistency?: unknown,
|
|
@@ -214,7 +221,7 @@ export class SdkEngineClient implements EngineClient {
|
|
|
214
221
|
processInstanceKey?: string;
|
|
215
222
|
assignee?: string;
|
|
216
223
|
candidateGroup?: string;
|
|
217
|
-
}): Promise<
|
|
224
|
+
}): Promise<UserTaskSummary[]> {
|
|
218
225
|
// User tasks are an eventually consistent read; ask for zero-wait consistency so
|
|
219
226
|
// the search reflects what is currently visible without blocking.
|
|
220
227
|
const body = await this.client.searchUserTasks(
|
|
@@ -228,14 +235,77 @@ export class SdkEngineClient implements EngineClient {
|
|
|
228
235
|
this.log("warn", "skipping user task with no key in engine response");
|
|
229
236
|
return [];
|
|
230
237
|
}
|
|
238
|
+
// The engine resolves a `formId="X"` linkage to the latest deployed form's key at
|
|
239
|
+
// task creation, so `formKey` (not a form id) is the linkage it reports on the task.
|
|
240
|
+
const formKey =
|
|
241
|
+
it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
|
|
242
|
+
const externalFormReference =
|
|
243
|
+
typeof it.externalFormReference === "string" && it.externalFormReference !== ""
|
|
244
|
+
? it.externalFormReference
|
|
245
|
+
: undefined;
|
|
231
246
|
return [{
|
|
232
247
|
userTaskKey: String(userTaskKey),
|
|
233
248
|
elementId: typeof it.elementId === "string" ? it.elementId : undefined,
|
|
234
249
|
variables: isRecord(it.variables) ? it.variables : undefined,
|
|
250
|
+
...(formKey ? { formKey } : {}),
|
|
251
|
+
...(externalFormReference ? { externalFormReference } : {}),
|
|
235
252
|
}];
|
|
236
253
|
});
|
|
237
254
|
}
|
|
238
255
|
|
|
256
|
+
async getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null> {
|
|
257
|
+
// The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
|
|
258
|
+
// `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
|
|
259
|
+
// form, so callers pass that key. `formId` is accepted as a fallback identifier for
|
|
260
|
+
// engines that address a form by its id; either way the engine returns the current
|
|
261
|
+
// form or 404s (→ null) when there is no such form.
|
|
262
|
+
//
|
|
263
|
+
// This is the single gate that resolves which identifier addresses the form: an
|
|
264
|
+
// empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
|
|
265
|
+
// shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
|
|
266
|
+
// through to a valid `formId` instead of being taken as a present-but-unresolvable key
|
|
267
|
+
// that short-circuits to null.
|
|
268
|
+
const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
|
|
269
|
+
if (key == null) return null;
|
|
270
|
+
let body: Record<string, unknown>;
|
|
271
|
+
try {
|
|
272
|
+
body = await this.client.getFormByKey(
|
|
273
|
+
{ formKey: key },
|
|
274
|
+
{ consistency: { waitUpToMs: 0 } },
|
|
275
|
+
);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.log("warn", "getForm: engine form fetch failed", {
|
|
278
|
+
key,
|
|
279
|
+
formKey: input.formKey,
|
|
280
|
+
formId: input.formId,
|
|
281
|
+
err: err instanceof Error ? err.message : String(err),
|
|
282
|
+
});
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
if (!isRecord(body)) return null;
|
|
286
|
+
const rawSchema = body.schema;
|
|
287
|
+
// The engine serializes the form-js schema as a JSON string; parse it to the object
|
|
288
|
+
// the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
|
|
289
|
+
let schema: Record<string, unknown> | undefined;
|
|
290
|
+
if (typeof rawSchema === "string") {
|
|
291
|
+
try {
|
|
292
|
+
const parsed: unknown = JSON.parse(rawSchema);
|
|
293
|
+
if (isRecord(parsed)) schema = parsed;
|
|
294
|
+
} catch {
|
|
295
|
+
this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
|
|
296
|
+
}
|
|
297
|
+
} else if (isRecord(rawSchema)) {
|
|
298
|
+
schema = rawSchema;
|
|
299
|
+
}
|
|
300
|
+
if (!schema) return null;
|
|
301
|
+
return {
|
|
302
|
+
schema,
|
|
303
|
+
...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
|
|
304
|
+
...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
|
|
305
|
+
...(typeof body.version === "number" ? { version: body.version } : {}),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
239
309
|
async completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void> {
|
|
240
310
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
241
311
|
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -10,20 +10,29 @@ export type {
|
|
|
10
10
|
|
|
11
11
|
// Manifest
|
|
12
12
|
export {
|
|
13
|
+
bindModeToHost,
|
|
13
14
|
expandEnv,
|
|
14
15
|
expandEnvString,
|
|
16
|
+
isBindMode,
|
|
15
17
|
loadManifest,
|
|
16
18
|
parseManifest,
|
|
19
|
+
resolveBindHost,
|
|
20
|
+
resolveBindMode,
|
|
17
21
|
workerJobType,
|
|
22
|
+
ALL_INTERFACES_HOST,
|
|
23
|
+
BIND_ENV_VAR,
|
|
24
|
+
LOOPBACK_HOST,
|
|
18
25
|
} from "./core/manifest.ts";
|
|
19
26
|
export type {
|
|
20
27
|
ActionDecl,
|
|
21
28
|
AppManifest,
|
|
29
|
+
BindMode,
|
|
22
30
|
ChatSurface,
|
|
23
31
|
DataSource,
|
|
24
32
|
DomainField,
|
|
25
33
|
DomainType,
|
|
26
34
|
LlmBinding,
|
|
35
|
+
NetworkConfig,
|
|
27
36
|
PagesSurface,
|
|
28
37
|
Surfaces,
|
|
29
38
|
TaskInboxSurface,
|
|
@@ -119,10 +128,8 @@ export type {
|
|
|
119
128
|
} from "./core/modules/llm.ts";
|
|
120
129
|
export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
121
130
|
|
|
122
|
-
// Model deploy
|
|
123
|
-
export { deployModels } from "./core/modules/deploy.ts";
|
|
124
|
-
export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
|
|
125
|
-
export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
|
|
131
|
+
// Model deploy (deploy-by-convention under `resources/`, ADR 0062)
|
|
132
|
+
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
|
|
126
133
|
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
|
|
127
134
|
export type { SchedulerDeps } from "./core/modules/scheduler.ts";
|
|
128
135
|
|
package/src/runtime/run.ts
CHANGED
package/src/toolkit/fsio.ts
CHANGED
|
@@ -25,6 +25,14 @@ export function createNodeGenIO(): GenIO {
|
|
|
25
25
|
return [];
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
+
async listSubdirs(path: string): Promise<string[]> {
|
|
29
|
+
try {
|
|
30
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
31
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
},
|
|
28
36
|
async exists(path: string): Promise<boolean> {
|
|
29
37
|
try {
|
|
30
38
|
await stat(path);
|
package/src/toolkit/gen.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test } from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import { runGen, collectArtifacts, type GenIO } from "./gen.ts";
|
|
3
|
+
import { runGen, collectArtifacts, readModels, type GenIO } from "./gen.ts";
|
|
4
4
|
import { defineFlow, envelope } from "@nanobpm/workflow";
|
|
5
5
|
import { MODEL_PROVENANCE } from "./models.ts";
|
|
6
6
|
|
|
@@ -29,6 +29,18 @@ function memIO(
|
|
|
29
29
|
}
|
|
30
30
|
return [...names];
|
|
31
31
|
},
|
|
32
|
+
async listSubdirs(p) {
|
|
33
|
+
const prefix = p.replace(/\/+$/, "") + "/";
|
|
34
|
+
const names = new Set<string>();
|
|
35
|
+
for (const f of Object.keys(files)) {
|
|
36
|
+
if (f.startsWith(prefix)) {
|
|
37
|
+
const rest = f.slice(prefix.length);
|
|
38
|
+
const slash = rest.indexOf("/");
|
|
39
|
+
if (slash > 0) names.add(rest.slice(0, slash));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [...names];
|
|
43
|
+
},
|
|
32
44
|
async exists(p) {
|
|
33
45
|
return p in files;
|
|
34
46
|
},
|
|
@@ -181,11 +193,11 @@ function codeFirstIO() {
|
|
|
181
193
|
);
|
|
182
194
|
}
|
|
183
195
|
|
|
184
|
-
test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
|
|
196
|
+
test("runGen derives resources/processes/<id>.bpmn from workflows/*.ts and feeds worker-io off it", async () => {
|
|
185
197
|
const io = codeFirstIO();
|
|
186
198
|
const res = await runGen({ root: "/cf", io });
|
|
187
|
-
const bpmn = io.files["/cf/processes/greet.bpmn"];
|
|
188
|
-
assert.ok(bpmn, "expected a derived processes/greet.bpmn");
|
|
199
|
+
const bpmn = io.files["/cf/resources/processes/greet.bpmn"];
|
|
200
|
+
assert.ok(bpmn, "expected a derived resources/processes/greet.bpmn");
|
|
189
201
|
assert.ok(bpmn.includes(MODEL_PROVENANCE), "derived .bpmn must be provenance-stamped");
|
|
190
202
|
assert.ok(bpmn.includes('type="greet:hello"'), "derived .bpmn carries the task type");
|
|
191
203
|
// worker-io is derived FROM the in-memory model, not from any on-disk file.
|
|
@@ -197,7 +209,7 @@ test("runGen derives processes/<id>.bpmn from workflows/*.ts and feeds worker-io
|
|
|
197
209
|
test("gen --no-models derives in-memory for worker-io but writes no .bpmn", async () => {
|
|
198
210
|
const io = codeFirstIO();
|
|
199
211
|
const res = await runGen({ root: "/cf", io, emitModels: false });
|
|
200
|
-
assert.equal(io.files["/cf/processes/greet.bpmn"], undefined, "must not write .bpmn");
|
|
212
|
+
assert.equal(io.files["/cf/resources/processes/greet.bpmn"], undefined, "must not write .bpmn");
|
|
201
213
|
// still derives the model in-memory so worker-io is populated.
|
|
202
214
|
assert.ok(io.files["/cf/nano-generated/worker-io.d.ts"].includes("greet:hello"));
|
|
203
215
|
assert.equal(res.incomplete, false);
|
|
@@ -206,17 +218,17 @@ test("gen --no-models derives in-memory for worker-io but writes no .bpmn", asyn
|
|
|
206
218
|
test("the provenance sweep removes a stale derived .bpmn no longer backed by a workflow", async () => {
|
|
207
219
|
const io = codeFirstIO();
|
|
208
220
|
// a leftover derived model from a since-deleted flow, provenance-stamped.
|
|
209
|
-
io.files["/cf/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
|
|
221
|
+
io.files["/cf/resources/processes/stale.bpmn"] = `<?xml version="1.0"?>\n${MODEL_PROVENANCE}\n<bpmn:x/>`;
|
|
210
222
|
await runGen({ root: "/cf", io });
|
|
211
|
-
assert.ok(io.files["/cf/processes/greet.bpmn"], "keeps the live derived model");
|
|
212
|
-
assert.equal(io.files["/cf/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
|
|
223
|
+
assert.ok(io.files["/cf/resources/processes/greet.bpmn"], "keeps the live derived model");
|
|
224
|
+
assert.equal(io.files["/cf/resources/processes/stale.bpmn"], undefined, "sweeps the stale derived model");
|
|
213
225
|
});
|
|
214
226
|
|
|
215
227
|
test("the sweep never touches an authored (un-stamped) .bpmn", async () => {
|
|
216
228
|
const io = codeFirstIO();
|
|
217
|
-
io.files["/cf/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
|
|
229
|
+
io.files["/cf/resources/processes/authored.bpmn"] = `<?xml version="1.0"?>\n<bpmn:authored/>`;
|
|
218
230
|
await runGen({ root: "/cf", io });
|
|
219
|
-
assert.ok(io.files["/cf/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
231
|
+
assert.ok(io.files["/cf/resources/processes/authored.bpmn"], "authored .bpmn must survive the sweep");
|
|
220
232
|
});
|
|
221
233
|
|
|
222
234
|
test("collectArtifacts trims api.spec whitespace so gen matches the runtime (no gen/runtime drift)", async () => {
|
|
@@ -324,3 +336,27 @@ test("gen fuses a model-authored nano:shape envelope into the worker-io contract
|
|
|
324
336
|
assert.ok(domain, "domain-rows.d.ts is emitted from the fused registry");
|
|
325
337
|
assert.match(domain!.content, /"ReviewIn":/);
|
|
326
338
|
});
|
|
339
|
+
|
|
340
|
+
// Guards the gen/runtime drift class: gen's readModels must key the `resources/` convention off the
|
|
341
|
+
// *absence* of the whole `models` block (mirroring the runtime deploy, ADR 0062), NOT off a missing
|
|
342
|
+
// `models.processes`. A `models` block present for other overrides (e.g. forms) must suppress the
|
|
343
|
+
// convention scan, so gen never derives process models the runtime deploy would never deploy.
|
|
344
|
+
test("readModels: a models block without processes suppresses the resources/ convention (no gen/runtime drift)", async () => {
|
|
345
|
+
const io = memIO({
|
|
346
|
+
"/app/resources/a.bpmn": "<bpmn:definitions/>",
|
|
347
|
+
"/app/resources/sub/b.bpmn": "<bpmn:definitions/>",
|
|
348
|
+
});
|
|
349
|
+
// No models block at all → convention scans resources/ (shallow, one level deep).
|
|
350
|
+
const byConvention = await readModels("/app", io, {});
|
|
351
|
+
assert.deepEqual(
|
|
352
|
+
byConvention.map((m) => m.path).sort(),
|
|
353
|
+
["resources/a.bpmn", "resources/sub/b.bpmn"],
|
|
354
|
+
"with no models block, gen discovers under resources/ by convention",
|
|
355
|
+
);
|
|
356
|
+
// A declared models block (even one that resolves to zero process files) is an explicit override:
|
|
357
|
+
// the convention scan must be skipped entirely, matching runtime deployModels.
|
|
358
|
+
const withEmptyModels = await readModels("/app", io, { models: {} });
|
|
359
|
+
assert.deepEqual(withEmptyModels, [], "a declared models block suppresses the resources/ convention");
|
|
360
|
+
const withOnlyProcesses = await readModels("/app", io, { models: { processes: [] } });
|
|
361
|
+
assert.deepEqual(withOnlyProcesses, [], "an explicitly empty models.processes yields no models, not a convention fallback");
|
|
362
|
+
});
|
package/src/toolkit/gen.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface GenIO {
|
|
|
21
21
|
writeText(path: string, content: string): Promise<void>;
|
|
22
22
|
/** File names (not paths) in a directory; empty if it does not exist. */
|
|
23
23
|
listDir(path: string): Promise<string[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Sub-directory names (not files) in a directory; empty if it does not exist. Optional: the
|
|
26
|
+
* deploy-by-convention model scan uses it to descend one level into `resources/<subdir>/`. When
|
|
27
|
+
* absent, only files directly under `resources/` are scanned.
|
|
28
|
+
*/
|
|
29
|
+
listSubdirs?(path: string): Promise<string[]>;
|
|
24
30
|
exists(path: string): Promise<boolean>;
|
|
25
31
|
/**
|
|
26
32
|
* Import an app module and return its exports. Optional: only present when the caller runs on
|
|
@@ -99,19 +105,66 @@ export async function expandPattern(root: string, io: GenIO, pattern: string): P
|
|
|
99
105
|
.sort();
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
/**
|
|
108
|
+
/** The deploy-by-convention directory (ADR 0062): when the manifest declares no `models`, both the
|
|
109
|
+
* runtime deploy and this codegen scan discover deployables/models under it — shallow, one level
|
|
110
|
+
* deep (`resources/*` and `resources/<subdir>/*`). Deploy-only by convention. */
|
|
111
|
+
export const RESOURCES_DIR = "resources";
|
|
112
|
+
|
|
113
|
+
/** Model file extensions the `nano:shape`/code-first scan reads by convention (BPMN + DMN). */
|
|
114
|
+
const MODEL_EXTS = [".bpmn", ".dmn"];
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Discover model files under `resources/` by convention — shallow, one level deep (`resources/*`
|
|
118
|
+
* and `resources/<subdir>/*`), never deeper — mirroring the runtime deploy walk so the codegen scan
|
|
119
|
+
* and the deploy see the same set. Returns root-relative paths, sorted. Descending one level needs
|
|
120
|
+
* `io.listSubdirs`; without it only the files directly under `resources/` are scanned.
|
|
121
|
+
*/
|
|
122
|
+
export async function discoverResourceModels(root: string, io: GenIO): Promise<string[]> {
|
|
123
|
+
const isModel = (n: string): boolean => MODEL_EXTS.some((e) => n.endsWith(e));
|
|
124
|
+
const out: string[] = [];
|
|
125
|
+
for (const n of await io.listDir(join(root, RESOURCES_DIR))) {
|
|
126
|
+
if (isModel(n)) out.push(`${RESOURCES_DIR}/${n}`);
|
|
127
|
+
}
|
|
128
|
+
const subdirs = io.listSubdirs ? await io.listSubdirs(join(root, RESOURCES_DIR)) : [];
|
|
129
|
+
for (const sub of subdirs.slice().sort()) {
|
|
130
|
+
for (const n of await io.listDir(join(root, `${RESOURCES_DIR}/${sub}`))) {
|
|
131
|
+
if (isModel(n)) out.push(`${RESOURCES_DIR}/${sub}/${n}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out.sort();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read + resolve the app's process models. Convention is keyed off the *absence* of the entire
|
|
139
|
+
* `models` block — mirroring the runtime deploy (ADR 0062): with no `models` block, models are
|
|
140
|
+
* discovered by convention under `resources/`; with a `models` block declared, `models.processes`
|
|
141
|
+
* is the explicit override (used verbatim, possibly empty). Keying off a missing `models.processes`
|
|
142
|
+
* alone would fall back to convention even when a `models` block is present for other overrides
|
|
143
|
+
* (e.g. forms), letting gen scan/derive process models the runtime deploy will never deploy —
|
|
144
|
+
* gen/runtime drift.
|
|
145
|
+
*/
|
|
103
146
|
export async function readModels(
|
|
104
147
|
root: string,
|
|
105
148
|
io: GenIO,
|
|
106
149
|
manifest: { models?: { processes?: string[] } },
|
|
107
150
|
): Promise<ModelSource[]> {
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
151
|
+
const byConvention = manifest.models === undefined;
|
|
152
|
+
let rels: string[];
|
|
153
|
+
if (byConvention) {
|
|
154
|
+
rels = await discoverResourceModels(root, io);
|
|
155
|
+
} else {
|
|
156
|
+
rels = [];
|
|
157
|
+
for (const pat of manifest.models?.processes ?? []) {
|
|
158
|
+
rels.push(...(await expandPattern(root, io, pat)));
|
|
113
159
|
}
|
|
114
160
|
}
|
|
161
|
+
const models: ModelSource[] = [];
|
|
162
|
+
const seen = new Set<string>();
|
|
163
|
+
for (const rel of rels) {
|
|
164
|
+
if (seen.has(rel)) continue;
|
|
165
|
+
seen.add(rel);
|
|
166
|
+
models.push({ path: rel, xml: await io.readText(join(root, rel)) });
|
|
167
|
+
}
|
|
115
168
|
return models;
|
|
116
169
|
}
|
|
117
170
|
|
|
@@ -64,13 +64,13 @@ test("bpmnFilename sanitizes ids into safe .bpmn filenames", () => {
|
|
|
64
64
|
assert.equal(bpmnFilename("a b:c"), "a-b-c.bpmn");
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
test("processesOutDir defaults to processes/, or the dir of a models.processes glob", () => {
|
|
68
|
-
assert.equal(processesOutDir({}), "processes");
|
|
67
|
+
test("processesOutDir defaults to resources/processes/, or the dir of a models.processes glob", () => {
|
|
68
|
+
assert.equal(processesOutDir({}), "resources/processes");
|
|
69
69
|
assert.equal(processesOutDir({ models: { processes: ["resources/processes/*.bpmn"] } }), "resources/processes");
|
|
70
70
|
assert.equal(processesOutDir({ models: { processes: ["src/models/*.bpmn"] } }), "src/models");
|
|
71
71
|
});
|
|
72
72
|
|
|
73
|
-
test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>.bpmn", async () => {
|
|
73
|
+
test("deriveModels turns workflows/*.ts into a provenance-stamped resources/processes/<id>.bpmn", async () => {
|
|
74
74
|
const io = memIO(
|
|
75
75
|
{ "/app/workflows/greet.ts": "// source" },
|
|
76
76
|
{ "workflows/greet.ts": { greet } },
|
|
@@ -81,12 +81,12 @@ test("deriveModels turns workflows/*.ts into a provenance-stamped processes/<id>
|
|
|
81
81
|
assert.equal(d.incomplete, false);
|
|
82
82
|
assert.deepEqual(d.ids, ["greet"]);
|
|
83
83
|
assert.equal(d.artifacts.length, 1);
|
|
84
|
-
assert.equal(d.artifacts[0].path, "processes/greet.bpmn");
|
|
84
|
+
assert.equal(d.artifacts[0].path, "resources/processes/greet.bpmn");
|
|
85
85
|
assert.match(d.artifacts[0].content, /<!--.*@nanobpm\/urban/);
|
|
86
86
|
assert.ok(d.artifacts[0].content.includes(MODEL_PROVENANCE));
|
|
87
87
|
assert.match(d.artifacts[0].content, /greet/);
|
|
88
88
|
// the in-memory ModelSource is fed to the type-contract derivers
|
|
89
|
-
assert.deepEqual(d.models.map((m) => m.path), ["processes/greet.bpmn"]);
|
|
89
|
+
assert.deepEqual(d.models.map((m) => m.path), ["resources/processes/greet.bpmn"]);
|
|
90
90
|
assert.equal(d.list[0].id, "greet");
|
|
91
91
|
assert.equal(d.list[0].kind, "declarative");
|
|
92
92
|
});
|
package/src/toolkit/models.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Unlike the pure derivers (migrations, domain, worker I/O), which transform strings, this
|
|
4
4
|
// imports the app's `workflows/*.ts`, turns each `defineFlow` into executable BPMN via
|
|
5
|
-
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `processes/<id>.bpmn
|
|
5
|
+
// `@nanobpm/workflow` (`toBpmn` + `bpmn-auto-layout` DI), and writes `resources/processes/<id>.bpmn`
|
|
6
|
+
// (the deploy-by-convention `resources/` tree, ADR 0062 — see `PROCESSES_DIR`).
|
|
6
7
|
// Executing the app's TypeScript needs a runtime module loader, so this is gated on the
|
|
7
8
|
// optional `GenIO.importModule` (present on Node with type-stripping, and on Deno). When the
|
|
8
9
|
// loader is absent — or the app authors its models directly (model-first) — derivation is a
|
|
@@ -19,9 +20,11 @@ import { isRecord } from "../runtime/core/guards.ts";
|
|
|
19
20
|
/** Default source glob for code-first flows (matches the code-first scaffold layout). */
|
|
20
21
|
export const DEFAULT_WORKFLOW_PATTERNS = ["workflows/*.ts"];
|
|
21
22
|
|
|
22
|
-
/** Default output dir for derived models
|
|
23
|
-
*
|
|
24
|
-
|
|
23
|
+
/** Default output dir for derived models when the manifest declares no `models.processes`: under
|
|
24
|
+
* the deploy-by-convention `resources/` tree (ADR 0062), one level deep, so a derived model lands
|
|
25
|
+
* exactly where the convention deploy + codegen scan look for it. When `models.processes` IS set,
|
|
26
|
+
* `processesOutDir` derives the dir from that glob instead (the model-first override). */
|
|
27
|
+
export const PROCESSES_DIR = "resources/processes";
|
|
25
28
|
|
|
26
29
|
/** Marker stamped into every derived `.bpmn` (an XML comment) so re-gen overwrites and the
|
|
27
30
|
* stale sweep deletes ONLY derived output — never an authored, human-owned model. */
|
|
@@ -68,7 +71,7 @@ export interface ModelError {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
export interface DerivedModels {
|
|
71
|
-
/** `processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
74
|
+
/** `resources/processes/<id>.bpmn` artifacts (path + provenance-stamped content). */
|
|
72
75
|
artifacts: DerivedArtifact[];
|
|
73
76
|
/** The same models, in-memory, for feeding the type-contract derivers (worker I/O, meta, messages). */
|
|
74
77
|
models: ModelSource[];
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { HostContext } from "../host.ts";
|
|
2
|
-
/** A template source: a list of globs/directories/files (name = file stem), or an explicit
|
|
3
|
-
* `name → content` map. */
|
|
4
|
-
export type TemplateSource = string[] | Record<string, string>;
|
|
5
|
-
/** Build the `name → content` template map from the given sources in order (later sources win on
|
|
6
|
-
* a name collision). Array sources are resolved to files (stem = name) and read; map sources are
|
|
7
|
-
* merged verbatim. */
|
|
8
|
-
export declare function resolveTemplates(host: HostContext, root: string, sources: ReadonlyArray<TemplateSource | undefined>): Promise<Record<string, string>>;
|
|
9
|
-
export interface TemplateApplication {
|
|
10
|
-
content: string;
|
|
11
|
-
/** Distinct placeholder names with no matching template (left in place, surfaced by the caller). */
|
|
12
|
-
unresolved: string[];
|
|
13
|
-
}
|
|
14
|
-
/** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
|
|
15
|
-
* for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
|
|
16
|
-
* not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
|
|
17
|
-
* dropped) and reported in `unresolved`. */
|
|
18
|
-
export declare function applyTemplates(content: string, contentType: string, templates: Record<string, string>): TemplateApplication;
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
// Template substitution for deployed model resources. A model file (`.bpmn`/`.dmn`/`.form`) may
|
|
2
|
-
// carry `{{ name }}` placeholders — e.g. an agent service task whose prompt lives in a
|
|
3
|
-
// `zeebe:header` value. Before deploy we replace each placeholder with the named template's
|
|
4
|
-
// content, escaped for the resource's content-type, so a large asset (a prompt) is authored once
|
|
5
|
-
// in a file and inlined into the model at deploy time rather than hand-pasted into XML or shipped
|
|
6
|
-
// as a bulky per-instance process variable.
|
|
7
|
-
//
|
|
8
|
-
// Templates come from two sources, merged in order (later wins on a name collision):
|
|
9
|
-
// 1. the manifest `models.templates` (globs, or a bare directory that is scanned), and
|
|
10
|
-
// 2. a programmatic `templates` deploy option (globs, or an explicit `name → content` map).
|
|
11
|
-
// A template's name is its file's stem (basename without extension): `prompts/review-round.md`
|
|
12
|
-
// resolves the placeholder `{{review-round}}`.
|
|
13
|
-
import { expandPatterns } from "../glob.js";
|
|
14
|
-
/** A file's stem: basename without its final extension (`a/b/review-round.md` → `review-round`). */
|
|
15
|
-
function stem(path) {
|
|
16
|
-
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
17
|
-
const dot = base.lastIndexOf(".");
|
|
18
|
-
return dot > 0 ? base.slice(0, dot) : base;
|
|
19
|
-
}
|
|
20
|
-
// A template name is referenceable only if it matches the placeholder charset (`[\w.-]`, see
|
|
21
|
-
// `PLACEHOLDER`). A file stem outside that set (e.g. containing spaces) could be loaded but never
|
|
22
|
-
// referenced, and its `{{…}}` would not even be detected as unresolved — so we reject it loudly.
|
|
23
|
-
const VALID_NAME = /^[\w.-]+$/;
|
|
24
|
-
/** Files contributed by one entry: a glob (`prompts/*.md`), a bare directory (scanned, non-glob),
|
|
25
|
-
* or a literal file. Returns root-prefixed paths, matching `expandPatterns`. */
|
|
26
|
-
async function filesFor(host, root, entry) {
|
|
27
|
-
if (entry.includes("*"))
|
|
28
|
-
return expandPatterns(host, root, [entry]);
|
|
29
|
-
const trimmed = entry.replace(/\/+$/, "");
|
|
30
|
-
// A non-glob entry is either a directory (scan it) or a literal file. Scan as a directory first;
|
|
31
|
-
// a non-empty directory resolves here.
|
|
32
|
-
const scanned = await expandPatterns(host, root, [`${trimmed}/*`]);
|
|
33
|
-
if (scanned.length > 0)
|
|
34
|
-
return scanned;
|
|
35
|
-
// No children matched: the entry is an empty (or subdir-only) directory, a literal file, or
|
|
36
|
-
// missing. A directory never appears in its parent's `listDir` (which yields file names only),
|
|
37
|
-
// so if the parent lists this basename it is a real file to read; otherwise treat the entry as an
|
|
38
|
-
// empty template directory that contributes nothing — and, crucially, emit no misleading
|
|
39
|
-
// "unreadable entry" warning for a valid-but-empty directory (readTextFile on it would EISDIR).
|
|
40
|
-
const slash = trimmed.lastIndexOf("/");
|
|
41
|
-
const base = trimmed.slice(slash + 1);
|
|
42
|
-
const parent = slash > 0 ? trimmed.slice(0, slash) : "";
|
|
43
|
-
const parentDir = parent ? `${root.replace(/\/+$/, "")}/${parent}` : root;
|
|
44
|
-
const siblings = await host.listDir(parentDir);
|
|
45
|
-
return siblings.includes(base) ? expandPatterns(host, root, [trimmed]) : [];
|
|
46
|
-
}
|
|
47
|
-
/** Build the `name → content` template map from the given sources in order (later sources win on
|
|
48
|
-
* a name collision). Array sources are resolved to files (stem = name) and read; map sources are
|
|
49
|
-
* merged verbatim. */
|
|
50
|
-
export async function resolveTemplates(host, root, sources) {
|
|
51
|
-
// A null-prototype dictionary: template names (file stems or caller-supplied map keys) are
|
|
52
|
-
// untrusted, so a name like `__proto__` must set a plain own property rather than trip the
|
|
53
|
-
// magic prototype setter that a normal `{}` inherits from `Object.prototype`.
|
|
54
|
-
const map = Object.create(null);
|
|
55
|
-
for (const source of sources) {
|
|
56
|
-
if (!source)
|
|
57
|
-
continue;
|
|
58
|
-
if (Array.isArray(source)) {
|
|
59
|
-
const seen = new Set();
|
|
60
|
-
for (const entry of source) {
|
|
61
|
-
for (const file of await filesFor(host, root, entry)) {
|
|
62
|
-
if (seen.has(file))
|
|
63
|
-
continue;
|
|
64
|
-
seen.add(file);
|
|
65
|
-
const name = stem(file);
|
|
66
|
-
if (!VALID_NAME.test(name)) {
|
|
67
|
-
// Loud failure for a misnamed template file rather than a silently unreferenceable entry.
|
|
68
|
-
host.log("warn", "template: skipped unsupported name", { file, name });
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
try {
|
|
72
|
-
map[name] = await host.readTextFile(file);
|
|
73
|
-
}
|
|
74
|
-
catch (err) {
|
|
75
|
-
// A directory path that slipped through the file fallback, or an unreadable file.
|
|
76
|
-
host.log("warn", "template: skipped unreadable entry", { file, error: String(err) });
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
Object.assign(map, source);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return map;
|
|
86
|
-
}
|
|
87
|
-
// `{{ name }}` — a double-brace placeholder around a template name. Double braces avoid clashing
|
|
88
|
-
// with FEEL's single-brace context syntax used elsewhere in BPMN.
|
|
89
|
-
const PLACEHOLDER = /\{\{\s*([\w.-]+)\s*\}\}/g;
|
|
90
|
-
/** Escape template content for an XML attribute value (e.g. a `zeebe:header` `value="…"`). Beyond
|
|
91
|
-
* the markup-significant characters, newlines and tabs MUST become character references:
|
|
92
|
-
* XML attribute-value normalization collapses literal newlines/tabs to spaces, which would
|
|
93
|
-
* destroy a multi-line prompt. Character references are exempt from that normalization, so
|
|
94
|
-
* ` ` round-trips as a newline. This escaping is also valid for XML element text, so it is
|
|
95
|
-
* the safe default for any `.bpmn`/`.dmn` placeholder position. */
|
|
96
|
-
function xmlAttrEscape(s) {
|
|
97
|
-
return s
|
|
98
|
-
.replace(/&/g, "&")
|
|
99
|
-
.replace(/</g, "<")
|
|
100
|
-
.replace(/>/g, ">")
|
|
101
|
-
.replace(/"/g, """)
|
|
102
|
-
.replace(/'/g, "'")
|
|
103
|
-
.replace(/\r/g, " ")
|
|
104
|
-
.replace(/\n/g, " ")
|
|
105
|
-
.replace(/\t/g, "	");
|
|
106
|
-
}
|
|
107
|
-
/** Escape template content for embedding inside a JSON string literal (a `.form` value). */
|
|
108
|
-
function jsonStringEscape(s) {
|
|
109
|
-
// Stringify to a valid JSON string, then drop the surrounding quotes → an inner-string fragment.
|
|
110
|
-
const quoted = JSON.stringify(s);
|
|
111
|
-
return quoted.slice(1, quoted.length - 1);
|
|
112
|
-
}
|
|
113
|
-
function escaperFor(contentType) {
|
|
114
|
-
if (contentType === "application/json")
|
|
115
|
-
return jsonStringEscape;
|
|
116
|
-
return xmlAttrEscape; // text/xml (.bpmn/.dmn) and any other markup
|
|
117
|
-
}
|
|
118
|
-
/** Replace every `{{ name }}` placeholder in `content` with the named template's content, escaped
|
|
119
|
-
* for `contentType`. Single-pass and non-recursive: a template that itself contains `{{…}}` is
|
|
120
|
-
* not re-expanded. Unknown placeholders are left verbatim (so the miss is visible, not silently
|
|
121
|
-
* dropped) and reported in `unresolved`. */
|
|
122
|
-
export function applyTemplates(content, contentType, templates) {
|
|
123
|
-
const escape = escaperFor(contentType);
|
|
124
|
-
const unresolved = [];
|
|
125
|
-
const out = content.replace(PLACEHOLDER, (whole, name) => {
|
|
126
|
-
if (Object.prototype.hasOwnProperty.call(templates, name))
|
|
127
|
-
return escape(templates[name]);
|
|
128
|
-
if (!unresolved.includes(name))
|
|
129
|
-
unresolved.push(name);
|
|
130
|
-
return whole;
|
|
131
|
-
});
|
|
132
|
-
return { content: out, unresolved };
|
|
133
|
-
}
|