Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 54 additions & 0 deletions src/sidecar-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
8 changes: 7 additions & 1 deletion src/sidecar-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,12 @@ function requestFor(name: string, args: Record<string, unknown>): 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 {
Expand Down Expand Up @@ -98,6 +103,7 @@ function requestFor(name: string, args: Record<string, unknown>): 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":
Expand Down
12 changes: 12 additions & 0 deletions src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
Expand Down Expand Up @@ -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"],
},
Expand Down
16 changes: 16 additions & 0 deletions src/uploads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions src/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
},
): Promise<ArtifactRow> {
if (!args.policy.accepts(args.mimeType)) {
Expand All @@ -248,5 +250,6 @@ export async function createFileArtifact(
...(args.generatedBy !== undefined ? { generatedBy: args.generatedBy } : {}),
...stored.source,
},
...(args.metadata !== undefined ? { metadata: args.metadata } : {}),
});
}
141 changes: 141 additions & 0 deletions src/workflow-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ const json = (body: unknown, headers: Record<string, string> = authed) => ({
body: JSON.stringify(body),
});

const patchJson = (body: unknown, headers: Record<string, string> = 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();
Expand Down Expand Up @@ -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<WorkflowArtifactEnv>(), {
Expand Down Expand Up @@ -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("<h1>rendered</h1>").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);
Expand Down Expand Up @@ -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);
});
});
Loading
Loading