diff --git a/CHANGELOG.md b/CHANGELOG.md index 09465b5..759e649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ always called out under their own heading. ### Added +- Run-scoped `POST /artifacts`, `POST /artifacts/binary`, and + `PATCH /artifacts/:id` (`mountWorkflowArtifacts`) accept an optional + `metadata` field, matching `mountArtifacts`' semantics exactly: omitted on + a revise carries the prior version's metadata forward, an explicit `null` + clears it, and any other value must be a JSON object or the request is + `400`. `artifact_create` and `artifact_write` in `ARTIFACT_TOOL_DEFINITIONS` + gain a matching optional `metadata` object parameter, and the sidecar + bundle forwards it to the route unchanged. `source` (`{ origin: "workflow", + runId }`) and `generatedBy` stay server-stamped from the resolved run + scope — never read from `metadata` or any other body field. - `artifact_version.content_sha256` (text, nullable), added by the new `0005_version_content_digest` migration and mirrored onto `artifact.content_sha256` the same way `metadata` already mirrors. Computed diff --git a/README.md b/README.md index a09aa45..955c6b4 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,14 @@ cover every entry in `ARTIFACT_TOOL_DEFINITIONS`. Every route is authenticated; is no unauthenticated case here the way collection reads have one on the tenant-session mount, since a workflow run always presents credentials. +`POST /artifacts`, `POST /artifacts/binary`, and `PATCH /artifacts/:id` each accept an +optional `metadata` field with the same semantics `mountArtifacts` validates: omitted on +a revise carries the prior version's metadata forward, an explicit `null` clears it, and +any other value must be a JSON object or the request is `400`. `source` (`{ origin: +"workflow", runId }`) and `generatedBy` are always server-stamped from the resolved run +scope — a body field of either name is never read, so a workflow run cannot mint +provenance for itself. + ### Agent tokens 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. credential is the agent's own hub token; the bundle never sees the secret and never names a host — the handle's origin pin resolves its relative paths. +`artifact_create` and `artifact_write` both take an optional `metadata` object — +application metadata stored with the version, e.g. which project and stage this belongs +to — which the bundle forwards to the route unchanged, omitting the field entirely when +the model does not supply one. + **Rate limiting is host-side.** `mountWorkflowArtifacts` mints no per-run quota — a host that wants one wraps `resolveRunScope` (returning `null` to reject) or puts its own middleware in front of the mounted app. `maxContentChars` (default 64,000) and diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts index 7762570..464e29c 100644 --- a/src/sidecar-bundle.test.ts +++ b/src/sidecar-bundle.test.ts @@ -81,6 +81,60 @@ describe("the artifacts sidecar bundle", () => { }); }); + test("passes metadata through to the create route unchanged", async () => { + const recorded: Recorded[] = []; + const bundle = artifacts(env(recorded, ok)); + await bundle.run( + { + id: "call-meta", + name: "artifact_create", + arguments: { + title: "Notes", + kind: "document", + content: "body", + metadata: { project: "acme-onboarding", stage: "draft" }, + }, + }, + signal, + ); + expect(JSON.parse(String(recorded[0]?.init?.body))).toEqual({ + title: "Notes", + kind: "document", + content: "body", + metadata: { project: "acme-onboarding", stage: "draft" }, + }); + }); + + test("passes metadata through to the write route unchanged", async () => { + const recorded: Recorded[] = []; + const bundle = artifacts(env(recorded, ok)); + await bundle.run( + { + id: "call-meta-write", + name: "artifact_write", + arguments: { artifactId: "a1", metadata: { stage: "final" } }, + }, + signal, + ); + expect(JSON.parse(String(recorded[0]?.init?.body))).toEqual({ + metadata: { stage: "final" }, + }); + }); + + test("omitting metadata omits it from the request body", async () => { + const recorded: Recorded[] = []; + const bundle = artifacts(env(recorded, ok)); + await bundle.run( + { + id: "call-no-meta", + name: "artifact_create", + arguments: { title: "Notes", kind: "document", content: "body" }, + }, + signal, + ); + expect(JSON.parse(String(recorded[0]?.init?.body))).not.toHaveProperty("metadata"); + }); + test("reads a pinned version through the read route", async () => { const recorded: Recorded[] = []; const bundle = artifacts(env(recorded, ok)); diff --git a/src/sidecar-bundle.ts b/src/sidecar-bundle.ts index 5266218..02ea251 100644 --- a/src/sidecar-bundle.ts +++ b/src/sidecar-bundle.ts @@ -61,7 +61,12 @@ function requestFor(name: string, args: Record): Request_ | und return { method: "POST", path: "/artifacts", - body: { title: args["title"], kind: args["kind"], content: args["content"] }, + body: { + title: args["title"], + kind: args["kind"], + content: args["content"], + ...(args["metadata"] !== undefined ? { metadata: args["metadata"] } : {}), + }, }; case "artifact_link_file": return { @@ -98,6 +103,7 @@ function requestFor(name: string, args: Record): Request_ | und body: { ...(str(args["title"]) !== undefined ? { title: args["title"] } : {}), ...(typeof args["content"] === "string" ? { content: args["content"] } : {}), + ...(args["metadata"] !== undefined ? { metadata: args["metadata"] } : {}), }, }; case "artifact_list": diff --git a/src/tools.test.ts b/src/tools.test.ts index 8b7f870..f394b9f 100644 --- a/src/tools.test.ts +++ b/src/tools.test.ts @@ -223,6 +223,18 @@ describe("tool definitions", () => { } }); + test("artifact_create and artifact_write both declare an optional metadata object", () => { + for (const name of ["artifact_create", "artifact_write"]) { + const definition = ARTIFACT_TOOL_DEFINITIONS.find((d) => d.name === name)!; + expect(definition.inputSchema.properties["metadata"]).toEqual({ + type: "object", + description: + "Optional application metadata stored with the version, e.g. which project and stage this belongs to.", + }); + expect(definition.inputSchema.required).not.toContain("metadata"); + } + }); + test("only the mutating tools declare a write side effect", () => { const writes = ARTIFACT_TOOL_DEFINITIONS.filter((d) => d.sideEffect === "write").map( (d) => d.name, diff --git a/src/tools.ts b/src/tools.ts index fa8a50d..5e7e430 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -289,6 +289,11 @@ export const ARTIFACT_TOOL_DEFINITIONS: readonly ArtifactToolDefinition[] = [ "Artifact kind, such as document, email, memo, note, or web_site for a multi-file static site stored as JSON { entry?, files: { path: content } }.", }, content: { type: "string", description: "The full text content." }, + metadata: { + type: "object", + description: + "Optional application metadata stored with the version, e.g. which project and stage this belongs to.", + }, }, required: ["title", "kind", "content"], }, @@ -373,6 +378,11 @@ export const ARTIFACT_TOOL_DEFINITIONS: readonly ArtifactToolDefinition[] = [ artifactId: { type: "string", description: "The artifact id to revise." }, title: { type: "string", description: "New title." }, content: { type: "string", description: "New full content." }, + metadata: { + type: "object", + description: + "Optional application metadata stored with the version, e.g. which project and stage this belongs to.", + }, }, required: ["artifactId"], }, diff --git a/src/uploads.test.ts b/src/uploads.test.ts index 9f955aa..3364659 100644 --- a/src/uploads.test.ts +++ b/src/uploads.test.ts @@ -234,6 +234,22 @@ describe("createFileArtifact", () => { expect(row.version).toBe(1); }); + test("stores an opaque metadata object on version 1 when given one", async () => { + const db = await testDb(); + const row = await db.transaction((tx) => + createFileArtifact(tx, InlineContentStore, { + scope: SCOPE, + ownerPrincipalId: SCOPE.principalId, + filename: "report.pdf", + mimeType: "application/pdf", + bytes: new Uint8Array([1, 2, 3]), + policy: ARTIFACT_UPLOAD_POLICY, + metadata: { project: "acme-onboarding" }, + }), + ); + expect(row.metadata).toEqual({ project: "acme-onboarding" }); + }); + test("a caller that fails after storing leaves neither an upload nor an artifact", async () => { const db = await testDb(); await expect( diff --git a/src/uploads.ts b/src/uploads.ts index 20fc7cd..10b2ade 100644 --- a/src/uploads.ts +++ b/src/uploads.ts @@ -222,6 +222,8 @@ export async function createFileArtifact( policy: UploadPolicy; origin?: string; generatedBy?: string; + /** Opaque to the package; stored on version 1 exactly like `createArtifact`'s. */ + metadata?: Record | null; }, ): Promise { if (!args.policy.accepts(args.mimeType)) { @@ -248,5 +250,6 @@ export async function createFileArtifact( ...(args.generatedBy !== undefined ? { generatedBy: args.generatedBy } : {}), ...stored.source, }, + ...(args.metadata !== undefined ? { metadata: args.metadata } : {}), }); } diff --git a/src/workflow-mount.test.ts b/src/workflow-mount.test.ts index da9f68c..b625471 100644 --- a/src/workflow-mount.test.ts +++ b/src/workflow-mount.test.ts @@ -41,6 +41,12 @@ const json = (body: unknown, headers: Record = authed) => ({ body: JSON.stringify(body), }); +const patchJson = (body: unknown, headers: Record = authed) => ({ + method: "PATCH", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), +}); + describe("auth", () => { test("401s with no bearer token", async () => { const db = await testDb(); @@ -90,6 +96,59 @@ describe("POST /artifacts", () => { expect(res.status).toBe(400); }); + test("stores an opaque metadata object on version 1", async () => { + const db = await testDb(); + const app = host(db); + const res = await app.request( + "/artifacts", + json({ + title: "Brief", + kind: "document", + content: "hello", + metadata: { project: "acme-onboarding", stage: "draft" }, + }), + ); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { data: { id: string; version: number } }; + expect(data.version).toBe(1); + + const row = await getArtifact(db, data.id); + expect(row?.metadata).toEqual({ project: "acme-onboarding", stage: "draft" }); + }); + + test("rejects a metadata value that is not a JSON object", async () => { + const db = await testDb(); + const app = host(db); + for (const metadata of ["a string", 42, ["array"]]) { + const res = await app.request( + "/artifacts", + json({ title: "Brief", kind: "document", content: "hello", metadata }), + ); + expect(res.status).toBe(400); + } + }); + + test("a body trying to smuggle source/runId is ignored: source stays server-stamped", async () => { + const db = await testDb(); + const app = host(db); + const res = await app.request( + "/artifacts", + json({ + title: "Brief", + kind: "document", + content: "hello", + source: { origin: "manual" }, + runId: "attacker-run", + generatedBy: "attacker", + }), + ); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { data: { id: string } }; + + const row = await getArtifact(db, data.id); + expect(row?.source).toEqual({ origin: "workflow", runId: "run-1" }); + }); + test("413s content over the configured character ceiling", async () => { const db = await testDb(); const app = mountWorkflowArtifacts(new Hono(), { @@ -179,6 +238,27 @@ describe("POST /artifacts/binary", () => { expect((row?.source as { generatedBy?: string })?.generatedBy).toBe("run-1"); }); + test("stores an opaque metadata object on version 1", async () => { + const db = await testDb(); + const app = host(db); + const contentBase64 = Buffer.from("

