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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@ canvases execute inside the chat host's MCP Apps sandbox; use trusted local canv
source, as for the other local views. Artifact
source and saved state can contain private data; keep the history database private.

## Projects and remix
## Projects, remix, and scheduled runs

Canvases and scripts support helper source files and pinned package dependencies.
Use the gallery's source file picker and Dependencies editor, or pass
Expand All @@ -416,4 +416,7 @@ Use **Remix** in the gallery, `canvas_remix` / `script_remix` through MCP, or
`canvas remix SOURCE NEW_NAME` locally. Copies retain source provenance and start
with fresh state, storage, secrets, and private hosted URLs.

See [project and remix contracts](docs/scripts.md) for details and limits.
Hosted and celld backends expose **Schedule** and **Run history** controls. Native
alarms support intervals and cron expressions with timezones, pause/resume, and
run-now. Run records live in host-owned SQLite. See
[project, remix, and scheduling contracts](docs/scripts.md) for details and limits.
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 67 additions & 16 deletions cloudflare/backend.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { DurableObject } from "cloudflare:workers";
import type { CanvasHttpRequest, CanvasHttpResponse } from "../src/httpTypes";

import { CanvasExecution, type CanvasActive, type CanvasClaim, type CanvasScheduleInput } from "./canvas-execution";

export type BackendEnv = { LOADER: WorkerLoader };
const MAX_BODY = 256 * 1024;

