@nanobpm/urban 0.47.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 +77 -5
- 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/derivers/shapes.d.ts +5 -0
- package/dist/toolkit/derivers/shapes.js +90 -0
- package/dist/toolkit/derivers/worker-io.d.ts +25 -0
- package/dist/toolkit/derivers/worker-io.js +104 -11
- package/dist/toolkit/fsio.js +9 -0
- package/dist/toolkit/gen.d.ts +26 -1
- package/dist/toolkit/gen.js +103 -22
- 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 +71 -3
- package/src/runtime/core/modules/pages.ts +77 -5
- 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/derivers/shapes.test.ts +66 -0
- package/src/toolkit/derivers/shapes.ts +93 -0
- package/src/toolkit/derivers/worker-io.test.ts +131 -0
- package/src/toolkit/derivers/worker-io.ts +116 -9
- package/src/toolkit/fsio.ts +8 -0
- package/src/toolkit/gen.test.ts +81 -10
- package/src/toolkit/gen.ts +109 -22
- 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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// materializes the app by mounting each module. Hosts (CLI/IDE/console/bare process) call
|
|
3
3
|
// this the same way; a host may mount a subset via `mount` flags (e.g. the console mounts
|
|
4
4
|
// its own surfaces but reuses deploy + workers + datasource).
|
|
5
|
-
import { loadManifest } from "./manifest.js";
|
|
5
|
+
import { loadManifest, resolveBindMode, bindModeToHost } from "./manifest.js";
|
|
6
6
|
import { validateManifest } from "./validate.js";
|
|
7
7
|
import { makeRouter } from "./router.js";
|
|
8
8
|
import { deployModels } from "./modules/deploy.js";
|
|
@@ -48,7 +48,7 @@ export async function createUrbanApp(opts) {
|
|
|
48
48
|
security: opts.mount?.security ?? true,
|
|
49
49
|
instanceTracking: opts.mount?.instanceTracking ?? true,
|
|
50
50
|
};
|
|
51
|
-
const ctx = { manifest, host, engine, root
|
|
51
|
+
const ctx = { manifest, host, engine, root };
|
|
52
52
|
// One app-level structured logger over the host sink, shared by the entrypoint (exposed as
|
|
53
53
|
// `app.log`) and the per-invocation `AppApi.log` (workers/route delegates `child()` it with their
|
|
54
54
|
// correlation context).
|
|
@@ -65,6 +65,12 @@ export async function createUrbanApp(opts) {
|
|
|
65
65
|
let started = false;
|
|
66
66
|
let httpPort;
|
|
67
67
|
const port = resolvePort(opts.port, host.env("PORT"));
|
|
68
|
+
// Which interface the HTTP server binds to (issue #235). Secure by default: loopback unless
|
|
69
|
+
// the manifest's `network.bind` (or the `URBAN_BIND` env override) opts into all interfaces.
|
|
70
|
+
// Derive the host address from the already-resolved mode so the two can never diverge (and we
|
|
71
|
+
// consult the env exactly once) — a second `resolveBindHost` call would re-read the override.
|
|
72
|
+
const bindMode = resolveBindMode(manifest, (n) => host.env(n));
|
|
73
|
+
const bindHost = bindModeToHost(bindMode);
|
|
68
74
|
// Release every mounted resource and reset internal state so a subsequent
|
|
69
75
|
// start() begins clean. Used by stop() and by start()'s failure path.
|
|
70
76
|
const teardown = async () => {
|
|
@@ -181,9 +187,26 @@ export async function createUrbanApp(opts) {
|
|
|
181
187
|
body: JSON.stringify({ ok: true, app: manifest.id }),
|
|
182
188
|
}),
|
|
183
189
|
});
|
|
184
|
-
server = await host.serveHttp(port, makeRouter(routes));
|
|
190
|
+
server = await host.serveHttp(port, makeRouter(routes), bindHost);
|
|
185
191
|
httpPort = server.port;
|
|
186
|
-
host.log("info", "urban app serving surfaces/triggers", {
|
|
192
|
+
host.log("info", "urban app serving surfaces/triggers", {
|
|
193
|
+
port: httpPort,
|
|
194
|
+
routes: routes.length,
|
|
195
|
+
bind: bindMode,
|
|
196
|
+
bindHost,
|
|
197
|
+
});
|
|
198
|
+
// Binding to all interfaces exposes the app (and its token-gated capability
|
|
199
|
+
// hooks) on the LAN. That is the explicit opt-in this setting exists for, but a
|
|
200
|
+
// non-loopback bind must not serve any LOCAL-mode "well-known localhost token"
|
|
201
|
+
// credential (issue #235) — consumers that mint one (e.g. the agentic channel's
|
|
202
|
+
// LOCAL mode) must gate it on a loopback bind. Surface the exposure so it is never
|
|
203
|
+
// silent.
|
|
204
|
+
if (bindMode === "all") {
|
|
205
|
+
host.log("warn", "urban app bound to all interfaces — reachable off-box on the LAN", {
|
|
206
|
+
port: httpPort,
|
|
207
|
+
bindHost,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
187
210
|
// ADR 0057 boot handshake: under a supervising host (the nano-bpm
|
|
188
211
|
// Studio sets NANOBPMN_APP_HANDSHAKE), announce the port we actually
|
|
189
212
|
// bound on a machine-readable stdout control line so the host can
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// silently — the whole point of consuming the package rather than vendoring a copy.
|
|
10
10
|
import schema from "@nanobpm/nano-app-schema/schema" with { type: "json" };
|
|
11
11
|
import { isRecord } from "./guards.js";
|
|
12
|
+
import { BIND_MODES, NETWORK_KEYS, isBindMode, isConfiguredStatusSelector } from "./manifest.js";
|
|
12
13
|
export class ManifestValidationError extends Error {
|
|
13
14
|
issues;
|
|
14
15
|
constructor(issues) {
|
|
@@ -32,6 +33,13 @@ export function collectManifestIssues(m) {
|
|
|
32
33
|
issues.push({ path: req, message: "required by nano-app.schema.json" });
|
|
33
34
|
}
|
|
34
35
|
const allowed = new Set(Object.keys(S.properties ?? {}));
|
|
36
|
+
// `network` (issue #235) is threaded as a runtime-side setting until
|
|
37
|
+
// @nanobpm/nano-app-schema ships the field in its JSON Schema; allow it here so the
|
|
38
|
+
// envelope check doesn't reject it. Remove this once the schema owns `network` (it
|
|
39
|
+
// then flows from `S.properties` like every other block). The matching local type
|
|
40
|
+
// (NetworkConfig) lives in manifest.ts — mirrored like the `api` binding (ADR 0058),
|
|
41
|
+
// never via a `declare module` augmentation (CI bans augmenting the schema type).
|
|
42
|
+
allowed.add("network");
|
|
35
43
|
if (S.additionalProperties === false) {
|
|
36
44
|
for (const k of Object.keys(obj)) {
|
|
37
45
|
if (!allowed.has(k)) {
|
|
@@ -39,6 +47,28 @@ export function collectManifestIssues(m) {
|
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
}
|
|
50
|
+
if ("network" in obj) {
|
|
51
|
+
const network = isRecord(obj.network) ? obj.network : undefined;
|
|
52
|
+
if (!network) {
|
|
53
|
+
issues.push({ path: "network", message: "must be an object" });
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
if ("bind" in network && !isBindMode(network.bind)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
path: "network.bind",
|
|
59
|
+
message: `must be one of: ${BIND_MODES.map((mode) => `"${mode}"`).join(", ")}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// `network` is mirrored runtime-side until the schema owns it, so this is its only
|
|
63
|
+
// validation surface: mirror the schema's `additionalProperties: false` intent and
|
|
64
|
+
// reject unknown keys so typos (e.g. `binn`) fail loudly instead of silently no-op'ing.
|
|
65
|
+
for (const k of Object.keys(network)) {
|
|
66
|
+
if (!NETWORK_KEYS.some((known) => known === k)) {
|
|
67
|
+
issues.push({ path: `network.${k}`, message: "unknown key (network additionalProperties: false)" });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
42
72
|
if ("schemaVersion" in obj && obj.schemaVersion !== 1) {
|
|
43
73
|
issues.push({ path: "schemaVersion", message: "must be 1" });
|
|
44
74
|
}
|
|
@@ -153,12 +183,46 @@ export function collectManifestIssues(m) {
|
|
|
153
183
|
message: "missing onTerminated.set patch (a non-empty column → value map)",
|
|
154
184
|
});
|
|
155
185
|
}
|
|
156
|
-
|
|
186
|
+
// `activeStatuses` (fail-closed allow-list) and `terminalStatuses` (fail-open exclusion) are
|
|
187
|
+
// both consumed as arrays of status strings. A malformed value — a bare string, or an array
|
|
188
|
+
// holding a non-string/empty entry — would misbehave at runtime (`new Set("abandoned")`
|
|
189
|
+
// becomes a set of characters; `activeStatuses.map(...)` crashes), so reject any non-`unset`
|
|
190
|
+
// shape that isn't a non-empty array of non-empty strings. An empty array is treated as
|
|
191
|
+
// "unset" (the runtime's `isConfiguredStatusSelector` gate does the same), so it is allowed.
|
|
192
|
+
for (const sel of ["activeStatuses", "terminalStatuses"]) {
|
|
193
|
+
const v = b?.[sel];
|
|
194
|
+
const malformed = v !== undefined &&
|
|
195
|
+
(!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.length === 0));
|
|
196
|
+
if (malformed) {
|
|
197
|
+
issues.push({
|
|
198
|
+
path: `instanceTracking[${i}].${sel}`,
|
|
199
|
+
message: `${sel} must be an array of non-empty strings`,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (isConfiguredStatusSelector(b?.activeStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
157
204
|
issues.push({
|
|
158
205
|
path: `instanceTracking[${i}].activeStatuses`,
|
|
159
206
|
message: "activeStatuses requires statusField",
|
|
160
207
|
});
|
|
161
208
|
}
|
|
209
|
+
// `terminalStatuses` is the fail-open exclusion selector (poll every row NOT in one of
|
|
210
|
+
// these). Like `activeStatuses` it reads `statusField`, so it too requires one; and the two
|
|
211
|
+
// selectors are mutually exclusive — declaring both is ambiguous, so reject it.
|
|
212
|
+
if (isConfiguredStatusSelector(b?.terminalStatuses) && (typeof b?.statusField !== "string" || b.statusField.length === 0)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path: `instanceTracking[${i}].terminalStatuses`,
|
|
215
|
+
message: "terminalStatuses requires statusField",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// Mutual exclusion is gated on the same "configured" predicate the runtime uses, so an empty
|
|
219
|
+
// array (which the runtime treats as unset) does not spuriously trip the both-declared error.
|
|
220
|
+
if (isConfiguredStatusSelector(b?.activeStatuses) && isConfiguredStatusSelector(b?.terminalStatuses)) {
|
|
221
|
+
issues.push({
|
|
222
|
+
path: `instanceTracking[${i}].terminalStatuses`,
|
|
223
|
+
message: "activeStatuses and terminalStatuses are mutually exclusive; declare only one",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
162
226
|
// `pollMs`, when set, schedules a self-rescheduling timer; a non-number/NaN/non-positive
|
|
163
227
|
// value would become a 0-delay hot loop at runtime, so reject it at author time.
|
|
164
228
|
if (b?.pollMs !== undefined && (typeof b.pollMs !== "number" || !Number.isFinite(b.pollMs) || b.pollMs <= 0)) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClient, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -81,6 +81,9 @@ export interface NanoSdkClient {
|
|
|
81
81
|
userTaskKey: string;
|
|
82
82
|
variables?: Record<string, unknown>;
|
|
83
83
|
}, options?: unknown): Promise<unknown>;
|
|
84
|
+
getFormByKey(input: {
|
|
85
|
+
formKey: string;
|
|
86
|
+
}, consistency?: unknown, options?: unknown): Promise<Record<string, unknown>>;
|
|
84
87
|
searchProcessInstances(input: {
|
|
85
88
|
filter?: Record<string, unknown>;
|
|
86
89
|
page?: Record<string, unknown>;
|
|
@@ -137,11 +140,11 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
137
140
|
processInstanceKey?: string;
|
|
138
141
|
assignee?: string;
|
|
139
142
|
candidateGroup?: string;
|
|
140
|
-
}): Promise<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
143
|
+
}): Promise<UserTaskSummary[]>;
|
|
144
|
+
getForm(input: {
|
|
145
|
+
formKey?: string;
|
|
146
|
+
formId?: string;
|
|
147
|
+
}): Promise<FormSchema | null>;
|
|
145
148
|
completeUserTask(userTaskKey: string, variables?: Record<string, unknown>): Promise<void>;
|
|
146
149
|
searchProcessInstances(filter?: {
|
|
147
150
|
processInstanceKeys?: string[];
|
|
@@ -18,7 +18,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
18
18
|
}
|
|
19
19
|
return path;
|
|
20
20
|
};
|
|
21
|
-
import { isBpmnError } from "../core/host.js";
|
|
21
|
+
import { isBpmnError, presentFormIdentifier } from "../core/host.js";
|
|
22
22
|
import { isRecord } from "../core/guards.js";
|
|
23
23
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
24
24
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -109,13 +109,77 @@ export class SdkEngineClient {
|
|
|
109
109
|
this.log("warn", "skipping user task with no key in engine response");
|
|
110
110
|
return [];
|
|
111
111
|
}
|
|
112
|
+
// The engine resolves a `formId="X"` linkage to the latest deployed form's key at
|
|
113
|
+
// task creation, so `formKey` (not a form id) is the linkage it reports on the task.
|
|
114
|
+
const formKey = it.formKey != null && it.formKey !== "" ? String(it.formKey) : undefined;
|
|
115
|
+
const externalFormReference = typeof it.externalFormReference === "string" && it.externalFormReference !== ""
|
|
116
|
+
? it.externalFormReference
|
|
117
|
+
: undefined;
|
|
112
118
|
return [{
|
|
113
119
|
userTaskKey: String(userTaskKey),
|
|
114
120
|
elementId: typeof it.elementId === "string" ? it.elementId : undefined,
|
|
115
121
|
variables: isRecord(it.variables) ? it.variables : undefined,
|
|
122
|
+
...(formKey ? { formKey } : {}),
|
|
123
|
+
...(externalFormReference ? { externalFormReference } : {}),
|
|
116
124
|
}];
|
|
117
125
|
});
|
|
118
126
|
}
|
|
127
|
+
async getForm(input) {
|
|
128
|
+
// The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
|
|
129
|
+
// `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
|
|
130
|
+
// form, so callers pass that key. `formId` is accepted as a fallback identifier for
|
|
131
|
+
// engines that address a form by its id; either way the engine returns the current
|
|
132
|
+
// form or 404s (→ null) when there is no such form.
|
|
133
|
+
//
|
|
134
|
+
// This is the single gate that resolves which identifier addresses the form: an
|
|
135
|
+
// empty/whitespace identifier is treated as *absent* (via `presentFormIdentifier`, the
|
|
136
|
+
// shared presence rule) so a blank `formKey` (e.g. a `?formKey=` query param) falls
|
|
137
|
+
// through to a valid `formId` instead of being taken as a present-but-unresolvable key
|
|
138
|
+
// that short-circuits to null.
|
|
139
|
+
const key = presentFormIdentifier(input.formKey) ?? presentFormIdentifier(input.formId);
|
|
140
|
+
if (key == null)
|
|
141
|
+
return null;
|
|
142
|
+
let body;
|
|
143
|
+
try {
|
|
144
|
+
body = await this.client.getFormByKey({ formKey: key }, { consistency: { waitUpToMs: 0 } });
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
this.log("warn", "getForm: engine form fetch failed", {
|
|
148
|
+
key,
|
|
149
|
+
formKey: input.formKey,
|
|
150
|
+
formId: input.formId,
|
|
151
|
+
err: err instanceof Error ? err.message : String(err),
|
|
152
|
+
});
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
if (!isRecord(body))
|
|
156
|
+
return null;
|
|
157
|
+
const rawSchema = body.schema;
|
|
158
|
+
// The engine serializes the form-js schema as a JSON string; parse it to the object
|
|
159
|
+
// the surface renders. Tolerate an already-parsed object (an in-memory/embedded engine).
|
|
160
|
+
let schema;
|
|
161
|
+
if (typeof rawSchema === "string") {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(rawSchema);
|
|
164
|
+
if (isRecord(parsed))
|
|
165
|
+
schema = parsed;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
this.log("warn", "getForm: form schema is not valid JSON", { key, formKey: input.formKey, formId: input.formId });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
else if (isRecord(rawSchema)) {
|
|
172
|
+
schema = rawSchema;
|
|
173
|
+
}
|
|
174
|
+
if (!schema)
|
|
175
|
+
return null;
|
|
176
|
+
return {
|
|
177
|
+
schema,
|
|
178
|
+
...(body.formKey != null && body.formKey !== "" ? { formKey: String(body.formKey) } : {}),
|
|
179
|
+
...(typeof body.formId === "string" && body.formId !== "" ? { formId: body.formId } : {}),
|
|
180
|
+
...(typeof body.version === "number" ? { version: body.version } : {}),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
119
183
|
async completeUserTask(userTaskKey, variables) {
|
|
120
184
|
await this.client.completeUserTask({ userTaskKey, variables: variables ?? {} });
|
|
121
185
|
}
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createUrbanApp } from "./core/runtime.ts";
|
|
2
2
|
export type { CreateUrbanAppOptions, MountFlags, UrbanApp, } from "./core/runtime.ts";
|
|
3
|
-
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.ts";
|
|
4
|
-
export type { ActionDecl, AppManifest, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
3
|
+
export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.ts";
|
|
4
|
+
export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
5
5
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
|
|
6
6
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
7
7
|
export type { EngineClient, EngineJob, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, SqliteDb, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
@@ -30,9 +30,7 @@ export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.ts"
|
|
|
30
30
|
export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.ts";
|
|
31
31
|
export type { ChatMessage, DecisionEvaluator, EnvLookup, LlmRuntime, LlmVars, ProviderConfig, } from "./core/modules/llm.ts";
|
|
32
32
|
export { evalCorrelation } from "./core/modules/triggers.ts";
|
|
33
|
-
export { deployModels } from "./core/modules/deploy.ts";
|
|
34
|
-
export { applyTemplates, resolveTemplates } from "./core/modules/templates.ts";
|
|
35
|
-
export type { TemplateApplication, TemplateSource } from "./core/modules/templates.ts";
|
|
33
|
+
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
|
|
36
34
|
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
|
|
37
35
|
export type { SchedulerDeps } from "./core/modules/scheduler.ts";
|
|
38
36
|
export { createNodeHost } from "./adapters/node.ts";
|
package/dist/runtime/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Core runtime
|
|
3
3
|
export { createUrbanApp } from "./core/runtime.js";
|
|
4
4
|
// Manifest
|
|
5
|
-
export { expandEnv, expandEnvString, loadManifest, parseManifest, workerJobType, } from "./core/manifest.js";
|
|
5
|
+
export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, parseManifest, resolveBindHost, resolveBindMode, workerJobType, ALL_INTERFACES_HOST, BIND_ENV_VAR, LOOPBACK_HOST, } from "./core/manifest.js";
|
|
6
6
|
// Validation
|
|
7
7
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.js";
|
|
8
8
|
// Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
|
|
@@ -24,9 +24,8 @@ export { defineOperation, mountApi, resolveOperationHandler, NotImplemented } fr
|
|
|
24
24
|
export { resolveHandler, sdkDecisionEvaluator } from "./core/modules/workers.js";
|
|
25
25
|
export { buildMessages, callLlm, resolveProvider, runLlmJob, } from "./core/modules/llm.js";
|
|
26
26
|
export { evalCorrelation } from "./core/modules/triggers.js";
|
|
27
|
-
// Model deploy
|
|
28
|
-
export { deployModels } from "./core/modules/deploy.js";
|
|
29
|
-
export { applyTemplates, resolveTemplates } from "./core/modules/templates.js";
|
|
27
|
+
// Model deploy (deploy-by-convention under `resources/`, ADR 0062)
|
|
28
|
+
export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.js";
|
|
30
29
|
export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.js";
|
|
31
30
|
// Adapters + engine + run entrypoint
|
|
32
31
|
export { createNodeHost } from "./adapters/node.js";
|
package/dist/runtime/run.js
CHANGED
|
@@ -35,6 +35,11 @@ export interface ShapeDecl {
|
|
|
35
35
|
/** Model-level metadata (`nano:meta`), free-form key/value (ADR 0040 §5). */
|
|
36
36
|
meta?: Record<string, string>;
|
|
37
37
|
}
|
|
38
|
+
/** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
|
|
39
|
+
* tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
|
|
40
|
+
* are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
|
|
41
|
+
* half of the console's `envelope_scan.rs`. */
|
|
42
|
+
export declare function scanModelShapes(xml: string): ShapeDecl[];
|
|
38
43
|
/** A scan/resolve-time problem with a shape, surfaced like the `workers[]` drift warning (never a
|
|
39
44
|
* silent merge). A shape with any `error` diagnostic is omitted from the fuse; a `warning` (e.g. a
|
|
40
45
|
* deliberate field shadow) still resolves. */
|
|
@@ -19,6 +19,96 @@
|
|
|
19
19
|
// `domain_types.ts`), so the toolkit is the single source of truth for the IDE's shape fuse
|
|
20
20
|
// (host dry-out nano-bpm#576).
|
|
21
21
|
import { isPrimitiveKeyword, sqliteAffinityToTs, } from "./domain.js";
|
|
22
|
+
// --- model scan (`nano:shape` XML → `ShapeDecl[]`) -------------------------------------------
|
|
23
|
+
// The regex-based BPMN scan mirrors the Rust `envelope_scan.rs` shape parser (and the sibling
|
|
24
|
+
// `meta.ts`/`worker-io.ts` scanners): the toolkit is the standalone `urban gen` path, so it must
|
|
25
|
+
// lift model-authored shapes itself rather than receive them from the host reifier.
|
|
26
|
+
/** Read a `name="…"` attribute off an element's opening tag (any namespace prefix on the name). */
|
|
27
|
+
function shapeAttr(tag, name) {
|
|
28
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
29
|
+
const v = m?.[1]?.trim();
|
|
30
|
+
return v != null && v.length > 0 ? v : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** A boolean attribute is true only for the exact literal `"true"` (mirrors `bool_attr`). */
|
|
33
|
+
function shapeBool(tag, name) {
|
|
34
|
+
const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
|
|
35
|
+
return m?.[1]?.trim() === "true";
|
|
36
|
+
}
|
|
37
|
+
/** Split a comma-delimited `fields="a, b"` list, trimming and dropping empties (`split_fields`). */
|
|
38
|
+
function splitFields(raw) {
|
|
39
|
+
return (raw ?? "").split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
40
|
+
}
|
|
41
|
+
/** The enclosing `bpmn:process` id (provenance for the `model:<processId>` tag), best-effort. */
|
|
42
|
+
function processId(xml) {
|
|
43
|
+
const m = xml.match(/<[\w.-]*:?process\b[^>]*>/);
|
|
44
|
+
return m ? shapeAttr(m[0], "id") : undefined;
|
|
45
|
+
}
|
|
46
|
+
// One `nano:shape` element: capture its open-tag attrs (2) and inner body (3, empty when self-closing).
|
|
47
|
+
const SHAPE_RE = /<([\w.-]*:)?shape\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1?shape\s*>)/g;
|
|
48
|
+
// One composition op child (`nano:carry|project|extend|reference`): the local name (2) + its attrs (3).
|
|
49
|
+
const SHAPE_OP_RE = /<([\w.-]*:)?(carry|project|extend|reference)\b([^>]*?)\/?>/g;
|
|
50
|
+
/** Parse one composition-op element into a `ShapeOp`. An op missing a required attribute is dropped
|
|
51
|
+
* (returns `undefined`) so a malformed model degrades rather than emitting a spurious binding —
|
|
52
|
+
* mirrors `parse_shape_op` in `envelope_scan.rs`. */
|
|
53
|
+
function parseShapeOp(local, attrs) {
|
|
54
|
+
const tag = `<x ${attrs}>`;
|
|
55
|
+
switch (local) {
|
|
56
|
+
case "carry": {
|
|
57
|
+
const ref = shapeAttr(tag, "ref");
|
|
58
|
+
return ref ? { op: "carry", ref } : undefined;
|
|
59
|
+
}
|
|
60
|
+
case "project": {
|
|
61
|
+
const ref = shapeAttr(tag, "ref");
|
|
62
|
+
if (!ref)
|
|
63
|
+
return undefined;
|
|
64
|
+
return { op: "project", ref, fields: splitFields(shapeAttr(tag, "fields")), via: shapeAttr(tag, "via") };
|
|
65
|
+
}
|
|
66
|
+
case "extend": {
|
|
67
|
+
const name = shapeAttr(tag, "name");
|
|
68
|
+
const type = shapeAttr(tag, "type");
|
|
69
|
+
if (!name || !type)
|
|
70
|
+
return undefined;
|
|
71
|
+
return { op: "extend", name, type, optional: shapeBool(tag, "optional"), list: shapeBool(tag, "list") };
|
|
72
|
+
}
|
|
73
|
+
case "reference": {
|
|
74
|
+
const name = shapeAttr(tag, "name");
|
|
75
|
+
const ref = shapeAttr(tag, "ref");
|
|
76
|
+
if (!name || !ref)
|
|
77
|
+
return undefined;
|
|
78
|
+
return { op: "reference", name, ref, spread: shapeBool(tag, "spread"), list: shapeBool(tag, "list") };
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Scan one BPMN document for its `nano:shape` declarations (ADR 0040 §9), in document order, each
|
|
85
|
+
* tagged with the enclosing process for provenance. A shape with no `id` is skipped; op children
|
|
86
|
+
* are folded in author (XML) order. This is the standalone-`urban-gen` port of the shape-parsing
|
|
87
|
+
* half of the console's `envelope_scan.rs`. */
|
|
88
|
+
export function scanModelShapes(xml) {
|
|
89
|
+
const proc = processId(xml);
|
|
90
|
+
const out = [];
|
|
91
|
+
let sm;
|
|
92
|
+
SHAPE_RE.lastIndex = 0;
|
|
93
|
+
while ((sm = SHAPE_RE.exec(xml)) !== null) {
|
|
94
|
+
const open = `<x ${sm[2]}>`;
|
|
95
|
+
const id = shapeAttr(open, "id");
|
|
96
|
+
if (!id)
|
|
97
|
+
continue;
|
|
98
|
+
const name = shapeAttr(open, "name");
|
|
99
|
+
const body = sm[3] ?? "";
|
|
100
|
+
const ops = [];
|
|
101
|
+
let om;
|
|
102
|
+
SHAPE_OP_RE.lastIndex = 0;
|
|
103
|
+
while ((om = SHAPE_OP_RE.exec(body)) !== null) {
|
|
104
|
+
const op = parseShapeOp(om[2], om[3] ?? "");
|
|
105
|
+
if (op)
|
|
106
|
+
ops.push(op);
|
|
107
|
+
}
|
|
108
|
+
out.push({ id, name, process: proc, ops });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
22
112
|
/** Map one datasource column to a domain field. A nullable column widens to `optional` (the
|
|
23
113
|
* composed shape is a motion snapshot: an absent value reads as undefined, not SQL NULL). The
|
|
24
114
|
* manifest keyword is inferred from the column's SQLite affinity so it flows through `fieldTsType`
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DerivedArtifact, Deriver } from "../artifact.ts";
|
|
2
|
+
import type { DomainTypeRegistry } from "./domain.ts";
|
|
2
3
|
export interface ModelSource {
|
|
3
4
|
/** Path (used only for diagnostics/ordering). */
|
|
4
5
|
path: string;
|
|
@@ -57,6 +58,30 @@ export declare const workerIoDeriver: Deriver<{
|
|
|
57
58
|
models: ModelSource[];
|
|
58
59
|
declaredTypeIds: Iterable<string>;
|
|
59
60
|
}>;
|
|
61
|
+
/** A data-envelope conflict: one `taskType` bound to variants that disagree on a field's *type*. */
|
|
62
|
+
export interface EnvelopeConflict {
|
|
63
|
+
taskType: string;
|
|
64
|
+
/** `"in"` (job.variables) or `"out"` (result). */
|
|
65
|
+
slot: "in" | "out";
|
|
66
|
+
field: string;
|
|
67
|
+
/** The distinct resolved type expressions seen for `field`, one per conflicting variant. */
|
|
68
|
+
types: string[];
|
|
69
|
+
/** The envelope ids that carry the conflicting field, in scan order. */
|
|
70
|
+
envelopes: string[];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Report data-envelope conflicts: a `taskType` serviced by more than one *distinct* envelope whose
|
|
74
|
+
* variants disagree on the resolved TYPE of a shared field. A single handler is keyed by `taskType`,
|
|
75
|
+
* so its input/output is the UNION of every variant (see `emitWorkerBindings`). A union is sound as
|
|
76
|
+
* long as a shared field means the same thing everywhere; differing presence or optionality is fine
|
|
77
|
+
* (that is the normal subset/superset relationship the union already narrows). But a field declared
|
|
78
|
+
* `string` in one variant and `number` in another is a genuine contract conflict the author must
|
|
79
|
+
* resolve (split the job type or unify the envelope) — this surfaces it instead of silently
|
|
80
|
+
* emitting `string | number`. Compatible variants (nwf's escalation subset) produce no diagnostic.
|
|
81
|
+
*/
|
|
82
|
+
export declare function detectEnvelopeConflicts(workers: WorkerBindingDecl[], types: DomainTypeRegistry): EnvelopeConflict[];
|
|
83
|
+
/** Human-readable warning for one envelope conflict, for the gen warning stream. */
|
|
84
|
+
export declare function formatEnvelopeConflict(c: EnvelopeConflict): string;
|
|
60
85
|
/**
|
|
61
86
|
* Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
|
|
62
87
|
* for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
|
|
@@ -96,21 +96,48 @@ export function emitWorkerBindings(workers, declaredTypeIds) {
|
|
|
96
96
|
const taskTypeUnion = taskTypes.length > 0
|
|
97
97
|
? taskTypes.map((t) => JSON.stringify(t)).join(" | ")
|
|
98
98
|
: "string";
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
// A `taskType` may be serviced by more than one model element (a worker reused across processes,
|
|
100
|
+
// or the same job on several boundaries), each declaring its own data envelope. The interface is
|
|
101
|
+
// keyed by `taskType`, so a single member must cover every caller. We UNION the distinct declared
|
|
102
|
+
// envelope ids: `DomainTypes["A"] | DomainTypes["B"]`. A union is exactly the right contract for a
|
|
103
|
+
// shared handler — it reads the safe common surface (a field is accessible only where it is
|
|
104
|
+
// present-and-compatibly-typed in *every* variant, so an optional/absent-in-one field is not
|
|
105
|
+
// guaranteed), which is the "required iff required in all callers" rule. A single variant collapses
|
|
106
|
+
// to a plain `DomainTypes["A"]`. Genuine field-type conflicts across variants are reported out of
|
|
107
|
+
// band (see `detectEnvelopeConflicts`), where the resolved field types are in hand.
|
|
108
|
+
const inIds = new Map();
|
|
109
|
+
const outIds = new Map();
|
|
110
|
+
const hdrKeys = new Map();
|
|
111
|
+
const pushDistinct = (m, key, val) => {
|
|
112
|
+
const list = m.get(key) ?? [];
|
|
113
|
+
if (!list.includes(val))
|
|
114
|
+
list.push(val);
|
|
115
|
+
m.set(key, list);
|
|
116
|
+
};
|
|
102
117
|
for (const w of workers) {
|
|
103
118
|
if (typeof w?.taskType !== "string" || w.taskType.length === 0)
|
|
104
119
|
continue;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
if (w.inputType && declared.has(w.inputType))
|
|
121
|
+
pushDistinct(inIds, w.taskType, w.inputType);
|
|
122
|
+
if (w.outputType && declared.has(w.outputType))
|
|
123
|
+
pushDistinct(outIds, w.taskType, w.outputType);
|
|
124
|
+
for (const k of w.headerKeys ?? []) {
|
|
125
|
+
if (typeof k === "string" && k.trim().length > 0)
|
|
126
|
+
pushDistinct(hdrKeys, w.taskType, k.trim());
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const unionRef = (ids) => ids.map((id) => typeRefFor(id, declared)).filter((r) => !!r).join(" | ");
|
|
130
|
+
const inputs = [...inIds.entries()]
|
|
131
|
+
.filter(([, ids]) => ids.length > 0)
|
|
132
|
+
.map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
|
|
133
|
+
const outputs = [...outIds.entries()]
|
|
134
|
+
.filter(([, ids]) => ids.length > 0)
|
|
135
|
+
.map(([t, ids]) => ` ${propKey(t)}: ${unionRef(ids)};`);
|
|
136
|
+
const headers = [];
|
|
137
|
+
for (const [t, keys] of hdrKeys.entries()) {
|
|
138
|
+
const hdrRef = headerRefFor(keys);
|
|
112
139
|
if (hdrRef)
|
|
113
|
-
headers.push(` ${propKey(
|
|
140
|
+
headers.push(` ${propKey(t)}: ${hdrRef};`);
|
|
114
141
|
}
|
|
115
142
|
const header = "// AUTO-GENERATED by nanobpmn from the App manifest (ADR 0033 §3).\n" +
|
|
116
143
|
"// The bridge from the process model to the worker type system: each declared\n" +
|
|
@@ -169,6 +196,72 @@ export const workerIoDeriver = {
|
|
|
169
196
|
describe: "Derive worker-io.d.ts (task type + data-envelope in/out) from BPMN models.",
|
|
170
197
|
derive: ({ models, declaredTypeIds }) => deriveWorkerBindings(models, declaredTypeIds),
|
|
171
198
|
};
|
|
199
|
+
function fieldSig(f) {
|
|
200
|
+
return `${f.type}${f.list ? "[]" : ""}`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Report data-envelope conflicts: a `taskType` serviced by more than one *distinct* envelope whose
|
|
204
|
+
* variants disagree on the resolved TYPE of a shared field. A single handler is keyed by `taskType`,
|
|
205
|
+
* so its input/output is the UNION of every variant (see `emitWorkerBindings`). A union is sound as
|
|
206
|
+
* long as a shared field means the same thing everywhere; differing presence or optionality is fine
|
|
207
|
+
* (that is the normal subset/superset relationship the union already narrows). But a field declared
|
|
208
|
+
* `string` in one variant and `number` in another is a genuine contract conflict the author must
|
|
209
|
+
* resolve (split the job type or unify the envelope) — this surfaces it instead of silently
|
|
210
|
+
* emitting `string | number`. Compatible variants (nwf's escalation subset) produce no diagnostic.
|
|
211
|
+
*/
|
|
212
|
+
export function detectEnvelopeConflicts(workers, types) {
|
|
213
|
+
const collect = (pick, slot) => {
|
|
214
|
+
const byType = new Map();
|
|
215
|
+
for (const w of workers) {
|
|
216
|
+
const id = pick(w);
|
|
217
|
+
if (typeof w?.taskType !== "string" || !w.taskType || !id || !Object.hasOwn(types, id))
|
|
218
|
+
continue;
|
|
219
|
+
const list = byType.get(w.taskType) ?? [];
|
|
220
|
+
if (!list.includes(id))
|
|
221
|
+
list.push(id);
|
|
222
|
+
byType.set(w.taskType, list);
|
|
223
|
+
}
|
|
224
|
+
const conflicts = [];
|
|
225
|
+
for (const [taskType, ids] of byType.entries()) {
|
|
226
|
+
if (ids.length < 2)
|
|
227
|
+
continue;
|
|
228
|
+
const fieldNames = new Set();
|
|
229
|
+
for (const id of ids)
|
|
230
|
+
for (const name of Object.keys(types[id].fields))
|
|
231
|
+
fieldNames.add(name);
|
|
232
|
+
for (const field of fieldNames) {
|
|
233
|
+
const seen = new Map(); // signature -> envelope ids carrying it
|
|
234
|
+
for (const id of ids) {
|
|
235
|
+
if (!Object.hasOwn(types[id].fields, field))
|
|
236
|
+
continue; // absent in this variant — a presence difference, not a type conflict
|
|
237
|
+
const f = types[id].fields[field];
|
|
238
|
+
const sig = fieldSig(f);
|
|
239
|
+
const carriers = seen.get(sig) ?? [];
|
|
240
|
+
carriers.push(id);
|
|
241
|
+
seen.set(sig, carriers);
|
|
242
|
+
}
|
|
243
|
+
if (seen.size > 1) {
|
|
244
|
+
conflicts.push({
|
|
245
|
+
taskType,
|
|
246
|
+
slot,
|
|
247
|
+
field,
|
|
248
|
+
types: [...seen.keys()],
|
|
249
|
+
envelopes: ids.filter((id) => Object.hasOwn(types[id].fields, field)),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return conflicts;
|
|
255
|
+
};
|
|
256
|
+
return [...collect((w) => w.inputType, "in"), ...collect((w) => w.outputType, "out")];
|
|
257
|
+
}
|
|
258
|
+
/** Human-readable warning for one envelope conflict, for the gen warning stream. */
|
|
259
|
+
export function formatEnvelopeConflict(c) {
|
|
260
|
+
return (`data-envelope conflict on worker "${c.taskType}" (${c.slot}): field "${c.field}" is ` +
|
|
261
|
+
`${c.types.join(" vs ")} across envelopes ${c.envelopes.map((e) => `"${e}"`).join(", ")}. ` +
|
|
262
|
+
`The taskType's ${c.slot === "in" ? "input" : "output"} is the union of its envelopes; a ` +
|
|
263
|
+
`field must have one type across all of them. Unify the envelope or split the job type.`);
|
|
264
|
+
}
|
|
172
265
|
/**
|
|
173
266
|
* Overlay the model-derived worker-IO map onto the manifest `workers[]`. The model is authoritative
|
|
174
267
|
* for the envelope (`inputType`/`outputType`), so every scanned `taskType` takes its I/O from
|
package/dist/toolkit/fsio.js
CHANGED
|
@@ -31,6 +31,15 @@ export function createNodeGenIO() {
|
|
|
31
31
|
return [];
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
|
+
async listSubdirs(path) {
|
|
35
|
+
try {
|
|
36
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
37
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
},
|
|
34
43
|
async exists(path) {
|
|
35
44
|
try {
|
|
36
45
|
await stat(path);
|