From 4b29f6d2936457287d3915841ef6af0eb634f141 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 15 Apr 2026 13:50:05 +0200 Subject: [PATCH] fix(ai-missions): swap structuredClone for JSON-roundtrip deepClone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser \`structuredClone\` itself fails on Svelte 5 \$state Proxies ("Failed to execute 'structuredClone' on 'Window': [object Array] could not be cloned") — it doesn't transparently unwrap the Proxy the way I'd hoped. The structured-clone algorithm refuses any non-cloneable host object, including Svelte's reactive wrappers. JSON.parse(JSON.stringify(...)) traverses through the Proxy by reading each property normally, producing a plain-data copy that Dexie can serialise without complaint. Mission payloads are pure JSON values (strings/numbers/arrays/objects) so JSON-roundtrip is lossless. The new \`deepClone\` helper is local to this file with a comment pointing at the structured-clone failure. 77/77 webapp tests still green. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/data/ai/missions/store.ts | 53 ++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/apps/mana/apps/web/src/lib/data/ai/missions/store.ts b/apps/mana/apps/web/src/lib/data/ai/missions/store.ts index b2019b6b6..68fb3c8df 100644 --- a/apps/mana/apps/web/src/lib/data/ai/missions/store.ts +++ b/apps/mana/apps/web/src/lib/data/ai/missions/store.ts @@ -24,6 +24,19 @@ import type { } from './types'; import { MISSIONS_TABLE } from './types'; +/** + * Strip Svelte 5 `$state` Proxies (and any other non-cloneable wrapper) + * by JSON-roundtripping. Mission payloads are plain JSON values + * (strings/numbers/booleans/arrays/objects) so this is lossless and + * faster than coordinating `$state.snapshot()` calls in every caller. + * + * `structuredClone` itself can't traverse Svelte's Proxy in browsers + * — it throws DataCloneError on the wrapped array. + */ +function deepClone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + const table = () => db.table(MISSIONS_TABLE); // ── Create ───────────────────────────────────────────────── @@ -38,12 +51,12 @@ export interface CreateMissionInput { export async function createMission(input: CreateMissionInput): Promise { const now = new Date().toISOString(); - // `structuredClone` strips Svelte 5 $state Proxies before the record + // `deepClone` strips Svelte 5 $state Proxies before the record // hits IndexedDB — without it, Dexie throws DataCloneError on the // proxied `inputs` array / `cadence` object that callers pass in // from `$state` bindings (e.g. the MissionInputPicker). - const inputsPlain = structuredClone(input.inputs ?? []); - const cadencePlain = structuredClone(input.cadence); + const inputsPlain = deepClone(input.inputs ?? []); + const cadencePlain = deepClone(input.cadence); const mission: Mission = { id: crypto.randomUUID(), createdAt: now, @@ -99,7 +112,7 @@ export interface MissionPatch { export async function updateMission(id: string, patch: MissionPatch): Promise { // Same Proxy-stripping reason as createMission. const mods: Partial = { - ...structuredClone(patch), + ...deepClone(patch), updatedAt: new Date().toISOString(), }; if (patch.cadence) { @@ -140,6 +153,34 @@ export async function deleteMission(id: string): Promise { await table().update(id, { deletedAt: new Date().toISOString() }); } +// ── Key-Grant (server-side execution opt-in) ────────────── + +/** Attach a freshly-minted grant to a mission so `mana-ai` can decrypt + * its encrypted inputs server-side. Overwrites any existing grant. The + * blob is produced by `grant-client.requestMissionGrant()` and must NOT + * be constructed client-side — only mana-auth knows the wrap key. */ +export async function setMissionGrant( + id: string, + grant: import('@mana/shared-ai').MissionGrant +): Promise { + // deepClone strips Svelte Proxy wrappers the caller might have + // attached — matches the pattern used in createMission / updateMission. + await table().update(id, { + grant: deepClone(grant), + updatedAt: new Date().toISOString(), + }); +} + +/** Revoke server-side execution. Leaves the mission otherwise intact — + * the foreground runner still works. Use when the user clicks the 🔒 + * icon in the Workbench. */ +export async function revokeMissionGrant(id: string): Promise { + await table().update(id, { + grant: undefined, + updatedAt: new Date().toISOString(), + }); +} + // ── Iterations ───────────────────────────────────────────── export interface StartIterationInput { @@ -158,7 +199,7 @@ export async function startIteration( startedAt: new Date().toISOString(), // Strip $state Proxies from the plan array so structured-clone // doesn't fail when Dexie serialises the row. - plan: structuredClone(input.plan), + plan: deepClone(input.plan), overallStatus: 'running', }; await table().update(missionId, { @@ -190,7 +231,7 @@ export async function finishIteration( finishedAt: new Date().toISOString(), overallStatus: input.overallStatus, ...(input.summary !== undefined ? { summary: input.summary } : {}), - ...(input.plan !== undefined ? { plan: structuredClone(input.plan) } : {}), + ...(input.plan !== undefined ? { plan: deepClone(input.plan) } : {}), } : it );