Expand Down Expand Up @@ -43,24 +45,73 @@ async function serializeResponse(response: Response, method: string): Promise<Ca
* class runs in a separate isolate and owns its own native SQLite database. */
export class CanvasBackend extends DurableObject<BackendEnv> {
private activeCode: string | undefined;
private execution: CanvasExecution;
constructor(state: DurableObjectState, env: BackendEnv) {
super(state, env);
this.execution = new CanvasExecution(state.storage);
}
activate(input: CanvasActive) { return this.execution.activate(input); }
async activeRevision() {
const active = await this.execution.active();
return active ? { version_id: active.version_id, revision: active.revision, has_server: active.code !== null } : null;
}
runs(input: { limit?: number } = {}) { return this.execution.runs(input.limit); }
async schedule(input: CanvasScheduleInput = {}) {
if (input.request) {
nativeRequest(input.request);
input = { ...input, request: { ...input.request, method: input.request.method ?? "GET", headers: input.request.headers ?? [] } };
}
if (input.action === "run_now") {
const schedule = await this.execution.get();
if (!schedule) throw new Error("No schedule configured");
await this.runScheduled(schedule.request, "manual");
return this.execution.get();
}
return this.execution.update(input);
}
async alarm() {
const claimed = await this.execution.claim();
if (claimed) await this.runScheduled(claimed.schedule.request, "schedule", claimed);
}
private async runScheduled(request: CanvasHttpRequest, trigger: "manual" | "schedule", claimed?: CanvasClaim) {
const active = claimed ? claimed.active : await this.execution.active();
if (!active?.code) {
const run = claimed?.run ?? this.execution.start(active?.version_id ?? "unavailable", trigger);
this.execution.finish(run, null);
return;
}
// Errors are recorded by request(). Never retry an invocation that may
// already have committed application writes.
try { await this.request({ ...active, code: active.code, request, trigger }, claimed?.run); } catch {}
}

async request(input: { code: string; hash: string; request: CanvasHttpRequest }): Promise<CanvasHttpResponse> {
async request(input: { code: string; hash: string; version_id?: string; request: CanvasHttpRequest; trigger?: "http" | "manual" | "schedule" }, claimedRun?: { id: string; started: number }): Promise<CanvasHttpResponse> {
const request = nativeRequest(input.request);
if (this.activeCode !== input.hash) {
// Reload code while preserving the facet's database. Never delete/reset
// storage as a side effect of an edit, preview, or source restoration.
this.ctx.facets.abort("canvas", "Canvas server code updated");
this.activeCode = input.hash;
const active = input.version_id ? undefined : await this.execution.active();
const revision = input.version_id ?? (active?.hash === input.hash ? active.version_id : input.hash);
const run = claimedRun ?? this.execution.start(revision, input.trigger ?? "http");
try {
if (this.activeCode !== input.hash) {
// Reload code while preserving the facet's database. Never delete/reset
// storage as a side effect of an edit, preview, or source restoration.
this.ctx.facets.abort("canvas", "Canvas server code updated");
this.activeCode = input.hash;
}
const facet = this.ctx.facets.get("canvas", () => {
const worker = this.env.LOADER.get(input.hash, async () => ({
compatibilityDate: "2026-09-06",
mainModule: "canvas-server.js",
modules: { "canvas-server.js": input.code },
globalOutbound: null,
}));
return { class: worker.getDurableObjectClass("CanvasServer") };
});
const response = await serializeResponse(await facet.fetch(request), request.method);
this.execution.finish(run, response.status);
return response;
} catch (error) {
this.execution.finish(run, null);
throw error;
}
const facet = this.ctx.facets.get("canvas", () => {
const worker = this.env.LOADER.get(input.hash, async () => ({
compatibilityDate: "2026-09-06",
mainModule: "canvas-server.js",
modules: { "canvas-server.js": input.code },
globalOutbound: null,
}));
return { class: worker.getDurableObjectClass("CanvasServer") };
});
return serializeResponse(await facet.fetch(request), request.method);
}
}
111 changes: 111 additions & 0 deletions cloudflare/canvas-execution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterAll, afterEach, expect, spyOn, test } from "bun:test";
import { Database } from "bun:sqlite";
import { CanvasExecution } from "./canvas-execution";

// Real SQLite for host history; the KV/alarm adapter exposes observable state
// without requiring a Workers isolate for scheduling state transitions.
function fixture() {
const db = new Database(":memory:");
const values = new Map<string, unknown>();
let alarm: number | null = null;
const storage = {
sql: { exec(query: string, ...params: (string | number | null)[]) {
const rows = db.prepare(query).all(...params);
return { toArray: () => rows };
} },
get: async (key: string) => values.get(key),
put: async (key: string, value: unknown) => { values.set(key, value); },
setAlarm: async (time: number) => { alarm = time; },
deleteAlarm: async () => { alarm = null; },
transaction: async (callback: (storage: unknown) => Promise<unknown>) => callback(storage),
};
return { execution: new CanvasExecution(storage as unknown as DurableObjectStorage), storage, alarm: () => alarm, db };
}
const now = spyOn(Date, "now");
afterEach(() => now.mockReset());
afterAll(() => now.mockRestore());
const active = { code: "server", hash: "hash", version_id: "revision-one", revision: 1 };
const request = { path: "/refresh", method: "POST", headers: [] as [string, string][] };

test("schedule claims once, pause cancels alarm, resume schedules a future occurrence", async () => {
now.mockReturnValue(1_800_000_000_000);
const { execution, alarm } = fixture();
await execution.activate(active);
const saved = await execution.update({ action: "set", interval_seconds: 60, request });
expect(saved).toMatchObject({ request, paused: false, next_run_at: Date.now() + 60_000 });
expect(alarm()).toBe(saved.next_run_at);
expect(await execution.claim()).toBeNull();
now.mockReturnValue(saved.next_run_at!);
expect(await execution.claim()).toMatchObject({ schedule: saved, active });
expect(await execution.claim()).toBeNull();
expect(alarm()).toBe(Date.now() + 60_000);
await execution.update({ action: "pause" });
expect(alarm()).toBeNull();
expect(await execution.claim()).toBeNull();
expect(await execution.update({ action: "resume" })).toMatchObject({ paused: false, next_run_at: Date.now() + 60_000 });
});

test("a claimed occurrence survives interruption without being replayed", async () => {
now.mockReturnValue(1_800_000_000_000);
const { execution, storage } = fixture();
await execution.activate(active);
const schedule = await execution.update({ action: "set", interval_seconds: 60, request });
now.mockReturnValue(schedule.next_run_at!);
const claimed = await execution.claim();
expect(claimed).not.toBeNull();
expect(execution.runs()).toMatchObject([{ id: claimed!.run.id, revision: active.version_id, status: "running", trigger: "schedule" }]);
const restarted = new CanvasExecution(storage as unknown as DurableObjectStorage);
expect(await restarted.claim()).toBeNull();
expect(restarted.runs()).toMatchObject([{ id: claimed!.run.id, status: "interrupted" }]);
});

test("validated activation is monotonic and server removal pauses scheduling", async () => {
now.mockReturnValue(1_800_000_000_000);
const { execution, alarm } = fixture();
await execution.activate({ ...active, revision: 3, version_id: "revision-three" });
await execution.activate(active);
expect((await execution.active())?.version_id).toBe("revision-three");
await execution.update({ action: "set", interval_seconds: 60, request });
await execution.activate({ ...active, code: null, revision: 4 });
expect(await execution.get()).toMatchObject({ paused: true, next_run_at: null });
expect(alarm()).toBeNull();
await expect(execution.update({ action: "resume" })).rejects.toThrow("no validated server");
});

test("invalid schedule cannot replace existing state, cron uses configured timezone", async () => {
now.mockReturnValue(Date.parse("2026-09-07T10:00:00Z"));
const { execution } = fixture();
await execution.activate(active);
const saved = await execution.update({ action: "set", cron: "0 9 * * *", timezone: "America/Los_Angeles", request });
expect(saved.next_run_at).toBe(Date.parse("2026-09-07T16:00:00Z"));
await expect(execution.update({ action: "set", interval_seconds: 5, request })).rejects.toThrow();
await expect(execution.update({ action: "set", interval_seconds: 60, cron: "* * * * *", request })).rejects.toThrow();
expect(await execution.get()).toEqual(saved);
});

test("history records revision, trigger, duration and failures; restart retains uncertainty", () => {
now.mockReturnValue(1_800_000_000_000);
const { execution, storage } = fixture();
const run = execution.start("version-1", "schedule");
now.mockReturnValue(Date.now() + 123);
execution.finish(run, 204);
expect(execution.runs()).toMatchObject([{ revision: "version-1", trigger: "schedule", duration_ms: 123, status: "succeeded", http_status: 204 }]);
const failed = execution.start("version-2", "http");
execution.finish(failed, 500);
const uncertain = execution.start("version-3", "manual");
const recovered = new CanvasExecution(storage as unknown as DurableObjectStorage);
expect(recovered.runs().find(run => run.id === uncertain.id)).toMatchObject({ status: "interrupted", finished_at: null });
expect(recovered.runs().find(run => run.id === failed.id)).toMatchObject({ status: "failed", http_status: 500 });
expect(() => recovered.runs(101)).toThrow();
});

test("compiled source and scheduled bodies larger than KV values persist in host SQLite", async () => {
now.mockReturnValue(1_800_000_000_000);
const { execution, storage } = fixture();
const source = "x".repeat(200_000), body = btoa("y".repeat(200_000));
await execution.activate({ ...active, code: source });
await execution.update({ action: "set", interval_seconds: 3600, request: { ...request, body } });
const restarted = new CanvasExecution(storage as unknown as DurableObjectStorage);
expect((await restarted.active())?.code).toBe(source);
expect((await restarted.get())?.request.body).toBe(body);
});
87 changes: 87 additions & 0 deletions cloudflare/canvas-execution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { CanvasHttpRequest } from "../src/httpTypes";
import { normalizeTiming, nextOccurrence, type ScheduleTiming } from "./schedule";

export type CanvasSchedule = ScheduleTiming & { paused: boolean; next_run_at: number | null; request: CanvasHttpRequest };
export type CanvasScheduleInput = ScheduleTiming & { action?: "get" | "set" | "pause" | "resume" | "run_now"; request?: CanvasHttpRequest };
export type CanvasRun = { id: string; revision: string; trigger: string; started_at: string; finished_at: string | null; duration_ms: number | null; status: string; http_status: number | null };
export type CanvasActive = { code: string | null; hash: string; version_id: string; revision: number };
export type CanvasClaim = { schedule: CanvasSchedule; active: CanvasActive | undefined; run: { id: string; started: number } };

/** Supervisor storage belongs to the host, separate from CanvasServer's facet. */
export class CanvasExecution {
constructor(private storage: DurableObjectStorage) {
storage.sql.exec("create table if not exists host_schedule(id integer primary key check(id=1),payload text not null)");
storage.sql.exec("create table if not exists active_server(id integer primary key check(id=1),payload text not null)");
storage.sql.exec("create table if not exists execution_runs(id text primary key,revision text not null,trigger text not null,started_at text not null,finished_at text,duration_ms integer,status text not null,http_status integer)");
storage.sql.exec("update execution_runs set status='interrupted' where status='running'");
}
async activate(active: CanvasActive) {
await this.storage.transaction(async storage => {
const previous = this.readActive();
if (previous && previous.revision > active.revision) return;
this.storage.sql.exec("insert into active_server(id,payload) values(1,?) on conflict(id) do update set payload=excluded.payload", JSON.stringify(active));
if (active.code === null) {
const schedule = this.readSchedule();
if (schedule) this.writeSchedule({ ...schedule, paused: true, next_run_at: null });
await storage.deleteAlarm();
}
});
}
private readActive(): CanvasActive | undefined {
const row = this.storage.sql.exec<{ payload: string }>("select payload from active_server where id=1").toArray()[0];
return row ? JSON.parse(row.payload) as CanvasActive : undefined;
}
async active() { return this.readActive(); }
start(revision: string, trigger: string) {
const id = crypto.randomUUID(), started = Date.now();
this.storage.sql.exec("insert into execution_runs(id,revision,trigger,started_at,status) values(?,?,?,?,?)", id, revision, trigger, new Date(started).toISOString(), "running");
return { id, started };
}
finish(run: { id: string; started: number }, status: number | null) {
this.storage.sql.exec("update execution_runs set finished_at=?,duration_ms=?,status=?,http_status=? where id=?", new Date().toISOString(), Date.now() - run.started, status !== null && status < 400 ? "succeeded" : "failed", status, run.id);
this.storage.sql.exec("delete from execution_runs where status!='running' and id not in (select id from execution_runs order by started_at desc,rowid desc limit 1000)");
}
runs(limit = 100) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error("limit must be between 1 and 100");
return this.storage.sql.exec<CanvasRun>("select * from execution_runs order by started_at desc,rowid desc limit ?", limit).toArray();
}
private readSchedule(): CanvasSchedule | null {
const row = this.storage.sql.exec<{ payload: string }>("select payload from host_schedule where id=1").toArray()[0];
return row ? JSON.parse(row.payload) as CanvasSchedule : null;
}
private writeSchedule(schedule: CanvasSchedule) {
this.storage.sql.exec("insert into host_schedule(id,payload) values(1,?) on conflict(id) do update set payload=excluded.payload", JSON.stringify(schedule));
}
async get() { return this.readSchedule(); }
async update(input: CanvasScheduleInput) {
const action = input.action ?? "get";
if (action === "get") return this.get();
if (!["set", "pause", "resume"].includes(action)) throw new Error("Invalid schedule action");
return this.storage.transaction(async storage => {
const previous = this.readSchedule();
if (action !== "set" && !previous) throw new Error("No schedule configured");
const active = this.readActive();
if (action !== "pause" && !active?.code) throw new Error("Canvas has no validated server");
const timing = action === "set" ? normalizeTiming(input) : previous!;
const request = action === "set" ? input.request ?? { path: "/", method: "GET", headers: [] } : previous!.request;
const result: CanvasSchedule = { ...normalizeTiming(timing), request, paused: action === "pause", next_run_at: action === "pause" ? null : nextOccurrence(timing) };
this.writeSchedule(result);
if (result.next_run_at === null) await storage.deleteAlarm(); else await storage.setAlarm(result.next_run_at);
return result;
});
}
/** Claim before invoking; redelivered alarms cannot repeat side effects. */
async claim(): Promise<CanvasClaim | null> {
return this.storage.transaction(async storage => {
const schedule = this.readSchedule();
if (!schedule || schedule.paused || schedule.next_run_at === null) return null;
if (schedule.next_run_at > Date.now()) { await storage.setAlarm(schedule.next_run_at); return null; }
const next = nextOccurrence(schedule);
this.writeSchedule({ ...schedule, next_run_at: next });
await storage.setAlarm(next);
const active = this.readActive();
const run = this.start(active?.version_id ?? "unavailable", "schedule");
return { schedule, active, run };
});
}
}
Loading
Loading