import { fnv1a64 } from "../worker/replay.mjs"; export const WORKSPACE_SCHEMA = "workspace-runtime.v1"; export const WORKSPACE_DEFAULT_QUOTA = 64 * 1024 * 1024; function clone(value) { return structuredClone(value); } export function normalizeWorkspacePath(value) { if (typeof value !== "string" || value.length === 0 || value.includes("\0") || value.includes("\\")) throw new Error("workspace_path_invalid"); let path = value; if (path === "/workspace") return ""; if (path.startsWith("/workspace/")) path = path.slice("/workspace/".length); else if (path.startsWith("/")) throw new Error("workspace_host_path"); const parts = path.split("/").filter(Boolean); if (parts.some((part) => part === "." || part === "..")) throw new Error("workspace_path_escape"); return parts.join("/"); } function encoded(data) { if (typeof data !== "string") throw new Error("workspace_data_invalid"); const bytes = new TextEncoder().encode(data); if (new TextDecoder("utf-8", { fatal: true }).decode(bytes) !== data) throw new Error("workspace_encoding_invalid"); return bytes; } export class WorkspaceRuntime { constructor({ quotaBytes = WORKSPACE_DEFAULT_QUOTA, files = {} } = {}) { if (!Number.isSafeInteger(quotaBytes) || quotaBytes < 1) throw new Error("workspace_quota_invalid"); this.quotaBytes = quotaBytes; this.files = new Map(); this.transactions = new Map(); for (const [path, data] of Object.entries(files)) this.files.set(normalizeWorkspacePath(path), data); this.assertQuota(this.files); } assertQuota(files) { let total = 0; for (const [path, data] of files) { if (!path) throw new Error("workspace_path_invalid"); const size = encoded(data).byteLength; if (size > this.quotaBytes) throw new Error("workspace_file_quota"); total += size; } if (total > this.quotaBytes) throw new Error("workspace_quota_exceeded"); } read(path) { const normalized = normalizeWorkspacePath(path); if (!this.files.has(normalized)) throw new Error("workspace_file_missing"); return this.files.get(normalized); } stat(path) { const normalized = normalizeWorkspacePath(path); if (!this.files.has(normalized)) throw new Error("workspace_file_missing"); const data = this.files.get(normalized); return { path: `/workspace/${normalized}`, bytes: encoded(data).byteLength, data_hash: fnv1a64(data) }; } list(prefix = "/workspace") { const normalized = normalizeWorkspacePath(prefix); return [...this.files.entries()].filter(([path]) => !normalized || path === normalized || path.startsWith(`${normalized}/`)).sort(([a], [b]) => a.localeCompare(b)).map(([path, data]) => ({ path: `/workspace/${path}`, bytes: encoded(data).byteLength, data_hash: fnv1a64(data) })); } begin(operationId) { if (typeof operationId !== "string" || operationId.length === 0 || this.transactions.has(operationId)) throw new Error("workspace_transaction_invalid"); this.transactions.set(operationId, new Map()); return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "prepared", state_hash: this.hash() }; } write(operationId, path, data) { const transaction = this.transactions.get(operationId); if (!transaction) throw new Error("workspace_transaction_missing"); const normalized = normalizeWorkspacePath(path); if (!normalized) throw new Error("workspace_path_invalid"); encoded(data); transaction.set(normalized, { kind: "write", data }); return { operation_id: operationId, path: `/workspace/${normalized}`, bytes: encoded(data).byteLength }; } remove(operationId, path) { const transaction = this.transactions.get(operationId); if (!transaction) throw new Error("workspace_transaction_missing"); const normalized = normalizeWorkspacePath(path); if (!normalized) throw new Error("workspace_path_invalid"); transaction.set(normalized, { kind: "delete" }); return { operation_id: operationId, path: `/workspace/${normalized}`, status: "deleted" }; } commit(operationId) { const transaction = this.transactions.get(operationId); if (!transaction) throw new Error("workspace_transaction_missing"); const candidate = new Map(this.files); for (const [path, change] of [...transaction.entries()].sort(([a], [b]) => a.localeCompare(b))) { if (change.kind === "delete") candidate.delete(path); else candidate.set(path, change.data); } this.assertQuota(candidate); this.files = candidate; this.transactions.delete(operationId); return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "committed", state_hash: this.hash(), files: this.list() }; } abort(operationId) { if (!this.transactions.has(operationId)) throw new Error("workspace_transaction_missing"); this.transactions.delete(operationId); return { schema: WORKSPACE_SCHEMA, operation_id: operationId, status: "aborted", state_hash: this.hash() }; } hash() { return fnv1a64(Object.fromEntries([...this.files.entries()].sort(([a], [b]) => a.localeCompare(b)))); } export() { return { schema: WORKSPACE_SCHEMA, quota_bytes: this.quotaBytes, files: Object.fromEntries([...this.files.entries()].sort(([a], [b]) => a.localeCompare(b))), state_hash: this.hash() }; } restore(value) { if (!value || value.schema !== WORKSPACE_SCHEMA || !value.files || value.state_hash !== fnv1a64(Object.fromEntries(Object.entries(value.files).sort(([a], [b]) => a.localeCompare(b))))) throw new Error("workspace_restore_invalid"); const candidate = new WorkspaceRuntime({ quotaBytes: value.quota_bytes, files: value.files }); this.quotaBytes = candidate.quotaBytes; this.files = candidate.files; this.transactions.clear(); return this.export(); } }