Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cloudflare/artifact-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class ArtifactService {

async linkDetails(target: ArtifactTarget) {
const link = await this.hosted?.links.find(target);
if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.publicOrigin ?? this.hosted!.origin}/${link.slug}` };
if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.publicOrigin ?? this.hosted!.origin}/${link.slug}`, live: link.live, liveId: link.version_id ?? link.script_hash };
const pending = await this.hosted?.links.draft(target);
return pending?.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {};
}
Expand Down
3 changes: 2 additions & 1 deletion cloudflare/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { DurableObject } from "cloudflare:workers";
import { LibraryOwnership, type LibrarySelection, type OwnershipEnvironment } from "./ownership";

export type ArtifactTarget = { libraryKey: string; workspace: string; kind: "artifact" | "script"; name: string };
export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string };
export type LiveRevision = { id: string; revision: number; revision_token: string };
export type LinkUpdate = { slug?: string; access?: "private" | "public"; version_id?: string; script_hash?: string; live?: LiveRevision };
export type ArtifactLink = ArtifactTarget & LinkUpdate & { slug: string; access: "private" | "public"; generation: number };
const reserved = new Set(["api", "mcp", "gallery", "health", "sign-in", "consent"]);
export function validateSlug(value: unknown): string {
Expand Down
13 changes: 11 additions & 2 deletions cloudflare/managed-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export class ManagedArtifactService {
private async linkDetails(kind: "artifact" | "script", workspace: string, name: string) {
const target = await this.links.admit({ libraryKey: this.libraryKey, workspace, kind, name });
const link = await this.links.find(target);
if (link) return { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}` };
if (link) return { slug: link.slug, access: link.access, url: `${this.publicOrigin}/${link.slug}`, live: link.live, liveId: link.version_id ?? link.script_hash };
const pending = await this.links.draft(target);
return pending.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {};
}
Expand All @@ -98,7 +98,16 @@ export class ManagedArtifactService {
}
}
}
for (const item of artifacts.values()) Object.assign(item, await this.linkDetails(item.kind!, item.workspace, item.name));
for (const item of artifacts.values()) {
Object.assign(item, await this.linkDetails(item.kind!, item.workspace, item.name));
if (item.working) {
const target = await this.links.admit({ libraryKey: this.libraryKey, workspace: item.workspace, kind: item.kind!, name: item.name });
const snapshot = item.kind === "script"
? await this.env.SCRIPTS.getByName(target.libraryKey).readRange({ workspace: item.workspace, name: item.name, end_line: 1 })
: await this.env.LIBRARIES.getByName(target.libraryKey).preview({ workspace: item.workspace, name: item.name });
item.draftRevision = snapshot.revision_token;
}
}
return { capabilities: { scripts: true, links: true, moves: true }, workspace: this.workspace,
artifacts: [...artifacts.values()].sort((a, b) => a.name.localeCompare(b.name) || a.workspace.localeCompare(b.workspace)), nextOffset: hasMore ? offset + 100 : null };
}
Expand Down
2 changes: 1 addition & 1 deletion cloudflare/script-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class CloudScriptService {
if (validation.ok) {
try {
const activated = await scripts.activate({ ...destination, source_hash: mutation.source_hash, code: compiled.js });
const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash });
const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash, live: activated.live });
if (!link) {
const { source, project: snapshotProject, ...summary } = mutation;
const superseded = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" };
Expand Down
3 changes: 2 additions & 1 deletion cloudflare/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ export class ScriptLibrary extends DurableObject<unknown> {
this.sql.exec("insert into compiled_scripts(workspace,name,hash,code) values(?,?,?,?) on conflict(workspace,name,hash) do nothing",row.workspace,row.name,hash,input.code);
this.sql.exec("update scripts set active_code=?,active_hash=? where workspace=? and name=?",input.code,hash,row.workspace,row.name);
});
return {ok:true,source_hash:row.source_hash,hash};
const version = this.sql.exec<{id: string; revision: number}>("select id,revision from script_versions where workspace=? and name=? and source_hash=? order by revision desc limit 1",row.workspace,row.name,row.source_hash).toArray()[0]!;
return {ok:true,source_hash:row.source_hash,hash,live:{...version,revision_token:projectRevision(row.source,null,this.projectStorage.read(row.project))}};
}
/** Internal execution lookup. Never return this response through management APIs. */
active(input: {workspace: string; name: string; hash?: string}) {
Expand Down
6 changes: 3 additions & 3 deletions cloudflare/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import browserRuntime from "../dist/cloudflare/browser-runtime.json";
import { assertProjectRevision, resolveProject, type ArtifactProject } from "./project";
import { assertProjectRevision, emptyProject, projectRevision, 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";
Expand Down Expand Up @@ -198,7 +198,7 @@ export class CloudArtifactService {
const { _meta, ...details } = await this.preview(snapshot, compiled);
const target = this.artifacts.target("artifact", mutation.name);
if (generation != null && details.ok && "artifact" in details && (settings.slug !== undefined || await this.hosted!.links.find(target))) {
const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.artifact.versionId });
const link = await this.hosted!.links.commit(target, generation, { ...settings, version_id: details.artifact.versionId, live: { id: details.artifact.versionId, revision: details.artifact.revision, revision_token: projectRevision(mutation.source, mutation.server_source, mutation.project ?? emptyProject()) } });
if (!link) {
const superseded = { ...payload, ok: false, superseded: true, error: "A newer update superseded this URL activation" };
return { ...text(superseded, true), structuredContent: superseded };
Expand Down Expand Up @@ -302,7 +302,7 @@ export class CloudArtifactService {
if (!compiled.ok) return text({ ok: false, diagnostics: compiled.diagnostics, check: formatArtifactCheck(compiled.diagnostics) }, true);
const { _meta, ...preview } = await this.preview(snapshot, compiled);
if (preview.ok && "artifact" in preview) {
const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.artifact.versionId });
const link = await hosted.links.commit(target, generation, { ...settings, version_id: preview.artifact.versionId, live: { id: preview.artifact.versionId, revision: preview.artifact.revision, revision_token: projectRevision(snapshot.source, snapshot.server_source, snapshot.project ?? emptyProject()) } });
if (!link) return text({ ok: false, superseded: true, error: "A newer update superseded this URL activation" }, true);
}
return { ...text({ ...preview, ...await this.artifacts.linkDetails(target) }), ...(_meta ? { _meta } : {}) };
Expand Down
2 changes: 2 additions & 0 deletions cloudflare/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ test("hosted conditional saves return conflicts and invalid drafts can be correc
expect(stale.response.status).toBe(409);
expect(stale.result.error).toContain("Project changed since");
expect(await (await fetch(`${origin}/${name}`)).text()).toBe("working");
const gallery = await (await fetch(`${origin}/api/gallery`)).json() as { artifacts: { name: string; live: { revision: number; revision_token: string }; draftRevision: string }[] };
expect(gallery.artifacts.find(item => item.name === name)).toMatchObject({ live: { revision: 1, revision_token: token }, draftRevision: newerToken });
const corrected = await write(good, newerToken);
expect(corrected.result.isError).not.toBe(true);
expect((await write(good, null)).response.status).toBe(409);
Expand Down
8 changes: 4 additions & 4 deletions e2e/gallery-drafts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,14 @@ test("gallery retains complete unsaved projects and new scripts and offers confl
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);
expect(await page.getByRole("button", { name: "Save and deploy", 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();
await page.getByRole("button", { name: "Restore and deploy", 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: "Save and deploy", 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();
Expand All @@ -80,7 +80,7 @@ test("gallery retains complete unsaved projects and new scripts and offers confl
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("button", { name: "Save and deploy", 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); }
Expand Down
4 changes: 2 additions & 2 deletions e2e/gallery-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati
await noHorizontalOverflow(desktop);
await screenshot(desktop, "gallery-desktop-source");
await desktop.getByRole("combobox", { name: "Version", exact: true }).selectOption("campaign-revision-1");
await desktop.getByRole("button", { name: "Restore revision", exact: true }).waitFor();
await desktop.getByRole("button", { name: "Restore and deploy", exact: true }).waitFor();
await desktop.waitForFunction(() => document.querySelector('[role="textbox"][aria-label="Campaign overview.artifact.tsx"]')?.textContent?.includes("Campaign revision 1"));
expect(await source.getAttribute("aria-readonly")).toBe("true");
await desktop.getByRole("group", { name: "Artifact view" }).getByRole("button", { name: "Preview", exact: true }).click();
Expand Down Expand Up @@ -165,7 +165,7 @@ test("gallery filters, previews revisions, and preserves mobile library navigati
await mobileSource.waitFor();
await mobileSource.fill(sourceFor(false).replace("Current campaign", "Mobile unsaved campaign"));
await mobileSource.press("ControlOrMeta+Home");
for (const control of [mobile.getByRole("combobox", { name: "Version", exact: true }), mobile.getByRole("button", { name: "Save artifact", exact: true }), mobile.getByRole("button", { name: "Find", exact: true }), mobile.getByText("Manage helper files", { exact: true }), mobile.getByText("Dependencies (0)", { exact: true })]) {
for (const control of [mobile.getByRole("combobox", { name: "Version", exact: true }), mobile.getByRole("button", { name: "Save and deploy", exact: true }), mobile.getByRole("button", { name: "Find", exact: true }), mobile.getByText("Manage helper files", { exact: true }), mobile.getByText("Dependencies (0)", { exact: true })]) {
await control.scrollIntoViewIfNeeded();
const bounds = (await control.boundingBox())!;
expect(bounds.x).toBeGreaterThanOrEqual(0);
Expand Down
41 changes: 39 additions & 2 deletions e2e/gallery-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ test("gallery edits scripts without execution and runs requests only on demand",
await views.getByRole("button", { name: "Requests", exact: true }).click();
await views.getByRole("button", { name: "Source", exact: true }).click();
expect(await page.getByRole("textbox", { name: "script.ts", exact: true }).innerText()).toContain('new Response("changed")');
await page.getByRole("button", { name: "Save script", exact: true }).click();
await page.getByRole("button", { name: "Save and deploy", exact: true }).click();
await page.getByRole("status").filter({ hasText: "Script saved" }).waitFor();
expect(calls.map(call => call.name)).toEqual(["script_write"]);
await views.getByRole("button", { name: "Requests", exact: true }).click();
Expand All @@ -66,7 +66,7 @@ test("gallery edits scripts without execution and runs requests only on demand",
await page.getByRole("button", { name: "New script" }).click();
await page.getByRole("textbox", { name: "Script name", exact: true }).fill("new-handler");
await page.getByRole("textbox", { name: "Script slug" }).fill("new-handler");
await page.getByRole("button", { name: "Save script", exact: true }).click();
await page.getByRole("button", { name: "Save and deploy", exact: true }).click();
await page.getByRole("button", { name: "new-handler Script", exact: true }).waitFor();
expect(await page.getByRole("button", { name: "new-handler Script", exact: true }).getAttribute("aria-current")).toBe("true");
expect(calls.filter(call => call.name === "script_run")).toHaveLength(1);
Expand All @@ -84,3 +84,40 @@ test("gallery edits scripts without execution and runs requests only on demand",
expect(await page.getByRole("textbox", { name: "URL slug" }).count()).toBe(0);
} finally { await browser.close(); server.stop(true); }
}, 30000);

test("invalid saved drafts show live targets and clickable helper diagnostics", 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 artifact = { key: "handler", kind: "script", name: "handler", workspace: "default", working: true, versions: [], live: { id: "good", revision: 1, revision_token: "good" }, draftRevision: "good" };
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: [artifact], capabilities: { links: true, scripts: true } });
if (url.pathname === "/api/source") return Response.json({ source: "export default {};", revision_token: "good", project: { files: { "lib/value.ts": "export const value = 1;\nexport const broken = missing;\n" }, dependencies: {}, lock: {} } });
if (url.pathname === "/api/tools") {
artifact.draftRevision = "invalid";
return Response.json({ isError: true, structuredContent: { applied: true, ok: false, revision_token: "invalid", diagnostics: [{ severity: "error", file: "lib/value.ts", line: 2, column: 23, message: "Cannot find name 'missing'." }] } });
}
return new Response('<div id="root"></div><script type="module" src="/client.js"></script>', { headers: { "content-type": "text/html" } });
} });
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto(server.url.href);
await page.getByRole("textbox", { name: "script.ts", exact: true }).fill("invalid source");
await page.getByRole("button", { name: "Save and deploy", exact: true }).click();
await page.getByRole("alert").filter({ hasText: "Invalid draft saved; live revision unchanged." }).waitFor();
expect(await page.locator(".deployment-status").innerText()).toBe("Live revision 1 · Saved draft; live revision unchanged");
await page.getByRole("button", { name: "lib/value.ts:2:23", exact: false }).click();
const helper = page.getByRole("textbox", { name: "lib/value.ts", exact: true });
await helper.waitFor();
expect(await helper.evaluate(el => el === document.activeElement)).toBe(true);
expect(await helper.evaluate(() => window.getSelection()?.anchorNode?.parentElement?.textContent)).toContain("export const broken");
await page.getByText("Raw details", { exact: true }).click();
expect(await page.locator(".save-feedback pre").innerText()).toContain('"revision_token": "invalid"');
await page.getByRole("group", { name: "Script view" }).getByRole("button", { name: "Requests", exact: true }).click();
expect(await page.getByText("Run target:", { exact: false }).innerText()).toContain("Live revision 1");
} finally { await browser.close(); server.stop(true); }
}, 30000);
Loading
Loading