diff --git a/cloudflare/library.test.ts b/cloudflare/library.test.ts index 74739ae..72d23d2 100644 --- a/cloudflare/library.test.ts +++ b/cloudflare/library.test.ts @@ -8,8 +8,34 @@ let runtime: Miniflare; type CallResult = { result?: T; error?: string }; type DraftResult = { ok: boolean; workspace: string; name: string; source: string; source_hash: string; + revision_token: string; server_source: string | null; state: Record; path: string; }; + +test("full-project conditional writes guard server, helper, dependency and lock changes atomically", async () => { + const request = (method: string, input: unknown) => call(method, input, "conditional"); + const rejects = (method: string, input: unknown) => fails(method, input, "Project changed since", "conditional"); + const identity = { workspace: "conditional-project", name: "report" }; + const project = { files: { "lib/value.ts": "export const value = 1;" }, dependencies: {}, lock: {} }; + let current = await request("writeDraft", { ...identity, source: "client\n", server_source: "server", project, expected_revision: null }); + expect((await request("preview", identity)).revision_token).toBe(current.revision_token); + await rejects("writeDraft", { ...identity, source: "overwrite", expected_revision: null }); + for (const change of [ + { server_source: "changed server" }, + { project: { ...project, files: { "lib/value.ts": "export const value = 2;" } } }, + { project: { ...project, dependencies: { example: "1.0.0" }, lock: { "node_modules/example/index.js": "version one" } } }, + { project: { ...project, dependencies: { example: "1.0.0" }, lock: { "node_modules/example/index.js": "changed lock" } } }, + ]) { + const previous = current; + current = await request("writeDraft", { ...identity, source: "client\n", ...change }); + expect(current.revision_token).not.toBe(previous.revision_token); + await rejects("writeDraft", { ...identity, source: "stale browser", server_source: "stale server", project, expected_revision: previous.revision_token }); + expect(await request("preview", identity)).toMatchObject({ source: current.source, server_source: current.server_source, revision_token: current.revision_token }); + } + const results = await Promise.allSettled(["first", "second"].map(source => request("writeDraft", { ...identity, source, expected_revision: current.revision_token }))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter(result => result.status === "rejected")).toHaveLength(1); +}); type VersionResult = { version: { id: string; revision: number; source: string; server_source: string | null; workspace: string; name: string }; event: { id: string; version_id: string; mode: string; initial_state: string }; diff --git a/cloudflare/library.ts b/cloudflare/library.ts index 8a59792..36190e9 100644 --- a/cloudflare/library.ts +++ b/cloudflare/library.ts @@ -1,7 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import { createHash } from "node:crypto"; import { ProjectStorage } from "./project-storage"; -import { emptyProject, normalizeProject, type ArtifactProject } from "./project"; +import { assertProjectRevision, emptyProject, normalizeProject, projectRevision, type ArtifactProject } from "./project"; import type { HistoryEntry, ServeEvent, Version } from "../src/historyTypes"; export const MAX_SOURCE_BYTES = 256 * 1024; @@ -218,13 +218,14 @@ export class ArtifactLibrary extends DurableObject { })); } - writeDraft(input: { workspace: string; name: string; source: string; server_source?: string | null; project?: ArtifactProject }) { + writeDraft(input: { workspace: string; name: string; source: string; server_source?: string | null; project?: ArtifactProject; expected_revision?: string | null }) { const workspace = workspaceName(input.workspace); const name = artifactName(input.name); const source = sourceText(input.source, true); const source_hash = sha256(source); const timestamp = now(); const requestedServer = input.server_source === undefined ? undefined : input.server_source === null ? null : sourceText(input.server_source); return this.state.storage.transactionSync(() => { const current = first(this.sql.exec("select * from drafts where workspace = ? and name = ?", workspace, name)); + assertProjectRevision(input.expected_revision, current ? projectRevision(current.source, current.server_source, this.projectStorage.read(current.project)) : null); const server_source = requestedServer === undefined ? current?.server_source ?? null : requestedServer; const project = input.project === undefined ? current?.project ?? this.projectStorage.encode(emptyProject()) : this.projectStorage.encode(input.project); this.sql.exec(`insert into drafts (workspace,name,source,server_source,source_hash,project,state,created_at,updated_at) @@ -234,7 +235,7 @@ export class ArtifactLibrary extends DurableObject { workspace, name, source, server_source, source_hash, project, timestamp, timestamp); const saved = this.draft(workspace, name); return { ok: true, path: sourcePath(workspace, name), workspace, name, source, server_source: saved.server_source, - source_hash, project: this.projectStorage.read(saved.project), state: JSON.parse(saved.state) as Record }; + source_hash, project: this.projectStorage.read(saved.project), revision_token: projectRevision(source, saved.server_source, this.projectStorage.read(saved.project)), state: JSON.parse(saved.state) as Record }; }); } @@ -280,6 +281,7 @@ export class ArtifactLibrary extends DurableObject { return { path: input.file ?? (part === "client" ? sourcePath(workspace, name) : serverSourcePath(workspace, name)), part, file: input.file, project, source_hash: input.file === undefined && part === "client" ? row.source_hash : sha256(selected), + revision_token: projectRevision(row.source, row.server_source, project), total_lines: lines.length, start_line: start, end_line: actualEnd, source: lines.slice(start - 1, actualEnd).join(""), next_line: actualEnd < lines.length ? actualEnd + 1 : null, @@ -459,7 +461,7 @@ export class ArtifactLibrary extends DurableObject { if (input.name) { if (input.event_id !== undefined) throw new Error("event_id requires version_id"); const name = artifactName(input.name); const draft = this.draft(workspace, name); - return { workspace, name, path: sourcePath(workspace, name), source: draft.source, server_source: draft.server_source, project: this.projectStorage.read(draft.project), + return { workspace, name, path: sourcePath(workspace, name), source: draft.source, server_source: draft.server_source, project: this.projectStorage.read(draft.project), revision_token: projectRevision(draft.source, draft.server_source, this.projectStorage.read(draft.project)), state: JSON.parse(draft.state) as Record, version_id: null, event_id: null, compiled_id: null }; } const version = this.findVersion(workspace, input.version_id!); @@ -468,7 +470,7 @@ export class ArtifactLibrary extends DurableObject { ? first(this.sql.exec("select * from serve_events where id = ? and version_id = ?", input.event_id, version.id)) : first(this.sql.exec("select * from serve_events where version_id = ? order by (mode = 'live') desc, rowid desc limit 1", version.id)); if (input.event_id && !event) throw new Error("serve event not found for version"); - return { workspace, name: version.name, path: version.source_path, source: version.source, server_source: version.server_source, project: version.project, + return { workspace, name: version.name, path: version.source_path, source: version.source, server_source: version.server_source, project: version.project, revision_token: projectRevision(version.source, version.server_source, version.project), state: event ? JSON.parse(event.initial_state) as Record : {}, version_id: version.id, event_id: event?.id ?? null, compiled_id: version.compiled_id }; } diff --git a/cloudflare/project.ts b/cloudflare/project.ts index 558c8e0..b1985b3 100644 --- a/cloudflare/project.ts +++ b/cloudflare/project.ts @@ -36,6 +36,16 @@ export function projectSourceHash(source: string, project: ArtifactProject): str return createHash("sha256").update(Object.values(project).every(value => Object.keys(value).length === 0) ? source : JSON.stringify([source, project])).digest("hex"); } +/** A full-project save must also detect server, helper and dependency changes. */ +export function projectRevision(source: string, server: string | null, project: ArtifactProject): string { + return createHash("sha256").update(JSON.stringify([source, server, normalizeProject(project)])).digest("hex"); +} + +export const REVISION_CONFLICT = "Project changed since it was loaded. Reload the saved project or compare it with your edits before saving."; +export function assertProjectRevision(expected: string | null | undefined, actual: string | null): void { + if (expected !== undefined && expected !== actual) throw new Error(REVISION_CONFLICT); +} + type Manifest = { name: string; version: string; dependencies?: Record; peerDependencies?: Record; peerDependenciesMeta?: Record; dist?: { tarball: string; integrity?: string } }; type Registry = { versions: Record }; const registryOrigin = "https://registry.npmjs.org"; diff --git a/cloudflare/script-service.ts b/cloudflare/script-service.ts index 9d69b8d..a0dbbec 100644 --- a/cloudflare/script-service.ts +++ b/cloudflare/script-service.ts @@ -1,5 +1,5 @@ import type { ScriptSchedule } from "./script-backend"; -import { resolveProject } from "./project"; +import { assertProjectRevision, resolveProject } from "./project"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { createHash } from "node:crypto"; import type { ArtifactEdit } from "./library"; @@ -78,6 +78,10 @@ export class CloudScriptService { } case "script_remix": case "script_write": case "script_edit": case "script_restore": { const target = this.artifacts.target("script", tool === "script_remix" ? args.new_name as string : name); + if (tool === "script_write" && args.expected_revision !== undefined) { + const current = await scripts.readRange(input).catch(error => { if (error instanceof Error && error.message.includes("script not found")) return null; throw error; }); + assertProjectRevision(args.expected_revision as string | null, current?.revision_token ?? null); + } // Read pending intent only after taking this operation's generation. // A concurrent explicit access change then either precedes this read or // supersedes this generation; it cannot be undone by stale metadata. @@ -97,7 +101,7 @@ export class CloudScriptService { const destination = {...input,name:target.name}; const previous = tool === "script_write" && args.project !== undefined ? await scripts.readRange(input).catch(error => { if (error instanceof Error && error.message.includes("script not found")) return undefined; throw error; }) : undefined; const project = tool === "script_write" && args.project !== undefined ? await resolveProject(args.project, previous?.project) : undefined; - const mutation = tool === "script_remix" ? await scripts.remix({workspace:this.artifacts.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string}) : tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string, project }) + const mutation = tool === "script_remix" ? await scripts.remix({workspace:this.artifacts.workspace,name:args.name as string | undefined,version_id:args.version_id as string | undefined,new_name:args.new_name as string}) : tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string, project, expected_revision: args.expected_revision as string | null | undefined }) : tool === "script_edit" ? await scripts.editDraft({ ...input, file: args.file as string | undefined, edits: args.edits as ArtifactEdit[], expected_hash: args.expected_hash as string | undefined }) : await scripts.restore({ workspace: this.artifacts.workspace, id: args.version_id as string }); generation ??= await hosted.links.begin(target); diff --git a/cloudflare/scripts.test.ts b/cloudflare/scripts.test.ts index eb3b044..d90716d 100644 --- a/cloudflare/scripts.test.ts +++ b/cloudflare/scripts.test.ts @@ -15,6 +15,22 @@ async function call(method: string,input: unknown,library="alice") { return value.result; } const identity={workspace:"default",name:"handler"}; +test("conditional script saves guard the whole project and preserve history and activation on conflicts", async () => { + const input = { workspace: "conditional", name: "handler" }; + const project = { files: { "lib/value.ts": "export const value = 1;" }, dependencies: {}, lock: {} }; + const first = await call("writeDraft", { ...input, source: "original", project, expected_revision: null }); + expect((await call("readRange", input)).revision_token).toBe(first.revision_token); + await call("activate", { ...input, source_hash: first.source_hash, code: "last working code" }); + const second = await call("writeDraft", { ...input, source: "original", project: { ...project, files: { "lib/value.ts": "agent edit" } } }); + await expect(call("writeDraft", { ...input, source: "stale browser", project, expected_revision: first.revision_token })).rejects.toThrow("Project changed since"); + expect((await call("readRange", input)).revision_token).toBe(second.revision_token); + expect(await call("history", input)).toHaveLength(2); + expect((await call("active", input)).code).toBe("last working code"); + await expect(call("writeDraft", { ...input, source: "name collision", expected_revision: null })).rejects.toThrow("Project changed since"); + const writes = await Promise.allSettled(["first writer", "second writer"].map(source => call("writeDraft", { ...input, source, expected_revision: second.revision_token }))); + expect(writes.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(writes.filter(result => result.status === "rejected")).toHaveLength(1); +}); test("versioned drafts, atomic edits, last valid activation, secrets and library isolation",async()=>{ const first=await call("writeDraft",{...identity,source:"first source"}); const firstActivation=await call("activate",{...identity,source_hash:first.source_hash,code:"valid-code"}); diff --git a/cloudflare/scripts.ts b/cloudflare/scripts.ts index ad04b36..51720ac 100644 --- a/cloudflare/scripts.ts +++ b/cloudflare/scripts.ts @@ -2,7 +2,7 @@ import { ProjectStorage } from "./project-storage"; import { DurableObject } from "cloudflare:workers"; import { sha256, MAX_SOURCE_BYTES, type ArtifactEdit } from "./library"; -import { emptyProject, normalizeProject, projectSourceHash, type ArtifactProject } from "./project"; +import { assertProjectRevision, emptyProject, normalizeProject, projectRevision, projectSourceHash, type ArtifactProject } from "./project"; const bytes = (value: string) => new TextEncoder().encode(value).byteLength; function text(value: unknown, label: string, max = 4096): string { @@ -58,9 +58,16 @@ export class ScriptLibrary extends DurableObject { this.sql.exec(`insert into scripts(workspace,name,source,source_hash,updated_at,project) values(?,?,?,?,?,?) on conflict(workspace,name) do update set source=excluded.source,source_hash=excluded.source_hash,updated_at=excluded.updated_at,project=excluded.project`,workspace,name,code,source_hash,updated_at,this.projectStorage.encode(project)); const previous = this.sql.exec<{revision: number; source_hash: string}>("select revision,source_hash from script_versions where workspace=? and name=? order by revision desc limit 1",workspace,name).toArray()[0]; if (previous?.source_hash !== source_hash || reason === "restore") this.sql.exec("insert into script_versions(id,workspace,name,revision,source,source_hash,created_at,reason,restored_from,project) values(?,?,?,?,?,?,?,?,?,?)",crypto.randomUUID(),workspace,name,(previous?.revision ?? 0)+1,code,source_hash,updated_at,reason,restoredFrom,this.projectStorage.encode(project)); - return {ok:true,workspace,name,path:`${workspace}/${name}.script.ts`,source:code,project,source_hash,updated_at}; + return {ok:true,workspace,name,path:`${workspace}/${name}.script.ts`,source:code,project,source_hash,revision_token:projectRevision(code,null,project),updated_at}; + } + writeDraft(input: {workspace: string; name: string; source: string; project?: ArtifactProject; expected_revision?: string | null}) { + return this.ctx.storage.transactionSync(() => { + const {workspace,name} = key(input); + const current = this.sql.exec("select * from scripts where workspace=? and name=?",workspace,name).toArray()[0]; + assertProjectRevision(input.expected_revision, current ? projectRevision(current.source,null,this.projectStorage.read(current.project)) : null); + return this.save(input,"edit"); + }); } - writeDraft(input: {workspace: string; name: string; source: string; project?: ArtifactProject}) { return this.ctx.storage.transactionSync(() => this.save(input,"edit")); } remix(input: {workspace: string; name?: string; version_id?: string; new_name: string}) { const {workspace,name} = key({workspace:input.workspace,name:input.new_name}); if (Boolean(input.name) === Boolean(input.version_id)) throw new Error("provide name or version_id, but not both"); @@ -89,7 +96,7 @@ export class ScriptLibrary extends DurableObject { const start = input.start_line ?? 1, end = input.end_line ?? start+199; if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start<1 || endlines.length) throw new Error("invalid line range"); const actualEnd = Math.min(end,lines.length); - return {workspace:row.workspace,name:row.name,path:input.file ?? `${row.workspace}/${row.name}.script.ts`,file:input.file,project,source:lines.slice(start-1,actualEnd).join(""),source_hash:input.file === undefined ? row.source_hash : sha256(selected),total_lines:lines.length,start_line:start,end_line:actualEnd,next_line:actualEnd { @@ -157,7 +164,8 @@ export class ScriptLibrary extends DurableObject { const workspace=text(input.workspace,"workspace"), id=text(input.id,"version id"); const row=this.sql.exec("select * from script_versions where workspace=? and id=?",workspace,id).toArray()[0]; if (!row) throw new Error("version not found in this workspace"); - return {...row,project:this.projectStorage.read(row.project),origin:this.sql.exec<{source_name: string;source_version_id: string}>("select source_name,source_version_id from script_remix_origins where workspace=? and name=?",workspace,row.name).toArray()[0] ?? null}; + const project = this.projectStorage.read(row.project); + return {...row,project,revision_token:projectRevision(row.source,null,project),origin:this.sql.exec<{source_name: string;source_version_id: string}>("select source_name,source_version_id from script_remix_origins where workspace=? and name=?",workspace,row.name).toArray()[0] ?? null}; } restore(input: {workspace: string; id: string}) { return this.ctx.storage.transactionSync(() => { diff --git a/cloudflare/service.ts b/cloudflare/service.ts index 6be03f4..4002cd7 100644 --- a/cloudflare/service.ts +++ b/cloudflare/service.ts @@ -1,5 +1,5 @@ import browserRuntime from "../dist/cloudflare/browser-runtime.json"; -import { resolveProject, type ArtifactProject } from "./project"; +import { assertProjectRevision, resolveProject, type ArtifactProject } from "./project"; import type { DurableObjectStub, DurableObjectNamespace } from "@cloudflare/workers-types"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import type { ArtifactLibrary, ArtifactEdit, CompiledArtifact } from "./library"; @@ -30,6 +30,7 @@ type Snapshot = { server_source: string | null; project?: ArtifactProject; state: Record; version_id?: string | null; compiled_id?: string | null; + revision_token?: string; }; type Mutation = Snapshot & { ok: boolean; applied?: boolean; changed?: boolean; restored?: boolean; source_hash?: string; edits_applied?: number; versionId?: string; revision?: number }; type Compiled = { ok: boolean; js?: string; diagnostics: Diagnostic[]; artifact?: CompiledArtifact }; @@ -87,11 +88,17 @@ export class CloudArtifactService { } case "artifact_write": { const target = this.artifacts.target("artifact", args.name as string); + // Reject an already stale save before superseding a pending activation. + // writeDraft repeats this check atomically after dependency resolution. + if (args.expected_revision !== undefined) { + const current = await this.snapshot({name: args.name as string}).catch(error => { if (error instanceof Error && error.message.includes("artifact not found")) return null; throw error; }); + assertProjectRevision(args.expected_revision as string | null, current?.revision_token ?? null); + } if (args.slug !== undefined) await this.artifacts.requireHosted().links.check(target, args.slug as string); const generation = await this.hosted?.links.begin(target); const previous = args.project === undefined ? undefined : await this.snapshot({name: args.name as string}).catch(error => { if (error instanceof Error && error.message.includes("artifact not found")) return undefined; throw error; }); const project = args.project === undefined ? undefined : await resolveProject(args.project, previous?.project, fetch, browserRuntime.sharedVersions); - return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined, project }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); + return this.mutationResult(await this.library.writeDraft({ workspace: this.workspace, name: args.name as string, source: args.contents as string, server_source: args.server as string | null | undefined, project, expected_revision: args.expected_revision as string | null | undefined }), generation, { ...(args.slug === undefined ? {} : { slug: args.slug as string }), ...(args.access === undefined ? {} : { access: args.access as "private" | "public" }) }); } case "artifact_edit": { const generation = await this.hosted?.links.begin(this.artifacts.target("artifact", args.name as string)); diff --git a/cloudflare/tool-contract.ts b/cloudflare/tool-contract.ts index ecb85d6..89d4752 100644 --- a/cloudflare/tool-contract.ts +++ b/cloudflare/tool-contract.ts @@ -128,6 +128,9 @@ const projectProperty = { type: "object", properties: { dependencies: { type: "object", maxProperties: 32, additionalProperties: { type: "string" }, description: "npm package names mapped to exact versions, for example {hono: '4.13.7'}. Resolved only on dependency changes; source and transitive dependency contents are archived together." }, }, additionalProperties: false }; for (const tool of CLOUD_MCP_TOOLS) { - if (tool.name === "artifact_write" || tool.name === "script_write") tool.inputSchema.properties!.project = projectProperty; + if (tool.name === "artifact_write" || tool.name === "script_write") { + tool.inputSchema.properties!.project = projectProperty; + tool.inputSchema.properties!.expected_revision = {type:["string","null"],pattern:"^[a-f0-9]{64}$",description:"Full-project revision_token from a source snapshot. Rejects stale client/server/helper/dependency writes atomically. Null requires a new name; omit for an unconditional write."}; + } if (["artifact_read", "artifact_edit", "script_read", "script_edit"].includes(tool.name)) tool.inputSchema.properties!.file = {type:"string", description:"Select a relative project.files helper module instead of the entrypoint."}; } diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts index e9cd124..04d7f01 100644 --- a/cloudflare/worker.test.ts +++ b/cloudflare/worker.test.ts @@ -63,6 +63,29 @@ function payload(result: Awaited>) { return JSON.parse(content[0]!.text); } +test("hosted conditional saves return conflicts and invalid drafts can be corrected with their new token", async () => { + const name = "guarded-handler"; + const good = 'export default { fetch() { return new Response("working"); } };'; + const write = async (contents: string, expected_revision?: string | null) => { + const response = await fetch(`${origin}/api/tools`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "script_write", arguments: { name, contents, expected_revision } }) }); + return { response, result: await response.json() as { isError?: boolean; structuredContent?: { revision_token: string; applied?: boolean }; error?: string } }; + }; + const created = await write(good, null); + expect(created.response.status).toBe(200); + const token = created.result.structuredContent!.revision_token; + const invalid = await write('export default { fetch() { return missing; } };', token); + expect(invalid.result).toMatchObject({ isError: true, structuredContent: { applied: true } }); + const newerToken = invalid.result.structuredContent!.revision_token; + expect(newerToken).not.toBe(token); + const stale = await write('export default { fetch() { return new Response("stale"); } };', token); + expect(stale.response.status).toBe(409); + expect(stale.result.error).toContain("Project changed since"); + expect(await (await fetch(`${origin}/${name}`)).text()).toBe("working"); + const corrected = await write(good, newerToken); + expect(corrected.result.isError).not.toBe(true); + expect((await write(good, null)).response.status).toBe(409); +}); + test("official HTTP MCP client lists contracts, writes, edits, restores and retrieves raw history", async () => { const tools = (await client.listTools()).tools; expect(tools.some(tool => tool.name === "artifact_write")).toBe(true); diff --git a/cloudflare/worker.ts b/cloudflare/worker.ts index 4d16732..7f8e038 100644 --- a/cloudflare/worker.ts +++ b/cloudflare/worker.ts @@ -194,6 +194,6 @@ app.get("/gallery/preview", async c => { app.get("/", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); app.get("/gallery", c => c.env.ASSETS.fetch(new Request(new URL("/index.html", c.req.url)))); app.get("/gallery.js", c => c.env.ASSETS.fetch(c.req.raw)); -app.onError((error, c) => c.json({ error: error.message }, error instanceof PluginError || error instanceof ArtifactFileError ? error.status : error instanceof ArtifactAuthConfigurationError ? 503 : /not found/.test(error.message) ? 404 : 400)); +app.onError((error, c) => c.json({ error: error.message }, error instanceof PluginError || error instanceof ArtifactFileError ? error.status : error instanceof ArtifactAuthConfigurationError ? 503 : error.message.startsWith("Project changed since") ? 409 : /not found/.test(error.message) ? 404 : 400)); export default app; diff --git a/docs/scripts.md b/docs/scripts.md index 9915445..595df91 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -105,6 +105,15 @@ Hosted Cloudflare and celld deployments use the same project contract. Local art Agents can call `script_guide` to retrieve the authoring contract through MCP. +Hosted `artifact_read`, `script_read`, and JSON source snapshots include a +`revision_token` for the complete project. Pass it as `expected_revision` on a +full write to reject changes made since the read, including backend, helper and +dependency changes. `expected_revision: null` requires a new name. Omit the field +for existing unconditional-write behavior. Browser editors use this guard, +retain unsaved buffers while switching items or revisions, and warn before +leaving the gallery. A conflict offers comparison with saved source or an +explicit reload; your edits remain available until you choose to discard them. + ## Remix Select a working copy or historical revision in the gallery, then **Remix** and choose a new name. MCP offers `artifact_remix` and `script_remix`, with `new_name` and exactly one of `name` or `version_id`. Hosted calls optionally accept a new `slug`; otherwise it defaults to the destination name. Sources and destinations belong to the authenticated library and selected workspace. diff --git a/e2e/gallery-drafts.test.ts b/e2e/gallery-drafts.test.ts new file mode 100644 index 0000000..5698267 --- /dev/null +++ b/e2e/gallery-drafts.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from "bun:test"; +import { chromium } from "playwright"; + +test("gallery retains complete unsaved projects and new scripts and offers conflict recovery", async () => { + const build = await Bun.build({ entrypoints: [new URL("../src/gallery/client.tsx", import.meta.url).pathname], target: "browser" }); + if (!build.success) throw new Error(build.logs.join("\n")); + const js = await build.outputs[0]!.text(); + const token = "a".repeat(64), latestToken = "b".repeat(64); + const artifacts = [ + { key: "report", name: "report", workspace: "default", working: true, versions: [{ id: "old", revision: 1, createdAt: "2026-09-01", reason: "edit", serveCount: 0 }] }, + { key: "handler", kind: "script", name: "handler", workspace: "default", working: true, versions: [] }, + ]; + const project = { files: { "lib/value.ts": "export const value = 1;" }, dependencies: {}, lock: {} }; + let conflict = true; + const calls: { name: string; arguments: Record }[] = []; + const server = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/client.js") return new Response(js, { headers: { "content-type": "text/javascript" } }); + if (url.pathname === "/api/session") return Response.json({}); + if (url.pathname === "/api/gallery") return Response.json({ workspace: "default", artifacts, capabilities: { links: true, scripts: true } }); + if (url.pathname === "/api/source") return Response.json({ + source: url.searchParams.get("kind") === "script" ? "export default {};" : url.searchParams.has("version") ? "historical client" : conflict ? "saved client" : "agent client", + server_source: url.searchParams.get("kind") === "script" ? undefined : "saved server", project, revision_token: conflict ? token : latestToken, + }); + if (url.pathname === "/api/tools") { + const call = await request.json() as typeof calls[number]; calls.push(call); + if (conflict) { conflict = false; return Response.json({ error: "Project changed since it was loaded." }, { status: 409 }); } + return Response.json({ structuredContent: { ok: true, applied: true, revision_token: "c".repeat(64) } }); + } + if (url.pathname === "/gallery/preview") return new Response("

Preview

", { headers: { "content-type": "text/html" } }); + return new Response('
', { headers: { "content-type": "text/html" } }); + } }); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(5000); + await page.goto(server.url.href); + await page.getByTitle("default/report", { exact: true }).click(); + await page.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Source", exact: true }).click(); + const client = page.getByRole("textbox", { name: "report.artifact.tsx", exact: true }); + await client.fill("unsaved client"); + await page.getByRole("button", { name: "report.artifact.server.ts", exact: true }).click(); + await page.getByRole("textbox", { name: "report.artifact.server.ts", exact: true }).fill("unsaved server"); + await page.getByRole("combobox", { name: "Helper file" }).selectOption("file:lib/value.ts"); + await page.getByRole("textbox", { name: "lib/value.ts", exact: true }).fill("unsaved helper"); + await page.getByText("Dependencies (0)", { exact: true }).click(); + await page.getByRole("textbox", { name: "Project dependencies" }).fill('{"example":"latest"}'); + await page.getByTitle("default/handler", { exact: true }).click(); + await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("unsaved script"); + await page.getByRole("button", { name: "New script", exact: true }).click(); + await page.getByRole("textbox", { name: "Script name", exact: true }).fill("unfinished-handler"); + await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("unfinished new script"); + await page.getByRole("button", { name: "Cancel", exact: true }).click(); + expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toBe("unsaved script"); + await page.getByRole("button", { name: "New script", exact: true }).click(); + expect(await page.getByRole("textbox", { name: "Script name", exact: true }).inputValue()).toBe("unfinished-handler"); + expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toBe("unfinished new script"); + await page.getByTitle("default/report", { exact: true }).click(); + expect(await client.innerText()).toBe("unsaved client"); + await page.getByRole("button", { name: "report.artifact.server.ts", exact: true }).click(); + expect(await page.getByRole("textbox", { name: "report.artifact.server.ts", exact: true }).innerText()).toBe("unsaved server"); + await page.getByRole("combobox", { name: "Helper file" }).selectOption("file:lib/value.ts"); + expect(await page.getByRole("textbox", { name: "lib/value.ts", exact: true }).innerText()).toBe("unsaved helper"); + await page.getByText("Dependencies (0)", { exact: true }).click(); + expect(await page.getByRole("textbox", { name: "Project dependencies" }).inputValue()).toBe('{"example":"latest"}'); + expect(await page.getByRole("button", { name: "Save artifact", exact: true }).isEnabled()).toBe(false); + await page.getByRole("textbox", { name: "Project dependencies" }).fill("{}"); + await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("old"); + await page.getByRole("button", { name: "Restore revision", exact: true }).waitFor(); + expect(await client.innerText()).toBe("historical client"); + await page.getByRole("combobox", { name: "Version", exact: true }).selectOption("working"); + expect(await client.innerText()).toBe("unsaved client"); + await page.getByRole("button", { name: "Save artifact", exact: true }).click(); + await page.getByRole("button", { name: "Compare saved project" }).waitFor(); + expect(calls[0]!.arguments).toMatchObject({ contents: "unsaved client", server: "unsaved server", expected_revision: token, project: { files: { "lib/value.ts": "unsaved helper" }, dependencies: {} } }); + await page.getByRole("button", { name: "Compare saved project" }).click(); + await page.getByText("Saved project (your edits remain in the editor)").waitFor(); + expect(await client.innerText()).toBe("unsaved client"); + page.once("dialog", dialog => void dialog.accept()); + await page.getByRole("button", { name: "Reload saved project" }).click(); + await page.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="report.artifact.tsx"]')?.textContent === "agent client"); + await client.fill("merged client"); + await page.getByRole("button", { name: "Save artifact", exact: true }).click(); + await page.getByRole("status").filter({ hasText: "Artifact saved" }).waitFor(); + expect(calls.at(-1)!.arguments.expected_revision).toBe(latestToken); + } finally { await browser.close(); server.stop(true); } +}, 30000); diff --git a/package.json b/package.json index 31564dc..2eb9bff 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "src/diagnostics.ts", "src/gallery/client.tsx", "src/gallery/hosted.tsx", + "src/gallery/drafts.tsx", "src/gallery/remix.tsx", "src/gallery/server.ts", "src/gallery/types.ts", @@ -105,7 +106,7 @@ "mcp": "bun run src/cli.ts mcp", "test": "bun test src scripts/prepare-plugins.test.ts examples/runner-status --timeout 30000", "test:e2e": "bun test e2e/herdr.e2e.test.ts", - "test:mcp-ui": "bun test e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", + "test:mcp-ui": "bun test e2e/gallery-drafts.test.ts e2e/gallery-move.test.ts e2e/project-editor.test.ts e2e/mcp-app.test.ts e2e/gallery-scripts.test.ts e2e/gallery-navigation.test.ts e2e/execution-controls.test.ts e2e/browser-plugins.test.ts e2e/plugin-bridge.test.ts e2e/routing.test.ts e2e/runner-status.test.ts --timeout 30000", "typecheck": "tsc --noEmit && tsc -p examples/runner-status/tsconfig.json --noEmit && tsc -p examples/runner-status/tsconfig.local.json --noEmit", "dev:cloudflare": "wrangler dev --local --var ENVIRONMENT:local", "prepare:celld": "bun run scripts/prepare-celld.ts", diff --git a/src/gallery/client.tsx b/src/gallery/client.tsx index 4f48523..fe4363c 100644 --- a/src/gallery/client.tsx +++ b/src/gallery/client.tsx @@ -9,6 +9,7 @@ import { RemixPanel } from "./remix"; import { Select } from "./select"; import { MovePanel } from "./move"; import { SourceEditor } from "./source-editor"; +import { DraftProvider, confirmLeavingDrafts, useDrafts } from "./drafts"; type Scope = "current" | "all"; type DetailTab = "preview" | "source" | "activity" | "requests" | "secrets"; @@ -123,6 +124,8 @@ const styles = ` .source-actions { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--line); flex-shrink: 0; } .source-actions p { margin: 0; color: var(--muted); } .source-actions .source-readonly { margin-right: auto; } + .save-conflict { flex-shrink: 0; padding: 8px 16px; max-height: 35vh; overflow: auto; } + .save-conflict button { border-color: var(--line); margin-right: 8px; } .script-panel label { color: var(--muted); font-size: 12px; } .script-panel :is(input, select) { color: var(--text); } .project-editor { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; } @@ -280,6 +283,7 @@ function formatDate(value: string): string { } function App() { + const { drafts } = useDrafts(); const [remixing, setRemixing] = useState(false); const [creatingScript, setCreatingScript] = useState(false); const [scope, setScope] = useState("current"); @@ -545,6 +549,7 @@ function App() { }; const signOut = async () => { + if (!confirmLeavingDrafts(drafts)) return; setSigningOut(true); setAccountError(""); try { @@ -584,6 +589,7 @@ function App() { {gallery?.workspace ? {gallery.workspace} : null}
{gallery?.libraryScope ? setName(event.target.value)} /> - - + + +

An HTTP handler. Visiting its URL runs the script and returns its response.

: null} - setContents(source)} readOnly={historical} disabled={!sourceReady || busy} onValidityChange={setProjectValid} /> + {draft ? buffer.update({project})} onEntryChange={(_, source) => buffer.update({source})} dependencyText={draft.dependencyText} onDependencyTextChange={text => buffer.update({}, text)} readOnly={historical} disabled={busy} onValidityChange={setProjectValid} /> :

Loading source…

}
{historical ? Read-only revision : null} + {draft?.dirty ? Unsaved changes : null} {onCancel ? : null} {status ?

{status}

: null} @@ -158,30 +194,42 @@ export function ScriptPanel({ artifact, workspace, version, sourceUrl, onSaved, } export function ArtifactSourcePanel({ artifact, version, sourceUrl, onSaved }: { artifact: GalleryArtifact; version: string; sourceUrl: string; onSaved: () => Promise }) { - const [snapshot, setSnapshot] = useState(null); - const [loadedUrl, setLoadedUrl] = useState(""); + const buffer = useProjectDraft(`${artifact.key}:${version}`); + const snapshot = buffer.draft?.content; + const [projectLoad, setProjectLoad] = useState(0); const [projectValid, setProjectValid] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [conflict, setConflict] = useState(false); const [status, setStatus] = useState(""); const historical = version !== "working"; useEffect(() => { - const controller = new AbortController(); setLoadedUrl(""); setSnapshot(null); setError(""); setStatus(""); setProjectValid(true); - void loadSourceSnapshot(sourceUrl, controller.signal).then(value => { if (!controller.signal.aborted) { setSnapshot(value); setLoadedUrl(sourceUrl); } }).catch(error => { if (!controller.signal.aborted) setError(message(error)); }); + if (snapshot) return; + const controller = new AbortController(); setError(""); setStatus(""); setProjectValid(true); + void loadSourceSnapshot(sourceUrl, controller.signal).then(value => { if (!controller.signal.aborted) buffer.reset(value); }).catch(error => { if (!controller.signal.aborted) setError(message(error)); }); return () => controller.abort(); - }, [sourceUrl]); - const loaded = snapshot !== null && loadedUrl === sourceUrl; + }, [sourceUrl, buffer.reset]); + const loaded = !!snapshot; async function save() { if (!loaded || !snapshot) return; setBusy(true); setError(""); setStatus(""); try { - await galleryTool(artifact.workspace, historical ? "artifact_restore" : "artifact_write", historical ? { version_id: version } : { name: artifact.name, contents: snapshot.source, server: snapshot.server_source ?? null, project: snapshot.project }); + const result = await galleryTool(artifact.workspace, historical ? "artifact_restore" : "artifact_write", historical ? { version_id: version } : { name: artifact.name, contents: snapshot.source, server: snapshot.server_source ?? null, project: snapshot.project, expected_revision: snapshot.revision_token }) as MutationResult; + buffer.saved(result.revision_token); setConflict(false); + if (historical) buffer.invalidateClean(`${artifact.key}:working`); await onSaved(); setStatus(historical ? "Revision restored" : "Artifact saved"); - } catch (error) { setError(message(error)); } finally { setBusy(false); } + } catch (error) { + if (error instanceof GalleryToolError) { + setConflict(error.status === 409); + if (error.result?.applied) { buffer.saved(error.result.revision_token); await onSaved(); } + } + setError(message(error)); + } finally { setBusy(false); } } return
{ event.preventDefault(); if (!busy && loaded && projectValid) void save(); }}> {error ?

{error}

: null} - {!loaded ?

Loading source…

: setSnapshot({ ...snapshot, project })} onEntryChange={(id, source) => setSnapshot(id === "server" ? { ...snapshot, server_source: source } : { ...snapshot, source })} readOnly={historical} disabled={busy} onValidityChange={setProjectValid} />} -
{historical ? Read-only revision : null}{status ?

{status}

: null}
+ {conflict ? { buffer.reset(snapshot); setConflict(false); setError(""); setProjectLoad(value => value + 1); }} /> : null} + {!snapshot ?

Loading source…

: buffer.update({project})} onEntryChange={(id, source) => buffer.update(id === "server" ? { server_source: source } : { source })} dependencyText={buffer.draft!.dependencyText} onDependencyTextChange={text => buffer.update({}, text)} readOnly={historical} disabled={busy} onValidityChange={setProjectValid} />} +
{historical ? Read-only revision : null}{buffer.draft?.dirty ? Unsaved changes : null}{status ?

{status}

: null}
; } diff --git a/src/gallery/project-editor.tsx b/src/gallery/project-editor.tsx index 54e3157..1cbed17 100644 --- a/src/gallery/project-editor.tsx +++ b/src/gallery/project-editor.tsx @@ -1,9 +1,14 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { SourceEditor } from "./source-editor"; import { Select } from "./select"; export type EditableProject = { files: Record; dependencies: Record }; export const emptyEditableProject = (): EditableProject => ({ files: {}, dependencies: {} }); +function parseDependencies(value: string): Record { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.entries(parsed).some(([name, version]) => !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name) || typeof version !== "string" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version))) throw new Error("Enter a JSON object mapping package names to exact versions."); + return parsed as Record; +} export function editableProject(value: unknown): EditableProject { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Source response is missing the project snapshot."); const project = value as Record; @@ -14,7 +19,7 @@ export function editableProject(value: unknown): EditableProject { return { files: { ...project.files as Record }, dependencies: { ...project.dependencies as Record } }; } -export function ProjectEditor({ entries, project, onProjectChange, onEntryChange, readOnly = false, disabled = false, onValidityChange }: { +export function ProjectEditor({ entries, project, onProjectChange, onEntryChange, readOnly = false, disabled = false, onValidityChange, dependencyText, onDependencyTextChange }: { entries: { id: string; filename: string; source: string }[]; project: EditableProject; onProjectChange: (project: EditableProject) => void; @@ -22,12 +27,19 @@ export function ProjectEditor({ entries, project, onProjectChange, onEntryChange readOnly?: boolean; disabled?: boolean; onValidityChange: (valid: boolean) => void; + dependencyText?: string; + onDependencyTextChange?: (text: string) => void; }) { const [selection, setSelection] = useState(entries[0]?.id ?? ""); const [filename, setFilename] = useState(""); const [fileError, setFileError] = useState(""); const [dependencies, setDependencies] = useState(JSON.stringify(project.dependencies, null, 2)); const [dependencyError, setDependencyError] = useState(""); + useEffect(() => { + if (dependencyText === undefined) return; + try { parseDependencies(dependencyText); setDependencyError(""); onValidityChange(true); } + catch (error) { setDependencyError(error instanceof Error ? error.message : String(error)); onValidityChange(false); } + }, [dependencyText, onValidityChange]); const entry = entries.find(item => item.id === selection); const helper = selection.startsWith("file:") ? selection.slice(5) : null; const selectedSource = entry?.source ?? (helper === null ? "" : project.files[helper] ?? ""); @@ -56,12 +68,11 @@ export function ProjectEditor({ entries, project, onProjectChange, onEntryChange
{fileError ?

{fileError}

: null} : null}
Dependencies ({Object.keys(project.dependencies).length})

Use package names and exact versions, for example {`{"lodash-es": "4.17.21"}`}.

-