diff --git a/README.md b/README.md index d7a4e26..acc436f 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ await close(); With no principal, every route except the list and counts answers `403`. Another tenant's artifact, or an unknown id, answers `404`. Set a request-body limit on the host for the upload and file-revise routes; they buffer the body before checking `MAX_UPLOAD_BYTES`. +Every write reports `contentSha256`, the hex sha256 stored with the new version: over the UTF-8 text, or over the file's bytes for an upload. `POST /artifacts` and `POST /artifacts/upload` carry it on each artifact, and `POST /artifacts/:id/versions` returns it beside `artifactId` and `version`. It is `null` only for a version written before digests existed. + ### `createWorkflowArtifactRoutes(deps)` Run-scoped routes for a workflow run, which authenticates with a bearer token and an `x-workflow-run-address` header instead of a session. @@ -102,6 +104,8 @@ Run-scoped routes for a workflow run, which authenticates with a bearer token an | `maxBinaryBytes` | `number` | Optional. Byte ceiling for `POST /artifacts/binary`. Defaults to `MAX_UPLOAD_BYTES`. | | `maxContentChars` | `number` | Optional. Character ceiling on the resulting content — full text or edited text — for `POST /artifacts` and `PATCH /artifacts/:id/versions`. Defaults to 64,000. | +Every write (`POST /artifacts`, `POST /artifacts/binary`, `POST /artifacts/link-file`, `PATCH /artifacts/:id`) answers `{ data: { id, version, contentSha256 } }`, with the same digest the tenant routes report. For `link-file` the digest covers the stored `preview` text (empty when none is given), not the workspace file at `path`. + ### `runArtifactMigrations(config, { schema })` Takes the same `DBConfig` and `schema` as Interchange's `runMigrations`. `schema` holds the host's `tenant` and `principal` tables; this package's tables always go in `artifacts`. @@ -110,7 +114,7 @@ Takes the same `DBConfig` and `schema` as Interchange's `runMigrations`. `schema `artifacts` is an `@intx/agent` tool pack of three tools. They call the run-scoped routes through the agent's `hub` credential. -- `artifact_write` creates an artifact when no id is given, from content or a workspace file path. With an id it revises, either with the full text or with `edits`: exact passages, each of which must appear once, or nothing is written. `expectedVersion` refuses the write if a newer version exists. +- `artifact_write` creates an artifact when no id is given, from content or a workspace file path. With an id it revises, either with the full text or with `edits`: exact passages, each of which must appear once, or nothing is written. `expectedVersion` refuses the write if a newer version exists. It returns `{ id, version, contentSha256 }`. - `artifact_read` returns the content, a past version with `version`, or a range with `offset` and `limit`. - `artifact_search` lists the newest artifacts, filtered by `kind` and by a `query` matched against title and content. diff --git a/e2e/agent-token-mount.test.ts b/e2e/agent-token-mount.test.ts index d0641e5..d958250 100644 --- a/e2e/agent-token-mount.test.ts +++ b/e2e/agent-token-mount.test.ts @@ -4,6 +4,7 @@ import { type AgentTokenAuth, type ResolvedWorkflowRunScope, } from "../src/workflow-mount.js"; +import { sha256Hex } from "../src/artifacts.js"; import { InlineContentStore } from "../src/content-store.js"; import { seedArtifact } from "./fixtures.js"; import { testDb } from "./helpers.js"; @@ -134,9 +135,11 @@ describe("the routes the artifact tools call", () => { }); expect(revised.status).toBe(200); expect( - (await revised.json()) as { data: { id: string; version: number } }, + (await revised.json()) as { + data: { id: string; version: number; contentSha256: string | null }; + }, ).toEqual({ - data: { id: artifact.id, version: 2 }, + data: { id: artifact.id, version: 2, contentSha256: sha256Hex("second") }, }); const read = await app.request(`/artifacts/${artifact.id}/read`, { diff --git a/e2e/mount.test.ts b/e2e/mount.test.ts index 2392542..3e69dbd 100644 --- a/e2e/mount.test.ts +++ b/e2e/mount.test.ts @@ -185,6 +185,7 @@ describe("POST /artifacts", () => { expect(body.artifact).toMatchObject({ kind: "document", source: { origin: "manual" }, + contentSha256: sha256Hex("body"), }); }); @@ -722,6 +723,7 @@ describe("versions", () => { expect(await res.json()).toMatchObject({ version: 2, metadata: { kind: "run", supersededByNodeId: null }, + contentSha256: sha256Hex("v2"), }); }); diff --git a/e2e/workflow-mount.test.ts b/e2e/workflow-mount.test.ts index 17e74d1..cbd250b 100644 --- a/e2e/workflow-mount.test.ts +++ b/e2e/workflow-mount.test.ts @@ -5,7 +5,11 @@ import { type ResolvedWorkflowRunScope, } from "../src/workflow-mount.js"; import { InlineContentStore } from "../src/content-store.js"; -import { getArtifact, setArtifactArchived } from "../src/artifacts.js"; +import { + getArtifact, + setArtifactArchived, + sha256Hex, +} from "../src/artifacts.js"; import { seedArtifact } from "./fixtures.js"; import { testDb } from "./helpers.js"; import type { ArtifactDb } from "../src/db.js"; @@ -104,9 +108,10 @@ describe("POST /artifacts", () => { ); expect(res.status).toBe(201); const { data } = (await res.json()) as { - data: { id: string; version: number }; + data: { id: string; version: number; contentSha256: string | null }; }; expect(data.version).toBe(1); + expect(data.contentSha256).toBe(sha256Hex("hello")); const row = await getArtifact(db, data.id); expect(row?.tenantId).toBe("acme"); @@ -409,7 +414,10 @@ describe("POST /artifacts/binary", () => { json({ filename: "brief.html", mimeType: "text/html", contentBase64 }), ); expect(res.status).toBe(201); - const { data } = (await res.json()) as { data: { id: string } }; + const { data } = (await res.json()) as { + data: { id: string; contentSha256: string | null }; + }; + expect(data.contentSha256).toBe(sha256Hex("

