@nanobpm/urban 0.52.0 → 0.53.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 +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- package/dist/runtime/core/modules/pages.js +397 -20
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- package/src/runtime/core/modules/pages.test.ts +309 -5
- package/src/runtime/core/modules/pages.ts +398 -20
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/index.ts +57 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- package/src/toolkit/models.ts +4 -3
package/src/runtime/core/glob.ts
CHANGED
|
@@ -63,12 +63,13 @@ export async function expandPatterns(
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Discover deployables under a convention directory (`resources/`), **
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* Discover deployables under a convention directory (`resources/`), **recursively** — every file
|
|
67
|
+
* directly under `dir` and under every sub-directory at any depth (issue #231). Recursive descent
|
|
68
|
+
* is safe because deploy now keys a convention resource by its path *relative to* `resources/`
|
|
69
|
+
* (POSIX), not its bare basename, so two files sharing a filename in different sub-dirs
|
|
70
|
+
* (`a/x.md`, `b/x.md`) resolve to distinct `resourceId`s and can never collide. Descent needs
|
|
71
|
+
* `host.listSubdirs`; a host that can't enumerate directories yields only the top-level files.
|
|
72
|
+
* Returns root-prefixed paths, sorted for determinism.
|
|
72
73
|
*/
|
|
73
74
|
export async function discoverResources(
|
|
74
75
|
host: HostContext,
|
|
@@ -77,11 +78,13 @@ export async function discoverResources(
|
|
|
77
78
|
): Promise<string[]> {
|
|
78
79
|
const base = joinPath(root, dir);
|
|
79
80
|
const out: string[] = [];
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
81
|
+
const walk = async (current: string): Promise<void> => {
|
|
82
|
+
for (const name of await host.listDir(current)) out.push(joinPath(current, name));
|
|
83
|
+
const subdirs = host.listSubdirs ? await host.listSubdirs(current) : [];
|
|
84
|
+
for (const sub of subdirs.slice().sort()) {
|
|
85
|
+
await walk(joinPath(current, sub));
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
await walk(base);
|
|
86
89
|
return out.sort();
|
|
87
90
|
}
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -267,7 +267,10 @@ export interface FormSchema {
|
|
|
267
267
|
* it against a live engine; tests implement it in-memory. Core modules depend only on this.
|
|
268
268
|
*/
|
|
269
269
|
export interface EngineClient {
|
|
270
|
-
/** Deploy model resources (BPMN/DMN/forms)
|
|
270
|
+
/** Deploy model resources (BPMN/DMN/forms) and generic resources (prompts, scripts, and other
|
|
271
|
+
* arbitrary files, content-typed e.g. `text/markdown`/`application/octet-stream`; the engine
|
|
272
|
+
* versions each generic resource per its `resourceId` — carried on `name`). Returns the number
|
|
273
|
+
* deployed. */
|
|
271
274
|
deployResources(
|
|
272
275
|
resources: { name: string; content: string; contentType: string }[],
|
|
273
276
|
): Promise<{ deployed: number }>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Tests for the app-scoped institutional-memory brief surface (ADR 0060 §2): `/app/agent` +
|
|
2
|
+
// `/app/agent.json` read through to the `urban gen`-derived `nano-generated/system-brief.*`.
|
|
3
|
+
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { mountAgent } from "./agent.ts";
|
|
7
|
+
import type { RuntimeContext } from "../context.ts";
|
|
8
|
+
import type { EngineClient, HostContext, HttpRequest, HttpResponse } from "../host.ts";
|
|
9
|
+
|
|
10
|
+
const fakeEngine: EngineClient = {
|
|
11
|
+
deployResources: async () => ({ deployed: 0 }),
|
|
12
|
+
createInstance: async () => ({ processInstanceKey: "pi" }),
|
|
13
|
+
cancelInstance: async () => {},
|
|
14
|
+
publishMessage: async () => {},
|
|
15
|
+
searchUserTasks: async () => [],
|
|
16
|
+
getForm: async () => null,
|
|
17
|
+
completeUserTask: async () => {},
|
|
18
|
+
searchProcessInstances: async () => [],
|
|
19
|
+
registerWorker: async (jobType) => ({ jobType, unsubscribe: async () => {} }),
|
|
20
|
+
close: async () => {},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** A RuntimeContext whose host resolves a fixed set of files (a missing path throws, like a real
|
|
24
|
+
* file port). Only the fields `mountAgent` touches are populated. */
|
|
25
|
+
function ctxWithFiles(files: Record<string, string>): RuntimeContext {
|
|
26
|
+
const host: HostContext = {
|
|
27
|
+
runtime: "node",
|
|
28
|
+
env: () => undefined,
|
|
29
|
+
readTextFile: async (p: string) => {
|
|
30
|
+
if (!(p in files)) throw new Error(`ENOENT ${p}`);
|
|
31
|
+
return files[p];
|
|
32
|
+
},
|
|
33
|
+
listDir: async () => [],
|
|
34
|
+
exists: async (p: string) => p in files,
|
|
35
|
+
openSqlite: () => {
|
|
36
|
+
throw new Error("sqlite not used in this test");
|
|
37
|
+
},
|
|
38
|
+
importModule: async () => ({}),
|
|
39
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
40
|
+
now: () => 0,
|
|
41
|
+
log: () => {},
|
|
42
|
+
};
|
|
43
|
+
return { root: ".", manifest: { schemaVersion: 1, id: "app", name: "App" }, host, engine: fakeEngine };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function call(route: { handler: (req: HttpRequest) => HttpResponse | Promise<HttpResponse> }): Promise<HttpResponse> {
|
|
47
|
+
return route.handler({
|
|
48
|
+
method: "GET",
|
|
49
|
+
path: "/",
|
|
50
|
+
query: new URLSearchParams(),
|
|
51
|
+
headers: new Headers(),
|
|
52
|
+
text: async () => "",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const routeAt = (ctx: RuntimeContext, path: string) => {
|
|
57
|
+
const r = mountAgent(ctx).routes.find((x) => x.path === path);
|
|
58
|
+
assert.ok(r, `expected an ${path} route`);
|
|
59
|
+
return r;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
test("GET /app/agent serves the derived system-brief.md as markdown", async () => {
|
|
63
|
+
const ctx = ctxWithFiles({ "./nano-generated/system-brief.md": "# App — system brief\n" });
|
|
64
|
+
const res = await call(routeAt(ctx, "/app/agent"));
|
|
65
|
+
assert.equal(res.status, 200);
|
|
66
|
+
assert.match(res.headers?.["content-type"] ?? "", /text\/markdown/);
|
|
67
|
+
assert.equal(res.body, "# App — system brief\n");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("GET /app/agent.json serves the derived system-brief.json verbatim", async () => {
|
|
71
|
+
const payload = '{\n "app": "App",\n "processes": ["p"]\n}\n';
|
|
72
|
+
const ctx = ctxWithFiles({ "./nano-generated/system-brief.json": payload });
|
|
73
|
+
const res = await call(routeAt(ctx, "/app/agent.json"));
|
|
74
|
+
assert.equal(res.status, 200);
|
|
75
|
+
assert.match(res.headers?.["content-type"] ?? "", /application\/json/);
|
|
76
|
+
assert.equal(res.body, payload);
|
|
77
|
+
assert.deepEqual(JSON.parse(res.body).processes, ["p"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("brief artifacts resolve under ctx.root (rooted host keys), not the raw cwd-relative path", async () => {
|
|
81
|
+
const ctx = ctxWithFiles({ "/srv/app/nano-generated/system-brief.md": "# rooted\n" });
|
|
82
|
+
ctx.root = "/srv/app";
|
|
83
|
+
const res = await call(routeAt(ctx, "/app/agent"));
|
|
84
|
+
assert.equal(res.status, 200);
|
|
85
|
+
assert.equal(res.body, "# rooted\n");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a missing brief (gen never ran) is a 404, not a crash", async () => {
|
|
89
|
+
const ctx = ctxWithFiles({});
|
|
90
|
+
const md = await call(routeAt(ctx, "/app/agent"));
|
|
91
|
+
const jsonRes = await call(routeAt(ctx, "/app/agent.json"));
|
|
92
|
+
assert.equal(md.status, 404);
|
|
93
|
+
assert.equal(jsonRes.status, 404);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a non-ENOENT read error surfaces (500 via the host) instead of being masked as a 404", async () => {
|
|
97
|
+
const ctx = ctxWithFiles({ "./nano-generated/system-brief.md": "unused" });
|
|
98
|
+
ctx.host.readTextFile = async () => {
|
|
99
|
+
throw new Error("EACCES: permission denied");
|
|
100
|
+
};
|
|
101
|
+
// exists() still reports the artifact present, so the handler must not swallow the read error
|
|
102
|
+
// into a 404 — it lets it propagate, which the host server turns into a 500.
|
|
103
|
+
await assert.rejects(call(routeAt(ctx, "/app/agent")), /EACCES/);
|
|
104
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// agent — the app-scoped institutional-memory brief surface (ADR 0060 §2). The app-level mirror
|
|
2
|
+
// of the node `/agent` brief (ADR 0051): where the node brief describes the whole runtime, this
|
|
3
|
+
// serves ONE app's derived `system-brief.md` / `system-brief.json` so an agent (or human) opening
|
|
4
|
+
// work on the app starts grounded in the app's own system model — its processes, service-task call
|
|
5
|
+
// graph, decisions, and ownership.
|
|
6
|
+
//
|
|
7
|
+
// GET /app/agent → nano-generated/system-brief.md (text/markdown)
|
|
8
|
+
// GET /app/agent.json → nano-generated/system-brief.json (application/json)
|
|
9
|
+
//
|
|
10
|
+
// The brief is derived by `urban gen` (systemBriefDeriver, wired in gen.ts) into the app's
|
|
11
|
+
// `nano-generated/` dir, so this route is a thin read-through of that artifact — no re-derivation
|
|
12
|
+
// at request time (the derived file is the single source of truth, kept honest by
|
|
13
|
+
// `urban gen --check`). Mounted unconditionally: the brief is a property of the app's models, not
|
|
14
|
+
// of any opt-in surface, so it is always addressable when a brief has been generated. An app whose
|
|
15
|
+
// models produced no brief (never ran gen) returns 404.
|
|
16
|
+
|
|
17
|
+
import { GENERATED_DIR } from "../../../toolkit/artifact.ts";
|
|
18
|
+
import { SYSTEM_BRIEF_JSON, SYSTEM_BRIEF_MD } from "../../../toolkit/derivers/system-brief.ts";
|
|
19
|
+
import type { RuntimeContext } from "../context.ts";
|
|
20
|
+
import type { HttpResponse } from "../host.ts";
|
|
21
|
+
import { json, type Route } from "../router.ts";
|
|
22
|
+
import { resolveAppPath } from "./datasource.ts";
|
|
23
|
+
|
|
24
|
+
/** The app-relative path of a generated brief artifact. */
|
|
25
|
+
const briefPath = (name: string): string => `${GENERATED_DIR}/${name}`;
|
|
26
|
+
|
|
27
|
+
export interface AgentHandle {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
routes: Route[];
|
|
30
|
+
describe(): Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function markdown(body: string): HttpResponse {
|
|
34
|
+
return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mount the `/app/agent` (+ `/app/agent.json`) institutional-memory brief routes. Reads the
|
|
39
|
+
* `urban gen`-derived artifacts through the host file port, resolving each under `ctx.root` via
|
|
40
|
+
* the shared `resolveAppPath` (like every other runtime module) so serving never depends on the
|
|
41
|
+
* host's current working directory. A genuinely absent artifact — checked with `host.exists`
|
|
42
|
+
* rather than a blanket catch — yields a 404 (gen never ran, or the app has no models); any other
|
|
43
|
+
* read failure (permissions, transient I/O) is left to surface as a 500 rather than being masked
|
|
44
|
+
* as "not generated".
|
|
45
|
+
*/
|
|
46
|
+
export function mountAgent(ctx: RuntimeContext): AgentHandle {
|
|
47
|
+
const artifactPath = (name: string): string => resolveAppPath(ctx.root, briefPath(name));
|
|
48
|
+
const notGenerated = (): HttpResponse =>
|
|
49
|
+
json({ error: "system brief not generated — run `urban gen`" }, 404);
|
|
50
|
+
const serve = async (name: string, toResponse: (body: string) => HttpResponse): Promise<HttpResponse> => {
|
|
51
|
+
const path = artifactPath(name);
|
|
52
|
+
if (!(await ctx.host.exists(path))) return notGenerated();
|
|
53
|
+
return toResponse(await ctx.host.readTextFile(path));
|
|
54
|
+
};
|
|
55
|
+
const routes: Route[] = [
|
|
56
|
+
{
|
|
57
|
+
method: "GET",
|
|
58
|
+
path: "/app/agent",
|
|
59
|
+
source: "surface:agent",
|
|
60
|
+
handler: () => serve(SYSTEM_BRIEF_MD, markdown),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
method: "GET",
|
|
64
|
+
path: "/app/agent.json",
|
|
65
|
+
source: "surface:agent",
|
|
66
|
+
handler: () =>
|
|
67
|
+
serve(SYSTEM_BRIEF_JSON, (body) => ({
|
|
68
|
+
status: 200,
|
|
69
|
+
headers: { "content-type": "application/json" },
|
|
70
|
+
body,
|
|
71
|
+
})),
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
ctx.host.log("info", "agent brief surface mounted", { routes: routes.length });
|
|
75
|
+
return { name: "agent", routes, describe: () => ({ routes: routes.map((r) => r.path) }) };
|
|
76
|
+
}
|
|
@@ -26,8 +26,12 @@ interface Harness {
|
|
|
26
26
|
|
|
27
27
|
/** A virtual-filesystem host over `files` (keyed by absolute path) plus a recording engine. The
|
|
28
28
|
* host implements `listDir` (files under a dir) and `listSubdirs` (immediate sub-directories), so
|
|
29
|
-
* the convention walk (`resources
|
|
30
|
-
function makeHarness(
|
|
29
|
+
* the recursive convention walk (every file under `resources/` at any depth) can be exercised. */
|
|
30
|
+
function makeHarness(
|
|
31
|
+
files: Record<string, string>,
|
|
32
|
+
manifest: Partial<AppManifest>,
|
|
33
|
+
engineOverride?: { deployResources: (r: DeployedResource[]) => Promise<{ deployed: number }> },
|
|
34
|
+
): Harness {
|
|
31
35
|
const logs: Harness["logs"] = [];
|
|
32
36
|
const deployed: DeployedResource[] = [];
|
|
33
37
|
const dirsUnder = (dir: string): Set<string> => {
|
|
@@ -58,7 +62,7 @@ function makeHarness(files: Record<string, string>, manifest: Partial<AppManifes
|
|
|
58
62
|
.map((f) => f.slice(f.lastIndexOf("/") + 1)),
|
|
59
63
|
listSubdirs: async (dir: string) => [...dirsUnder(dir)],
|
|
60
64
|
} as unknown as HostContext;
|
|
61
|
-
const engine = {
|
|
65
|
+
const engine = engineOverride ?? {
|
|
62
66
|
deployResources: async (resources: DeployedResource[]) => {
|
|
63
67
|
deployed.push(...resources);
|
|
64
68
|
return { deployed: resources.length };
|
|
@@ -138,43 +142,107 @@ test("deployModels discovers resources/ by convention when no models are declare
|
|
|
138
142
|
{},
|
|
139
143
|
);
|
|
140
144
|
const res = await deployModels(ctx);
|
|
145
|
+
// Convention resources are keyed by their path RELATIVE to resources/ (POSIX), not the basename.
|
|
141
146
|
const names = deployed.map((d) => d.name).sort();
|
|
142
|
-
assert.deepEqual(names, ["decide.dmn", "greet.form", "order.bpmn", "review.md"]);
|
|
147
|
+
assert.deepEqual(names, ["decide.dmn", "forms/greet.form", "order.bpmn", "prompts/review.md"]);
|
|
143
148
|
assert.equal(res.deployed, 4);
|
|
144
|
-
// A prompt (.md under resources/) is a
|
|
145
|
-
|
|
146
|
-
|
|
149
|
+
// A prompt (.md under resources/) is a generic resource content-typed text/markdown, deployed
|
|
150
|
+
// verbatim and keyed by its relative-path resourceId.
|
|
151
|
+
const prompt = deployed.find((d) => d.name === "prompts/review.md");
|
|
152
|
+
assert.equal(prompt?.contentType, "text/markdown");
|
|
147
153
|
assert.equal(prompt?.content, "Do the review");
|
|
148
154
|
assert.ok(logs.some((l) => l.fields?.byConvention === true));
|
|
149
155
|
});
|
|
150
156
|
|
|
151
|
-
test("deployModels convention walk is
|
|
157
|
+
test("deployModels convention walk is recursive — every file at any depth", async () => {
|
|
152
158
|
const { ctx, deployed } = makeHarness(
|
|
153
159
|
{
|
|
154
160
|
"/app/resources/top.bpmn": "<a/>",
|
|
155
161
|
"/app/resources/sub/one.bpmn": "<b/>",
|
|
156
|
-
// Two levels deep:
|
|
162
|
+
// Two levels deep: now swept in (recursive-by-convention, issue #231).
|
|
157
163
|
"/app/resources/sub/deeper/two.bpmn": "<c/>",
|
|
158
164
|
},
|
|
159
165
|
{},
|
|
160
166
|
);
|
|
161
167
|
await deployModels(ctx);
|
|
162
168
|
const names = deployed.map((d) => d.name).sort();
|
|
163
|
-
assert.deepEqual(names, ["one.bpmn", "top.bpmn"]);
|
|
169
|
+
assert.deepEqual(names, ["sub/deeper/two.bpmn", "sub/one.bpmn", "top.bpmn"]);
|
|
164
170
|
});
|
|
165
171
|
|
|
166
|
-
test("deployModels
|
|
167
|
-
|
|
172
|
+
test("deployModels keys same-named files in different subdirs as distinct relative-path resources", async () => {
|
|
173
|
+
// resources/a/x.md and resources/b/x.md share a basename but deploy as distinct resourceIds —
|
|
174
|
+
// no collision (issue #231).
|
|
175
|
+
const { ctx, deployed } = makeHarness(
|
|
168
176
|
{
|
|
169
|
-
"/app/resources/a/order.
|
|
170
|
-
"/app/resources/b/order.
|
|
177
|
+
"/app/resources/a/order.md": "<a/>",
|
|
178
|
+
"/app/resources/b/order.md": "<b/>",
|
|
171
179
|
},
|
|
172
180
|
{},
|
|
173
181
|
);
|
|
174
|
-
await
|
|
182
|
+
const res = await deployModels(ctx);
|
|
183
|
+
assert.equal(res.deployed, 2);
|
|
184
|
+
const byName = Object.fromEntries(deployed.map((d) => [d.name, d]));
|
|
185
|
+
assert.equal(byName["a/order.md"].content, "<a/>");
|
|
186
|
+
assert.equal(byName["b/order.md"].content, "<b/>");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("deployModels infers a content type per extension for generic resources", async () => {
|
|
190
|
+
const { ctx, deployed } = makeHarness(
|
|
191
|
+
{
|
|
192
|
+
"/app/resources/prompts/plan.md": "# plan",
|
|
193
|
+
"/app/resources/data/config.json": "{}",
|
|
194
|
+
"/app/resources/notes/readme.txt": "hi",
|
|
195
|
+
"/app/resources/bin/blob.rpa": "\u0000binary",
|
|
196
|
+
},
|
|
197
|
+
{},
|
|
198
|
+
);
|
|
199
|
+
await deployModels(ctx);
|
|
200
|
+
const byName = Object.fromEntries(deployed.map((d) => [d.name, d]));
|
|
201
|
+
assert.equal(byName["prompts/plan.md"].contentType, "text/markdown");
|
|
202
|
+
assert.equal(byName["data/config.json"].contentType, "application/json");
|
|
203
|
+
assert.equal(byName["notes/readme.txt"].contentType, "text/plain");
|
|
204
|
+
// Unknown extension → octet-stream, deployed verbatim (never mislabelled/mangled).
|
|
205
|
+
assert.equal(byName["bin/blob.rpa"].contentType, "application/octet-stream");
|
|
206
|
+
assert.equal(byName["bin/blob.rpa"].content, "\u0000binary");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("deployModels deploys a generic resource verbatim (no {{token}} substitution)", async () => {
|
|
210
|
+
const { ctx, deployed } = makeHarness(
|
|
211
|
+
{ "/app/resources/prompts/review.md": "Review {{pr}} carefully" },
|
|
212
|
+
{},
|
|
213
|
+
);
|
|
214
|
+
await deployModels(ctx);
|
|
215
|
+
assert.equal(deployed[0].content, "Review {{pr}} carefully");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("deployModels redeploy is idempotent per resourceId+content, new content bumps the version", async () => {
|
|
219
|
+
// Model the engine's name+checksum duplicate rule: identical resourceId+content is skipped (no
|
|
220
|
+
// version bump); changed content advances the latest pointer (issue #231 acceptance).
|
|
221
|
+
const versions = new Map<string, string[]>();
|
|
222
|
+
const engine = {
|
|
223
|
+
deployResources: async (resources: DeployedResource[]) => {
|
|
224
|
+
let deployed = 0;
|
|
225
|
+
for (const r of resources) {
|
|
226
|
+
const history = versions.get(r.name) ?? [];
|
|
227
|
+
if (history[history.length - 1] !== r.content) {
|
|
228
|
+
history.push(r.content);
|
|
229
|
+
versions.set(r.name, history);
|
|
230
|
+
deployed++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { deployed };
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
const files: Record<string, string> = { "/app/resources/prompts/plan.md": "v1" };
|
|
237
|
+
const mk = () => makeHarness(files, {}, engine).ctx;
|
|
238
|
+
assert.equal((await deployModels(mk())).deployed, 1); // first deploy → v1
|
|
239
|
+
assert.equal((await deployModels(mk())).deployed, 0); // unchanged → no-op
|
|
240
|
+
files["/app/resources/prompts/plan.md"] = "v2";
|
|
241
|
+
assert.equal((await deployModels(mk())).deployed, 1); // changed → new version
|
|
242
|
+
assert.deepEqual(versions.get("prompts/plan.md"), ["v1", "v2"]);
|
|
175
243
|
});
|
|
176
244
|
|
|
177
|
-
test("deployModels errors on a basename collision under an explicit override
|
|
245
|
+
test("deployModels errors on a basename collision under an explicit override", async () => {
|
|
178
246
|
const { ctx } = makeHarness(
|
|
179
247
|
{
|
|
180
248
|
"/app/processes/order.bpmn": "<a/>",
|
|
@@ -2,26 +2,53 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Resources are found by ONE of two paths:
|
|
4
4
|
// 1. Convention (the default, ADR 0062): when the manifest declares no `models`, deployables are
|
|
5
|
-
// discovered by walking `resources/` —
|
|
6
|
-
// everything under it (and nothing outside it) is
|
|
7
|
-
// top-level `*.md`) live outside `resources/` and are
|
|
5
|
+
// discovered by walking `resources/` **recursively** — every file under it, at any depth
|
|
6
|
+
// (issue #231). `resources/` is deploy-only: everything under it (and nothing outside it) is
|
|
7
|
+
// deployed. Docs (`docs/`, `AGENTS.md`, top-level `*.md`) live outside `resources/` and are
|
|
8
|
+
// never swept in. A convention resource's deploy key (`resourceId`) is its path **relative to
|
|
9
|
+
// `resources/`** (POSIX-normalised), e.g. `resources/prompts/plan.md` → `prompts/plan.md`, so
|
|
10
|
+
// a linked service task references it with `zeebe:linkedResource resourceId="prompts/plan.md"`.
|
|
11
|
+
// The relative path (not the bare basename) preserves sub-directory structure and lets two
|
|
12
|
+
// files sharing a filename in different sub-dirs (`a/x.md`, `b/x.md`) deploy as distinct
|
|
13
|
+
// resources — there is no basename-collision hazard.
|
|
8
14
|
// 2. Explicit override: when the manifest declares `models` globs, those are used exactly (the
|
|
9
|
-
// convention walk is skipped) — the escape hatch for a non-convention layout.
|
|
15
|
+
// convention walk is skipped) — the escape hatch for a non-convention layout. Override
|
|
16
|
+
// resources are keyed by basename (filename only), so a basename collision across the declared
|
|
17
|
+
// globs is a hard error, detected up front.
|
|
10
18
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
19
|
+
// Content is deployed **verbatim** — there is no deploy-time `{{token}}` substitution (removed in
|
|
20
|
+
// ADR 0062) — so a generic resource (a prompt `.md`, an RPA/script file) is deployed as-is.
|
|
21
|
+
// The deploy pipeline is **UTF-8 text only**, end-to-end: `host.readTextFile()` yields a `string`
|
|
22
|
+
// and the engine's `deployResources` takes `content: string`, so there is no byte channel and
|
|
23
|
+
// arbitrary binary is out of scope by design. "Verbatim" therefore means the UTF-8 text is passed
|
|
24
|
+
// through unchanged; a non-UTF-8/binary file swept into `resources/` is not a supported input (see
|
|
25
|
+
// `contentTypeFor`'s `application/octet-stream` note).
|
|
14
26
|
|
|
15
27
|
import type { RuntimeContext } from "../context.ts";
|
|
16
28
|
import { discoverResources, expandPatterns } from "../glob.ts";
|
|
17
29
|
|
|
18
|
-
/** The convention directory: deploy-only, walked
|
|
30
|
+
/** The convention directory: deploy-only, walked recursively when no `models` are declared. */
|
|
19
31
|
export const RESOURCES_DIR = "resources";
|
|
20
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Classify a file into an engine content type by extension. Models get their real XML/JSON types;
|
|
35
|
+
* generic resources get a real text content type where the extension is known (`.md`, `.json`,
|
|
36
|
+
* `.txt`), falling back to `application/octet-stream` for anything unrecognised (so an unknown
|
|
37
|
+
* extension is never mislabelled as a specific text type). Note the deploy pipeline is UTF-8 text
|
|
38
|
+
* only (`readTextFile` → engine `content: string`): the `octet-stream` fallback is a conservative
|
|
39
|
+
* MIME label for an unrecognised *text* resource (e.g. a script with an uncommon extension), not a
|
|
40
|
+
* promise of binary fidelity — a genuinely binary file cannot be preserved byte-for-byte and is
|
|
41
|
+
* out of scope. The engine deploys a non-model content type as a generic resource
|
|
42
|
+
* (versioned per `resourceId`); a service task resolves it via
|
|
43
|
+
* `zeebe:linkedResource resourceId="<id>" bindingType:latest`.
|
|
44
|
+
*/
|
|
21
45
|
function contentTypeFor(path: string): string {
|
|
22
46
|
if (path.endsWith(".bpmn")) return "text/xml";
|
|
23
47
|
if (path.endsWith(".dmn")) return "text/xml";
|
|
24
48
|
if (path.endsWith(".form")) return "application/json";
|
|
49
|
+
if (path.endsWith(".md")) return "text/markdown";
|
|
50
|
+
if (path.endsWith(".json")) return "application/json";
|
|
51
|
+
if (path.endsWith(".txt")) return "text/plain";
|
|
25
52
|
return "application/octet-stream";
|
|
26
53
|
}
|
|
27
54
|
|
|
@@ -30,8 +57,19 @@ function baseName(path: string): string {
|
|
|
30
57
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
31
58
|
}
|
|
32
59
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
60
|
+
/**
|
|
61
|
+
* A convention resource's `resourceId`: its path relative to `<root>/resources/` (POSIX). Given a
|
|
62
|
+
* root-prefixed discovered path (`/app/resources/prompts/plan.md`) it returns `prompts/plan.md`.
|
|
63
|
+
* Falls back to the basename if — defensively — the path is not under the resources prefix.
|
|
64
|
+
*/
|
|
65
|
+
function relativeResourceId(path: string, root: string): string {
|
|
66
|
+
const prefix = `${root.replace(/\/+$/, "")}/${RESOURCES_DIR}/`;
|
|
67
|
+
return path.startsWith(prefix) ? path.slice(prefix.length) : baseName(path);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fail loudly when two resolved files share a basename (the override dedupe key), naming the
|
|
71
|
+
* colliding paths so the author can rename or relocate one. Only relevant for the `models`
|
|
72
|
+
* override path; convention resources are keyed by relative path and cannot collide. */
|
|
35
73
|
function assertNoBasenameCollisions(files: string[]): void {
|
|
36
74
|
const byName = new Map<string, string[]>();
|
|
37
75
|
for (const path of files) {
|
|
@@ -52,11 +90,14 @@ function assertNoBasenameCollisions(files: string[]): void {
|
|
|
52
90
|
}
|
|
53
91
|
|
|
54
92
|
/**
|
|
55
|
-
* Deploy the app's
|
|
93
|
+
* Deploy the app's resources.
|
|
56
94
|
*
|
|
57
|
-
* With no manifest `models`, deployables are discovered by convention under `resources/`
|
|
58
|
-
*
|
|
59
|
-
*
|
|
95
|
+
* With no manifest `models`, deployables are discovered by convention under `resources/`
|
|
96
|
+
* (recursively — every file at any depth) and each is keyed by its path relative to `resources/`
|
|
97
|
+
* (POSIX). With `models` globs declared, those are used verbatim and keyed by basename (a basename
|
|
98
|
+
* collision is a hard error). Content is deployed verbatim (no template substitution); each file is
|
|
99
|
+
* content-typed by extension so a generic resource (e.g. a prompt `.md`) deploys as a versioned
|
|
100
|
+
* generic resource the engine resolves via `zeebe:linkedResource bindingType:latest`.
|
|
60
101
|
*/
|
|
61
102
|
export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: number; files: string[] }> {
|
|
62
103
|
const models = ctx.manifest.models;
|
|
@@ -81,10 +122,12 @@ export async function deployModels(ctx: RuntimeContext): Promise<{ deployed: num
|
|
|
81
122
|
}
|
|
82
123
|
return { deployed: 0, files: [] };
|
|
83
124
|
}
|
|
84
|
-
|
|
125
|
+
// Convention resources are keyed by their (unique) relative path; only the override path, whose
|
|
126
|
+
// basename key can collide across globs, needs the collision guard.
|
|
127
|
+
if (!byConvention) assertNoBasenameCollisions(files);
|
|
85
128
|
const resources = await Promise.all(
|
|
86
129
|
files.map(async (path) => ({
|
|
87
|
-
name: baseName(path),
|
|
130
|
+
name: byConvention ? relativeResourceId(path, ctx.root) : baseName(path),
|
|
88
131
|
content: await ctx.host.readTextFile(path),
|
|
89
132
|
contentType: contentTypeFor(path),
|
|
90
133
|
})),
|