Skip to content

Commit 031fc9e

Browse files
feat(workflow-mount): run-scoped routes and agent tools accept metadata (#19)
The run-scoped create (text and binary) and revise bodies accept optional metadata with the browser-facing semantics: omitted carries the previous version's forward, null clears, an object replaces. artifact_create and artifact_write take an optional metadata object and the sidecar bundle passes it through. Provenance stays server-stamped: source, producing run, tenant and principal are built only from the resolved run scope. The revise body now requires at least one of title, content or metadata. CL-8722
1 parent 0190e6c commit 031fc9e

10 files changed

Lines changed: 287 additions & 2 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@ always called out under their own heading.
1111

1212
### Added
1313

14+
- Run-scoped `POST /artifacts`, `POST /artifacts/binary`, and
15+
`PATCH /artifacts/:id` (`mountWorkflowArtifacts`) accept an optional
16+
`metadata` field, matching `mountArtifacts`' semantics exactly: omitted on
17+
a revise carries the prior version's metadata forward, an explicit `null`
18+
clears it, and any other value must be a JSON object or the request is
19+
`400`. `artifact_create` and `artifact_write` in `ARTIFACT_TOOL_DEFINITIONS`
20+
gain a matching optional `metadata` object parameter, and the sidecar
21+
bundle forwards it to the route unchanged. `source` (`{ origin: "workflow",
22+
runId }`) and `generatedBy` stay server-stamped from the resolved run
23+
scope — never read from `metadata` or any other body field.
1424
- `artifact_version.content_sha256` (text, nullable), added by the new
1525
`0005_version_content_digest` migration and mirrored onto
1626
`artifact.content_sha256` the same way `metadata` already mirrors. Computed

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ cover every entry in `ARTIFACT_TOOL_DEFINITIONS`. Every route is authenticated;
121121
is no unauthenticated case here the way collection reads have one on the
122122
tenant-session mount, since a workflow run always presents credentials.
123123

124+
`POST /artifacts`, `POST /artifacts/binary`, and `PATCH /artifacts/:id` each accept an
125+
optional `metadata` field with the same semantics `mountArtifacts` validates: omitted on
126+
a revise carries the prior version's metadata forward, an explicit `null` clears it, and
127+
any other value must be a JSON object or the request is `400`. `source` (`{ origin:
128+
"workflow", runId }`) and `generatedBy` are always server-stamped from the resolved run
129+
scope — a body field of either name is never read, so a workflow run cannot mint
130+
provenance for itself.
131+
124132
### Agent tokens
125133

126134
A deployed agent has no sidecar token of its own. Pass `agentToken` and a bearer the
@@ -150,6 +158,11 @@ calls the routes above through that mediated fetch with the run address header.
150158
credential is the agent's own hub token; the bundle never sees the secret and never
151159
names a host — the handle's origin pin resolves its relative paths.
152160

161+
`artifact_create` and `artifact_write` both take an optional `metadata` object —
162+
application metadata stored with the version, e.g. which project and stage this belongs
163+
to — which the bundle forwards to the route unchanged, omitting the field entirely when
164+
the model does not supply one.
165+
153166
**Rate limiting is host-side.** `mountWorkflowArtifacts` mints no per-run quota — a host
154167
that wants one wraps `resolveRunScope` (returning `null` to reject) or puts its own
155168
middleware in front of the mounted app. `maxContentChars` (default 64,000) and

‎src/sidecar-bundle.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,60 @@ describe("the artifacts sidecar bundle", () => {
8181
});
8282
});
8383