rendered

")); const row = await getArtifact(db, data.id); expect(row?.tenantId).toBe("acme"); @@ -520,16 +528,22 @@ describe("creator grant minting", () => { ]); }); - test("POST /artifacts/link-file mints write+archive creator grants", async () => { + test("POST /artifacts/link-file mints write+archive creator grants and digests the preview", async () => { const db = await testDb(); const res = await host(db).request( "/artifacts/link-file", - json({ title: "Report", kind: "document", path: "out/report.md" }), + json({ + title: "Report", + kind: "document", + path: "out/report.md", + preview: "summary", + }), ); expect(res.status).toBe(201); const { data } = (await res.json()) as { - data: { id: string; version: number }; + data: { id: string; version: number; contentSha256: string | null }; }; + expect(data.contentSha256).toBe(sha256Hex("summary")); expect(await grantsFor(db, data.id)).toEqual([ { action: "archive", origin: "creator" }, { action: "write", origin: "creator" }, @@ -569,8 +583,11 @@ describe("PATCH /artifacts/:id", () => { patchJson({ content: "v2", metadata: { stage: "review" } }), ); expect(res.status).toBe(200); - const { data } = (await res.json()) as { data: { version: number } }; + const { data } = (await res.json()) as { + data: { version: number; contentSha256: string | null }; + }; expect(data.version).toBe(2); + expect(data.contentSha256).toBe(sha256Hex("v2")); const updated = await getArtifact(db, row.id); expect(updated?.metadata).toEqual({ stage: "review" }); diff --git a/src/sidecar-bundle.test.ts b/src/sidecar-bundle.test.ts index 47bbe18..1db90c9 100644 --- a/src/sidecar-bundle.test.ts +++ b/src/sidecar-bundle.test.ts @@ -41,10 +41,15 @@ function env(recorded: Recorded[], respond: () => Response) { } const ok = () => - new Response(JSON.stringify({ data: { id: "art_1", version: 1 } }), { - status: 201, - headers: { "content-type": "application/json" }, - }); + new Response( + JSON.stringify({ + data: { id: "art_1", version: 1, contentSha256: "ab12" }, + }), + { + status: 201, + headers: { "content-type": "application/json" }, + }, + ); const signal = new AbortController().signal; @@ -70,6 +75,11 @@ describe("the artifacts sidecar bundle", () => { ); expect(result.isError).toBeUndefined(); + expect(JSON.parse(String(result.content))).toEqual({ + id: "art_1", + version: 1, + contentSha256: "ab12", + }); expect(recorded).toHaveLength(1); expect(recorded[0]?.url).toBe("/api/workflow-artifacts/artifacts"); const headers = recorded[0]?.init?.headers as Record; diff --git a/src/tools.ts b/src/tools.ts index 660f9b5..73ad166 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -249,7 +249,7 @@ export const ARTIFACT_TOOL_DEFINITIONS: readonly ArtifactToolDefinition[] = [ name: "artifact_write", sideEffect: "write", description: - "Create or revise an artifact. Every call returns the artifact id and its new version; pass that version as expectedVersion on your next revision. Create: omit artifactId and pass title, kind, and content (or path, for a file you already wrote in your workspace). Revise: pass artifactId with edits to change exact passages, or content to replace the whole text. Prefer edits for small changes. If an edit is refused, nothing was written: read the artifact again and retry.", + "Create or revise an artifact. Every call returns the artifact id, its new version and contentSha256, the sha256 of the stored content; pass that version as expectedVersion on your next revision. Create: omit artifactId and pass title, kind, and content (or path, for a file you already wrote in your workspace). Revise: pass artifactId with edits to change exact passages, or content to replace the whole text. Prefer edits for small changes. If an edit is refused, nothing was written: read the artifact again and retry.", inputSchema: { type: "object", properties: { diff --git a/src/workflow-mount.ts b/src/workflow-mount.ts index 8de9ebc..f415c38 100644 --- a/src/workflow-mount.ts +++ b/src/workflow-mount.ts @@ -129,6 +129,8 @@ export type CreateWorkflowArtifactRoutesDeps = { export type CreatedWorkflowArtifact = { readonly id: string; readonly version: number; + /** sha256 (hex) stored with this version; null only on a pre-digest row. */ + readonly contentSha256: string | null; }; // Opaque to this package, same shape `mount.ts` validates: any JSON object, @@ -358,6 +360,7 @@ export function createWorkflowArtifactRoutes({ const created: CreatedWorkflowArtifact = { id: row.id, version: row.version, + contentSha256: row.contentSha256, }; return c.json({ data: created }, 201); }); @@ -425,6 +428,7 @@ export function createWorkflowArtifactRoutes({ const created: CreatedWorkflowArtifact = { id: row.id, version: row.version, + contentSha256: row.contentSha256, }; return c.json({ data: created }, 201); } catch (err) { @@ -493,6 +497,7 @@ export function createWorkflowArtifactRoutes({ const created: CreatedWorkflowArtifact = { id: row.id, version: row.version, + contentSha256: row.contentSha256, }; return c.json({ data: created }, 201); }); @@ -567,6 +572,7 @@ export function createWorkflowArtifactRoutes({ const revised: CreatedWorkflowArtifact = { id: written.artifactId, version: written.version, + contentSha256: written.contentSha256, }; return c.json({ data: revised }); });