diff --git a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts index 16e2c7eab..6ec68a2b1 100644 --- a/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-pagination-old-daemon.spec.ts @@ -66,7 +66,7 @@ test("does not repeat an assistant block when the current app paginates a publis localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, ); const history = await holdOlderHistoryPages(page, agent, daemon.port); diff --git a/packages/app/e2e/browser/command-center-agent-controls.spec.ts b/packages/app/e2e/browser/command-center-agent-controls.spec.ts index 3021a242e..08597285f 100644 --- a/packages/app/e2e/browser/command-center-agent-controls.spec.ts +++ b/packages/app/e2e/browser/command-center-agent-controls.spec.ts @@ -1,4 +1,5 @@ import { test } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center"; import { applyCommandCenterAgentControls, @@ -12,17 +13,15 @@ import { import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise { await page.addInitScript( - ({ preferencesKey, serverId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId, provider: "mock", providerPreferences: { mock: { @@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page): mode: "load-test", }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } diff --git a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts index 05f636007..ddd415ed9 100644 --- a/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts +++ b/packages/app/e2e/browser/new-workspace-codex-mode-preferences.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { gotoAppShell } from "../support/helpers/app"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute } from "../support/helpers/mock-agent"; @@ -10,7 +11,6 @@ import { import { expectNoTruncation } from "../support/helpers/no-truncation"; import { escapeRegex } from "../support/helpers/regex"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record | return maybeEnvelope.message as Record; } -async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise { +async function seedCodexDefaultPermissionPreferences(page: Page): Promise { await page.addInitScript( - ({ preferencesKey, serverId: seededServerId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId: seededServerId, provider: "codex", providerPreferences: { codex: { @@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin model: "ten-second-stream", }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } @@ -155,10 +154,9 @@ test.describe("New workspace Codex mode preferences", () => { test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({ page, }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); - await seedCodexDefaultPermissionPreferences(page, serverId); + await seedCodexDefaultPermissionPreferences(page); try { await gotoAppShell(page); @@ -194,9 +192,8 @@ test.describe("New workspace Codex mode preferences", () => { }); test("uses the live Codex agent mode as the next New Workspace default", async ({ page }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" }); - await seedCodexDefaultPermissionPreferences(page, serverId); + await seedCodexDefaultPermissionPreferences(page); try { const agent = await seeded.client.createAgent({ diff --git a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts index 61a58fee8..2b6ab5d53 100644 --- a/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts +++ b/packages/app/e2e/browser/new-workspace-mode-cycle-safety.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from "../support/fixtures"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { openAgentRoute } from "../support/helpers/mock-agent"; import { @@ -6,7 +7,6 @@ import { selectNewWorkspaceProject, } from "../support/helpers/new-workspace"; import { seedWorkspace } from "../support/helpers/seed-client"; -import { getServerId } from "../support/helpers/server-id"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; @@ -62,13 +62,12 @@ async function recordSetAgentModeRequests(page: Page): Promise<{ }; } -async function seedCodexDefaultPreferences(page: Page, serverId: string): Promise { +async function seedCodexDefaultPreferences(page: Page): Promise { await page.addInitScript( - ({ preferencesKey, serverId: seededServerId }) => { + ({ preferencesKey }) => { localStorage.setItem( preferencesKey, JSON.stringify({ - serverId: seededServerId, provider: "codex", providerPreferences: { codex: { @@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis }, mock: { model: "ten-second-stream" }, }, - }), + } satisfies FormPreferences), ); }, - { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId }, + { preferencesKey: CREATE_AGENT_PREFERENCES_KEY }, ); } @@ -104,9 +103,8 @@ test.describe("New Workspace mode cycle safety", () => { // control and silently change that (possibly running) agent's mode — e.g. into a // permissive/bypass mode. See use-keyboard-action-handler.ts. test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => { - const serverId = getServerId(); const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" }); - await seedCodexDefaultPreferences(page, serverId); + await seedCodexDefaultPreferences(page); const modeRequests = await recordSetAgentModeRequests(page); try { diff --git a/packages/app/e2e/browser/sessions-empty.spec.ts b/packages/app/e2e/browser/sessions-empty.spec.ts index 05884b45a..32769996e 100644 --- a/packages/app/e2e/browser/sessions-empty.spec.ts +++ b/packages/app/e2e/browser/sessions-empty.spec.ts @@ -31,7 +31,7 @@ test("Sessions shows an empty placeholder when the host has no history", async ( localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, + { seededHost: host, preferences: buildCreateAgentPreferences() }, ); await gotoAppShell(page); diff --git a/packages/app/e2e/browser/workspace-model-restart.spec.ts b/packages/app/e2e/browser/workspace-model-restart.spec.ts index c77b0bb1e..54cbb0c0e 100644 --- a/packages/app/e2e/browser/workspace-model-restart.spec.ts +++ b/packages/app/e2e/browser/workspace-model-restart.spec.ts @@ -7,6 +7,7 @@ import path from "node:path"; import net from "node:net"; import { expect, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; +import type { FormPreferences } from "@/create-agent-preferences/preferences"; import { metroTest as test } from "../support/fixtures"; import { buildSeededHost } from "../support/helpers/daemon-registry"; import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader"; @@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: { daemon: host, preferences: { - serverId: input.serverId, provider: "codex", providerPreferences: { - codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, + codex: { + model: "gpt-5.4-mini", + thinkingByModel: { "gpt-5.4-mini": "low" }, + }, }, - }, + } satisfies FormPreferences, }, ); } diff --git a/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts index 4cf294d69..c4d2ccfff 100644 --- a/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts +++ b/packages/app/e2e/browser/worktree-restore-after-restart.spec.ts @@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => { label: "restart daemon", nowIso, }), - preferences: buildCreateAgentPreferences(serverId), + preferences: buildCreateAgentPreferences(), }, ); } diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index e8352a32e..723f04808 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -132,7 +132,7 @@ const test = metroTest.extend< endpoint: `127.0.0.1:${daemonPort}`, nowIso, }); - const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId); + const createAgentPreferences = buildCreateAgentPreferences(); await page.addInitScript( ({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => { diff --git a/packages/app/e2e/support/helpers/archive-tab.ts b/packages/app/e2e/support/helpers/archive-tab.ts index 201bb189a..c33414412 100644 --- a/packages/app/e2e/support/helpers/archive-tab.ts +++ b/packages/app/e2e/support/helpers/archive-tab.ts @@ -26,7 +26,7 @@ function buildSeededStoragePayload() { endpoint: `127.0.0.1:${getE2EDaemonPort()}`, nowIso, }), - preferences: buildCreateAgentPreferences(getServerId()), + preferences: buildCreateAgentPreferences(), }; } diff --git a/packages/app/e2e/support/helpers/daemon-registry.ts b/packages/app/e2e/support/helpers/daemon-registry.ts index 6ef72ddc2..f3b63a751 100644 --- a/packages/app/e2e/support/helpers/daemon-registry.ts +++ b/packages/app/e2e/support/helpers/daemon-registry.ts @@ -1,14 +1,23 @@ +import type { + FormPreferences, + ProviderPreferences, +} from "../../../src/create-agent-preferences/preferences"; + export const TEST_HOST_LABEL = "localhost"; export const TEST_PROVIDER_PREFERENCES = { claude: { model: "haiku" }, - codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, -} as const; + codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } }, +} satisfies Record; -export function buildDirectTcpConnection(endpoint: string) { +export function buildDirectTcpConnection(endpoint: string): { + id: string; + type: "directTcp"; + endpoint: string; +} { return { id: `direct:${endpoint}`, - type: "directTcp" as const, + type: "directTcp", endpoint, }; } @@ -33,12 +42,11 @@ export function buildSeededHost(input: { export const TEST_MOCK_PROVIDER_PREFERENCES = { ...TEST_PROVIDER_PREFERENCES, mock: { model: "ten-second-stream" }, -} as const; +} satisfies Record; -export function buildCreateAgentPreferences(serverId: string) { +export function buildCreateAgentPreferences() { return { - serverId, - provider: "mock" as const, + provider: "mock", providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES, - }; + } satisfies FormPreferences; } diff --git a/packages/app/e2e/support/helpers/relay-deployment.ts b/packages/app/e2e/support/helpers/relay-deployment.ts index 19e480d48..3d727e080 100644 --- a/packages/app/e2e/support/helpers/relay-deployment.ts +++ b/packages/app/e2e/support/helpers/relay-deployment.ts @@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay( localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost])); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); }, - { storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) }, + { storedHost: host, preferences: buildCreateAgentPreferences() }, ); const relaySocketOpened = new Promise((resolve) => { diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index d87db35ef..2af795375 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -132,7 +132,7 @@ export async function seedSavedSettingsHosts( if (!firstHost) { throw new Error("Expected at least one settings host fixture."); } - const preferences = buildCreateAgentPreferences(firstHost.serverId); + const preferences = buildCreateAgentPreferences(); await page.evaluate( ({ keys, storedRegistry, storedPreferences }) => { diff --git a/packages/app/e2e/support/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index 96ad96f3c..fe78109fe 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -106,7 +106,7 @@ class StartupScenario { if (!firstHost) { throw new Error("Expected at least one startup test host."); } - const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId); + const createAgentPreferences = buildStoredCreateAgentPreferences(); await this.page.evaluate( ({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => { @@ -245,6 +245,6 @@ function buildStoredHost(input: { return buildSeededHost(input); } -function buildStoredCreateAgentPreferences(serverId: string) { - return buildCreateAgentPreferences(serverId); +function buildStoredCreateAgentPreferences() { + return buildCreateAgentPreferences(); } diff --git a/packages/app/src/agent-profiles/migration/index.test.ts b/packages/app/src/agent-profiles/migration/index.test.ts index cf40001e7..a2034b5d1 100644 --- a/packages/app/src/agent-profiles/migration/index.test.ts +++ b/packages/app/src/agent-profiles/migration/index.test.ts @@ -18,6 +18,10 @@ class MemoryStorage { async setItem(key: string, value: string): Promise { this.values.set(key, value); } + + async removeItem(key: string): Promise { + this.values.delete(key); + } } class FakeProfileHost { diff --git a/packages/app/src/agent-profiles/migration/index.ts b/packages/app/src/agent-profiles/migration/index.ts index 964ef248f..f16042075 100644 --- a/packages/app/src/agent-profiles/migration/index.ts +++ b/packages/app/src/agent-profiles/migration/index.ts @@ -2,29 +2,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { z } from "zod"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { AgentProfile } from "@getpaseo/protocol/messages"; +import { FormPreferencesSchema } from "@/create-agent-preferences/preferences"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; const PREFERENCES_KEY = "@paseo:create-agent-preferences"; const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:"; const CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const; -const legacyPreferencesSchema = z.object({ - favoriteModels: z - .array( - z.object({ - provider: z.string().trim().min(1), - modelId: z.string().trim().min(1), - }), - ) - .optional(), -}); - -type LegacyFavorite = NonNullable< - z.infer["favoriteModels"] ->[number]; +type LegacyFavorite = NonNullable["favoriteModels"]>[number]; export interface LegacyFavoriteMigrationStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } export interface LegacyFavoriteMigrationHost { @@ -47,18 +37,6 @@ function completionKey(serverId: string): string { return `${COMPLETION_KEY_PREFIX}${serverId}`; } -function parseStoredPreferences(raw: string | null): LegacyFavorite[] { - if (!raw) { - return []; - } - try { - const result = legacyPreferencesSchema.safeParse(JSON.parse(raw)); - return result.success ? (result.data.favoriteModels ?? []) : []; - } catch { - return []; - } -} - function favoriteKey(favorite: Pick): string { return `${favorite.provider}\u0000${favorite.modelId}`; } @@ -205,11 +183,16 @@ export class LegacyFavoriteProfileMigration { return; } const markerKey = completionKey(serverId); - if ((await this.storage.getItem(markerKey)) === "1") { + if ((await readValidatedString(this.storage, markerKey, z.literal("1"))) === "1") { return; } - const favorites = parseStoredPreferences(await this.storage.getItem(PREFERENCES_KEY)); + const preferences = await readValidatedJson( + this.storage, + PREFERENCES_KEY, + FormPreferencesSchema, + ); + const favorites = preferences?.favoriteModels ?? []; if (favorites.length === 0) { await this.storage.setItem(markerKey, "1"); return; diff --git a/packages/app/src/contexts/sidebar-callout-context.tsx b/packages/app/src/contexts/sidebar-callout-context.tsx index 2e337aadd..548a32edc 100644 --- a/packages/app/src/contexts/sidebar-callout-context.tsx +++ b/packages/app/src/contexts/sidebar-callout-context.tsx @@ -13,9 +13,9 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import { clearSidebarCallouts, createSidebarCalloutState, + DismissedCalloutKeysSchema, dismissSidebarCallout, loadDismissedCalloutKeys, - parseDismissedCalloutKeys, selectActiveSidebarCallout, serializeDismissedCalloutKeys, showSidebarCallout, @@ -24,6 +24,7 @@ import { type SidebarCalloutState, unregisterSidebarCallout, } from "./sidebar-callout-state"; +import { readValidatedJson } from "@/storage/validated-storage"; export type { SidebarCalloutOptions } from "./sidebar-callout-state"; @@ -62,8 +63,12 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) { async function loadDismissedKeys(): Promise { let dismissedKeys: ReadonlySet; try { - const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); - dismissedKeys = parseDismissedCalloutKeys(value); + const value = await readValidatedJson( + AsyncStorage, + DISMISSED_CALLOUTS_STORAGE_KEY, + DismissedCalloutKeysSchema, + ); + dismissedKeys = new Set(value ?? []); } catch (error) { console.error("[SidebarCallouts] Failed to load dismissed callouts", error); dismissedKeys = stateRef.current.dismissedKeys; diff --git a/packages/app/src/contexts/sidebar-callout-state.test.ts b/packages/app/src/contexts/sidebar-callout-state.test.ts index 9b7d1a13a..f73468f0b 100644 --- a/packages/app/src/contexts/sidebar-callout-state.test.ts +++ b/packages/app/src/contexts/sidebar-callout-state.test.ts @@ -4,7 +4,6 @@ import { createSidebarCalloutState, dismissSidebarCallout, loadDismissedCalloutKeys, - parseDismissedCalloutKeys, selectActiveSidebarCallout, serializeDismissedCalloutKeys, showSidebarCallout, @@ -118,12 +117,6 @@ describe("sidebar callout state", () => { expect(selectActiveSidebarCallout(state)?.title).toBe("Update available"); }); - it("parses stored dismissal keys defensively", () => { - expect(parseDismissedCalloutKeys(JSON.stringify(["a", 4, "b"]))).toEqual(new Set(["a", "b"])); - expect(parseDismissedCalloutKeys("{")).toEqual(new Set()); - expect(parseDismissedCalloutKeys(JSON.stringify({ key: "a" }))).toEqual(new Set()); - }); - it("clears visible callouts without dropping dismissal state", () => { let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"])); state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state; diff --git a/packages/app/src/contexts/sidebar-callout-state.ts b/packages/app/src/contexts/sidebar-callout-state.ts index fa1da011a..0cd83c53f 100644 --- a/packages/app/src/contexts/sidebar-callout-state.ts +++ b/packages/app/src/contexts/sidebar-callout-state.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { z } from "zod"; import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout"; export interface SidebarCalloutOptions { @@ -44,21 +45,7 @@ export function normalizeDismissalKey(key: string | null | undefined): string | return trimmed ? trimmed : null; } -export function parseDismissedCalloutKeys(value: string | null): Set { - if (!value) { - return new Set(); - } - - try { - const parsed = JSON.parse(value) as unknown; - if (!Array.isArray(parsed)) { - return new Set(); - } - return new Set(parsed.filter((entry): entry is string => typeof entry === "string")); - } catch { - return new Set(); - } -} +export const DismissedCalloutKeysSchema = z.array(z.string()); export function serializeDismissedCalloutKeys(keys: ReadonlySet): string { return JSON.stringify([...keys]); diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 7529a8072..cb48416d7 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -137,6 +137,36 @@ describe("create agent preferences", () => { expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); }); + it("strips the explicitly supported legacy location fields", () => { + expect( + parseFormPreferences({ + workingDir: "/old/workspace", + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "full-access", + thinkingOptionId: "high", + }, + }, + serverId: "old-host", + }), + ).toEqual({ + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.4-mini", + mode: "full-access", + thinkingByModel: { "gpt-5.4-mini": "high" }, + }, + }, + }); + }); + + it("rejects unknown persisted fields outside the explicit legacy shape", () => { + expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); + }); + it("persists and reloads the workspace isolation choice", async () => { const storage = new FakeCreateAgentPreferenceStorage(); const preferences = new CreateAgentPreferencesService(storage); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 7153fa8b4..710df9efd 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,19 +1,38 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -const providerPreferencesSchema = z.object({ +const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); + +export interface ProviderPreferences { + model?: string; + mode?: string; + thinkingByModel?: Record; + featureValues?: Record; +} + +export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; + +export interface FormPreferences { + provider?: string; + providerPreferences?: Record; + favoriteModels?: Array<{ provider: string; modelId: string }>; + isolation?: "local" | "worktree"; + launchTarget?: LaunchTarget; +} + +const providerPreferencesSchema: z.ZodType = z.strictObject({ model: z.string().optional(), mode: z.string().optional(), thinkingByModel: z.record(z.string(), z.string()).optional(), - featureValues: z.record(z.string(), z.unknown()).optional(), + featureValues: featureValuesSchema.optional(), }); -const launchTargetSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("chat") }), - z.object({ kind: z.literal("terminal"), profileId: z.string() }), +const launchTargetSchema: z.ZodType = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("chat") }), + z.strictObject({ kind: z.literal("terminal"), profileId: z.string() }), ]); -const formPreferencesSchema = z.object({ +export const FormPreferencesSchema = z.strictObject({ provider: z.string().optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), // COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. @@ -21,7 +40,7 @@ const formPreferencesSchema = z.object({ // import it; ordinary preference writes must not erase it first. favoriteModels: z .array( - z.object({ + z.strictObject({ provider: z.string(), modelId: z.string(), }), @@ -31,16 +50,50 @@ const formPreferencesSchema = z.object({ // What the New workspace composer submits to: the chat agent (default) or a // terminal profile. See `@/new-workspace-launch` for resolution/fallback. launchTarget: launchTargetSchema.optional(), +}) satisfies z.ZodType; + +const LegacyProviderPreferencesSchema = z.strictObject({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingOptionId: z.string().optional(), }); -export type ProviderPreferences = z.infer; -export type FormPreferences = z.infer; -export type LaunchTarget = z.infer; +const LegacyFormPreferencesSchema = z + .strictObject({ + workingDir: z.string().optional(), + provider: z.string().optional(), + serverId: z.string().optional(), + providerPreferences: z.record(z.string(), LegacyProviderPreferencesSchema).optional(), + }) + .transform(({ provider, providerPreferences }): FormPreferences => { + const migratedProviderPreferences: Record = {}; + for (const [providerId, legacy] of Object.entries(providerPreferences ?? {})) { + const model = legacy.model; + migratedProviderPreferences[providerId] = { + ...(model !== undefined ? { model } : {}), + ...(legacy.mode !== undefined ? { mode: legacy.mode } : {}), + ...(model !== undefined && legacy.thinkingOptionId !== undefined + ? { thinkingByModel: { [model]: legacy.thinkingOptionId } } + : {}), + }; + } + return { + ...(provider !== undefined ? { provider } : {}), + ...(providerPreferences !== undefined + ? { providerPreferences: migratedProviderPreferences } + : {}), + }; + }); + +export const StoredFormPreferencesSchema: z.ZodType = z.union([ + FormPreferencesSchema, + LegacyFormPreferencesSchema, +]); export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; export function parseFormPreferences(value: unknown): FormPreferences { - const result = formPreferencesSchema.safeParse(value); + const result = StoredFormPreferencesSchema.safeParse(value); return result.success ? result.data : DEFAULT_FORM_PREFERENCES; } @@ -115,6 +168,7 @@ export function mergeCreateAgentSelectionPreferences(args: { const modelId = args.modelId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; + const featureValues = featureValuesSchema.safeParse(args.featureValues); return mergeProviderPreferences({ preferences: args.preferences, @@ -123,7 +177,7 @@ export function mergeCreateAgentSelectionPreferences(args: { model: modelId || undefined, mode: modeId || undefined, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), - ...(args.featureValues ? { featureValues: args.featureValues } : {}), + ...(featureValues.success ? { featureValues: featureValues.data } : {}), }, }); } diff --git a/packages/app/src/create-agent-preferences/storage.ts b/packages/app/src/create-agent-preferences/storage.ts index 2dd231142..951c9efcf 100644 --- a/packages/app/src/create-agent-preferences/storage.ts +++ b/packages/app/src/create-agent-preferences/storage.ts @@ -1,5 +1,10 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { FormPreferences } from "./preferences"; +import { readValidatedJson } from "@/storage/validated-storage"; +import { + FormPreferencesSchema, + StoredFormPreferencesSchema, + type FormPreferences, +} from "./preferences"; export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences"; @@ -10,19 +15,19 @@ export interface CreateAgentPreferenceStorage { export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { async read(): Promise { - const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); - if (!stored) { - return null; - } - - try { - return JSON.parse(stored); - } catch { - return null; - } + return readValidatedJson( + AsyncStorage, + CREATE_AGENT_PREFERENCES_STORAGE_KEY, + StoredFormPreferencesSchema, + ); } async write(preferences: FormPreferences): Promise { - await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(preferences)); + const result = FormPreferencesSchema.safeParse(preferences); + if (!result.success) { + await AsyncStorage.removeItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); + return; + } + await AsyncStorage.setItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY, JSON.stringify(result.data)); } } diff --git a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts index 6b0fcf245..84d7cc53d 100644 --- a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts +++ b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts @@ -73,5 +73,5 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt } function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; + return structuredClone(value); } diff --git a/packages/app/src/data/provider-snapshot-cache.ts b/packages/app/src/data/provider-snapshot-cache.ts index d252613d1..ed4823a39 100644 --- a/packages/app/src/data/provider-snapshot-cache.ts +++ b/packages/app/src/data/provider-snapshot-cache.ts @@ -6,6 +6,7 @@ import { type CompactProviderSnapshot, } from "@getpaseo/protocol/provider-snapshot-codec"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; +import { z } from "zod"; const CACHE_VERSION = 1; const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; @@ -44,6 +45,13 @@ interface ProviderSnapshotIndex { entries: ProviderSnapshotIndexEntry[]; } +const StoredProviderSnapshotSchema: z.ZodType = z.strictObject({ + version: z.literal(CACHE_VERSION), + hash: z.string(), + generatedAt: z.string().datetime({ offset: true }), + compactSnapshot: CompactProviderSnapshotSchema, +}); + export interface CachedProviderSnapshot extends StoredProviderSnapshot { entries: ProviderSnapshotEntry[]; } @@ -81,22 +89,8 @@ function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIn function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { const parsed: unknown = JSON.parse(value); - if (typeof parsed !== "object" || parsed === null) return null; - const version = Reflect.get(parsed, "version"); - const hash = Reflect.get(parsed, "hash"); - const generatedAt = Reflect.get(parsed, "generatedAt"); - const compactSnapshot = CompactProviderSnapshotSchema.safeParse( - Reflect.get(parsed, "compactSnapshot"), - ); - if ( - version !== CACHE_VERSION || - typeof hash !== "string" || - typeof generatedAt !== "string" || - !compactSnapshot.success - ) { - return null; - } - return { version, hash, generatedAt, compactSnapshot: compactSnapshot.data }; + const result = StoredProviderSnapshotSchema.safeParse(parsed); + return result.success ? result.data : null; } export function createProviderSnapshotCache( @@ -173,12 +167,18 @@ export function createProviderSnapshotCache( }): Promise { const currentIndex = await ensureCacheIndex(); const key = cacheKey(input.serverId, input.cwd); - const value = JSON.stringify({ + const stored = StoredProviderSnapshotSchema.safeParse({ version: CACHE_VERSION, hash: input.hash, generatedAt: input.generatedAt, compactSnapshot: input.compactSnapshot, - } satisfies StoredProviderSnapshot); + }); + if (!stored.success) { + await storage.removeItem(key); + await persistIndex(currentIndex.entries.filter((entry) => entry.key !== key)); + return; + } + const value = JSON.stringify(stored.data); const incomingBytes = storedBytes(key, value); const canStoreIncoming = incomingBytes <= maxBytes; const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); diff --git a/packages/app/src/desktop/browser/store/index.ts b/packages/app/src/desktop/browser/store/index.ts index 7ff21c733..16558eaa0 100644 --- a/packages/app/src/desktop/browser/store/index.ts +++ b/packages/app/src/desktop/browser/store/index.ts @@ -1,9 +1,11 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { applyBrowserPatch, + BrowserIndexStateSchema, type BrowserIndexState, type BrowserRecord, type BrowserRecordPatch, @@ -74,7 +76,7 @@ export const useBrowserStore = create()( }), { name: "workspace-browser-store", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, BrowserIndexStateSchema), partialize: (state) => sanitizeBrowsersForPersist(state), merge: (persistedState, currentState) => ({ ...currentState, diff --git a/packages/app/src/desktop/browser/store/state.ts b/packages/app/src/desktop/browser/store/state.ts index 3909a7aa3..47d0de245 100644 --- a/packages/app/src/desktop/browser/store/state.ts +++ b/packages/app/src/desktop/browser/store/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type BrowserViewport = | { mode: "responsive" } | { mode: "fixed"; width: number; height: number }; @@ -23,6 +25,32 @@ export interface BrowserIndexState { browsersById: Record; } +const BrowserViewportSchema = z.discriminatedUnion("mode", [ + z.strictObject({ mode: z.literal("responsive") }), + z.strictObject({ + mode: z.literal("fixed"), + width: z.number().positive(), + height: z.number().positive(), + }), +]); + +const BrowserRecordSchema = z.strictObject({ + browserId: z.string(), + url: z.string(), + title: z.string(), + isLoading: z.boolean(), + canGoBack: z.boolean(), + canGoForward: z.boolean(), + faviconUrl: z.string().nullable(), + lastError: z.string().nullable(), + viewport: BrowserViewportSchema.optional().default(RESPONSIVE_BROWSER_VIEWPORT), + createdAt: z.number(), +}); + +export const BrowserIndexStateSchema: z.ZodType = z.strictObject({ + browsersById: z.record(z.string(), BrowserRecordSchema), +}); + export function createFixedBrowserViewport(width: number, height: number): BrowserViewport { return { mode: "fixed", @@ -32,19 +60,10 @@ export function createFixedBrowserViewport(width: number, height: number): Brows } export function normalizeBrowserViewport(value: unknown): BrowserViewport { - if ( - value && - typeof value === "object" && - (value as { mode?: unknown }).mode === "fixed" && - typeof (value as { width?: unknown }).width === "number" && - typeof (value as { height?: unknown }).height === "number" - ) { - return createFixedBrowserViewport( - (value as { width: number }).width, - (value as { height: number }).height, - ); - } - return RESPONSIVE_BROWSER_VIEWPORT; + const result = BrowserViewportSchema.safeParse(value); + return result.success && result.data.mode === "fixed" + ? createFixedBrowserViewport(result.data.width, result.data.height) + : RESPONSIVE_BROWSER_VIEWPORT; } function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean { @@ -56,27 +75,8 @@ function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): b } export function normalizeBrowserIndexState(value: unknown): BrowserIndexState { - if (!value || typeof value !== "object") { - return { browsersById: {} }; - } - const browsersById = (value as { browsersById?: unknown }).browsersById; - if (!browsersById || typeof browsersById !== "object") { - return { browsersById: {} }; - } - return { - browsersById: Object.fromEntries( - Object.entries(browsersById).map(([browserId, record]) => { - const browser = record as BrowserRecord & { viewport?: unknown }; - return [ - browserId, - { - ...browser, - viewport: normalizeBrowserViewport(browser.viewport), - } satisfies BrowserRecord, - ]; - }), - ), - }; + const result = BrowserIndexStateSchema.safeParse(value); + return result.success ? result.data : { browsersById: {} }; } export function trimNonEmpty(value: string | null | undefined): string | null { diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 63f6c7425..05e65a21a 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "re import { Info } from "lucide-react-native"; import { withUnistyles } from "react-native-unistyles"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; import { useTranslation } from "react-i18next"; import type { Theme } from "@/styles/theme"; import { getForgePresentation, type Forge } from "@/git/forge"; @@ -29,6 +30,7 @@ import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-ar import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import { readValidatedString } from "@/storage/validated-storage"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -361,10 +363,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use } let isActive = true; setShipDefault("pr"); - AsyncStorage.getItem(shipDefaultStorageKey) + readValidatedString(AsyncStorage, shipDefaultStorageKey, z.enum(["pr", "merge"])) .then((value) => { if (!isActive) return; - if (value === "pr" || value === "merge") { + if (value) { setShipDefault(value); return; } diff --git a/packages/app/src/hooks/use-changes-preferences/fakes.ts b/packages/app/src/hooks/use-changes-preferences/fakes.ts index 96e7a8490..62f194a5d 100644 --- a/packages/app/src/hooks/use-changes-preferences/fakes.ts +++ b/packages/app/src/hooks/use-changes-preferences/fakes.ts @@ -16,5 +16,8 @@ export function createInMemoryKeyValueStorage( async setItem(key, value) { entries.set(key, value); }, + async removeItem(key) { + entries.delete(key); + }, }; } diff --git a/packages/app/src/hooks/use-changes-preferences/storage.ts b/packages/app/src/hooks/use-changes-preferences/storage.ts index 3ceaadead..05a0038dc 100644 --- a/packages/app/src/hooks/use-changes-preferences/storage.ts +++ b/packages/app/src/hooks/use-changes-preferences/storage.ts @@ -1,11 +1,12 @@ import type { QueryClient } from "@tanstack/react-query"; import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; -const changesPreferencesSchema = z.object({ +const changesPreferencesSchema = z.strictObject({ layout: z.enum(["unified", "split"]).optional(), viewMode: z.enum(["flat", "tree"]).optional(), wrapLines: z.boolean().optional(), @@ -32,28 +33,28 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = { export interface KeyValueStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise { - const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); - if (legacyValue === "true") { - return true; - } - if (legacyValue === "false") { - return false; - } - return null; + const legacyValue = await readValidatedString( + storage, + LEGACY_WRAP_LINES_STORAGE_KEY, + z.enum(["true", "false"]), + ); + return legacyValue === null ? null : legacyValue === "true"; } export async function loadChangesPreferencesFromStorage( storage: KeyValueStorage, ): Promise { - const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); + const stored = await readValidatedJson( + storage, + CHANGES_PREFERENCES_STORAGE_KEY, + changesPreferencesSchema, + ); if (stored) { - const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); - if (parsed.success) { - return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data }; - } + return { ...DEFAULT_CHANGES_PREFERENCES, ...stored }; } const legacyWrapLines = await loadLegacyWrapLinesPreference(storage); diff --git a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts index 874b28122..620e76efd 100644 --- a/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts +++ b/packages/app/src/hooks/use-keyboard-shortcut-overrides.ts @@ -1,15 +1,18 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts"; import { createShortcutOverrideStore, type ShortcutOverrideStore, } from "@/keyboard/shortcut-override-store"; +import { readValidatedJson } from "@/storage/validated-storage"; const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides"; const QUERY_KEY = ["keyboard-shortcut-overrides"]; const EMPTY_OVERRIDES: ShortcutOverrides = {}; +const ShortcutOverridesSchema = z.record(z.string(), z.string().nullable()); export interface UseKeyboardShortcutOverridesReturn { overrides: ShortcutOverrides; @@ -76,10 +79,10 @@ function getStore(queryClient: QueryClient): ShortcutOverrideStore { async function loadOverridesFromStorage(): Promise { try { - const stored = await AsyncStorage.getItem(STORAGE_KEY); - if (stored) { - return JSON.parse(stored) as ShortcutOverrides; - } + return ( + (await readValidatedJson(AsyncStorage, STORAGE_KEY, ShortcutOverridesSchema)) ?? + EMPTY_OVERRIDES + ); } catch (err) { console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err); } diff --git a/packages/app/src/hooks/use-preferred-editor.ts b/packages/app/src/hooks/use-preferred-editor.ts index 480fd3d56..b21618518 100644 --- a/packages/app/src/hooks/use-preferred-editor.ts +++ b/packages/app/src/hooks/use-preferred-editor.ts @@ -1,6 +1,8 @@ import { useCallback } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; type EditorTargetId = string; @@ -8,11 +10,7 @@ const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor"; const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; async function loadPreferredEditor(): Promise { - const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY); - if (!stored) { - return null; - } - return stored.trim() || null; + return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1)); } export function resolvePreferredEditorId( diff --git a/packages/app/src/hooks/use-settings/fakes.ts b/packages/app/src/hooks/use-settings/fakes.ts index 0e7ce025d..691c3e9c1 100644 --- a/packages/app/src/hooks/use-settings/fakes.ts +++ b/packages/app/src/hooks/use-settings/fakes.ts @@ -17,6 +17,9 @@ export function createInMemoryKeyValueStorage( async setItem(key, value) { entries.set(key, value); }, + async removeItem(key) { + entries.delete(key); + }, }; } diff --git a/packages/app/src/hooks/use-settings/storage.test.ts b/packages/app/src/hooks/use-settings/storage.test.ts index b421c27cb..87fdd300f 100644 --- a/packages/app/src/hooks/use-settings/storage.test.ts +++ b/packages/app/src/hooks/use-settings/storage.test.ts @@ -15,6 +15,7 @@ import { type SettingsDeps, } from "./storage"; import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes"; +import { THEME_OPTIONS } from "@/styles/theme"; const LEGACY_SETTINGS_KEY = "@paseo:settings"; @@ -42,6 +43,18 @@ describe("loadAppSettingsFromStorage", () => { expect(result.theme).toBe("auto"); }); + it.each(THEME_OPTIONS)("loads the persisted $name theme", async ({ name }) => { + const deps = makeDeps({ + storage: createInMemoryKeyValueStorage({ + [APP_SETTINGS_KEY]: JSON.stringify({ theme: name }), + }), + }); + + const result = await loadAppSettingsFromStorage(deps); + + expect(result.theme).toBe(name); + }); + it("seeds storage with the client defaults when nothing is persisted", async () => { const deps = makeDeps(); @@ -382,14 +395,14 @@ describe("appearance settings", () => { expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); }); - it("maps an unrecognized tool call detail level to overview", async () => { + it("clears settings with an unrecognized tool call detail level", async () => { const deps = makeDeps({ storage: createInMemoryKeyValueStorage({ [APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), }), }); - expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); + expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed"); }); it("migrates a switched-off checks row item to the hidden checks display", async () => { diff --git a/packages/app/src/hooks/use-settings/storage.ts b/packages/app/src/hooks/use-settings/storage.ts index 3b78310fa..0b7a733de 100644 --- a/packages/app/src/hooks/use-settings/storage.ts +++ b/packages/app/src/hooks/use-settings/storage.ts @@ -14,6 +14,8 @@ import { type SidebarRowItems, } from "@/components/sidebar/display-preferences/row-items"; import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme"; +import { z } from "zod"; +import { readValidatedJson } from "@/storage/validated-storage"; export const APP_SETTINGS_KEY = "@paseo:app-settings"; export const APP_SETTINGS_QUERY_KEY = ["app-settings"]; @@ -28,6 +30,7 @@ export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none"; export type ToolCallDetailLevel = "overview" | "detailed"; const VALID_THEMES = new Set(THEME_OPTIONS.map((option) => option.name)); +const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name)); const VALID_SERVICE_URL_BEHAVIORS = new Set(["ask", "in-app", "external"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set(["title", "branch"]); const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set([ @@ -74,14 +77,45 @@ export interface Settings extends AppSettings { releaseChannel: ReleaseChannel; } -/** - * `sidebarRowItems` is widened back to `unknown` because it is still read for a value the - * current shape no longer has — see `isChecksHiddenByLegacyRowItem`. - */ -type StoredAppSettings = Partial> & { - compactToolCalls?: unknown; - sidebarRowItems?: unknown; -}; +const SidebarRowItemsSchema = z.strictObject({ + host: z.boolean().optional(), + changeRequest: z.boolean().optional(), + services: z.boolean().optional(), + checks: z.boolean().optional(), + scripts: z.boolean().optional(), +}); + +const StoredAppSettingsSchema = z.strictObject({ + theme: ThemePreferenceSchema.optional(), + language: z + .enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"]) + .optional(), + sendBehavior: z.enum(["interrupt", "queue"]).optional(), + serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(), + terminalScrollbackLines: z.union([z.number(), z.string()]).optional(), + useLegacyTerminalRenderer: z.boolean().optional(), + uiFontFamily: z.string().optional(), + monoFontFamily: z.string().optional(), + uiFontSize: z.union([z.number(), z.string()]).optional(), + codeFontSize: z.union([z.number(), z.string()]).optional(), + syntaxTheme: z.string().refine(isSyntaxThemeId).optional(), + workspaceTitleSource: z.enum(["title", "branch"]).optional(), + sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(), + sidebarRowItems: SidebarRowItemsSchema.optional(), + sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(), + autoExpandReasoning: z.boolean().optional(), + toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(), + compactToolCalls: z.boolean().optional(), + chatOutlineEnabled: z.boolean().optional(), + vimKeybindings: z.boolean().optional(), + // COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key. + manageBuiltInDaemon: z.boolean().optional(), + releaseChannel: z.enum(["stable", "beta"]).optional(), +}); + +const LegacyRendererSettingsSchema = StoredAppSettingsSchema; + +type StoredAppSettings = z.infer; export const DEFAULT_CLIENT_SETTINGS: AppSettings = { theme: "auto", @@ -114,6 +148,7 @@ export const DEFAULT_APP_SETTINGS: Settings = { export interface KeyValueStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } export interface DesktopSettingsBridge { @@ -146,17 +181,20 @@ export async function saveAppSettings(input: { export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise { try { - const stored = await deps.storage.getItem(APP_SETTINGS_KEY); + const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema); if (stored) { - return normalizeAppSettings(JSON.parse(stored)); + return normalizeAppSettings(stored); } - const legacyStored = await deps.storage.getItem(LEGACY_SETTINGS_KEY); + const legacyStored = await readValidatedJson( + deps.storage, + LEGACY_SETTINGS_KEY, + LegacyRendererSettingsSchema, + ); if (legacyStored) { - const legacyParsed = JSON.parse(legacyStored) as Record; const next = { ...DEFAULT_CLIENT_SETTINGS, - ...pickAppSettingsFromLegacy(legacyParsed), + ...pickAppSettingsFromLegacy(legacyStored), } satisfies AppSettings; await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); return next; @@ -197,11 +235,11 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise { return result; } -function pickAppSettingsFromLegacy(legacy: Record): Partial { +function pickAppSettingsFromLegacy( + legacy: z.infer, +): Partial { const result: Partial = {}; if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { result.theme = legacy.theme; @@ -420,15 +460,11 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P async function loadRendererSettingsPayload( storage: KeyValueStorage, -): Promise | null> { - const current = await storage.getItem(APP_SETTINGS_KEY); +): Promise | null> { + const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema); if (current) { - return JSON.parse(current) as Record; + return current; } - const legacy = await storage.getItem(LEGACY_SETTINGS_KEY); - if (!legacy) { - return null; - } - return JSON.parse(legacy) as Record; + return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema); } diff --git a/packages/app/src/hosts/appearance.ts b/packages/app/src/hosts/appearance.ts index 8f4fc80a2..e51f4410d 100644 --- a/packages/app/src/hosts/appearance.ts +++ b/packages/app/src/hosts/appearance.ts @@ -1,5 +1,6 @@ import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors"; import type { HostProfile } from "@/types/host-connection"; +import { z } from "zod"; export type HostColor = "none" | IdentityColorName; @@ -19,27 +20,18 @@ export interface HostAppearance { badgeDisplay: HostBadgeDisplay | null; } +export const HostAppearanceSchema: z.ZodType = z.strictObject({ + color: z.enum(["none", ...IDENTITY_COLOR_NAMES]), + badgeDisplay: z.enum(["name", "icon", "hidden"]).nullable(), +}); + export function defaultHostAppearance(): HostAppearance { return { color: "none", badgeDisplay: null }; } -function isHostColor(value: unknown): value is HostColor { - return HOST_COLORS.some((color) => color === value); -} - -function isHostBadgeDisplay(value: unknown): value is HostBadgeDisplay { - return HOST_BADGE_DISPLAYS.some((display) => display === value); -} - export function normalizeStoredHostAppearance(value: unknown): HostAppearance { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return defaultHostAppearance(); - } - const record = value as Record; - return { - color: isHostColor(record.color) ? record.color : "none", - badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null, - }; + const result = HostAppearanceSchema.safeParse(value); + return result.success ? result.data : defaultHostAppearance(); } export function resolveHostBadgeDisplay(input: { diff --git a/packages/app/src/push-notifications/internal/subscriptions.ts b/packages/app/src/push-notifications/internal/subscriptions.ts index 7a2934767..26df575eb 100644 --- a/packages/app/src/push-notifications/internal/subscriptions.ts +++ b/packages/app/src/push-notifications/internal/subscriptions.ts @@ -2,23 +2,22 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types"; const STORAGE_PREFIX = "@paseo:expo-push-token:"; +const ExpoPushTokenSchema = z.string().trim().min(1); function storageKey(serverId: string): string { return `${STORAGE_PREFIX}${serverId}`; } function getExpoProjectId(): string | null { - const constants = Constants as unknown as { - easConfig?: { projectId?: unknown }; - expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; - }; - const fromEas = constants.easConfig?.projectId; - if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim(); - const fromExtra = constants.expoConfig?.extra?.eas?.projectId; - return typeof fromExtra === "string" && fromExtra.trim() ? fromExtra.trim() : null; + const fromEas = ExpoPushTokenSchema.safeParse(Constants.easConfig?.projectId); + if (fromEas.success) return fromEas.data; + const fromExtra = ExpoPushTokenSchema.safeParse(Constants.expoConfig?.extra?.eas?.projectId); + return fromExtra.success ? fromExtra.data : null; } async function ensurePushPermission(): Promise { @@ -31,7 +30,7 @@ async function ensurePushPermission(): Promise { async function resolveToken(serverId: string): Promise { const key = storageKey(serverId); - const cached = await AsyncStorage.getItem(key); + const cached = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema); if (!(await ensurePushPermission())) { await AsyncStorage.removeItem(key); return null; @@ -87,7 +86,7 @@ export function startSubscription(input: StartPushNotificationsInput): () => voi export async function revokeSubscription(input: RevokePushNotificationsInput): Promise { const key = storageKey(input.serverId); - const token = await AsyncStorage.getItem(key); + const token = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema); if ( token && input.client?.isConnected && diff --git a/packages/app/src/review/state.ts b/packages/app/src/review/state.ts index dff34ff03..1b5057a64 100644 --- a/packages/app/src/review/state.ts +++ b/packages/app/src/review/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export type ReviewDraftMode = "uncommitted" | "base"; export type ReviewDraftSide = "old" | "new"; @@ -30,8 +32,27 @@ export interface ReviewDraftStoreState { // Only drafts are persisted; diffModeOverrides is intentionally excluded. export interface SerializedReviewDraftState { drafts: Record; + activeModesByScope?: Record; } +const IsoDateTimeSchema = z.string().datetime({ offset: true }); +export const ReviewDraftCommentSchema: z.ZodType = z.strictObject({ + id: z.string(), + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + createdAt: IsoDateTimeSchema, + updatedAt: IsoDateTimeSchema, +}); + +export const SerializedReviewDraftStateSchema: z.ZodType = + z.strictObject({ + drafts: z.record(z.string(), z.array(ReviewDraftCommentSchema)), + // COMPAT(reviewDraftModes): v1 persisted this field; v2 discards it during migration. + activeModesByScope: z.record(z.string(), z.enum(["uncommitted", "base"])).optional(), + }); + export function setDiffModeOverrideInState( state: ReviewDraftStoreState, input: { scopeKey: string; override: DiffModeOverride }, @@ -160,45 +181,11 @@ export function serializeReviewDraftState( } export function normalizePersistedState(state: unknown): ReviewDraftStoreState { - if (!state || typeof state !== "object") { - return { drafts: {}, diffModeOverrides: {} }; - } - // activeModesByScope may be present in old persisted JSON — tolerate and ignore it. - const persisted = state as { drafts?: unknown }; - const drafts = persisted.drafts; - if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) { - return { drafts: {}, diffModeOverrides: {} }; - } - - const normalized: Record = {}; - for (const [key, value] of Object.entries(drafts)) { - if (!Array.isArray(value)) { - continue; - } - normalized[key] = value.filter((comment): comment is ReviewDraftComment => - isReviewDraftComment(comment), - ); - } - - return { drafts: normalized, diffModeOverrides: {} }; -} - -export function isReviewDraftComment(value: unknown): value is ReviewDraftComment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.filePath === "string" && - (record.side === "old" || record.side === "new") && - typeof record.lineNumber === "number" && - Number.isInteger(record.lineNumber) && - record.lineNumber > 0 && - typeof record.body === "string" && - typeof record.createdAt === "string" && - typeof record.updatedAt === "string" - ); + const result = SerializedReviewDraftStateSchema.safeParse(state); + return { + drafts: result.success ? result.data.drafts : {}, + diffModeOverrides: {}, + }; } function applyCommentUpdates( diff --git a/packages/app/src/review/store.test.ts b/packages/app/src/review/store.test.ts index 62e68f015..8c8dea946 100644 --- a/packages/app/src/review/store.test.ts +++ b/packages/app/src/review/store.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; import type { ParsedDiffFile } from "@/git/use-diff-query"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { buildReviewAttachmentSnapshot, buildReviewDraftKey, @@ -16,6 +18,7 @@ import { type ReviewDraftComment, type ReviewDraftStoreState, serializeReviewDraftState, + SerializedReviewDraftStateSchema, setDiffModeOverrideInState, updateCommentInState, } from "./state"; @@ -69,6 +72,20 @@ function makeFile(): ParsedDiffFile { }; } +function createMemoryStorage(): StateStorage & { values: Map } { + const values = new Map(); + return { + values, + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }; +} + describe("buildReviewDraftKey", () => { it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => { const base = buildReviewDraftKey({ @@ -122,7 +139,27 @@ describe("buildReviewDraftKey", () => { }); describe("normalizePersistedState", () => { - it("filters invalid draft comments and ignores activeModesByScope from old persisted JSON", () => { + it("keeps v1 review comments for migration while dropping the legacy mode field", async () => { + const backing = createMemoryStorage(); + const legacyState = { + drafts: { "review:key": [makeComment()] }, + activeModesByScope: { "review:scope": "base" }, + }; + backing.values.set( + "@paseo:review-draft-store", + JSON.stringify({ state: legacyState, version: 1 }), + ); + const storage = createValidatedPersistStorage(backing, SerializedReviewDraftStateSchema); + + const stored = await storage.getItem("@paseo:review-draft-store"); + const normalized = normalizePersistedState(stored?.state); + + expect(normalized.drafts["review:key"]).toEqual([makeComment()]); + expect(normalized.diffModeOverrides).toEqual({}); + expect(backing.values.has("@paseo:review-draft-store")).toBe(true); + }); + + it("rejects the complete payload when any draft comment or field is invalid", () => { const normalized = normalizePersistedState({ drafts: { "review:key": [ @@ -146,17 +183,7 @@ describe("normalizePersistedState", () => { }); expect(normalized.diffModeOverrides).toEqual({}); - expect(normalized.drafts["review:key"]).toEqual([ - { - id: "comment-1", - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Keep me.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - ]); + expect(normalized.drafts).toEqual({}); }); it("returns empty state for null, non-object, or malformed inputs", () => { diff --git a/packages/app/src/review/store.ts b/packages/app/src/review/store.ts index d1cc5e16b..3f9d7c55e 100644 --- a/packages/app/src/review/store.ts +++ b/packages/app/src/review/store.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import type { ComposerAttachment } from "@/attachments/types"; import type { ParsedDiffFile } from "@/git/use-diff-query"; import { @@ -17,12 +17,14 @@ import { type ReviewDraftSide, type ReviewDraftStoreState, serializeReviewDraftState, + SerializedReviewDraftStateSchema, setDiffModeOverrideInState, updateCommentInState, } from "@/review/state"; import { generateMessageId } from "@/types/stream"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DiffModeOverride, @@ -168,7 +170,7 @@ export const useReviewDraftStore = create()( { name: "@paseo:review-draft-store", version: STORE_VERSION, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, SerializedReviewDraftStateSchema), partialize: (state) => serializeReviewDraftState(state), migrate: async (state) => normalizePersistedState(state), }, diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 1fe16b72c..9fc711d68 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -445,6 +445,9 @@ function createMemoryHostRuntimeStorage(entries: Record = {}): H setItem: async (key, value) => { values.set(key, value); }, + removeItem: async (key) => { + values.delete(key); + }, }; } diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 5bd205074..eff24ee32 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import equal from "fast-deep-equal/es6"; import { DaemonClient, + type DaemonClientConfig, type ConnectionState, type FetchAgentsOptions, } from "@getpaseo/client/internal/daemon-client"; @@ -11,6 +12,7 @@ import { normalizeStoredHostProfile, upsertHostConnectionInProfiles, registryHasConnection, + StoredHostRegistrySchema, type HostConnection, type HostProfile, } from "@/types/host-connection"; @@ -28,6 +30,8 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon"; import { isWeb } from "@/constants/platform"; import { connectToDaemon } from "@/utils/test-daemon-connection"; import { getOrCreateClientId } from "@/utils/client-id"; +import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "@/storage/validated-storage"; import { selectBestConnection, type ConnectionCandidate, @@ -163,6 +167,7 @@ export interface HostRuntimeControllerDeps { export interface HostRuntimeStorage { getItem: (key: string) => Promise; setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; } export interface HostRuntimeStartOptions { @@ -483,12 +488,12 @@ function createDefaultDeps(): HostRuntimeControllerDeps { const base = { suppressSendErrors: true, clientId, - clientType: "mobile" as const, + clientType: "mobile", appVersion: resolveAppVersion() ?? undefined, runtimeGeneration, capabilities: appCapabilities, trace: nativePerformanceTrace, - }; + } satisfies Omit; if (connection.type === "directSocket" || connection.type === "directPipe") { return new DaemonClient({ ...base, @@ -1288,9 +1293,10 @@ export interface InitialDaemonConnectionHint { useTls?: boolean; } -function isInitialConnectionHintRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const InitialDaemonConnectionHintSchema: z.ZodType = z.object({ + listen: z.string().trim().min(1), + useTls: z.boolean().optional().default(false), +}); export function readInitialDaemonConnectionHint(input?: { isWebRuntime?: boolean; @@ -1299,18 +1305,10 @@ export function readInitialDaemonConnectionHint(input?: { if (!isWebRuntime || typeof globalThis === "undefined") { return null; } - const value = (globalThis as Record)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY]; - if (!isInitialConnectionHintRecord(value)) { - return null; - } - const listen = typeof value.listen === "string" ? value.listen.trim() : ""; - if (!listen) { - return null; - } - return { - listen, - useTls: value.useTls === true, - }; + const result = InitialDaemonConnectionHintSchema.safeParse( + Reflect.get(globalThis, INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY), + ); + return result.success ? result.data : null; } function readConfiguredLocalDaemonOverride(): string | null { @@ -1419,7 +1417,7 @@ export class HostRuntimeStore { let isE2E: string | null = null; try { - isE2E = await this.storage.getItem(E2E_STORAGE_KEY); + isE2E = await readValidatedString(this.storage, E2E_STORAGE_KEY, z.string().min(1)); } catch { return; } @@ -1452,17 +1450,25 @@ export class HostRuntimeStore { let shouldPersistHosts = false; let profiles: HostProfile[] = []; try { - const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY); + const stored = await readValidatedJson( + this.storage, + REGISTRY_STORAGE_KEY, + StoredHostRegistrySchema, + ); if (stored) { - const parsed = JSON.parse(stored) as unknown; - if (Array.isArray(parsed)) { - const normalizedProfiles = parsed - .map((entry) => normalizeStoredHostProfile(entry)) - .filter((entry): entry is HostProfile => entry !== null); - profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); - if (profiles.length !== normalizedProfiles.length) { - shouldPersistHosts = true; + const normalizedProfiles: HostProfile[] = []; + for (const entry of stored) { + const profile = normalizeStoredHostProfile(entry); + if (!profile) { + await this.storage.removeItem(REGISTRY_STORAGE_KEY); + normalizedProfiles.length = 0; + break; } + normalizedProfiles.push(profile); + } + profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); + if (profiles.length !== normalizedProfiles.length) { + shouldPersistHosts = true; } } this.hosts = profiles; @@ -1922,7 +1928,11 @@ export class HostRuntimeStore { void this.persistHosts().catch((error) => console.error("[HostRuntime] Failed to persist host registry", error), ); - return next.find((daemon) => daemon.serverId === input.serverId) as HostProfile; + const profile = next.find((daemon) => daemon.serverId === input.serverId); + if (!profile) { + throw new Error(`Host ${input.serverId} was not inserted`); + } + return profile; } private setHostsAndSync( @@ -2321,25 +2331,19 @@ export class HostRuntimeStore { let singletonHostRuntimeStore: HostRuntimeStore | null = null; const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore"; -type HostRuntimeGlobal = typeof globalThis & { - [HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore; -}; - export function getHostRuntimeStore(): HostRuntimeStore { if (singletonHostRuntimeStore) { return singletonHostRuntimeStore; } - const runtimeGlobal = globalThis as HostRuntimeGlobal; - if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) { - singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null; - if (singletonHostRuntimeStore) { - return singletonHostRuntimeStore; - } + const existing = Reflect.get(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY); + if (existing instanceof HostRuntimeStore) { + singletonHostRuntimeStore = existing; + return existing; } singletonHostRuntimeStore = new HostRuntimeStore(); - runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore; + Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore); return singletonHostRuntimeStore; } @@ -2396,12 +2400,11 @@ export function useHostRuntimeConnectionStatuses( return useMemo(() => { // The aggregate version is the reactivity trigger; re-read snapshots on every host tick. void version; - return new Map( - serverIds.map( - (serverId) => - [serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const, - ), - ); + const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [ + serverId, + store.getSnapshot(serverId)?.connectionStatus ?? "connecting", + ]); + return new Map(entries); }, [serverIds, store, version]); } diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 8149032f3..6066aed51 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -25,6 +25,10 @@ class MemoryStorage implements ReplicaCacheStorage { this.writes += 1; this.values.set(key, value); } + + async removeItem(key: string): Promise { + this.values.delete(key); + } } function workspace( @@ -272,7 +276,7 @@ describe("ReplicaCache", () => { version: number; hosts: Array<{ timeline: Record | null }>; }; - expect(persisted.version).toBe(4); + expect(persisted.version).toBe(5); expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); }); @@ -331,12 +335,12 @@ describe("ReplicaCache", () => { expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); }); - it("rejects version 3 cache data and overwrites it on flush", async () => { + it("rejects and clears version 4 cache data before overwriting it on flush", async () => { const storage = new MemoryStorage(); storage.values.set( "@paseo:replica-cache", JSON.stringify({ - version: 3, + version: 4, hosts: [ { serverId: SERVER_ID, @@ -357,11 +361,12 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.restore(); + expect(storage.values.has("@paseo:replica-cache")).toBe(false); await cache.flush(); expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ - version: 4, + version: 5, hosts: [], }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index c773327bb..7459b526e 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,19 +1,14 @@ import { Buffer } from "buffer"; import { z } from "zod"; -import { - AgentSnapshotPayloadSchema, - ProjectPlacementPayloadSchema, - WorkspaceDescriptorPayloadSchema, - WorkspaceProjectDescriptorPayloadSchema, - type AgentSnapshotPayload, - type WorkspaceDescriptorPayload, -} from "@getpaseo/protocol/messages"; +import { AgentStatusSchema } from "@getpaseo/protocol/messages"; +import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, selectAgentTimelineState, useSessionStore, type Agent, + type AgentTimelineState, type SessionReplica, type SessionState, type ProjectDescriptor, @@ -23,39 +18,217 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream" import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const STORAGE_KEY = "@paseo:replica-cache"; -const CACHE_VERSION = 4; +const CACHE_VERSION = 5; const PERSIST_DELAY_MS = 750; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 1024 * 1024; -const DATE_TAG = "__paseoDate"; - -const StoredAgentSchema = z.object({ - snapshot: AgentSnapshotPayloadSchema, - projectPlacement: ProjectPlacementPayloadSchema.nullable(), - lastActivityAt: z.string(), +const IsoDateSchema = z.iso.datetime(); +const TimelinePositionSchema = z.strictObject({ + epoch: z.string(), + seq: z.number().int().nonnegative(), }); -const StoredTimelineSchema = z.object({ +const TimelineItemBaseShape = { + id: z.string(), + timelineCursor: TimelinePositionSchema.optional(), + timestamp: IsoDateSchema, +}; + +const TodoEntrySchema = z.strictObject({ + text: z.string(), + completed: z.boolean(), + id: z.string().optional(), + status: z.enum(["pending", "in_progress", "completed"]).optional(), + activeForm: z.string().optional(), +}); + +const TaskActivitySchema = z.discriminatedUnion("type", [ + z.strictObject({ type: z.literal("created"), count: z.number().int().nonnegative() }), + z.strictObject({ + type: z.enum(["added", "started", "completed", "reopened"]), + task: z.string(), + }), +]); + +const StoredTimelineItemSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("user_message"), + clientMessageId: z.string().optional(), + messageId: z.string().optional(), + text: z.string(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("assistant_message"), + messageId: z.string().optional(), + text: z.string(), + blockGroupId: z.string().optional(), + blockIndex: z.number().int().nonnegative().optional(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("thought"), + text: z.string(), + status: z.enum(["loading", "ready"]), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("todo_list"), + provider: AgentProviderSchema, + items: z.array(TodoEntrySchema), + activity: TaskActivitySchema, + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("activity_log"), + activityType: z.enum(["system", "info", "success", "error"]), + message: z.string(), + }), + z.strictObject({ + ...TimelineItemBaseShape, + kind: z.literal("compaction"), + status: z.enum(["loading", "completed"]), + trigger: z.enum(["auto", "manual"]).optional(), + preTokens: z.number().nonnegative().optional(), + }), +]); + +const AgentCapabilitiesSchema = z.strictObject({ + supportsStreaming: z.boolean(), + supportsSessionPersistence: z.boolean(), + supportsSessionListing: z.boolean().optional(), + supportsDynamicModes: z.boolean(), + supportsMcpServers: z.boolean(), + supportsReasoningStream: z.boolean(), + supportsToolInvocations: z.boolean(), + supportsRewindConversation: z.boolean().optional(), + supportsRewindFiles: z.boolean().optional(), + supportsRewindBoth: z.boolean().optional(), +}); + +const StoredAgentSnapshotSchema = z.strictObject({ + id: z.string(), + provider: AgentProviderSchema, + cwd: z.string(), + workspaceId: z.string().optional(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable().optional(), + createdAt: IsoDateSchema, + updatedAt: IsoDateSchema, + lastUserMessageAt: IsoDateSchema.nullable(), + status: AgentStatusSchema, + activeTurn: z + .strictObject({ + turnId: z.string(), + startedAt: IsoDateSchema.nullable(), + }) + .nullable() + .optional(), + capabilities: AgentCapabilitiesSchema, + currentModeId: z.string().nullable(), + availableModes: z.array(z.never()).max(0), + pendingPermissions: z.array(z.never()).max(0), + persistence: z.null(), + lastError: z.string().optional(), + title: z.string().nullable(), + labels: z.record(z.string(), z.string()), + requiresAttention: z.boolean().optional(), + attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), + attentionTimestamp: IsoDateSchema.nullable().optional(), + archivedAt: IsoDateSchema.nullable().optional(), +}); + +const StoredAgentSchema = z.strictObject({ + snapshot: StoredAgentSnapshotSchema, + projectPlacement: z.null(), + lastActivityAt: IsoDateSchema, +}); + +const WorkspaceScriptSchema = z.strictObject({ + scriptName: z.string(), + type: z.enum(["script", "service"]), + hostname: z.string(), + port: z.number().int().positive().nullable(), + localProxyUrl: z.string().nullable().optional(), + publicProxyUrl: z.string().nullable().optional(), + proxyUrl: z.string().nullable(), + lifecycle: z.enum(["running", "stopped"]), + health: z.enum(["healthy", "unhealthy"]).nullable(), + exitCode: z.number().nullable(), + terminalId: z.string().nullable(), +}); + +const WorkspaceGitRuntimeSchema = z + .strictObject({ + currentBranch: z.string().nullable().optional(), + remoteUrl: z.string().nullable().optional(), + isPaseoOwnedWorktree: z.boolean().optional(), + isDirty: z.boolean().nullable().optional(), + aheadBehind: z.strictObject({ ahead: z.number(), behind: z.number() }).nullable().optional(), + aheadOfOrigin: z.number().nullable().optional(), + behindOfOrigin: z.number().nullable().optional(), + }) + .nullable() + .optional(); + +const StoredWorkspaceSchema = z.strictObject({ + id: z.string(), + projectId: z.string(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable(), + projectCustomIconRevision: z.string().nullable(), + projectRootPath: z.string(), + workspaceDirectory: z.string(), + worktreeSlug: z.string().optional(), + projectKind: z.enum(["git", "non_git", "directory"]), + workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), + name: z.string(), + title: z.string().nullable(), + pinnedAt: z.string().nullable(), + status: z.enum(["needs_input", "failed", "running", "attention", "done"]), + statusEnteredAt: IsoDateSchema.nullable(), + activityAt: z.null(), + archivingAt: z.string().nullable(), + diffStat: z.strictObject({ additions: z.number(), deletions: z.number() }).nullable(), + scripts: z.array(WorkspaceScriptSchema), + gitRuntime: WorkspaceGitRuntimeSchema, + forge: z.string().optional(), +}); + +const StoredProjectSchema = z.strictObject({ + projectId: z.string(), + projectKey: z.string().optional(), + projectDisplayName: z.string(), + projectCustomName: z.string().nullable(), + projectRootPath: z.string(), + projectKind: z.enum(["git", "non_git", "directory"]), +}); + +const StoredTimelineSchema = z.strictObject({ agentId: z.string(), - items: z.unknown(), + items: z.array(StoredTimelineItemSchema), }); -const StoredHostSchema = z.object({ +const StoredHostSchema = z.strictObject({ serverId: z.string(), agents: z.array(StoredAgentSchema), - workspaces: z.array(WorkspaceDescriptorPayloadSchema), - projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]), - emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema), + workspaces: z.array(StoredWorkspaceSchema), + projects: z.array(StoredProjectSchema), + emptyProjects: z.array(StoredProjectSchema), timeline: StoredTimelineSchema.nullable(), }); -const StoredCacheSchema = z.object({ +const StoredCacheSchema = z.strictObject({ version: z.literal(CACHE_VERSION), hosts: z.array(StoredHostSchema), }); type StoredAgent = z.infer; type StoredHost = z.infer; +type StoredTimelineItem = z.infer; +type StoredWorkspace = z.infer; +type StoredProject = z.infer; interface ReplicaInput { agent: Agent | undefined; @@ -67,104 +240,177 @@ interface ReplicaInput { export interface ReplicaCacheStorage { getItem: (key: string) => Promise; setItem: (key: string, value: string) => Promise; + removeItem: (key: string) => Promise; } interface ReplicaCacheOptions { maxBytes?: number; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function hasString(value: Record, key: string): boolean { - return typeof value[key] === "string"; -} - -function isStreamItem(value: unknown): value is StreamItem { - if (!isRecord(value) || !hasString(value, "id") || !(value.timestamp instanceof Date)) { - return false; - } - switch (value.kind) { - case "user_message": - case "assistant_message": - return hasString(value, "text"); - case "thought": - return hasString(value, "text") && (value.status === "loading" || value.status === "ready"); - case "tool_call": - return isRecord(value.payload) && isRecord(value.payload.data); - case "todo_list": - return hasString(value, "provider") && Array.isArray(value.items); - case "activity_log": - return hasString(value, "message") && hasString(value, "activityType"); - case "compaction": - return value.status === "loading" || value.status === "completed"; - default: - return false; - } -} - -function encodeDates(value: unknown): unknown { - if (value instanceof Date) { - return { [DATE_TAG]: value.toISOString() }; - } - if (Array.isArray(value)) { - return value.map(encodeDates); - } - if (!isRecord(value)) { - return value; - } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, encodeDates(entry)])); -} - -function decodeDates(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(decodeDates); - } - if (!isRecord(value)) { - return value; - } - if (Object.keys(value).length === 1 && typeof value[DATE_TAG] === "string") { - const date = new Date(value[DATE_TAG]); - return Number.isNaN(date.getTime()) ? value : date; - } - return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, decodeDates(entry)])); -} - function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] { if (!stored) { return null; } - const decoded = decodeDates(stored.items); - if (!Array.isArray(decoded) || !decoded.every(isStreamItem)) { - return null; - } return { agentId: stored.agentId, - items: decoded, + items: stored.items.map(deserializeTimelineItem), }; } +function timelineBase(item: StreamItem) { + return { + id: item.id, + ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + timestamp: item.timestamp.toISOString(), + }; +} + +function serializeTimelineItem(item: StreamItem): StoredTimelineItem | null { + const base = timelineBase(item); + switch (item.kind) { + case "user_message": + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; + case "assistant_message": + return { + ...base, + kind: item.kind, + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), + ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), + }; + case "thought": + return { ...base, kind: item.kind, text: item.text, status: item.status }; + case "todo_list": + return { + ...base, + kind: item.kind, + provider: item.provider, + items: item.items, + activity: item.activity, + }; + case "activity_log": + return { + ...base, + kind: item.kind, + activityType: item.activityType, + message: item.message, + }; + case "compaction": + return { + ...base, + kind: item.kind, + status: item.status, + ...(item.trigger ? { trigger: item.trigger } : {}), + ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), + }; + case "tool_call": + return null; + } +} + +function deserializeTimelineItem(item: StoredTimelineItem): StreamItem { + const base = { + id: item.id, + ...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}), + timestamp: new Date(item.timestamp), + }; + switch (item.kind) { + case "user_message": + return { + ...base, + kind: item.kind, + ...(item.clientMessageId ? { clientMessageId: item.clientMessageId } : {}), + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + }; + case "assistant_message": + return { + ...base, + kind: item.kind, + ...(item.messageId ? { messageId: item.messageId } : {}), + text: item.text, + ...(item.blockGroupId ? { blockGroupId: item.blockGroupId } : {}), + ...(item.blockIndex !== undefined ? { blockIndex: item.blockIndex } : {}), + }; + case "thought": + return { ...base, kind: item.kind, text: item.text, status: item.status }; + case "todo_list": + return { + ...base, + kind: item.kind, + provider: item.provider, + items: item.items, + activity: item.activity, + }; + case "activity_log": + return { + ...base, + kind: item.kind, + activityType: item.activityType, + message: item.message, + }; + case "compaction": + return { + ...base, + kind: item.kind, + status: item.status, + ...(item.trigger ? { trigger: item.trigger } : {}), + ...(item.preTokens !== undefined ? { preTokens: item.preTokens } : {}), + }; + } +} + function serializeAgent(agent: Agent): StoredAgent { - const snapshot: AgentSnapshotPayload = { + const snapshot = { id: agent.id, provider: agent.provider, cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), model: agent.model, - ...(agent.features ? { features: agent.features } : {}), thinkingOptionId: agent.thinkingOptionId ?? null, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - capabilities: agent.capabilities, + ...(agent.activeTurn?.turnId + ? { + activeTurn: { + turnId: agent.activeTurn.turnId, + startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + }, + } + : {}), + capabilities: { + supportsStreaming: agent.capabilities.supportsStreaming, + supportsSessionPersistence: agent.capabilities.supportsSessionPersistence, + ...(agent.capabilities.supportsSessionListing !== undefined + ? { supportsSessionListing: agent.capabilities.supportsSessionListing } + : {}), + supportsDynamicModes: agent.capabilities.supportsDynamicModes, + supportsMcpServers: agent.capabilities.supportsMcpServers, + supportsReasoningStream: agent.capabilities.supportsReasoningStream, + supportsToolInvocations: agent.capabilities.supportsToolInvocations, + ...(agent.capabilities.supportsRewindConversation !== undefined + ? { supportsRewindConversation: agent.capabilities.supportsRewindConversation } + : {}), + ...(agent.capabilities.supportsRewindFiles !== undefined + ? { supportsRewindFiles: agent.capabilities.supportsRewindFiles } + : {}), + ...(agent.capabilities.supportsRewindBoth !== undefined + ? { supportsRewindBoth: agent.capabilities.supportsRewindBoth } + : {}), + }, currentModeId: agent.currentModeId, - availableModes: agent.availableModes, + availableModes: [], pendingPermissions: [], - persistence: agent.persistence, - ...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}), - ...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}), + persistence: null, ...(agent.lastError ? { lastError: agent.lastError } : {}), title: agent.title, labels: agent.labels, @@ -175,7 +421,7 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, - projectPlacement: agent.projectPlacement ?? null, + projectPlacement: null, lastActivityAt: agent.lastActivityAt.toISOString(), }; } @@ -188,7 +434,7 @@ function deserializeAgent(serverId: string, stored: StoredAgent): Agent { }; } -function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptorPayload { +function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { id: workspace.id, projectId: workspace.projectId, @@ -208,15 +454,25 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor activityAt: null, archivingAt: workspace.archivingAt, diffStat: workspace.diffStat, - scripts: workspace.scripts, + scripts: workspace.scripts.map((script) => ({ + scriptName: script.scriptName, + type: script.type, + hostname: script.hostname, + port: script.port, + ...(script.localProxyUrl !== undefined ? { localProxyUrl: script.localProxyUrl } : {}), + ...(script.publicProxyUrl !== undefined ? { publicProxyUrl: script.publicProxyUrl } : {}), + proxyUrl: script.proxyUrl, + lifecycle: script.lifecycle, + health: script.health, + exitCode: script.exitCode, + terminalId: script.terminalId, + })), gitRuntime: workspace.gitRuntime, - githubRuntime: workspace.githubRuntime, forge: workspace.forge, - project: workspace.project, }; } -function serializeProject(project: ProjectDescriptor) { +function serializeProject(project: ProjectDescriptor): StoredProject { return { projectId: project.projectId, ...(project.projectKey ? { projectKey: project.projectKey } : {}), @@ -244,9 +500,9 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl (candidate) => candidate.workspaceDirectory === agent.cwd, )) : undefined; - const timeline = agentId + const timeline: AgentTimelineState = agentId ? selectAgentTimelineState(session, agentId) - : { status: "cold" as const }; + : { status: "cold" }; return { agent, workspace, @@ -256,9 +512,10 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl } function serializeHost(serverId: string, input: ReplicaInput): StoredHost { - const items = input.timelineItems?.filter( - (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), - ); + const items = input.timelineItems + ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) + .map(serializeTimelineItem) + .filter((item) => item !== null); return { serverId, agents: input.agent ? [serializeAgent(input.agent)] : [], @@ -269,7 +526,7 @@ function serializeHost(serverId: string, input: ReplicaInput): StoredHost { input.agent && items ? { agentId: input.agent.id, - items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), + items: items.slice(-MAX_TIMELINE_ITEMS), } : null, }; @@ -360,10 +617,14 @@ export class ReplicaCache { try { parsed = JSON.parse(raw); } catch { + await this.clearInvalidCache(); return; } const cache = StoredCacheSchema.safeParse(parsed); - if (!cache.success) return; + if (!cache.success) { + await this.clearInvalidCache(); + return; + } for (const host of cache.data.hosts) { if (!this.activeServerIds.has(host.serverId)) { this.needsPersist = true; @@ -372,7 +633,13 @@ export class ReplicaCache { this.storedHosts.set(host.serverId, host); if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); } - if (this.buildBoundedPayload().evicted) this.needsPersist = true; + const bounded = this.buildBoundedPayload(); + if (!bounded) { + await this.clearInvalidCache(); + this.storedHosts.clear(); + return; + } + if (bounded.evicted) this.needsPersist = true; for (const host of this.storedHosts.values()) { useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); const session = useSessionStore.getState().sessions[host.serverId]; @@ -427,11 +694,15 @@ export class ReplicaCache { this.persistTimer = null; } this.captureSessions(); - const { payload } = this.buildBoundedPayload(); + const bounded = this.buildBoundedPayload(); this.needsPersist = false; + if (!bounded) { + await this.clearInvalidCache(); + return; + } const write = this.writeQueue .catch(() => undefined) - .then(() => this.storage.setItem(STORAGE_KEY, payload)); + .then(() => this.storage.setItem(STORAGE_KEY, bounded.payload)); this.writeQueue = write; await write.catch(() => undefined); } @@ -461,24 +732,35 @@ export class ReplicaCache { return true; } - private buildBoundedPayload(): { payload: string; evicted: boolean } { + private buildBoundedPayload(): { payload: string; evicted: boolean } | null { let evicted = false; let payload = this.serialize(); + if (payload === null) return null; while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) { const oldestServerId = this.storedHosts.keys().next().value; if (oldestServerId === undefined) break; this.storedHosts.delete(oldestServerId); evicted = true; payload = this.serialize(); + if (payload === null) return null; } return { payload, evicted }; } - private serialize(): string { - return JSON.stringify({ + private serialize(): string | null { + const cache = StoredCacheSchema.safeParse({ version: CACHE_VERSION, hosts: Array.from(this.storedHosts.values()), }); + return cache.success ? JSON.stringify(cache.data) : null; + } + + private async clearInvalidCache(): Promise { + try { + await this.storage.removeItem(STORAGE_KEY); + } catch { + // A storage failure must not turn invalid cached data into application state. + } } private schedulePersist(): void { diff --git a/packages/app/src/storage/validated-persist-storage.test.ts b/packages/app/src/storage/validated-persist-storage.test.ts new file mode 100644 index 000000000..7255086e3 --- /dev/null +++ b/packages/app/src/storage/validated-persist-storage.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import type { StateStorage } from "zustand/middleware"; +import { createValidatedPersistStorage } from "./validated-persist-storage"; + +class MemoryStorage implements StateStorage { + readonly values = new Map(); + + getItem(name: string): string | null { + return this.values.get(name) ?? null; + } + + setItem(name: string, value: string): void { + this.values.set(name, value); + } + + removeItem(name: string): void { + this.values.delete(name); + } +} + +const StateSchema = z.strictObject({ enabled: z.boolean() }); + +describe("createValidatedPersistStorage", () => { + it.each([ + ["malformed JSON", "{"], + ["invalid state", JSON.stringify({ state: { enabled: "yes" }, version: 1 })], + ["unknown state fields", JSON.stringify({ state: { enabled: true, extra: true }, version: 1 })], + [ + "unknown envelope fields", + JSON.stringify({ state: { enabled: true }, version: 1, extra: true }), + ], + ])("clears %s", async (_label, stored) => { + const backing = new MemoryStorage(); + backing.values.set("settings", stored); + const storage = createValidatedPersistStorage(backing, StateSchema); + + await expect(storage.getItem("settings")).resolves.toBeNull(); + expect(backing.values.has("settings")).toBe(false); + }); + + it("returns schema-validated state", async () => { + const backing = new MemoryStorage(); + backing.values.set("settings", JSON.stringify({ state: { enabled: true }, version: 1 })); + const storage = createValidatedPersistStorage(backing, StateSchema); + + await expect(storage.getItem("settings")).resolves.toEqual({ + state: { enabled: true }, + version: 1, + }); + }); + + it("clears a value rejected by the schema before writing", async () => { + const backing = new MemoryStorage(); + backing.values.set("settings", JSON.stringify({ state: { count: 1 } })); + const storage = createValidatedPersistStorage( + backing, + z.strictObject({ count: z.number().finite() }), + ); + + await storage.setItem("settings", { state: { count: Number.NaN } }); + + expect(backing.values.has("settings")).toBe(false); + }); +}); diff --git a/packages/app/src/storage/validated-persist-storage.ts b/packages/app/src/storage/validated-persist-storage.ts new file mode 100644 index 000000000..07459f864 --- /dev/null +++ b/packages/app/src/storage/validated-persist-storage.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import type { PersistStorage, StateStorage } from "zustand/middleware"; + +export function createValidatedPersistStorage( + backingStorage: StateStorage, + stateSchema: z.ZodType, +): PersistStorage { + const envelopeSchema = z.strictObject({ + state: stateSchema, + version: z.number().int().nonnegative().optional(), + }); + + return { + getItem: async (name) => { + const raw = await backingStorage.getItem(name); + if (raw === null) return null; + + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await backingStorage.removeItem(name); + return null; + } + + const result = envelopeSchema.safeParse(decoded); + if (!result.success) { + await backingStorage.removeItem(name); + return null; + } + return result.data; + }, + setItem: async (name, value) => { + const result = envelopeSchema.safeParse(value); + if (!result.success) { + await backingStorage.removeItem(name); + return; + } + await backingStorage.setItem(name, JSON.stringify(result.data)); + }, + removeItem: (name) => backingStorage.removeItem(name), + }; +} diff --git a/packages/app/src/storage/validated-storage.test.ts b/packages/app/src/storage/validated-storage.test.ts new file mode 100644 index 000000000..8ebd7339d --- /dev/null +++ b/packages/app/src/storage/validated-storage.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { readValidatedJson, readValidatedString } from "./validated-storage"; + +class MemoryStorage { + readonly values = new Map(); + + async getItem(key: string): Promise { + return this.values.get(key) ?? null; + } + + async removeItem(key: string): Promise { + this.values.delete(key); + } +} + +describe("validated storage reads", () => { + it.each([ + ["malformed JSON", "{"], + ["wrong field type", JSON.stringify({ enabled: "yes" })], + ["unknown field", JSON.stringify({ enabled: true, surprise: true })], + ])("clears invalid JSON payloads: %s", async (_label, raw) => { + const storage = new MemoryStorage(); + storage.values.set("settings", raw); + + await expect( + readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })), + ).resolves.toBeNull(); + expect(storage.values.has("settings")).toBe(false); + }); + + it("clears invalid scalar values", async () => { + const storage = new MemoryStorage(); + storage.values.set("channel", "nightly"); + + await expect( + readValidatedString(storage, "channel", z.enum(["stable", "beta"])), + ).resolves.toBeNull(); + expect(storage.values.has("channel")).toBe(false); + }); + + it("returns only schema-validated values", async () => { + const storage = new MemoryStorage(); + storage.values.set("settings", JSON.stringify({ enabled: true })); + + await expect( + readValidatedJson(storage, "settings", z.strictObject({ enabled: z.boolean() })), + ).resolves.toEqual({ enabled: true }); + expect(storage.values.has("settings")).toBe(true); + }); +}); diff --git a/packages/app/src/storage/validated-storage.ts b/packages/app/src/storage/validated-storage.ts new file mode 100644 index 000000000..b47c92e49 --- /dev/null +++ b/packages/app/src/storage/validated-storage.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export interface ValidatedStorage { + getItem(key: string): Promise; + removeItem(key: string): Promise; +} + +async function clearInvalidValue(storage: ValidatedStorage, key: string): Promise { + try { + await storage.removeItem(key); + } catch { + // Invalid persisted data must never cross the boundary, even when cleanup fails. + } +} + +export async function readValidatedString( + storage: ValidatedStorage, + key: string, + schema: z.ZodType, +): Promise { + const raw = await storage.getItem(key); + if (raw === null) return null; + const result = schema.safeParse(raw); + if (result.success) return result.data; + await clearInvalidValue(storage, key); + return null; +} + +export async function readValidatedJson( + storage: ValidatedStorage, + key: string, + schema: z.ZodType, +): Promise { + const raw = await storage.getItem(key); + if (raw === null) return null; + let decoded: unknown; + try { + decoded = JSON.parse(raw); + } catch { + await clearInvalidValue(storage, key); + return null; + } + const result = schema.safeParse(decoded); + if (result.success) return result.data; + await clearInvalidValue(storage, key); + return null; +} diff --git a/packages/app/src/stores/draft-store/index.ts b/packages/app/src/stores/draft-store/index.ts index 528517041..4b89ee98c 100644 --- a/packages/app/src/stores/draft-store/index.ts +++ b/packages/app/src/stores/draft-store/index.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types"; import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file"; @@ -27,8 +27,14 @@ import { type DraftRecord, type DraftStoreState, } from "./state"; -import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { + migrateDraftInput, + migratePersistedState, + type MigrateLegacyImages, + PersistedDraftStoreSchema, +} from "./migration"; import { createDraftPersistStorage } from "./persistence"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { DraftInput, DraftLifecycleState } from "./state"; @@ -58,7 +64,7 @@ type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions; let gcScheduled = false; const draftPersistStorage = createDraftPersistStorage( - createJSONStorage(() => AsyncStorage), + createValidatedPersistStorage(AsyncStorage, PersistedDraftStoreSchema), ); export function flushDraftPersistStorage(): Promise { @@ -414,12 +420,11 @@ export const useDraftStore = create()( version: DRAFT_STORE_VERSION, storage: draftPersistStorage, partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }), - migrate: (persistedState) => { - return migratePersistedState(persistedState, { + migrate: (state) => + migratePersistedState(state, { migrateLegacyImages, nowMs: Date.now(), - }); - }, + }), onRehydrateStorage: () => { return () => { void migrateAllLegacyDrafts(); diff --git a/packages/app/src/stores/draft-store/migration.test.ts b/packages/app/src/stores/draft-store/migration.test.ts index 6ef20097f..218e74c4e 100644 --- a/packages/app/src/stores/draft-store/migration.test.ts +++ b/packages/app/src/stores/draft-store/migration.test.ts @@ -1,11 +1,31 @@ import { describe, expect, it } from "vitest"; +import type { StateStorage } from "zustand/middleware"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; -import { migratePersistedState, type MigrateLegacyImages } from "./migration"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; +import { + migratePersistedState, + type MigrateLegacyImages, + PersistedDraftStoreSchema, +} from "./migration"; import { isAttachmentMetadata, type DraftRecord } from "./state"; const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => images.filter(isAttachmentMetadata); +function createMemoryStorage(): StateStorage & { values: Map } { + const values = new Map(); + return { + values, + getItem: async (key) => values.get(key) ?? null, + setItem: async (key, value) => { + values.set(key, value); + }, + removeItem: async (key) => { + values.delete(key); + }, + }; +} + function activeDraft( text: string, updatedAt: number, @@ -96,6 +116,30 @@ function workspaceReviewAttachment(): Extract { + it("keeps a supported legacy draft for migration", async () => { + const backing = createMemoryStorage(); + const legacyState = { + drafts: { + "draft:unsent": { + text: "do not lose this", + attachments: [], + }, + }, + createModalDraft: null, + }; + backing.values.set("paseo-drafts", JSON.stringify({ state: legacyState, version: 4 })); + const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema); + + const stored = await storage.getItem("paseo-drafts"); + const migrated = await migratePersistedState(stored?.state, { + migrateLegacyImages: passThroughMigrateLegacyImages, + nowMs: 1, + }); + + expect(migrated.drafts["draft:unsent"]?.input.text).toBe("do not lose this"); + expect(backing.values.has("paseo-drafts")).toBe(true); + }); + it("promotes the newest legacy New Workspace draft into the singleton surface", async () => { const forkDraft = activeDraft("fork context", 1700000000003); const agentDraft = activeDraft("agent prompt", 1700000000004); @@ -242,25 +286,35 @@ describe("draft-store migration", () => { }); }); - it("rejects workspace review attachments from migrated draft attachments", async () => { - const migrated = await migratePersistedState( - { - drafts: { - "agent:server:agent": { - input: { - text: "hello", - attachments: [workspaceReviewAttachment()], - }, - lifecycle: "active", - updatedAt: 1700000000001, - version: 2, + it("drops a legacy workspace review attachment without deleting its draft", async () => { + const backing = createMemoryStorage(); + const persistedState = { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + attachments: [workspaceReviewAttachment()], }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 2, }, - createModalDraft: null, }, - { migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, - ); + createModalDraft: null, + }; + backing.values.set("paseo-drafts", JSON.stringify({ state: persistedState, version: 4 })); + const storage = createValidatedPersistStorage(backing, PersistedDraftStoreSchema); - expect(migrated.drafts["agent:server:agent"]?.input.attachments).toEqual([]); + const stored = await storage.getItem("paseo-drafts"); + const migrated = await migratePersistedState(stored?.state, { + migrateLegacyImages: passThroughMigrateLegacyImages, + nowMs: 1700000000002, + }); + + expect(migrated.drafts["agent:server:agent"]?.input).toEqual({ + text: "hello", + attachments: [], + }); + expect(backing.values.has("paseo-drafts")).toBe(true); }); }); diff --git a/packages/app/src/stores/draft-store/migration.ts b/packages/app/src/stores/draft-store/migration.ts index fc21087ca..7d1756fc7 100644 --- a/packages/app/src/stores/draft-store/migration.ts +++ b/packages/app/src/stores/draft-store/migration.ts @@ -1,11 +1,12 @@ import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys"; +import { z } from "zod"; import { - isAttachmentMetadata, - isLegacyDraftImage, - isUserComposerAttachment, + AttachmentMetadataSchema, + LegacyDraftImageSchema, normalizeAttachmentMetadata, normalizeComposerAttachment, + UserComposerAttachmentSchema, type CanonicalDraftInput, type DraftLifecycleState, type DraftRecord, @@ -13,28 +14,108 @@ import { type PersistedDraftImage, } from "./state"; +const LegacyPullRequestItemSchema = z.strictObject({ + kind: z.literal("pr"), + forge: z.string().optional(), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}); +const LegacyGithubPrAttachmentSchema = z.strictObject({ + kind: z.literal("github_pr"), + item: LegacyPullRequestItemSchema, + owner: z.literal("new-workspace-picker").optional(), +}); +const LegacyReviewAttachmentContextLineSchema = z.strictObject({ + oldLineNumber: z.number().int().positive().nullable(), + newLineNumber: z.number().int().positive().nullable(), + type: z.enum(["add", "remove", "context"]), + content: z.string(), +}); +const LegacyReviewAttachmentSchema = z.strictObject({ + kind: z.literal("review"), + reviewDraftKey: z.string(), + commentCount: z.number().int().nonnegative(), + attachment: z.strictObject({ + type: z.literal("review"), + mimeType: z.literal("application/paseo-review"), + cwd: z.string(), + mode: z.enum(["uncommitted", "base"]), + baseRef: z.string().nullable().optional(), + comments: z.array( + z.strictObject({ + filePath: z.string(), + side: z.enum(["old", "new"]), + lineNumber: z.number().int().positive(), + body: z.string(), + context: z.strictObject({ + hunkHeader: z.string(), + targetLine: LegacyReviewAttachmentContextLineSchema, + lines: z.array(LegacyReviewAttachmentContextLineSchema), + }), + }), + ), + }), +}); +const PersistedComposerAttachmentSchema = z.union([ + UserComposerAttachmentSchema, + LegacyGithubPrAttachmentSchema, + LegacyReviewAttachmentSchema, +]); +type PersistedComposerAttachment = z.infer; +const LegacyAttachmentMetadataSchema = AttachmentMetadataSchema.extend({ + previewUri: z.string(), +}); +type PersistedImage = PersistedDraftImage | z.infer; + +const RawDraftInputSchema = z.strictObject({ + text: z.string().optional(), + attachments: z.array(PersistedComposerAttachmentSchema).optional(), + images: z + .array( + z.union([AttachmentMetadataSchema, LegacyAttachmentMetadataSchema, LegacyDraftImageSchema]), + ) + .optional(), + cwd: z.string().optional(), +}); +const DraftLifecycleSchema = z.enum(["active", "abandoned", "sent"]); +const NestedDraftRecordSchema = z.strictObject({ + input: RawDraftInputSchema, + lifecycle: DraftLifecycleSchema.optional(), + updatedAt: z.number().optional(), + version: z.number().int().positive().optional(), +}); +const FlatDraftRecordSchema = RawDraftInputSchema.extend({ + lifecycle: DraftLifecycleSchema.optional(), + updatedAt: z.number().optional(), + version: z.number().int().positive().optional(), +}); +const PersistedDraftRecordSchema = z.union([NestedDraftRecordSchema, FlatDraftRecordSchema]); +export const PersistedDraftStoreSchema = z.strictObject({ + drafts: z.record(z.string(), PersistedDraftRecordSchema).optional(), + createModalDraft: PersistedDraftRecordSchema.nullable().optional(), +}); +type PersistedDraftRecord = z.infer; + export type MigrateLegacyImages = ( images: readonly PersistedDraftImage[], ) => Promise; -function normalizePersistedImage(value: unknown): PersistedDraftImage | null { - if (isAttachmentMetadata(value)) { +function normalizePersistedImage(value: PersistedImage): PersistedDraftImage { + if ("id" in value) { return normalizeAttachmentMetadata(value); } - if (isLegacyDraftImage(value)) { - return { - uri: value.uri, - ...(value.mimeType ? { mimeType: value.mimeType } : {}), - }; - } - return null; -} - -function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null { - if (!isUserComposerAttachment(value)) { - return null; - } - return normalizeComposerAttachment(value); + return { + uri: value.uri, + ...(value.mimeType ? { mimeType: value.mimeType } : {}), + }; } function legacyImagesToAttachments( @@ -46,24 +127,36 @@ function legacyImagesToAttachments( })); } +function normalizePersistedComposerAttachment( + attachment: PersistedComposerAttachment, +): UserComposerAttachment | null { + if (attachment.kind === "review") { + return null; + } + if (attachment.kind === "github_pr" && attachment.item.kind === "pr") { + return { + kind: "github_pr", + item: { ...attachment.item, kind: "change_request" }, + ...(attachment.owner ? { owner: attachment.owner } : {}), + }; + } + const result = UserComposerAttachmentSchema.safeParse(attachment); + if (!result.success) { + throw new Error("Persisted composer attachment failed validation after migration"); + } + return normalizeComposerAttachment(result.data); +} + export async function migrateDraftInput( input: { rawInput: unknown }, ports: { migrateLegacyImages: MigrateLegacyImages }, ): Promise { - const rawInput = - input.rawInput && typeof input.rawInput === "object" - ? (input.rawInput as Record) - : {}; - const attachments = Array.isArray(rawInput.attachments) - ? rawInput.attachments - .map((entry) => normalizePersistedComposerAttachment(entry)) - .filter((entry): entry is UserComposerAttachment => entry !== null) - : []; - const legacyImages = Array.isArray(rawInput.images) - ? rawInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : []; + const result = RawDraftInputSchema.safeParse(input.rawInput); + const rawInput = result.success ? result.data : {}; + const attachments = (rawInput.attachments ?? []) + .map(normalizePersistedComposerAttachment) + .filter((attachment): attachment is UserComposerAttachment => attachment !== null); + const legacyImages = (rawInput.images ?? []).map(normalizePersistedImage); const migratedImages = await ports.migrateLegacyImages(legacyImages); return { @@ -72,30 +165,29 @@ export async function migrateDraftInput( }; } -function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { - if (lifecycle === "sent" || lifecycle === "abandoned") { - return lifecycle as DraftLifecycleState; - } - return "active"; +function resolvePersistedLifecycle( + lifecycle: DraftLifecycleState | undefined, +): DraftLifecycleState { + return lifecycle ?? "active"; } -function extractRawInput(record: Record): unknown { - if ("input" in record && record.input && typeof record.input === "object") { +function extractRawInput(record: PersistedDraftRecord): z.infer { + if ("input" in record) { return record.input; } return record; } async function buildMigratedDraftRecord( - parsed: Record, + parsed: PersistedDraftRecord, ports: { migrateLegacyImages: MigrateLegacyImages }, nowMs: number, ): Promise { return { input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports), lifecycle: resolvePersistedLifecycle(parsed.lifecycle), - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs, - version: typeof parsed.version === "number" ? parsed.version : 1, + updatedAt: parsed.updatedAt ?? nowMs, + version: parsed.version ?? 1, }; } @@ -143,30 +235,17 @@ export async function migratePersistedState( state: unknown, ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, ): Promise { - const input = (state ?? {}) as { - drafts?: Record; - createModalDraft?: unknown; - }; + const result = PersistedDraftStoreSchema.safeParse(state); + const input = result.success ? result.data : {}; const nextDrafts: Record = {}; for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { - if (!rawRecord || typeof rawRecord !== "object") { - continue; - } - nextDrafts[draftKey] = await buildMigratedDraftRecord( - rawRecord as Record, - ports, - ports.nowMs, - ); + nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord, ports, ports.nowMs); } let createModalDraft: DraftRecord | null = null; - if (input.createModalDraft && typeof input.createModalDraft === "object") { - createModalDraft = await buildMigratedDraftRecord( - input.createModalDraft as Record, - ports, - ports.nowMs, - ); + if (input.createModalDraft) { + createModalDraft = await buildMigratedDraftRecord(input.createModalDraft, ports, ports.nowMs); } return { diff --git a/packages/app/src/stores/draft-store/persistence.ts b/packages/app/src/stores/draft-store/persistence.ts index ab7c374c5..f56b67130 100644 --- a/packages/app/src/stores/draft-store/persistence.ts +++ b/packages/app/src/stores/draft-store/persistence.ts @@ -1,7 +1,6 @@ import type { PersistStorage } from "zustand/middleware"; export const DRAFT_PERSIST_INTERVAL_MS = 200; - export interface PersistenceScheduler { now: () => number; schedule: (callback: () => void, delayMs: number) => unknown; @@ -12,10 +11,28 @@ export interface DraftPersistStorage extends PersistStorage { flush: () => Promise; } +let nextSystemTimerId = 0; +const systemTimers = new Map>(); const systemScheduler: PersistenceScheduler = { now: Date.now, - schedule: (callback, delayMs) => setTimeout(callback, delayMs), - cancel: (handle) => clearTimeout(handle as ReturnType), + schedule: (callback, delayMs) => { + const id = nextSystemTimerId++; + systemTimers.set( + id, + setTimeout(() => { + systemTimers.delete(id); + callback(); + }, delayMs), + ); + return id; + }, + cancel: (handle) => { + if (typeof handle !== "number") return; + const timer = systemTimers.get(handle); + if (timer === undefined) return; + clearTimeout(timer); + systemTimers.delete(handle); + }, }; export function createDraftPersistStorage( diff --git a/packages/app/src/stores/draft-store/state.ts b/packages/app/src/stores/draft-store/state.ts index a2314ec3a..ee53825ed 100644 --- a/packages/app/src/stores/draft-store/state.ts +++ b/packages/app/src/stores/draft-store/state.ts @@ -3,12 +3,7 @@ import { type AttachmentMetadata, type UserComposerAttachment, } from "@/attachments/types"; -import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file"; -import { - ForgeSearchItemSchema, - GitHubSearchItemSchema, - UploadedFileAttachmentSchema, -} from "@getpaseo/protocol/messages"; +import { z } from "zod"; export const DRAFT_STORE_VERSION = 5; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; @@ -41,26 +36,95 @@ export interface DraftStoreState { createModalDraft: DraftRecord | null; } +export const AttachmentMetadataSchema = z.strictObject({ + id: z.string(), + mimeType: z.string(), + storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]), + storageKey: z.string(), + fileName: z.string().nullable().optional(), + byteSize: z.number().nullable().optional(), + createdAt: z.number(), +}) satisfies z.ZodType; +export const LegacyDraftImageSchema: z.ZodType = z.strictObject({ + uri: z.string(), + mimeType: z.string().optional(), +}); +const UploadedFileSchema = z.strictObject({ + type: z.literal("uploaded_file"), + id: z.string(), + fileName: z.string(), + mimeType: z.string(), + size: z.number().int().nonnegative(), + path: z.string(), +}); +const ForgeItemFields = { + forge: z.string().optional(), + number: z.number(), + title: z.string(), + url: z.string(), + state: z.string(), + body: z.string().nullable(), + labels: z.array(z.string()), + projectPath: z.string().optional(), + baseRefName: z.string().nullable().optional(), + headRefName: z.string().nullable().optional(), + updatedAt: z.string().optional(), +}; +const IssueItemSchema = z.strictObject({ kind: z.literal("issue"), ...ForgeItemFields }); +const ChangeRequestItemSchema = z.strictObject({ + kind: z.literal("change_request"), + ...ForgeItemFields, +}); +export const UserComposerAttachmentSchema: z.ZodType = z.discriminatedUnion( + "kind", + [ + z.strictObject({ kind: z.literal("image"), metadata: AttachmentMetadataSchema }), + z.strictObject({ kind: z.literal("file"), attachment: UploadedFileSchema }), + z.strictObject({ + kind: z.literal("workspace_file"), + path: z.string(), + selection: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("whole_file") }), + z.strictObject({ + kind: z.literal("line_range"), + startLine: z.number().int().positive(), + endLine: z.number().int().positive(), + }), + ]), + }), + z.strictObject({ kind: z.literal("forge_issue"), item: IssueItemSchema }), + z.strictObject({ kind: z.literal("forge_change_request"), item: ChangeRequestItemSchema }), + z.strictObject({ kind: z.literal("github_issue"), item: IssueItemSchema }), + z.strictObject({ + kind: z.literal("github_pr"), + item: ChangeRequestItemSchema, + owner: z.literal(NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER).optional(), + }), + ], +); +export const CanonicalDraftInputSchema = z.strictObject({ + text: z.string(), + attachments: z.array(UserComposerAttachmentSchema), + // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. + cwd: z.string().optional(), +}); +const DraftRecordSchema: z.ZodType = z.strictObject({ + input: CanonicalDraftInputSchema, + lifecycle: z.enum(["active", "abandoned", "sent"]), + updatedAt: z.number(), + version: z.number().int().positive(), +}); +export const DraftStoreStateSchema: z.ZodType = z.strictObject({ + drafts: z.record(z.string(), DraftRecordSchema), + createModalDraft: DraftRecordSchema.nullable(), +}); + export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return ( - typeof record.id === "string" && - typeof record.mimeType === "string" && - typeof record.storageType === "string" && - typeof record.storageKey === "string" && - typeof record.createdAt === "number" - ); + return AttachmentMetadataSchema.safeParse(value).success; } export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - return typeof record.uri === "string"; + return LegacyDraftImageSchema.safeParse(value).success; } export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { @@ -80,39 +144,7 @@ export function normalizeAttachmentMetadata(image: AttachmentMetadata): Attachme } export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { - if (!value || typeof value !== "object") { - return false; - } - const record = value as Record; - if (record.kind === "image") { - const metadata = record.metadata; - return isAttachmentMetadata(metadata); - } - if (record.kind === "workspace_file") { - return isWorkspaceFileComposerAttachment(value); - } - if (record.kind === "file") { - return UploadedFileAttachmentSchema.safeParse(record.attachment).success; - } - if ( - record.kind !== "forge_issue" && - record.kind !== "forge_change_request" && - record.kind !== "github_issue" && - record.kind !== "github_pr" - ) { - return false; - } - if ( - record.kind === "github_pr" && - record.owner !== undefined && - record.owner !== NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER - ) { - return false; - } - return ( - ForgeSearchItemSchema.safeParse(record.item).success || - GitHubSearchItemSchema.safeParse(record.item).success - ); + return UserComposerAttachmentSchema.safeParse(value).success; } export function normalizeComposerAttachment( @@ -131,33 +163,11 @@ export function normalizeComposerAttachment( selection: attachment.selection, }; } - if (attachment.kind === "github_pr") { - const item = - (attachment.item as { kind: string }).kind === "pr" - ? { ...attachment.item, kind: "change_request" as const } - : attachment.item; - return { - kind: "github_pr", - item, - ...(attachment.owner === NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER - ? { owner: attachment.owner } - : {}), - }; - } return attachment; } export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { - if (!value || typeof value !== "object") { - return false; - } - const input = value as Record; - // COMPAT(draft-cwd): accept legacy persisted drafts that include cwd. Stop accepting after 2026-11-09. - return ( - typeof input.text === "string" && - Array.isArray(input.attachments) && - input.attachments.every(isUserComposerAttachment) - ); + return CanonicalDraftInputSchema.safeParse(value).success; } export function toDraftInputIfReady( diff --git a/packages/app/src/stores/last-workspace-selection.test.ts b/packages/app/src/stores/last-workspace-selection.test.ts index 06f4dceec..e5fc979be 100644 --- a/packages/app/src/stores/last-workspace-selection.test.ts +++ b/packages/app/src/stores/last-workspace-selection.test.ts @@ -20,12 +20,19 @@ class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage this.saved = value; } + async clear(): Promise { + this.saved = null; + } + finishHydrationWith(selection: ActiveWorkspaceSelection | null) { this.finishRead(selection ? JSON.stringify(selection) : null); } getSavedSelection(): ActiveWorkspaceSelection | null { - return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null; + if (!this.saved) return null; + const parsed: unknown = JSON.parse(this.saved); + if (!parsed || typeof parsed !== "object") return null; + return this.saved ? JSON.parse(this.saved) : null; } } diff --git a/packages/app/src/stores/last-workspace-selection.ts b/packages/app/src/stores/last-workspace-selection.ts index f7bd3bc2e..6e689e0a4 100644 --- a/packages/app/src/stores/last-workspace-selection.ts +++ b/packages/app/src/stores/last-workspace-selection.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export interface ActiveWorkspaceSelection { serverId: string; workspaceId: string; @@ -8,19 +10,17 @@ export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route- export interface LastWorkspaceSelectionStorage { read(): Promise; write(value: string): Promise; + clear(): Promise; } +const ActiveWorkspaceSelectionSchema: z.ZodType = z.strictObject({ + serverId: z.string().trim().min(1), + workspaceId: z.string().trim().min(1), +}); + function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { - if (!input || typeof input !== "object" || Array.isArray(input)) { - return null; - } - const record = input as Record; - const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; - const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : ""; - if (!serverId || !workspaceId) { - return null; - } - return { serverId, workspaceId }; + const result = ActiveWorkspaceSelectionSchema.safeParse(input); + return result.success ? result.data : null; } function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null { @@ -75,6 +75,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio .then((stored) => { if (revision === hydrationRevision) { selection = parseStoredWorkspaceSelection(stored); + if (stored !== null && selection === null) { + void storage.clear().catch(() => {}); + } } return undefined; }) diff --git a/packages/app/src/stores/navigation-active-workspace-store/index.ts b/packages/app/src/stores/navigation-active-workspace-store/index.ts index 6cc819193..9adcd6c6b 100644 --- a/packages/app/src/stores/navigation-active-workspace-store/index.ts +++ b/packages/app/src/stores/navigation-active-workspace-store/index.ts @@ -25,6 +25,7 @@ export type { NavigateToWorkspaceInput } from "./navigation"; const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), + clear: () => AsyncStorage.removeItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), }; const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( diff --git a/packages/app/src/stores/panel-store/index.ts b/packages/app/src/stores/panel-store/index.ts index baac870f4..6695ee5f4 100644 --- a/packages/app/src/stores/panel-store/index.ts +++ b/packages/app/src/stores/panel-store/index.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; +import { persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildExplorerCheckoutKey, @@ -24,6 +24,7 @@ import { MIN_EXPLORER_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH, migratePanelState, + PanelPersistedStateSchema, selectIsAgentListOpen, selectIsFileExplorerOpen, setMobilePanelTarget, @@ -37,6 +38,7 @@ import { type SortOption, } from "./state"; import { isWeb } from "@/constants/platform"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type { ExplorerTab } from "../explorer-tab-memory"; export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; export type { @@ -311,9 +313,8 @@ export const usePanelStore = create()( { name: "panel-state", version: 12, - storage: createJSONStorage(() => AsyncStorage), - migrate: (persistedState, version) => - migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState, + storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema), + migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }), partialize: (state) => ({ desktop: state.desktop, explorerTab: state.explorerTab, diff --git a/packages/app/src/stores/panel-store/state.ts b/packages/app/src/stores/panel-store/state.ts index a7e13288b..611c133b5 100644 --- a/packages/app/src/stores/panel-store/state.ts +++ b/packages/app/src/stores/panel-store/state.ts @@ -5,6 +5,7 @@ import { type ExplorerTab, } from "../explorer-tab-memory"; import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; +import { z } from "zod"; export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; @@ -161,7 +162,37 @@ export function buildToggleFileExplorerPatch( return { desktop: { ...state.desktop, fileExplorerOpen: false } }; } -type MigratablePanelState = Record; +const ExplorerTabSchema = z.enum(["changes", "files", "pr"]); +const DesktopSidebarStorageSchema = z.strictObject({ + agentListOpen: z.boolean().optional(), + fileExplorerOpen: z.boolean().optional(), + focusModeEnabled: z.boolean().optional(), + zoomed: z.boolean().optional(), + focused: z.boolean().optional(), +}); + +export const PanelPersistedStateSchema = z.strictObject({ + mobileView: z.enum(["agent", "agent-list", "file-explorer"]).optional(), + mobilePanel: z + .strictObject({ + target: z.enum(["agent", "agent-list", "file-explorer"]), + revision: z.number().int().nonnegative(), + }) + .optional(), + desktop: DesktopSidebarStorageSchema.optional(), + explorerTab: ExplorerTabSchema.optional(), + explorerTabByCheckout: z.record(z.string(), ExplorerTabSchema).optional(), + expandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + diffExpandedPathsByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + diffCollapsedFoldersByWorkspace: z.record(z.string(), z.array(z.string())).optional(), + sidebarWidth: z.number().optional(), + explorerWidth: z.number().optional(), + explorerSortOption: z.enum(["name", "modified", "size"]).optional(), + explorerShowHiddenFiles: z.boolean().optional(), + explorerFilesSplitRatio: z.number().optional(), +}); + +type MigratablePanelState = z.infer; function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void { if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { @@ -193,7 +224,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: state.explorerTabByCheckout = {}; return; } - const entries = Object.entries(state.explorerTabByCheckout as Record); + const entries = Object.entries(state.explorerTabByCheckout); const next: Record = {}; for (const [key, value] of entries) { if (!isExplorerTab(value)) { @@ -205,7 +236,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version: } function migratePanelDesktopFocusMode(state: MigratablePanelState): void { - const desktop = state.desktop as Record | undefined; + const desktop = state.desktop; if (!desktop) { return; } @@ -227,7 +258,8 @@ export function migratePanelState( version: number, options: { isWeb: boolean }, ): MigratablePanelState { - const state = (persistedState ?? {}) as MigratablePanelState; + const result = PanelPersistedStateSchema.safeParse(persistedState); + const state: MigratablePanelState = result.success ? result.data : {}; const { isWeb } = options; if (version < 2) { diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts index abda2aac7..c4fb6239a 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/index.ts @@ -1,9 +1,12 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import { type CollapsedProjectsState, + type PersistedCollapsedProjects, mergePersistedCollapsedProjects, + PersistedCollapsedProjectsSchema, serializeCollapsedProjects, setProjectCollapsed, togglePinnedCollapsed, @@ -19,7 +22,7 @@ interface SidebarCollapsedSectionsState extends CollapsedProjectsState { } export const useSidebarCollapsedSectionsStore = create()( - persist( + persist( (set) => ({ collapsedProjectKeys: new Set(), collapsedStatusGroupKeys: new Set(), @@ -34,13 +37,10 @@ export const useSidebarCollapsedSectionsStore = create AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, PersistedCollapsedProjectsSchema), partialize: (state) => serializeCollapsedProjects(state), merge: (persistedState, currentState) => - mergePersistedCollapsedProjects( - persistedState as { collapsedProjectKeys?: unknown } | undefined, - currentState, - ), + mergePersistedCollapsedProjects(persistedState, currentState), }, ), ); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts index 051d7cc7c..f2d9258b6 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.test.ts @@ -52,13 +52,13 @@ describe("sidebar collapsed projects transitions", () => { expect(restored.collapsedPinned).toBe(true); }); - it("restores collapsed project keys from persisted preferences", () => { + it("rejects the complete value when a persisted project key is invalid", () => { const restored = mergePersistedCollapsedProjects( { collapsedProjectKeys: ["project-a", "project-b", 42] }, emptyState(), ); - expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); + expect(Array.from(restored.collapsedProjectKeys)).toEqual([]); expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]); }); diff --git a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts index 3a03ab0ac..4d84205e1 100644 --- a/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts +++ b/packages/app/src/stores/sidebar-collapsed-sections-store/state.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export interface CollapsedProjectsState { collapsedProjectKeys: Set; collapsedStatusGroupKeys: Set; @@ -5,11 +7,18 @@ export interface CollapsedProjectsState { } export interface PersistedCollapsedProjects { - collapsedProjectKeys?: unknown; - collapsedStatusGroupKeys?: unknown; - collapsedPinned?: unknown; + collapsedProjectKeys?: string[]; + collapsedStatusGroupKeys?: string[]; + collapsedPinned?: boolean; } +export const PersistedCollapsedProjectsSchema: z.ZodType = + z.strictObject({ + collapsedProjectKeys: z.array(z.string()).optional(), + collapsedStatusGroupKeys: z.array(z.string()).optional(), + collapsedPinned: z.boolean().optional(), + }); + export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState { return { ...state, collapsedPinned: !state.collapsedPinned }; } @@ -67,22 +76,21 @@ export function serializeCollapsedProjects(state: CollapsedProjectsState): { } export function mergePersistedCollapsedProjects( - persisted: PersistedCollapsedProjects | undefined, + persistedValue: unknown, current: S, ): S { - if ( - !persisted?.collapsedProjectKeys && - !persisted?.collapsedStatusGroupKeys && - persisted?.collapsedPinned === undefined - ) { + const result = PersistedCollapsedProjectsSchema.safeParse(persistedValue); + if (!result.success) { return current; } - const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys); - const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys); - const restoredPinned = - typeof persisted.collapsedPinned === "boolean" - ? persisted.collapsedPinned - : current.collapsedPinned; + const persisted = result.data; + const restoredProjects = deserializeCollapsedKeys( + persisted.collapsedProjectKeys ?? Array.from(current.collapsedProjectKeys), + ); + const restoredStatusGroups = deserializeCollapsedKeys( + persisted.collapsedStatusGroupKeys ?? Array.from(current.collapsedStatusGroupKeys), + ); + const restoredPinned = persisted.collapsedPinned ?? current.collapsedPinned; if ( areSetsEqual(current.collapsedProjectKeys, restoredProjects) && areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) && @@ -98,11 +106,8 @@ export function mergePersistedCollapsedProjects { - if (!Array.isArray(value)) { - return new Set(); - } - return new Set(value.filter((key): key is string => typeof key === "string")); +function deserializeCollapsedKeys(value: string[]): Set { + return new Set(value); } function areSetsEqual(left: Set, right: Set): boolean { diff --git a/packages/app/src/stores/sidebar-order-store.ts b/packages/app/src/stores/sidebar-order-store.ts index 307e432c0..bd6bdbbf3 100644 --- a/packages/app/src/stores/sidebar-order-store.ts +++ b/packages/app/src/stores/sidebar-order-store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; interface SidebarOrderStoreState { projectOrder: string[]; @@ -18,6 +20,14 @@ interface SidebarOrderPersistedState { workspaceOrderByServerAndProject?: Record; } +const StringArrayRecordSchema = z.record(z.string(), z.array(z.string())); +const SidebarOrderPersistedStateSchema = z.strictObject({ + projectOrder: z.array(z.string()).optional(), + workspaceOrderByProject: StringArrayRecordSchema.optional(), + projectOrderByServerId: StringArrayRecordSchema.optional(), + workspaceOrderByServerAndProject: StringArrayRecordSchema.optional(), +}); + interface SidebarWorkspaceOrderScope { serverId: string; projectViewKey: string; @@ -71,11 +81,11 @@ export function migrateSidebarOrderState(persistedState: unknown): { projectOrder: string[]; workspaceOrderByProject: Record; } { - const state = persistedState as SidebarOrderPersistedState | undefined; - - if (!state) { + const result = SidebarOrderPersistedStateSchema.safeParse(persistedState); + if (!result.success) { return { projectOrder: [], workspaceOrderByProject: {} }; } + const state: SidebarOrderPersistedState = result.data; const projectOrder = normalizeKeys(state.projectOrder ?? []); const seenProjects = new Set(projectOrder); @@ -135,7 +145,7 @@ export const useSidebarOrderStore = create()( }), { name: "sidebar-project-workspace-order", - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema), partialize: (state) => ({ projectOrder: state.projectOrder, workspaceOrderByProject: state.workspaceOrderByProject, diff --git a/packages/app/src/stores/sidebar-view-store.ts b/packages/app/src/stores/sidebar-view-store.ts index 52d2198d9..e8d77b0a0 100644 --- a/packages/app/src/stores/sidebar-view-store.ts +++ b/packages/app/src/stores/sidebar-view-store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export type SidebarGroupMode = "project" | "status"; @@ -23,51 +25,55 @@ interface SidebarViewPersistedState { hostFilters: string[]; } -function isSidebarGroupMode(value: unknown): value is SidebarGroupMode { - return value === "project" || value === "status"; -} +const SidebarGroupModeSchema = z.enum(["project", "status"]); +const SidebarViewPersistedStateSchema = z.strictObject({ + groupMode: SidebarGroupModeSchema.optional(), + hostFilters: z.array(z.string()).optional(), + hostFilter: z.string().nullable().optional(), + groupModeByServerId: z.record(z.string(), SidebarGroupModeSchema).optional(), +}); -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +type SidebarViewStorageState = z.infer; -function readLegacyGroupMode(persistedState: Record): SidebarGroupMode | null { +function readLegacyGroupMode(persistedState: SidebarViewStorageState): SidebarGroupMode | null { const groupModeByServerId = persistedState.groupModeByServerId; - if (!isRecord(groupModeByServerId)) { + if (!groupModeByServerId) { return null; } - const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode); + const modes = Object.values(groupModeByServerId); if (modes.length === 0) return null; return modes.includes("status") ? "status" : "project"; } // Reads the host filter from any persisted shape: the current `hostFilters` array, or the // pre-v2 single `hostFilter` string (null/absent meant "all hosts"). -function readHostFilters(persistedState: Record): string[] { +function readHostFilters(persistedState: SidebarViewStorageState): string[] { const hostFilters = persistedState.hostFilters; - if (Array.isArray(hostFilters)) { - return hostFilters.filter((value): value is string => typeof value === "string"); + if (hostFilters) { + return hostFilters; } // COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted // sidebar state (a single `hostFilter` string) has aged out. const legacyHostFilter = persistedState.hostFilter; - return typeof legacyHostFilter === "string" ? [legacyHostFilter] : []; + return legacyHostFilter ? [legacyHostFilter] : []; } export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { - if (!isRecord(persistedState)) { + const result = SidebarViewPersistedStateSchema.safeParse(persistedState); + if (!result.success) { return { groupMode: "project", hostFilters: [] }; } + const state = result.data; - const legacyGroupMode = readLegacyGroupMode(persistedState); + const legacyGroupMode = readLegacyGroupMode(state); if (legacyGroupMode) { return { groupMode: legacyGroupMode, hostFilters: [] }; } return { - groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project", - hostFilters: readHostFilters(persistedState), + groupMode: state.groupMode ?? "project", + hostFilters: readHostFilters(state), }; } @@ -116,7 +122,10 @@ export const useSidebarViewStore = create()( { name: SIDEBAR_VIEW_STORAGE_KEY, version: SIDEBAR_VIEW_STORE_VERSION, - storage: createJSONStorage(createSidebarViewStorage), + storage: createValidatedPersistStorage( + createSidebarViewStorage(), + SidebarViewPersistedStateSchema, + ), partialize: (state) => ({ groupMode: state.groupMode, hostFilters: state.hostFilters, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 24d162eae..4951d2868 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -15,6 +15,7 @@ vi.mock("@react-native-async-storage/async-storage", () => { }; }); +import AsyncStorage from "@react-native-async-storage/async-storage"; import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model"; import { collectAllPanes, @@ -316,6 +317,24 @@ describe("workspace-layout-store actions", () => { }); }); + it("keeps layouts written before split sizes were persisted", async () => { + const legacyLayout = createDefaultLayout(); + await AsyncStorage.setItem( + "workspace-layout-state", + JSON.stringify({ + state: { layoutByWorkspace: { legacy: legacyLayout } }, + version: 1, + }), + ); + const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds()); + + await restored.persist.rehydrate(); + + expect(restored.getState().layoutByWorkspace.legacy).toEqual(legacyLayout); + expect(restored.getState().splitSizesByWorkspace).toEqual({}); + await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull(); + }); + it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => { useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); const workspaceKey = createWorkspaceKey(); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index dee6c5869..8631756a6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -1,7 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { useEffect, useState } from "react"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { defaultWorkspaceLayoutIds, @@ -42,6 +43,7 @@ import { type WorkspaceLayout, } from "@/stores/workspace-layout-actions"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; export { collectAllPanes, @@ -123,6 +125,83 @@ interface WorkspaceFocusRestorationState { const MAX_TREE_DEPTH = 4; +const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ + provider: z.string(), + cwd: z.string(), + modeId: z.string().nullable(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), +}); +const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("draft"), + draftId: z.string(), + setup: WorkspaceDraftTabSetupStorageSchema.optional(), + }), + z.strictObject({ kind: z.literal("agent"), agentId: z.string() }), + z.strictObject({ + kind: z.literal("provider_subagent"), + parentAgentId: z.string(), + subagentId: z.string(), + }), + z.strictObject({ kind: z.literal("terminal"), terminalId: z.string() }), + z.strictObject({ kind: z.literal("browser"), browserId: z.string() }), + z.strictObject({ + kind: z.literal("file"), + path: z.string(), + lineStart: z.number().int().positive().optional(), + lineEnd: z.number().int().positive().optional(), + }), + z.strictObject({ + kind: z.literal("working_diff"), + focusPath: z.string().optional(), + focusRequestId: z.number().optional(), + // COMPAT(workingDiffTarget): accepted from pre-canonical tab ids; normalization removes them. + mode: z.enum(["uncommitted", "base"]).optional(), + baseRef: z.string().nullable().optional(), + ignoreWhitespace: z.boolean().optional(), + }), + z.strictObject({ kind: z.literal("setup"), workspaceId: z.string() }), + z.strictObject({ kind: z.literal("commit_diff"), sha: z.string() }), +]); +const WorkspaceTabStorageSchema = z.strictObject({ + tabId: z.string(), + target: WorkspaceTabTargetStorageSchema, + createdAt: z.number(), +}); +const SplitNodeStorageSchema: z.ZodType = z.lazy(() => + z.discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("pane"), + pane: z.strictObject({ + id: z.string(), + tabIds: z.array(z.string()), + focusedTabId: z.string().nullable(), + tabs: z.array(WorkspaceTabStorageSchema).optional(), + }), + }), + z.strictObject({ + kind: z.literal("group"), + group: z.strictObject({ + id: z.string(), + direction: z.enum(["horizontal", "vertical"]), + children: z.array(SplitNodeStorageSchema), + sizes: z.array(z.number()), + }), + }), + ]), +); +const WorkspaceLayoutStorageSchema: z.ZodType = z.strictObject({ + root: SplitNodeStorageSchema, + focusedPaneId: z.string().nullable(), + parentTabIdByTabId: z.record(z.string(), z.string()).optional(), +}); +const WorkspaceLayoutPersistedStateSchema = z.strictObject({ + layoutByWorkspace: z.record(z.string(), WorkspaceLayoutStorageSchema), + splitSizesByWorkspace: z.record(z.string(), z.record(z.string(), z.array(z.number()))).optional(), +}); + function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -942,7 +1021,7 @@ export function createWorkspaceLayoutStore( { name: "workspace-layout-state", version: 1, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema), partialize: (state) => { const layoutByWorkspace: Record = {}; for (const key in state.layoutByWorkspace) { @@ -957,6 +1036,17 @@ export function createWorkspaceLayoutStore( splitSizesByWorkspace: state.splitSizesByWorkspace, }; }, + merge: (persistedState, currentState) => { + const result = WorkspaceLayoutPersistedStateSchema.safeParse(persistedState); + if (!result.success) { + return currentState; + } + return { + ...currentState, + layoutByWorkspace: result.data.layoutByWorkspace, + splitSizesByWorkspace: result.data.splitSizesByWorkspace ?? {}, + }; + }, }, ), ); diff --git a/packages/app/src/types/host-connection.ts b/packages/app/src/types/host-connection.ts index 421f5868c..2105312e8 100644 --- a/packages/app/src/types/host-connection.ts +++ b/packages/app/src/types/host-connection.ts @@ -9,8 +9,9 @@ import { import { type HostAppearance, defaultHostAppearance, - normalizeStoredHostAppearance, + HostAppearanceSchema, } from "@/hosts/appearance"; +import { z } from "zod"; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; @@ -293,54 +294,74 @@ export function connectionFromListen(listen: string): HostConnection | null { } } -function isPlainRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +const StoredHostConnectionSchema = z.discriminatedUnion("type", [ + z.strictObject({ + id: z.string().optional(), + type: z.literal("directTcp"), + endpoint: z.string(), + useTls: z.boolean().optional(), + password: z.string().optional(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("directSocket"), + path: z.string(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("directPipe"), + path: z.string(), + }), + z.strictObject({ + id: z.string().optional(), + type: z.literal("relay"), + relayEndpoint: z.string(), + useTls: z.boolean().optional(), + daemonPublicKeyB64: z.string(), + }), +]); +const StoredHostProfileSchema = z.strictObject({ + serverId: z.string().trim().min(1), + label: z.string().optional(), + appearance: HostAppearanceSchema.optional(), + lifecycle: z.strictObject({}).optional(), + connections: z.array(StoredHostConnectionSchema).min(1), + preferredConnectionId: z.string().nullable().optional(), + createdAt: z.string().datetime({ offset: true }).optional(), + updatedAt: z.string().datetime({ offset: true }).optional(), +}); +export const StoredHostRegistrySchema = z.array(StoredHostProfileSchema); +type StoredHostConnection = z.infer; -function toObjectRecord(value: unknown): Record | undefined { - return isPlainRecord(value) ? value : undefined; -} - -function normalizeStoredConnection(connection: unknown): HostConnection | null { - const record = toObjectRecord(connection); - if (!record) { - return null; - } - const type = record.type; - if (type === "directTcp") { +function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null { + if (connection.type === "directTcp") { try { - const endpoint = normalizeLoopbackToLocalhost( - normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""), - ); + const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint)); return DirectTcpHostConnectionSchema.parse({ id: `direct:${endpoint}`, type: "directTcp", endpoint, - useTls: record.useTls, - ...(typeof record.password === "string" ? { password: record.password } : {}), + useTls: connection.useTls, + ...(connection.password !== undefined ? { password: connection.password } : {}), }); } catch { return null; } } - if (type === "directSocket") { - const path = (typeof record.path === "string" ? record.path : "").trim(); + if (connection.type === "directSocket") { + const path = connection.path.trim(); return path ? { id: `socket:${path}`, type: "directSocket", path } : null; } - if (type === "directPipe") { - const path = (typeof record.path === "string" ? record.path : "").trim(); + if (connection.type === "directPipe") { + const path = connection.path.trim(); return path ? { id: `pipe:${path}`, type: "directPipe", path } : null; } - if (type === "relay") { + if (connection.type === "relay") { try { - const relayEndpoint = normalizeHostPort( - typeof record.relayEndpoint === "string" ? record.relayEndpoint : "", - ); - const daemonPublicKeyB64 = ( - typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : "" - ).trim(); + const relayEndpoint = normalizeHostPort(connection.relayEndpoint); + const daemonPublicKeyB64 = connection.daemonPublicKeyB64.trim(); if (!daemonPublicKeyB64) return null; - const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined; + const useTls = connection.useTls; return { id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, type: "relay", @@ -357,17 +378,14 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null { } export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { - const record = toObjectRecord(entry); - if (!record) { - return null; - } - const serverId = typeof record.serverId === "string" ? record.serverId.trim() : ""; - if (!serverId) { + const result = StoredHostProfileSchema.safeParse(entry); + if (!result.success) { return null; } + const record = result.data; + const serverId = record.serverId; - const rawConnections = Array.isArray(record.connections) ? record.connections : []; - const connections = rawConnections + const connections = record.connections .map((connection) => normalizeStoredConnection(connection)) .filter((connection): connection is HostConnection => connection !== null); if (connections.length === 0) { @@ -375,12 +393,10 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { } const now = new Date().toISOString(); - const label = normalizeHostLabel( - typeof record.label === "string" ? record.label : null, - serverId, - ); + const label = normalizeHostLabel(record.label, serverId); const preferredConnectionId = - typeof record.preferredConnectionId === "string" && + record.preferredConnectionId !== null && + record.preferredConnectionId !== undefined && connections.some((connection) => connection.id === record.preferredConnectionId) ? record.preferredConnectionId : (connections[0]?.id ?? null); @@ -388,12 +404,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { return { serverId, label, - appearance: normalizeStoredHostAppearance(record.appearance), + appearance: record.appearance ?? defaultHostAppearance(), lifecycle: defaultLifecycle(), connections, preferredConnectionId, - createdAt: typeof record.createdAt === "string" ? record.createdAt : now, - updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : now, + createdAt: record.createdAt ?? now, + updatedAt: record.updatedAt ?? now, }; } diff --git a/packages/app/src/utils/client-id.test.ts b/packages/app/src/utils/client-id.test.ts index 647ea48b0..7184ab554 100644 --- a/packages/app/src/utils/client-id.test.ts +++ b/packages/app/src/utils/client-id.test.ts @@ -18,6 +18,9 @@ function inMemoryStorage(initial: Record = {}): InMemoryStorage storage.setCallCount += 1; items.set(key, value); }, + async removeItem(key) { + items.delete(key); + }, }; return storage; } diff --git a/packages/app/src/utils/client-id.ts b/packages/app/src/utils/client-id.ts index 52397e91b..d72111098 100644 --- a/packages/app/src/utils/client-id.ts +++ b/packages/app/src/utils/client-id.ts @@ -1,23 +1,20 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import { z } from "zod"; +import { readValidatedString } from "@/storage/validated-storage"; const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1"; export interface ClientIdStorage { getItem(key: string): Promise; setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; } export interface ClientIdResolver { getOrCreate(): Promise; } -function normalizeStoredClientId(value: unknown): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} +const ClientIdSchema = z.string().trim().min(1); export function createClientIdResolver(deps: { storage: ClientIdStorage; @@ -38,8 +35,7 @@ export function createClientIdResolver(deps: { } inFlight = (async () => { - const stored = await deps.storage.getItem(storageKey); - const existing = normalizeStoredClientId(stored); + const existing = await readValidatedString(deps.storage, storageKey, ClientIdSchema); if (existing) { cached = existing; return existing; @@ -61,7 +57,7 @@ export function createClientIdResolver(deps: { } function generateUuidFromGlobalCrypto(): string { - const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; + const cryptoObj = globalThis.crypto; if (cryptoObj && typeof cryptoObj.randomUUID === "function") { return cryptoObj.randomUUID().replace(/-/g, ""); } diff --git a/packages/app/src/workspace-pins/store.ts b/packages/app/src/workspace-pins/store.ts index 8fd3178a9..45a49c471 100644 --- a/packages/app/src/workspace-pins/store.ts +++ b/packages/app/src/workspace-pins/store.ts @@ -1,7 +1,9 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { persist } from "zustand/middleware"; +import { z } from "zod"; import { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; interface PinnedTargetsState { pinned: PinnedTabTarget[]; @@ -10,6 +12,15 @@ interface PinnedTargetsState { } const DEFAULT_PINNED_TARGETS: PinnedTabTarget[] = [{ kind: "terminal" }, { kind: "browser" }]; +const PinnedTabTargetSchema = z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("draft") }), + z.strictObject({ kind: z.literal("terminal") }), + z.strictObject({ kind: z.literal("browser") }), + z.strictObject({ kind: z.literal("profile"), profileId: z.string() }), +]); +const PinnedTargetsPersistedStateSchema = z.strictObject({ + pinned: z.array(PinnedTabTargetSchema), +}); function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] { const next = [...DEFAULT_PINNED_TARGETS]; @@ -22,7 +33,7 @@ function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] } export const usePinnedTargetsStore = create()( - persist( + persist>( (set, get) => ({ pinned: [], toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })), @@ -32,21 +43,21 @@ export const usePinnedTargetsStore = create()( name: "pinned-tab-targets", version: 1, merge: (persistedState, currentState) => { - const persisted = persistedState as Partial | null; + const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState); return { ...currentState, - ...persisted, - pinned: persisted?.pinned ?? applyDefaultPinnedTargets([]), + pinned: result.success ? result.data.pinned : applyDefaultPinnedTargets([]), }; }, - storage: createJSONStorage(() => AsyncStorage), + storage: createValidatedPersistStorage(AsyncStorage, PinnedTargetsPersistedStateSchema), partialize: (state) => ({ pinned: state.pinned }), migrate: (persistedState, version) => { + const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState); + const pinned = result.success ? result.data.pinned : []; if (version === 0) { - const pinned = (persistedState as { pinned?: PinnedTabTarget[] } | null)?.pinned ?? []; return { pinned: applyDefaultPinnedTargets(pinned) }; } - return persistedState as PinnedTargetsState; + return { pinned }; }, }, ), diff --git a/packages/app/src/workspace-service-routes/store.test.ts b/packages/app/src/workspace-service-routes/store.test.ts index ba2bab1f2..719c4f9ac 100644 --- a/packages/app/src/workspace-service-routes/store.test.ts +++ b/packages/app/src/workspace-service-routes/store.test.ts @@ -32,7 +32,7 @@ describe("workspace service route preferences", () => { expect(restored.getState().byServerId).toEqual({ desktop: "direct", devbox: "public" }); }); - it("drops invalid route kinds from persisted storage", async () => { + it("clears the complete persisted value when one route kind is invalid", async () => { const storage = createMemoryStorage({ "workspace-service-route-preferences": JSON.stringify({ state: { byServerId: { desktop: "direct", broken: "unknown" } }, @@ -42,6 +42,7 @@ describe("workspace service route preferences", () => { const store = createWorkspaceServiceRoutePreferencesStore(storage); await store.persist.rehydrate(); - expect(store.getState().byServerId).toEqual({ desktop: "direct" }); + expect(store.getState().byServerId).toEqual({}); + expect(storage.values.has("workspace-service-route-preferences")).toBe(false); }); }); diff --git a/packages/app/src/workspace-service-routes/store.ts b/packages/app/src/workspace-service-routes/store.ts index 15479f08e..bf061effc 100644 --- a/packages/app/src/workspace-service-routes/store.ts +++ b/packages/app/src/workspace-service-routes/store.ts @@ -1,6 +1,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; import { create } from "zustand"; -import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; +import { persist, type StateStorage } from "zustand/middleware"; +import { z } from "zod"; +import { createValidatedPersistStorage } from "@/storage/validated-persist-storage"; import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links"; interface WorkspaceServiceRoutePreferencesState { @@ -8,25 +10,19 @@ interface WorkspaceServiceRoutePreferencesState { setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void; } -function isWorkspaceScriptLinkKind(value: unknown): value is WorkspaceScriptLinkKind { - return value === "public" || value === "paseo" || value === "direct"; -} - -function sanitizePreferences(value: unknown): Record { - if (!value || typeof value !== "object") return {}; - const byServerId = (value as { byServerId?: unknown }).byServerId; - if (!byServerId || typeof byServerId !== "object") return {}; - - const result: Record = {}; - for (const [serverId, kind] of Object.entries(byServerId)) { - if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind; - } - return result; -} +const WorkspaceScriptLinkKindSchema = z.enum(["public", "paseo", "direct"]); +const WorkspaceServiceRoutePreferencesSchema = z.strictObject({ + byServerId: z.record(z.string(), WorkspaceScriptLinkKindSchema), +}); export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) { return create()( - persist( + persist< + WorkspaceServiceRoutePreferencesState, + [], + [], + z.infer + >( (set) => ({ byServerId: {}, setPreferredRoute: (serverId, kind) => @@ -35,12 +31,15 @@ export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorag { name: "workspace-service-route-preferences", version: 1, - storage: createJSONStorage(() => storage), + storage: createValidatedPersistStorage(storage, WorkspaceServiceRoutePreferencesSchema), partialize: (state) => ({ byServerId: state.byServerId }), - merge: (persistedState, currentState) => ({ - ...currentState, - byServerId: sanitizePreferences(persistedState), - }), + merge: (persistedState, currentState) => { + const result = WorkspaceServiceRoutePreferencesSchema.safeParse(persistedState); + return { + ...currentState, + byServerId: result.success ? result.data.byServerId : {}, + }; + }, }, ), );