84+
test("passes metadata through to the create route unchanged", async () => {
85+
const recorded: Recorded[] = [];
86+
const bundle = artifacts(env(recorded, ok));
87+
await bundle.run(
88+
{
89+
id: "call-meta",
90+
name: "artifact_create",
91+
arguments: {
92+
title: "Notes",
93+
kind: "document",
94+
content: "body",
95+
metadata: { project: "acme-onboarding", stage: "draft" },
96+
},
97+
},
98+
signal,
99+
);
100+
expect(JSON.parse(String(recorded[0]?.init?.body))).toEqual({
101+
title: "Notes",
102+
kind: "document",
103+
content: "body",
104+
metadata: { project: "acme-onboarding", stage: "draft" },
105+
});
106+
});
107+
108+
test("passes metadata through to the write route unchanged", async () => {
109+
const recorded: Recorded[] = [];
110+
const bundle = artifacts(env(recorded, ok));
111+
await bundle.run(
112+
{
113+
id: "call-meta-write",
114+
name: "artifact_write",
115+
arguments: { artifactId: "a1", metadata: { stage: "final" } },
116+
},
117+
signal,
118+
);
119+
expect(JSON.parse(String(recorded[0]?.init?.body))).toEqual({
120+
metadata: { stage: "final" },
121+
});
122+
});
123+
124+
test("omitting metadata omits it from the request body", async () => {
125+
const recorded: Recorded[] = [];
126+
const bundle = artifacts(env(recorded, ok));
127+
await bundle.run(
128+
{
129+
id: "call-no-meta",
130+
name: "artifact_create",
131+
arguments: { title: "Notes", kind: "document", content: "body" },
132+
},
133+
signal,
134+
);
135+
expect(JSON.parse(String(recorded[0]?.init?.body))).not.toHaveProperty("metadata");
136+
});
137+
84138
test("reads a pinned version through the read route", async () => {
85139
const recorded: Recorded[] = [];
86140
const bundle = artifacts(env(recorded, ok));

‎src/sidecar-bundle.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ function requestFor(name: string, args: Record<string, unknown>): Request_ | und
6161
return {
6262
method: "POST",
6363
path: "/artifacts",
64-
body: { title: args["title"], kind: args["kind"], content: args["content"] },
64+
body: {
65+
title: args["title"],
66+
kind: args["kind"],
67+
content: args["content"],
68+
...(args["metadata"] !== undefined ? { metadata: args["metadata"] } : {}),
69+
},
6570
};
6671
case "artifact_link_file":
6772
return {
@@ -98,6 +103,7 @@ function requestFor(name: string, args: Record<string, unknown>): Request_ | und
98103
body: {
99104
...(str(args["title"]) !== undefined ? { title: args["title"] } : {}),
100105
...(typeof args["content"] === "string" ? { content: args["content"] } : {}),
106+
...(args["metadata"] !== undefined ? { metadata: args["metadata"] } : {}),
101107
},
102108
};
103109
case "artifact_list":

‎src/tools.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,18 @@ describe("tool definitions", () => {
223223
}
224224
});
225225

226+
test("artifact_create and artifact_write both declare an optional metadata object", () => {
227+
for (const name of ["artifact_create", "artifact_write"]) {
228+
const definition = ARTIFACT_TOOL_DEFINITIONS.find((d) => d.name === name)!;
229+
expect(definition.inputSchema.properties["metadata"]).toEqual({
230+
type: "object",
231+
description:
232+
"Optional application metadata stored with the version, e.g. which project and stage this belongs to.",
233+
});
234+
expect(definition.inputSchema.required).not.toContain("metadata");
235+
}
236+
});
237+
226238
test("only the mutating tools declare a write side effect", () => {
227239
const writes = ARTIFACT_TOOL_DEFINITIONS.filter((d) => d.sideEffect === "write").map(
228240
(d) => d.name,

‎src/tools.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,11 @@ export const ARTIFACT_TOOL_DEFINITIONS: readonly ArtifactToolDefinition[] = [
289289
"Artifact kind, such as document, email, memo, note, or web_site for a multi-file static site stored as JSON { entry?, files: { path: content } }.",
290290
},
291291
content: { type: "string", description: "The full text content." },
292+
metadata: {
293+
type: "object",
294+
description:
295+
"Optional application metadata stored with the version, e.g. which project and stage this belongs to.",
296+
},
292297
},
293298
required: ["title", "kind", "content"],
294299
},
@@ -373,6 +378,11 @@ export const ARTIFACT_TOOL_DEFINITIONS: readonly ArtifactToolDefinition[] = [
373378
artifactId: { type: "string", description: "The artifact id to revise." },
374379
title: { type: "string", description: "New title." },
375380
content: { type: "string", description: "New full content." },
381+
metadata: {
382+
type: "object",
383+
description:
384+
"Optional application metadata stored with the version, e.g. which project and stage this belongs to.",
385+
},
376386
},
377387
required: ["artifactId"],
378388
},

‎src/uploads.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,22 @@ describe("createFileArtifact", () => {
234234
expect(row.version).toBe(1);
235235
});
236236

237+
test("stores an opaque metadata object on version 1 when given one", async () => {
238+
const db = await testDb();
239+
const row = await db.transaction((tx) =>
240+
createFileArtifact(tx, InlineContentStore, {
241+
scope: SCOPE,
242+
ownerPrincipalId: SCOPE.principalId,
243+
filename: "report.pdf",
244+
mimeType: "application/pdf",
245+
bytes: new Uint8Array([1, 2, 3]),
246+
policy: ARTIFACT_UPLOAD_POLICY,
247+
metadata: { project: "acme-onboarding" },
248+
}),
249+
);
250+
expect(row.metadata).toEqual({ project: "acme-onboarding" });
251+
});
252+
237253
test("a caller that fails after storing leaves neither an upload nor an artifact", async () => {
238254
const db = await testDb();
239255
await expect(

‎src/uploads.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,8 @@ export async function createFileArtifact(
222222
policy: UploadPolicy;
223223
origin?: string;
224224
generatedBy?: string;
225+
/** Opaque to the package; stored on version 1 exactly like `createArtifact`'s. */
226+
metadata?: Record<string, unknown> | null;
225227
},
226228
): Promise<ArtifactRow> {
227229
if (!args.policy.accepts(args.mimeType)) {
@@ -248,5 +250,6 @@ export async function createFileArtifact(
248250
...(args.generatedBy !== undefined ? { generatedBy: args.generatedBy } : {}),
249251
...stored.source,
250252
},
253+
...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
251254
});
252255
}

‎src/workflow-mount.test.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ const json = (body: unknown, headers: Record<string, string> = authed) => ({
4141
body: JSON.stringify(body),
4242
});
4343

44+
const patchJson = (body: unknown, headers: Record<string, string> = authed) => ({
45+
method: "PATCH",
46+
headers: { "content-type": "application/json", ...headers },
47+
body: JSON.stringify(body),
48+
});
49+
4450
describe("auth", () => {
4551
test("401s with no bearer token", async () => {
4652
const db = await testDb();
@@ -90,6 +96,59 @@ describe("POST /artifacts", () => {
9096
expect(res.status).toBe(400);
9197
});
9298

99+
test("stores an opaque metadata object on version 1", async () => {
100+
const db = await testDb();
101+
const app = host(db);
102+
const res = await app.request(
103+
"/artifacts",
104+
json({
105+
title: "Brief",
106+
kind: "document",
107+
content: "hello",
108+
metadata: { project: "acme-onboarding", stage: "draft" },
109+
}),
110+
);
111+
expect(res.status).toBe(201);
112+
const { data } = (await res.json()) as { data: { id: string; version: number } };
113+
expect(data.version).toBe(1);
114+
115+
const row = await getArtifact(db, data.id);
116+
expect(row?.metadata).toEqual({ project: "acme-onboarding", stage: "draft" });
117+
});
118+
119+
test("rejects a metadata value that is not a JSON object", async () => {
120+
const db = await testDb();
121+
const app = host(db);
122+
for (const metadata of ["a string", 42, ["array"]]) {
123+
const res = await app.request(
124+
"/artifacts",
125+
json({ title: "Brief", kind: "document", content: "hello", metadata }),
126+
);
127+
expect(res.status).toBe(400);
128+
}
129+
});
130+
131+
test("a body trying to smuggle source/runId is ignored: source stays server-stamped", async () => {
132+
const db = await testDb();
133+
const app = host(db);
134+
const res = await app.request(
135+
"/artifacts",
136+
json({
137+
title: "Brief",
138+
kind: "document",
139+
content: "hello",
140+
source: { origin: "manual" },
141+
runId: "attacker-run",
142+
generatedBy: "attacker",
143+
}),
144+
);
145+
expect(res.status).toBe(201);
146+
const { data } = (await res.json()) as { data: { id: string } };
147+
148+
const row = await getArtifact(db, data.id);
149+
expect(row?.source).toEqual({ origin: "workflow", runId: "run-1" });
150+
});
151+
93152
test("413s content over the configured character ceiling", async () => {
94153
const db = await testDb();
95154
const app = mountWorkflowArtifacts(new Hono<WorkflowArtifactEnv>(), {
@@ -179,6 +238,27 @@ describe("POST /artifacts/binary", () => {
179238
expect((row?.source as { generatedBy?: string })?.generatedBy).toBe("run-1");
180239
});
181240

241+
test("stores an opaque metadata object on version 1", async () => {
242+
const db = await testDb();
243+
const app = host(db);
244+
const contentBase64 = Buffer.from("<h1>rendered</h1>").toString("base64");
245+
246+
const res = await app.request(
247+
"/artifacts/binary",
248+
json({
249+
filename: "brief.html",
250+
mimeType: "text/html",
251+
contentBase64,
252+
metadata: { project: "acme-onboarding" },
253+
}),
254+
);
255+
expect(res.status).toBe(201);
256+
const { data } = (await res.json()) as { data: { id: string } };
257+
258+
const row = await getArtifact(db, data.id);
259+
expect(row?.metadata).toEqual({ project: "acme-onboarding" });
260+
});
261+
182262
test("415s an unsupported file type", async () => {
183263
const db = await testDb();
184264
const app = host(db);
@@ -212,3 +292,64 @@ describe("POST /artifacts/binary", () => {
212292
expect(res.status).toBe(413);
213293
});
214294
});
295+
296+
describe("PATCH /artifacts/:id", () => {
297+
test("sets metadata and returns it in the response", async () => {
298+
const db = await testDb();
299+
const app = host(db);
300+
const row = await seedArtifact(db, { tenantId: "acme", title: "Draft", content: "v1" });
301+
302+
const res = await app.request(
303+
`/artifacts/${row.id}`,
304+
patchJson({ content: "v2", metadata: { stage: "review" } }),
305+
);
306+
expect(res.status).toBe(200);
307+
const { data } = (await res.json()) as { data: { version: number } };
308+
expect(data.version).toBe(2);
309+
310+
const updated = await getArtifact(db, row.id);
311+
expect(updated?.metadata).toEqual({ stage: "review" });
312+
});
313+
314+
test("omitting metadata carries the prior version's metadata forward", async () => {
315+
const db = await testDb();
316+
const app = host(db);
317+
const row = await seedArtifact(db, { tenantId: "acme", title: "Draft", content: "v1" });
318+
await app.request(`/artifacts/${row.id}`, patchJson({ metadata: { stage: "draft" } }));
319+
320+
await app.request(`/artifacts/${row.id}`, patchJson({ content: "v3" }));
321+
322+
const updated = await getArtifact(db, row.id);
323+
expect(updated?.metadata).toEqual({ stage: "draft" });
324+
});
325+
326+
test("an explicit null metadata clears it", async () => {
327+
const db = await testDb();
328+
const app = host(db);
329+
const row = await seedArtifact(db, { tenantId: "acme", title: "Draft", content: "v1" });
330+
await app.request(`/artifacts/${row.id}`, patchJson({ metadata: { stage: "draft" } }));
331+
332+
await app.request(`/artifacts/${row.id}`, patchJson({ metadata: null }));
333+
334+
const updated = await getArtifact(db, row.id);
335+
expect(updated?.metadata).toBeNull();
336+
});
337+
338+
test("rejects a metadata value that is not a JSON object", async () => {
339+
const db = await testDb();
340+
const app = host(db);
341+
const row = await seedArtifact(db, { tenantId: "acme" });
342+
343+
const res = await app.request(`/artifacts/${row.id}`, patchJson({ metadata: "nope" }));
344+
expect(res.status).toBe(400);
345+
});
346+
347+
test("rejects an empty body with no title, content, or metadata", async () => {
348+
const db = await testDb();
349+
const app = host(db);
350+
const row = await seedArtifact(db, { tenantId: "acme" });
351+
352+
const res = await app.request(`/artifacts/${row.id}`, patchJson({}));
353+
expect(res.status).toBe(400);
354+
});
355+
});

0 commit comments

Comments
 (0)