Skip to content

Commit e93ecb8

Browse files
committed
feat(workflow): let hosts check or redirect run-scoped writes
1 parent 5b14de4 commit e93ecb8

9 files changed

Lines changed: 487 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ Run-scoped routes for a workflow run, which authenticates with a bearer token an
101101
| `uploadPolicy` | `UploadPolicy` | Optional. MIME types `POST /artifacts/binary` accepts. Defaults to `ARTIFACT_UPLOAD_POLICY`. |
102102
| `maxBinaryBytes` | `number` | Optional. Byte ceiling for `POST /artifacts/binary`. Defaults to `MAX_UPLOAD_BYTES`. |
103103
| `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. |
104+
| `beforeWrite` | `WorkflowBeforeWrite` | Optional. Checks each text create (`POST /artifacts`) and revision (`PATCH /artifacts/:id`) before it is written. Omitted, every valid write lands. |
105+
106+
`beforeWrite({ scope, kind, write, content })` gets the run scope, the artifact kind, `write` as `{ op: "create" }` or `{ op: "revise", artifactId }`, and the content the version will hold after any edits. It returns one of:
107+
108+
- `{ action: "allow" }` writes as usual.
109+
- `{ action: "refuse", code, message }` answers `422` with `{ code, message }`, and the agent's tool error is `message`. `code` must match `/^[a-z0-9_]+$/`; `message` must be 1 to 1,000 characters.
110+
- `{ action: "redirect", artifactId, expectedVersion? }` turns a create into a revision of that artifact, which must be of the same kind and in the run's tenant, answering `200` with its `{ id, version }`. The revision takes the create's title. A missing, archived or other-tenant artifact answers `404`, and a stale `expectedVersion` answers `409` telling the agent to read the existing artifact and revise it.
111+
112+
Any other decision, a redirect returned for a revision, or a redirect to an artifact of another kind answers `500` and writes nothing. Binary uploads and linked files are not checked.
113+
114+
A create's check is not serialized: two concurrent creates can both be allowed, so a policy like "one plan per tenant" can still end with two plans when runs race. A revision's check runs inside the transaction holding the artifact's row lock, so it should be fast and must not write that artifact.
104115

105116
### `runArtifactMigrations(config, { schema })`
106117

‎e2e/reference-host.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,3 +722,39 @@ describe("the migration runner is re-runnable", () => {
722722
expect(survived.artifacts.length).toBeGreaterThan(0);
723723
});
724724
});
725+
726+
describe("the host's beforeWrite policy on agent writes", () => {
727+
const runPost = (body: unknown): RequestInit => ({
728+
method: "POST",
729+
headers: { "content-type": "application/json", ...host.runHeaders },
730+
body: JSON.stringify(body),
731+
});
732+
733+
test("refuses a plan with no heading, then folds a second plan into the first", async () => {
734+
const refused = await host.request(
735+
"/api/workflow-artifacts/artifacts",
736+
runPost({ title: "Plan", kind: "plan", content: "Ship." }),
737+
);
738+
expect(refused.status).toBe(422);
739+
expect(await refused.json()).toEqual({
740+
code: "plan_needs_heading",
741+
message: "Start the plan with a '# ' heading.",
742+
});
743+
744+
const first = await host.request(
745+
"/api/workflow-artifacts/artifacts",
746+
runPost({ title: "Plan", kind: "plan", content: "# Plan\nShip." }),
747+
);
748+
expect(first.status).toBe(201);
749+
const { data: created } = await json<{ data: { id: string } }>(first);
750+
751+
const second = await host.request(
752+
"/api/workflow-artifacts/artifacts",
753+
runPost({ title: "Plan v2", kind: "plan", content: "# Plan\nShip now." }),
754+
);
755+
expect(second.status).toBe(200);
756+
expect(await second.json()).toEqual({
757+
data: { id: created.id, version: 2 },
758+
});
759+
});
760+
});

‎e2e/workflow-mount.test.ts‎

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,16 @@ import { sql } from "drizzle-orm";
33
import {
44
createWorkflowArtifactRoutes,
55
type ResolvedWorkflowRunScope,
6+
type WorkflowBeforeWrite,
7+
type WorkflowWriteCheck,
8+
type WorkflowWriteDecision,
69
} from "../src/workflow-mount.js";
710
import { InlineContentStore } from "../src/content-store.js";
8-
import { getArtifact, setArtifactArchived } from "../src/artifacts.js";
11+
import {
12+
getArtifact,
13+
listArtifacts,
14+
setArtifactArchived,
15+
} from "../src/artifacts.js";
916
import { seedArtifact } from "./fixtures.js";
1017
import { testDb } from "./helpers.js";
1118
import type { ArtifactDb } from "../src/db.js";
@@ -24,6 +31,7 @@ function host(
2431
opts: {
2532
resolves?: ResolvedWorkflowRunScope | null;
2633
maxContentChars?: number;
34+
beforeWrite?: WorkflowBeforeWrite;
2735
} = {},
2836
) {
2937
return createWorkflowArtifactRoutes({
@@ -32,6 +40,9 @@ function host(
3240
...(opts.maxContentChars !== undefined
3341
? { maxContentChars: opts.maxContentChars }
3442
: {}),
43+
...(opts.beforeWrite !== undefined
44+
? { beforeWrite: opts.beforeWrite }
45+
: {}),
3546
resolveRunScope: (token, address) => {
3647
if (opts.resolves === undefined) {
3748
return token === VALID_TOKEN && address === VALID_ADDRESS
@@ -806,3 +817,160 @@ describe("user-input errors", () => {
806817
expect((await read("?version=2147483648")).status).toBe(400);
807818
});
808819
});
820+
821+
describe("beforeWrite", () => {
822+
const plan = { title: "Plan", kind: "plan", content: "## Plan\nShip." };
823+
const created = async (res: Response) =>
824+
((await res.json()) as { data: { id: string; version: number } }).data;
825+
826+
test("a refused create is 422 with the code and message, and writes nothing", async () => {
827+
const db = await testDb();
828+
const checks: WorkflowWriteCheck[] = [];
829+
const app = host(db, {
830+
beforeWrite: (check) => {
831+
checks.push(check);
832+
return {
833+
action: "refuse",
834+
code: "plan_exists",
835+
message: "Revise the existing plan instead.",
836+
};
837+
},
838+
});
839+
const res = await app.request("/artifacts", json(plan));
840+
expect(res.status).toBe(422);
841+
expect(await res.json()).toEqual({
842+
code: "plan_exists",
843+
message: "Revise the existing plan instead.",
844+
});
845+
expect(checks).toEqual([
846+
{
847+
scope: RUN_SCOPE,
848+
kind: "plan",
849+
write: { op: "create" },
850+
content: plan.content,
851+
},
852+
]);
853+
expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(0);
854+
});
855+
856+
test("a refused revision sees the edited content and leaves the version alone", async () => {
857+
const db = await testDb();
858+
const row = await seedArtifact(db, {
859+
tenantId: "acme",
860+
kind: "design",
861+
content: "## Plan\nShip.",
862+
});
863+
const checks: WorkflowWriteCheck[] = [];
864+
const app = host(db, {
865+
beforeWrite: (check) => {
866+
checks.push(check);
867+
return { action: "refuse", code: "too_vague", message: "Add a date." };
868+
},
869+
});
870+
const res = await app.request(
871+
`/artifacts/${row.id}`,
872+
patchJson({ edits: [{ oldText: "Ship.", newText: "Ship soon." }] }),
873+
);
874+
expect(res.status).toBe(422);
875+
expect(checks).toEqual([
876+
{
877+
scope: RUN_SCOPE,
878+
kind: "design",
879+
write: { op: "revise", artifactId: row.id },
880+
content: "## Plan\nShip soon.",
881+
},
882+
]);
883+
expect((await getArtifact(db, row.id))?.version).toBe(1);
884+
});
885+
886+
test("a redirected create revises the named artifact under expectedVersion", async () => {
887+
const db = await testDb();
888+
const row = await seedArtifact(db, { tenantId: "acme", kind: "plan" });
889+
const redirectTo = (expectedVersion: number) =>
890+
host(db, {
891+
beforeWrite: () => ({
892+
action: "redirect",
893+
artifactId: row.id,
894+
expectedVersion,
895+
}),
896+
});
897+
898+
const res = await redirectTo(1).request("/artifacts", json(plan));
899+
expect(res.status).toBe(200);
900+
expect(await created(res)).toEqual({ id: row.id, version: 2 });
901+
const revised = await getArtifact(db, row.id);
902+
expect(revised?.title).toBe("Plan");
903+
expect(revised?.content).toBe(plan.content);
904+
expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(1);
905+
906+
const stale = await redirectTo(1).request("/artifacts", json(plan));
907+
expect(stale.status).toBe(409);
908+
expect(await stale.json()).toEqual({
909+
error: `A plan already exists as artifact ${row.id} at version 2. Read it, then revise it.`,
910+
});
911+
expect((await getArtifact(db, row.id))?.version).toBe(2);
912+
});
913+
914+
test("a redirect to another tenant's artifact is 404 and writes nothing", async () => {
915+
const db = await testDb();
916+
const foreign = await seedArtifact(db, { tenantId: "other" });
917+
const app = host(db, {
918+
beforeWrite: () => ({ action: "redirect", artifactId: foreign.id }),
919+
});
920+
const res = await app.request("/artifacts", json(plan));
921+
expect(res.status).toBe(404);
922+
expect(await res.json()).toEqual({
923+
error:
924+
"This plan could not be created: the existing plan it should revise was not found.",
925+
});
926+
expect((await getArtifact(db, foreign.id))?.version).toBe(1);
927+
expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(0);
928+
});
929+
930+
test("a redirect to an artifact of another kind fails closed", async () => {
931+
const db = await testDb();
932+
const design = await seedArtifact(db, { tenantId: "acme", kind: "design" });
933+
const app = host(db, {
934+
beforeWrite: () => ({ action: "redirect", artifactId: design.id }),
935+
});
936+
expect((await app.request("/artifacts", json(plan))).status).toBe(500);
937+
const unchanged = await getArtifact(db, design.id);
938+
expect(unchanged?.version).toBe(1);
939+
expect(unchanged?.content).not.toBe(plan.content);
940+
expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(1);
941+
});
942+
943+
test("a malformed decision fails closed", async () => {
944+
const db = await testDb();
945+
const row = await seedArtifact(db, { tenantId: "acme" });
946+
const malformed: unknown[] = [
947+
{ action: "deny" },
948+
{ action: "refuse", code: "Bad Code", message: "x" },
949+
{ action: "refuse", code: "no_message" },
950+
{ action: "refuse", code: "empty_message", message: " " },
951+
{ action: "refuse", code: "long_message", message: "x".repeat(1_001) },
952+
];
953+
for (const decision of malformed) {
954+
// A host bug that the decision type alone cannot rule out at runtime.
955+
const app = host(db, {
956+
beforeWrite: () => decision as WorkflowWriteDecision,
957+
});
958+
expect((await app.request("/artifacts", json(plan))).status).toBe(500);
959+
const revise = await app.request(
960+
`/artifacts/${row.id}`,
961+
patchJson({ content: "changed" }),
962+
);
963+
expect(revise.status).toBe(500);
964+
}
965+
const reviseRedirect = host(db, {
966+
beforeWrite: () => ({ action: "redirect", artifactId: row.id }),
967+
});
968+
const res = await reviseRedirect.request(
969+
`/artifacts/${row.id}`,
970+
patchJson({ content: "changed" }),
971+
);
972+
expect(res.status).toBe(500);
973+
expect((await getArtifact(db, row.id))?.version).toBe(1);
974+
expect((await listArtifacts(db, "acme", {})).rows).toHaveLength(1);
975+
});
976+
});

‎examples/reference-host/src/index.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,16 @@ import {
4141
import {
4242
InlineContentStore,
4343
createArtifactRoutes,
44+
createWorkflowArtifactRoutes,
45+
listArtifacts,
4446
runArtifactMigrations,
4547
type ArtifactDb,
4648
type ResolvedPrincipal,
4749
type ContentStore,
4850
type SerializedArtifactBase,
51+
type WorkflowBeforeWrite,
4952
} from "../../../src/index.js";
53+
import { WORKFLOW_ARTIFACTS_BASE_PATH } from "../../../src/sidecar-bundle.js";
5054
import { databaseConfig, DATABASE_URL } from "../../../e2e/helpers.js";
5155

5256
const EPOCH = new Date(0);
@@ -66,6 +70,39 @@ async function decorate(
6670

6771
export type Session = { userId: string } | null;
6872

73+
/** The one workflow run this host resolves, standing in for its run table. */
74+
const RUN_TOKEN = "reference-sidecar-token";
75+
const RUN_ADDRESS = "reference-run";
76+
77+
/**
78+
* The host's write policy for agents: a plan opens with a heading, and a
79+
* tenant keeps one plan, so a second plan create revises the first.
80+
*/
81+
function planPolicy(db: ArtifactDb): WorkflowBeforeWrite {
82+
return async ({ scope, kind, write, content }) => {
83+
if (kind !== "plan") return { action: "allow" };
84+
if (!content.startsWith("# ")) {
85+
return {
86+
action: "refuse",
87+
code: "plan_needs_heading",
88+
message: "Start the plan with a '# ' heading.",
89+
};
90+
}
91+
if (write.op === "revise") return { action: "allow" };
92+
const { rows } = await listArtifacts(db, scope.tenantId, {
93+
kind: "plan",
94+
limit: 1,
95+
});
96+
const [existing] = rows;
97+
if (existing === undefined) return { action: "allow" };
98+
return {
99+
action: "redirect",
100+
artifactId: existing.id,
101+
expectedVersion: existing.version,
102+
};
103+
};
104+
}
105+
69106
export type ReferenceHost = {
70107
db: ArtifactDb;
71108
/** The `DBConfig` the host migrates with. */
@@ -81,6 +118,8 @@ export type ReferenceHost = {
81118
* `createFileArtifact` with this.
82119
*/
83120
scope: () => ResolvedPrincipal;
121+
/** Headers a workflow run presents to `WORKFLOW_ARTIFACTS_BASE_PATH`. */
122+
runHeaders: Record<string, string>;
84123
/** Who the hub's session middleware will report. `null` means signed out. */
85124
setSession: (session: Session) => void;
86125
/** Request the default host (InlineContentStore, real DB-backed grants). */
@@ -355,6 +394,22 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
355394
decorate,
356395
}),
357396
);
397+
app.route(
398+
WORKFLOW_ARTIFACTS_BASE_PATH,
399+
createWorkflowArtifactRoutes({
400+
db,
401+
contentStore,
402+
resolveRunScope: (token, address) =>
403+
token === RUN_TOKEN && address === RUN_ADDRESS
404+
? {
405+
tenantId: tenant.id,
406+
principalId: agentPrincipal,
407+
runId: address,
408+
}
409+
: null,
410+
beforeWrite: planPolicy(db),
411+
}),
412+
);
358413
const mounted = app.route("/api", api);
359414
// `Hono#request` may answer synchronously; normalize to a promise so every
360415
// caller can simply await it.
@@ -371,6 +426,10 @@ export async function createReferenceHost(): Promise<ReferenceHost> {
371426
config,
372427
tenantId: tenant.id,
373428
agentPrincipal,
429+
runHeaders: {
430+
authorization: `Bearer ${RUN_TOKEN}`,
431+
"x-workflow-run-address": RUN_ADDRESS,
432+
},
374433
scope: () => ({
375434
tenantId: tenant.id,
376435
principalId: principals.find(

‎src/artifacts.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,9 @@ export async function reviseArtifactVersion(
419419
* thrown and nothing is written. Omitted preserves today's behavior.
420420
*/
421421
expectedVersion?: number;
422+
/** Runs under the lock with the content this version will hold; a throw
423+
* rolls the revision back. */
424+
beforeWrite?: (locked: ArtifactRow, content: string) => Promise<void>;
422425
},
423426
now: Date,
424427
): Promise<ArtifactRow> {
@@ -478,6 +481,7 @@ export async function reviseArtifactVersion(
478481
);
479482
}
480483
}
484+
if (args.beforeWrite !== undefined) await args.beforeWrite(existing, content);
481485
const metadata =
482486
args.metadata === undefined
483487
? (existing.metadata as Record<string, unknown> | null)
@@ -542,6 +546,8 @@ export async function writeArtifactVersion(
542546
parentVersionIds?: string[] | null;
543547
/** See `VersionConflictError` — omitted preserves today's behavior. */
544548
expectedVersion?: number;
549+
/** See {@link reviseArtifactVersion}. */
550+
beforeWrite?: (locked: ArtifactRow, content: string) => Promise<void>;
545551
},
546552
): Promise<{
547553
artifactId: string;

‎src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ export type {
1111
CreateWorkflowArtifactRoutesDeps,
1212
ResolvedWorkflowRunScope,
1313
WorkflowArtifactEnv,
14+
WorkflowBeforeWrite,
1415
WorkflowRunResolver,
16+
WorkflowWriteCheck,
17+
WorkflowWriteDecision,
1518
} from "./workflow-mount.js";
1619

1720
export {

0 commit comments

Comments
 (0)