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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand All @@ -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.

Expand Down
7 changes: 5 additions & 2 deletions e2e/agent-token-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`, {
Expand Down
2 changes: 2 additions & 0 deletions e2e/mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ describe("POST /artifacts", () => {
expect(body.artifact).toMatchObject({
kind: "document",
source: { origin: "manual" },
contentSha256: sha256Hex("body"),
});
});

Expand Down Expand Up @@ -722,6 +723,7 @@ describe("versions", () => {
expect(await res.json()).toMatchObject({
version: 2,
metadata: { kind: "run", supersededByNodeId: null },
contentSha256: sha256Hex("v2"),
});
});

Expand Down
31 changes: 24 additions & 7 deletions e2e/workflow-mount.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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("<h1>rendered</h1>"));

const row = await getArtifact(db, data.id);
expect(row?.tenantId).toBe("acme");
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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" });
Expand Down
18 changes: 14 additions & 4 deletions src/sidecar-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, string>;
Expand Down
2 changes: 1 addition & 1 deletion src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
6 changes: 6 additions & 0 deletions src/workflow-mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -567,6 +572,7 @@ export function createWorkflowArtifactRoutes({
const revised: CreatedWorkflowArtifact = {
id: written.artifactId,
version: written.version,
contentSha256: written.contentSha256,
};
return c.json({ data: revised });
});
Expand Down
Loading