rendered

").toString("base64"); + + const res = await app.request( + "/artifacts/binary", + json({ + filename: "brief.html", + mimeType: "text/html", + contentBase64, + metadata: { project: "acme-onboarding" }, + }), + ); + expect(res.status).toBe(201); + const { data } = (await res.json()) as { data: { id: string } }; + + const row = await getArtifact(db, data.id); + expect(row?.metadata).toEqual({ project: "acme-onboarding" }); + }); + test("415s an unsupported file type", async () => { const db = await testDb(); const app = host(db); @@ -212,3 +292,64 @@ describe("POST /artifacts/binary", () => { expect(res.status).toBe(413); }); }); + +describe("PATCH /artifacts/:id", () => { + test("sets metadata and returns it in the response", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { tenantId: "acme", title: "Draft", content: "v1" }); + + const res = await app.request( + `/artifacts/${row.id}`, + patchJson({ content: "v2", metadata: { stage: "review" } }), + ); + expect(res.status).toBe(200); + const { data } = (await res.json()) as { data: { version: number } }; + expect(data.version).toBe(2); + + const updated = await getArtifact(db, row.id); + expect(updated?.metadata).toEqual({ stage: "review" }); + }); + + test("omitting metadata carries the prior version's metadata forward", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { tenantId: "acme", title: "Draft", content: "v1" }); + await app.request(`/artifacts/${row.id}`, patchJson({ metadata: { stage: "draft" } })); + + await app.request(`/artifacts/${row.id}`, patchJson({ content: "v3" })); + + const updated = await getArtifact(db, row.id); + expect(updated?.metadata).toEqual({ stage: "draft" }); + }); + + test("an explicit null metadata clears it", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { tenantId: "acme", title: "Draft", content: "v1" }); + await app.request(`/artifacts/${row.id}`, patchJson({ metadata: { stage: "draft" } })); + + await app.request(`/artifacts/${row.id}`, patchJson({ metadata: null })); + + const updated = await getArtifact(db, row.id); + expect(updated?.metadata).toBeNull(); + }); + + test("rejects a metadata value that is not a JSON object", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { tenantId: "acme" }); + + const res = await app.request(`/artifacts/${row.id}`, patchJson({ metadata: "nope" })); + expect(res.status).toBe(400); + }); + + test("rejects an empty body with no title, content, or metadata", async () => { + const db = await testDb(); + const app = host(db); + const row = await seedArtifact(db, { tenantId: "acme" }); + + const res = await app.request(`/artifacts/${row.id}`, patchJson({})); + expect(res.status).toBe(400); + }); +}); diff --git a/src/workflow-mount.ts b/src/workflow-mount.ts index 6f480ed..5880b59 100644 --- a/src/workflow-mount.ts +++ b/src/workflow-mount.ts @@ -24,6 +24,7 @@ import { findArtifactByTitle, getArtifact, listArtifacts, + MetadataShape, serializeArtifact, serializeArtifactListItem, SKILL_DRAFT_KIND, @@ -119,16 +120,22 @@ export type MountWorkflowArtifactsOpts = { export type CreatedWorkflowArtifact = { readonly id: string; readonly version: number }; +// Opaque to this package, same shape `mount.ts` validates: any JSON object, +// or `null` to clear it explicitly. Mirrors `NullableMetadata` there. +const NullableMetadata = MetadataShape.or("null"); + const CreateWorkflowArtifactBody = type({ title: "string > 0", kind: "string > 0", content: "string > 0", + "metadata?": NullableMetadata, }); const CreateWorkflowBinaryArtifactBody = type({ filename: "string > 0", mimeType: "string > 0", contentBase64: "string > 0", + "metadata?": NullableMetadata, }); const LinkWorkflowFileBody = type({ @@ -138,10 +145,20 @@ const LinkWorkflowFileBody = type({ "preview?": "string", }); +// `metadata` is opaque and, when omitted, carries the prior version's +// metadata forward — same semantics as `ReviseArtifactRequest` in mount.ts — +// so at least one of the three must be present or there is nothing to revise. const ReviseWorkflowArtifactBody = type({ "title?": "string > 0", "content?": "string", -}); + "metadata?": NullableMetadata, +}).narrow( + (body, ctx) => + body.title !== undefined || + body.content !== undefined || + body.metadata !== undefined || + ctx.mustBe("a body with content, title, and/or metadata"), +); /** Omits the key entirely when absent or unparseable, so the behavior's own * default applies rather than a coerced zero. */ @@ -267,6 +284,7 @@ export function mountWorkflowArtifacts( title: parsed.title, content: parsed.content, source: { origin: "workflow", runId: scope.runId }, + ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), }), ); const created: CreatedWorkflowArtifact = { id: row.id, version: row.version }; @@ -323,6 +341,7 @@ export function mountWorkflowArtifacts( bytes: new Uint8Array(bytes), policy: uploadPolicy, generatedBy: scope.runId, + ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), }), ); const created: CreatedWorkflowArtifact = { id: row.id, version: row.version }; @@ -420,6 +439,7 @@ export function mountWorkflowArtifacts( artifactId, ...(parsed.title !== undefined ? { title: parsed.title } : {}), ...(parsed.content !== undefined ? { content: parsed.content } : {}), + ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}), }); const revised: CreatedWorkflowArtifact = { id: written.artifactId,