fix(app): validate persisted client state (#3289)

* fix(app): validate persisted client state

Persisted replica data could satisfy TypeScript through an assertion while missing fields required by renderers. Treat every AsyncStorage value as untrusted input, clear values that fail strict schemas, and version the replica DTO so incompatible caches cannot hydrate.

* fix(app): preserve validated legacy state

* fix(app): preserve validated preferences

Preserve supported legacy location fields and every shipped theme while keeping unknown persisted fields rejected. Align browser test seeds with the current preference shape so strict validation exercises the intended provider.

* fix(app): migrate legacy agent preferences

Released clients stored host/location fields and per-provider thinkingOptionId together. Migrate those exact strict bytes into current preferences without admitting unknown fields.

* fix(app): preserve drafts with legacy reviews

Accept the exact strict shape released for workspace review attachments so migration can discard that obsolete attachment without clearing every persisted draft.
This commit is contained in:
Mohamed Boudra
2026-08-13 12:02:39 +02:00
committed by GitHub
parent c190fed27c
commit 36312352db
69 changed files with 1801 additions and 800 deletions
@@ -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:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); 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); const history = await holdOlderHistoryPages(page, agent, daemon.port);
@@ -1,4 +1,5 @@
import { test } from "../support/fixtures"; import { test } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center"; import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
import { import {
applyCommandCenterAgentControls, applyCommandCenterAgentControls,
@@ -12,17 +13,15 @@ import {
import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> { async function seedMockDraftPreferences(page: import("@playwright/test").Page): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ preferencesKey, serverId }) => { ({ preferencesKey }) => {
localStorage.setItem( localStorage.setItem(
preferencesKey, preferencesKey,
JSON.stringify({ JSON.stringify({
serverId,
provider: "mock", provider: "mock",
providerPreferences: { providerPreferences: {
mock: { mock: {
@@ -30,10 +29,10 @@ async function seedMockDraftPreferences(page: import("@playwright/test").Page):
mode: "load-test", mode: "load-test",
}, },
}, },
}), } satisfies FormPreferences),
); );
}, },
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, serverId: getServerId() }, { preferencesKey: CREATE_AGENT_PREFERENCES_KEY },
); );
} }
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute } from "../support/helpers/mock-agent"; import { openAgentRoute } from "../support/helpers/mock-agent";
@@ -10,7 +11,6 @@ import {
import { expectNoTruncation } from "../support/helpers/no-truncation"; import { expectNoTruncation } from "../support/helpers/no-truncation";
import { escapeRegex } from "../support/helpers/regex"; import { escapeRegex } from "../support/helpers/regex";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences";
@@ -49,13 +49,12 @@ function getSessionMessage(message: WebSocketMessage): Record<string, unknown> |
return maybeEnvelope.message as Record<string, unknown>; return maybeEnvelope.message as Record<string, unknown>;
} }
async function seedCodexDefaultPermissionPreferences(page: Page, serverId: string): Promise<void> { async function seedCodexDefaultPermissionPreferences(page: Page): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => { ({ preferencesKey }) => {
localStorage.setItem( localStorage.setItem(
preferencesKey, preferencesKey,
JSON.stringify({ JSON.stringify({
serverId: seededServerId,
provider: "codex", provider: "codex",
providerPreferences: { providerPreferences: {
codex: { codex: {
@@ -69,10 +68,10 @@ async function seedCodexDefaultPermissionPreferences(page: Page, serverId: strin
model: "ten-second-stream", 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 ({ test("keeps Full Access as the global Codex mode after the workspace draft auto-submit handoff", async ({
page, page,
}) => { }) => {
const serverId = getServerId();
const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" }); const seeded = await seedWorkspace({ repoPrefix: "codex-mode-preferences-" });
const createAgentRecorder = await recordAndBlockCreateAgentRequests(page); const createAgentRecorder = await recordAndBlockCreateAgentRequests(page);
await seedCodexDefaultPermissionPreferences(page, serverId); await seedCodexDefaultPermissionPreferences(page);
try { try {
await gotoAppShell(page); 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 }) => { 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-" }); const seeded = await seedWorkspace({ repoPrefix: "codex-live-mode-preferences-" });
await seedCodexDefaultPermissionPreferences(page, serverId); await seedCodexDefaultPermissionPreferences(page);
try { try {
const agent = await seeded.client.createAgent({ const agent = await seeded.client.createAgent({
@@ -1,4 +1,5 @@
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { openAgentRoute } from "../support/helpers/mock-agent"; import { openAgentRoute } from "../support/helpers/mock-agent";
import { import {
@@ -6,7 +7,6 @@ import {
selectNewWorkspaceProject, selectNewWorkspaceProject,
} from "../support/helpers/new-workspace"; } from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client"; import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
const CREATE_AGENT_PREFERENCES_KEY = "@paseo:create-agent-preferences"; 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<void> { async function seedCodexDefaultPreferences(page: Page): Promise<void> {
await page.addInitScript( await page.addInitScript(
({ preferencesKey, serverId: seededServerId }) => { ({ preferencesKey }) => {
localStorage.setItem( localStorage.setItem(
preferencesKey, preferencesKey,
JSON.stringify({ JSON.stringify({
serverId: seededServerId,
provider: "codex", provider: "codex",
providerPreferences: { providerPreferences: {
codex: { codex: {
@@ -78,10 +77,10 @@ async function seedCodexDefaultPreferences(page: Page, serverId: string): Promis
}, },
mock: { model: "ten-second-stream" }, 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 // control and silently change that (possibly running) agent's mode — e.g. into a
// permissive/bypass mode. See use-keyboard-action-handler.ts. // permissive/bypass mode. See use-keyboard-action-handler.ts.
test("Shift+Tab in New Workspace never changes a backgrounded agent's mode", async ({ page }) => { 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-" }); const seeded = await seedWorkspace({ repoPrefix: "mode-cycle-safety-" });
await seedCodexDefaultPreferences(page, serverId); await seedCodexDefaultPreferences(page);
const modeRequests = await recordSetAgentModeRequests(page); const modeRequests = await recordSetAgentModeRequests(page);
try { try {
@@ -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:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
}, },
{ seededHost: host, preferences: buildCreateAgentPreferences(serverId) }, { seededHost: host, preferences: buildCreateAgentPreferences() },
); );
await gotoAppShell(page); await gotoAppShell(page);
@@ -7,6 +7,7 @@ import path from "node:path";
import net from "node:net"; import net from "node:net";
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes"; import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import type { FormPreferences } from "@/create-agent-preferences/preferences";
import { metroTest as test } from "../support/fixtures"; import { metroTest as test } from "../support/fixtures";
import { buildSeededHost } from "../support/helpers/daemon-registry"; import { buildSeededHost } from "../support/helpers/daemon-registry";
import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader"; import { loadDaemonClientConstructor } from "../support/helpers/daemon-client-loader";
@@ -348,12 +349,14 @@ async function seedBrowserForDaemon(page: Page, input: { serverId: string; port:
{ {
daemon: host, daemon: host,
preferences: { preferences: {
serverId: input.serverId,
provider: "codex", provider: "codex",
providerPreferences: { providerPreferences: {
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, codex: {
model: "gpt-5.4-mini",
thinkingByModel: { "gpt-5.4-mini": "low" },
}, },
}, },
} satisfies FormPreferences,
}, },
); );
} }
@@ -75,7 +75,7 @@ test.describe("Worktree restore after daemon restart", () => {
label: "restart daemon", label: "restart daemon",
nowIso, nowIso,
}), }),
preferences: buildCreateAgentPreferences(serverId), preferences: buildCreateAgentPreferences(),
}, },
); );
} }
+1 -1
View File
@@ -132,7 +132,7 @@ const test = metroTest.extend<
endpoint: `127.0.0.1:${daemonPort}`, endpoint: `127.0.0.1:${daemonPort}`,
nowIso, nowIso,
}); });
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId); const createAgentPreferences = buildCreateAgentPreferences();
await page.addInitScript( await page.addInitScript(
({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => { ({ daemon, preferences, seedNonce: nonce, extraHostsKey }) => {
@@ -26,7 +26,7 @@ function buildSeededStoragePayload() {
endpoint: `127.0.0.1:${getE2EDaemonPort()}`, endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
nowIso, nowIso,
}), }),
preferences: buildCreateAgentPreferences(getServerId()), preferences: buildCreateAgentPreferences(),
}; };
} }
@@ -1,14 +1,23 @@
import type {
FormPreferences,
ProviderPreferences,
} from "../../../src/create-agent-preferences/preferences";
export const TEST_HOST_LABEL = "localhost"; export const TEST_HOST_LABEL = "localhost";
export const TEST_PROVIDER_PREFERENCES = { export const TEST_PROVIDER_PREFERENCES = {
claude: { model: "haiku" }, claude: { model: "haiku" },
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" }, codex: { model: "gpt-5.4-mini", thinkingByModel: { "gpt-5.4-mini": "low" } },
} as const; } satisfies Record<string, ProviderPreferences>;
export function buildDirectTcpConnection(endpoint: string) { export function buildDirectTcpConnection(endpoint: string): {
id: string;
type: "directTcp";
endpoint: string;
} {
return { return {
id: `direct:${endpoint}`, id: `direct:${endpoint}`,
type: "directTcp" as const, type: "directTcp",
endpoint, endpoint,
}; };
} }
@@ -33,12 +42,11 @@ export function buildSeededHost(input: {
export const TEST_MOCK_PROVIDER_PREFERENCES = { export const TEST_MOCK_PROVIDER_PREFERENCES = {
...TEST_PROVIDER_PREFERENCES, ...TEST_PROVIDER_PREFERENCES,
mock: { model: "ten-second-stream" }, mock: { model: "ten-second-stream" },
} as const; } satisfies Record<string, ProviderPreferences>;
export function buildCreateAgentPreferences(serverId: string) { export function buildCreateAgentPreferences() {
return { return {
serverId, provider: "mock",
provider: "mock" as const,
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES, providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
}; } satisfies FormPreferences;
} }
@@ -60,7 +60,7 @@ export async function connectDaemonWebAppOnlyThroughRelay(
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost])); localStorage.setItem("@paseo:daemon-registry", JSON.stringify([storedHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences)); localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
}, },
{ storedHost: host, preferences: buildCreateAgentPreferences(offer.serverId) }, { storedHost: host, preferences: buildCreateAgentPreferences() },
); );
const relaySocketOpened = new Promise<void>((resolve) => { const relaySocketOpened = new Promise<void>((resolve) => {
+1 -1
View File
@@ -132,7 +132,7 @@ export async function seedSavedSettingsHosts(
if (!firstHost) { if (!firstHost) {
throw new Error("Expected at least one settings host fixture."); throw new Error("Expected at least one settings host fixture.");
} }
const preferences = buildCreateAgentPreferences(firstHost.serverId); const preferences = buildCreateAgentPreferences();
await page.evaluate( await page.evaluate(
({ keys, storedRegistry, storedPreferences }) => { ({ keys, storedRegistry, storedPreferences }) => {
@@ -106,7 +106,7 @@ class StartupScenario {
if (!firstHost) { if (!firstHost) {
throw new Error("Expected at least one startup test host."); throw new Error("Expected at least one startup test host.");
} }
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId); const createAgentPreferences = buildStoredCreateAgentPreferences();
await this.page.evaluate( await this.page.evaluate(
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => { ({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
@@ -245,6 +245,6 @@ function buildStoredHost(input: {
return buildSeededHost(input); return buildSeededHost(input);
} }
function buildStoredCreateAgentPreferences(serverId: string) { function buildStoredCreateAgentPreferences() {
return buildCreateAgentPreferences(serverId); return buildCreateAgentPreferences();
} }
@@ -18,6 +18,10 @@ class MemoryStorage {
async setItem(key: string, value: string): Promise<void> { async setItem(key: string, value: string): Promise<void> {
this.values.set(key, value); this.values.set(key, value);
} }
async removeItem(key: string): Promise<void> {
this.values.delete(key);
}
} }
class FakeProfileHost { class FakeProfileHost {
@@ -2,29 +2,19 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod"; import { z } from "zod";
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages"; 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 PREFERENCES_KEY = "@paseo:create-agent-preferences";
const COMPLETION_KEY_PREFIX = "@paseo:legacy-favorites-to-agent-profiles:v1:"; 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 CATALOG_LOADING_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000, 4_000] as const;
const legacyPreferencesSchema = z.object({ type LegacyFavorite = NonNullable<z.infer<typeof FormPreferencesSchema>["favoriteModels"]>[number];
favoriteModels: z
.array(
z.object({
provider: z.string().trim().min(1),
modelId: z.string().trim().min(1),
}),
)
.optional(),
});
type LegacyFavorite = NonNullable<
z.infer<typeof legacyPreferencesSchema>["favoriteModels"]
>[number];
export interface LegacyFavoriteMigrationStorage { export interface LegacyFavoriteMigrationStorage {
getItem(key: string): Promise<string | null>; getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>; setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
} }
export interface LegacyFavoriteMigrationHost { export interface LegacyFavoriteMigrationHost {
@@ -47,18 +37,6 @@ function completionKey(serverId: string): string {
return `${COMPLETION_KEY_PREFIX}${serverId}`; 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<LegacyFavorite, "provider" | "modelId">): string { function favoriteKey(favorite: Pick<LegacyFavorite, "provider" | "modelId">): string {
return `${favorite.provider}\u0000${favorite.modelId}`; return `${favorite.provider}\u0000${favorite.modelId}`;
} }
@@ -205,11 +183,16 @@ export class LegacyFavoriteProfileMigration {
return; return;
} }
const markerKey = completionKey(serverId); const markerKey = completionKey(serverId);
if ((await this.storage.getItem(markerKey)) === "1") { if ((await readValidatedString(this.storage, markerKey, z.literal("1"))) === "1") {
return; 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) { if (favorites.length === 0) {
await this.storage.setItem(markerKey, "1"); await this.storage.setItem(markerKey, "1");
return; return;
@@ -13,9 +13,9 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { import {
clearSidebarCallouts, clearSidebarCallouts,
createSidebarCalloutState, createSidebarCalloutState,
DismissedCalloutKeysSchema,
dismissSidebarCallout, dismissSidebarCallout,
loadDismissedCalloutKeys, loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout, selectActiveSidebarCallout,
serializeDismissedCalloutKeys, serializeDismissedCalloutKeys,
showSidebarCallout, showSidebarCallout,
@@ -24,6 +24,7 @@ import {
type SidebarCalloutState, type SidebarCalloutState,
unregisterSidebarCallout, unregisterSidebarCallout,
} from "./sidebar-callout-state"; } from "./sidebar-callout-state";
import { readValidatedJson } from "@/storage/validated-storage";
export type { SidebarCalloutOptions } from "./sidebar-callout-state"; export type { SidebarCalloutOptions } from "./sidebar-callout-state";
@@ -62,8 +63,12 @@ export function SidebarCalloutProvider({ children }: { children: ReactNode }) {
async function loadDismissedKeys(): Promise<void> { async function loadDismissedKeys(): Promise<void> {
let dismissedKeys: ReadonlySet<string>; let dismissedKeys: ReadonlySet<string>;
try { try {
const value = await AsyncStorage.getItem(DISMISSED_CALLOUTS_STORAGE_KEY); const value = await readValidatedJson(
dismissedKeys = parseDismissedCalloutKeys(value); AsyncStorage,
DISMISSED_CALLOUTS_STORAGE_KEY,
DismissedCalloutKeysSchema,
);
dismissedKeys = new Set(value ?? []);
} catch (error) { } catch (error) {
console.error("[SidebarCallouts] Failed to load dismissed callouts", error); console.error("[SidebarCallouts] Failed to load dismissed callouts", error);
dismissedKeys = stateRef.current.dismissedKeys; dismissedKeys = stateRef.current.dismissedKeys;
@@ -4,7 +4,6 @@ import {
createSidebarCalloutState, createSidebarCalloutState,
dismissSidebarCallout, dismissSidebarCallout,
loadDismissedCalloutKeys, loadDismissedCalloutKeys,
parseDismissedCalloutKeys,
selectActiveSidebarCallout, selectActiveSidebarCallout,
serializeDismissedCalloutKeys, serializeDismissedCalloutKeys,
showSidebarCallout, showSidebarCallout,
@@ -118,12 +117,6 @@ describe("sidebar callout state", () => {
expect(selectActiveSidebarCallout(state)?.title).toBe("Update available"); 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", () => { it("clears visible callouts without dropping dismissal state", () => {
let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"])); let state = loadDismissedCalloutKeys(createSidebarCalloutState(), new Set(["dismissed"]));
state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state; state = showSidebarCallout(state, { id: "visible", title: "Visible" }).state;
@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { z } from "zod";
import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout"; import type { SidebarCalloutAction, SidebarCalloutVariant } from "@/components/sidebar-callout";
export interface SidebarCalloutOptions { export interface SidebarCalloutOptions {
@@ -44,21 +45,7 @@ export function normalizeDismissalKey(key: string | null | undefined): string |
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
export function parseDismissedCalloutKeys(value: string | null): Set<string> { export const DismissedCalloutKeysSchema = z.array(z.string());
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 function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string { export function serializeDismissedCalloutKeys(keys: ReadonlySet<string>): string {
return JSON.stringify([...keys]); return JSON.stringify([...keys]);
@@ -137,6 +137,36 @@ describe("create agent preferences", () => {
expect(parseFormPreferences({ providerPreferences: { codex: { mode: 42 } } })).toEqual({}); 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 () => { it("persists and reloads the workspace isolation choice", async () => {
const storage = new FakeCreateAgentPreferenceStorage(); const storage = new FakeCreateAgentPreferenceStorage();
const preferences = new CreateAgentPreferencesService(storage); const preferences = new CreateAgentPreferencesService(storage);
@@ -1,19 +1,38 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; 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<string, string>;
featureValues?: Record<string, unknown>;
}
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
export interface FormPreferences {
provider?: string;
providerPreferences?: Record<string, ProviderPreferences>;
favoriteModels?: Array<{ provider: string; modelId: string }>;
isolation?: "local" | "worktree";
launchTarget?: LaunchTarget;
}
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
model: z.string().optional(), model: z.string().optional(),
mode: z.string().optional(), mode: z.string().optional(),
thinkingByModel: z.record(z.string(), 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", [ const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("chat") }), z.strictObject({ kind: z.literal("chat") }),
z.object({ kind: z.literal("terminal"), profileId: z.string() }), z.strictObject({ kind: z.literal("terminal"), profileId: z.string() }),
]); ]);
const formPreferencesSchema = z.object({ export const FormPreferencesSchema = z.strictObject({
provider: z.string().optional(), provider: z.string().optional(),
providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(),
// COMPAT(agentProfileFavoriteMigration): favourites were removed in v0.3.2. // 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. // import it; ordinary preference writes must not erase it first.
favoriteModels: z favoriteModels: z
.array( .array(
z.object({ z.strictObject({
provider: z.string(), provider: z.string(),
modelId: 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 // What the New workspace composer submits to: the chat agent (default) or a
// terminal profile. See `@/new-workspace-launch` for resolution/fallback. // terminal profile. See `@/new-workspace-launch` for resolution/fallback.
launchTarget: launchTargetSchema.optional(), launchTarget: launchTargetSchema.optional(),
}) satisfies z.ZodType<FormPreferences>;
const LegacyProviderPreferencesSchema = z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingOptionId: z.string().optional(),
}); });
export type ProviderPreferences = z.infer<typeof providerPreferencesSchema>; const LegacyFormPreferencesSchema = z
export type FormPreferences = z.infer<typeof formPreferencesSchema>; .strictObject({
export type LaunchTarget = z.infer<typeof launchTargetSchema>; 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<string, ProviderPreferences> = {};
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<FormPreferences> = z.union([
FormPreferencesSchema,
LegacyFormPreferencesSchema,
]);
export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; export const DEFAULT_FORM_PREFERENCES: FormPreferences = {};
export function parseFormPreferences(value: unknown): 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; return result.success ? result.data : DEFAULT_FORM_PREFERENCES;
} }
@@ -115,6 +168,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? ""; const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({ return mergeProviderPreferences({
preferences: args.preferences, preferences: args.preferences,
@@ -123,7 +177,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
model: modelId || undefined, model: modelId || undefined,
mode: modeId || undefined, mode: modeId || undefined,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(args.featureValues ? { featureValues: args.featureValues } : {}), ...(featureValues.success ? { featureValues: featureValues.data } : {}),
}, },
}); });
} }
@@ -1,5 +1,10 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; 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"; export const CREATE_AGENT_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
@@ -10,19 +15,19 @@ export interface CreateAgentPreferenceStorage {
export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { export class AsyncStorageCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage {
async read(): Promise<unknown> { async read(): Promise<unknown> {
const stored = await AsyncStorage.getItem(CREATE_AGENT_PREFERENCES_STORAGE_KEY); return readValidatedJson(
if (!stored) { AsyncStorage,
return null; CREATE_AGENT_PREFERENCES_STORAGE_KEY,
} StoredFormPreferencesSchema,
);
try {
return JSON.parse(stored);
} catch {
return null;
}
} }
async write(preferences: FormPreferences): Promise<void> { async write(preferences: FormPreferences): Promise<void> {
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));
} }
} }
@@ -73,5 +73,5 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt
} }
function clone<T>(value: T): T { function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T; return structuredClone(value);
} }
@@ -6,6 +6,7 @@ import {
type CompactProviderSnapshot, type CompactProviderSnapshot,
} from "@getpaseo/protocol/provider-snapshot-codec"; } from "@getpaseo/protocol/provider-snapshot-codec";
import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages"; import { CompactProviderSnapshotSchema } from "@getpaseo/protocol/messages";
import { z } from "zod";
const CACHE_VERSION = 1; const CACHE_VERSION = 1;
const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1"; const CACHE_KEY_PREFIX = "@paseo/provider-snapshot/v1";
@@ -44,6 +45,13 @@ interface ProviderSnapshotIndex {
entries: ProviderSnapshotIndexEntry[]; entries: ProviderSnapshotIndexEntry[];
} }
const StoredProviderSnapshotSchema: z.ZodType<StoredProviderSnapshot> = z.strictObject({
version: z.literal(CACHE_VERSION),
hash: z.string(),
generatedAt: z.string().datetime({ offset: true }),
compactSnapshot: CompactProviderSnapshotSchema,
});
export interface CachedProviderSnapshot extends StoredProviderSnapshot { export interface CachedProviderSnapshot extends StoredProviderSnapshot {
entries: ProviderSnapshotEntry[]; entries: ProviderSnapshotEntry[];
} }
@@ -81,22 +89,8 @@ function oldestFirst(left: ProviderSnapshotIndexEntry, right: ProviderSnapshotIn
function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null { function parseStoredProviderSnapshot(value: string): StoredProviderSnapshot | null {
const parsed: unknown = JSON.parse(value); const parsed: unknown = JSON.parse(value);
if (typeof parsed !== "object" || parsed === null) return null; const result = StoredProviderSnapshotSchema.safeParse(parsed);
const version = Reflect.get(parsed, "version"); return result.success ? result.data : null;
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 };
} }
export function createProviderSnapshotCache( export function createProviderSnapshotCache(
@@ -173,12 +167,18 @@ export function createProviderSnapshotCache(
}): Promise<void> { }): Promise<void> {
const currentIndex = await ensureCacheIndex(); const currentIndex = await ensureCacheIndex();
const key = cacheKey(input.serverId, input.cwd); const key = cacheKey(input.serverId, input.cwd);
const value = JSON.stringify({ const stored = StoredProviderSnapshotSchema.safeParse({
version: CACHE_VERSION, version: CACHE_VERSION,
hash: input.hash, hash: input.hash,
generatedAt: input.generatedAt, generatedAt: input.generatedAt,
compactSnapshot: input.compactSnapshot, 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 incomingBytes = storedBytes(key, value);
const canStoreIncoming = incomingBytes <= maxBytes; const canStoreIncoming = incomingBytes <= maxBytes;
const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key); const retainedEntries = currentIndex.entries.filter((entry) => entry.key !== key);
@@ -1,9 +1,11 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas"; import { BrowserAutomationBrowserIdSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import { import {
applyBrowserPatch, applyBrowserPatch,
BrowserIndexStateSchema,
type BrowserIndexState, type BrowserIndexState,
type BrowserRecord, type BrowserRecord,
type BrowserRecordPatch, type BrowserRecordPatch,
@@ -74,7 +76,7 @@ export const useBrowserStore = create<BrowserStoreState>()(
}), }),
{ {
name: "workspace-browser-store", name: "workspace-browser-store",
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, BrowserIndexStateSchema),
partialize: (state) => sanitizeBrowsersForPersist(state), partialize: (state) => sanitizeBrowsersForPersist(state),
merge: (persistedState, currentState) => ({ merge: (persistedState, currentState) => ({
...currentState, ...currentState,
+34 -34
View File
@@ -1,3 +1,5 @@
import { z } from "zod";
export type BrowserViewport = export type BrowserViewport =
| { mode: "responsive" } | { mode: "responsive" }
| { mode: "fixed"; width: number; height: number }; | { mode: "fixed"; width: number; height: number };
@@ -23,6 +25,32 @@ export interface BrowserIndexState {
browsersById: Record<string, BrowserRecord>; browsersById: Record<string, BrowserRecord>;
} }
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<BrowserIndexState> = z.strictObject({
browsersById: z.record(z.string(), BrowserRecordSchema),
});
export function createFixedBrowserViewport(width: number, height: number): BrowserViewport { export function createFixedBrowserViewport(width: number, height: number): BrowserViewport {
return { return {
mode: "fixed", mode: "fixed",
@@ -32,19 +60,10 @@ export function createFixedBrowserViewport(width: number, height: number): Brows
} }
export function normalizeBrowserViewport(value: unknown): BrowserViewport { export function normalizeBrowserViewport(value: unknown): BrowserViewport {
if ( const result = BrowserViewportSchema.safeParse(value);
value && return result.success && result.data.mode === "fixed"
typeof value === "object" && ? createFixedBrowserViewport(result.data.width, result.data.height)
(value as { mode?: unknown }).mode === "fixed" && : RESPONSIVE_BROWSER_VIEWPORT;
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;
} }
function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean { function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): boolean {
@@ -56,27 +75,8 @@ function browserViewportsEqual(left: BrowserViewport, right: BrowserViewport): b
} }
export function normalizeBrowserIndexState(value: unknown): BrowserIndexState { export function normalizeBrowserIndexState(value: unknown): BrowserIndexState {
if (!value || typeof value !== "object") { const result = BrowserIndexStateSchema.safeParse(value);
return { browsersById: {} }; return result.success ? result.data : { 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,
];
}),
),
};
} }
export function trimNonEmpty(value: string | null | undefined): string | null { export function trimNonEmpty(value: string | null | undefined): string | null {
+4 -2
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useMemo, type ReactElement } from "re
import { Info } from "lucide-react-native"; import { Info } from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { z } from "zod";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { getForgePresentation, type Forge } from "@/git/forge"; 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 { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { readValidatedString } from "@/storage/validated-storage";
export type { GitActionId, GitAction, GitActions } from "@/git/policy"; export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -361,10 +363,10 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
} }
let isActive = true; let isActive = true;
setShipDefault("pr"); setShipDefault("pr");
AsyncStorage.getItem(shipDefaultStorageKey) readValidatedString(AsyncStorage, shipDefaultStorageKey, z.enum(["pr", "merge"]))
.then((value) => { .then((value) => {
if (!isActive) return; if (!isActive) return;
if (value === "pr" || value === "merge") { if (value) {
setShipDefault(value); setShipDefault(value);
return; return;
} }
@@ -16,5 +16,8 @@ export function createInMemoryKeyValueStorage(
async setItem(key, value) { async setItem(key, value) {
entries.set(key, value); entries.set(key, value);
}, },
async removeItem(key) {
entries.delete(key);
},
}; };
} }
@@ -1,11 +1,12 @@
import type { QueryClient } from "@tanstack/react-query"; import type { QueryClient } from "@tanstack/react-query";
import { z } from "zod"; import { z } from "zod";
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences"; export const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines"; export const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"]; export const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({ const changesPreferencesSchema = z.strictObject({
layout: z.enum(["unified", "split"]).optional(), layout: z.enum(["unified", "split"]).optional(),
viewMode: z.enum(["flat", "tree"]).optional(), viewMode: z.enum(["flat", "tree"]).optional(),
wrapLines: z.boolean().optional(), wrapLines: z.boolean().optional(),
@@ -32,28 +33,28 @@ export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
export interface KeyValueStorage { export interface KeyValueStorage {
getItem(key: string): Promise<string | null>; getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>; setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
} }
async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise<boolean | null> { async function loadLegacyWrapLinesPreference(storage: KeyValueStorage): Promise<boolean | null> {
const legacyValue = await storage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY); const legacyValue = await readValidatedString(
if (legacyValue === "true") { storage,
return true; LEGACY_WRAP_LINES_STORAGE_KEY,
} z.enum(["true", "false"]),
if (legacyValue === "false") { );
return false; return legacyValue === null ? null : legacyValue === "true";
}
return null;
} }
export async function loadChangesPreferencesFromStorage( export async function loadChangesPreferencesFromStorage(
storage: KeyValueStorage, storage: KeyValueStorage,
): Promise<ChangesPreferences> { ): Promise<ChangesPreferences> {
const stored = await storage.getItem(CHANGES_PREFERENCES_STORAGE_KEY); const stored = await readValidatedJson(
storage,
CHANGES_PREFERENCES_STORAGE_KEY,
changesPreferencesSchema,
);
if (stored) { if (stored) {
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored)); return { ...DEFAULT_CHANGES_PREFERENCES, ...stored };
if (parsed.success) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
}
} }
const legacyWrapLines = await loadLegacyWrapLinesPreference(storage); const legacyWrapLines = await loadLegacyWrapLinesPreference(storage);
@@ -1,15 +1,18 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query"; import { type QueryClient, useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts"; import type { ShortcutOverrides } from "@/keyboard/keyboard-shortcuts";
import { import {
createShortcutOverrideStore, createShortcutOverrideStore,
type ShortcutOverrideStore, type ShortcutOverrideStore,
} from "@/keyboard/shortcut-override-store"; } from "@/keyboard/shortcut-override-store";
import { readValidatedJson } from "@/storage/validated-storage";
const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides"; const STORAGE_KEY = "@paseo:keyboard-shortcut-overrides";
const QUERY_KEY = ["keyboard-shortcut-overrides"]; const QUERY_KEY = ["keyboard-shortcut-overrides"];
const EMPTY_OVERRIDES: ShortcutOverrides = {}; const EMPTY_OVERRIDES: ShortcutOverrides = {};
const ShortcutOverridesSchema = z.record(z.string(), z.string().nullable());
export interface UseKeyboardShortcutOverridesReturn { export interface UseKeyboardShortcutOverridesReturn {
overrides: ShortcutOverrides; overrides: ShortcutOverrides;
@@ -76,10 +79,10 @@ function getStore(queryClient: QueryClient): ShortcutOverrideStore {
async function loadOverridesFromStorage(): Promise<ShortcutOverrides> { async function loadOverridesFromStorage(): Promise<ShortcutOverrides> {
try { try {
const stored = await AsyncStorage.getItem(STORAGE_KEY); return (
if (stored) { (await readValidatedJson(AsyncStorage, STORAGE_KEY, ShortcutOverridesSchema)) ??
return JSON.parse(stored) as ShortcutOverrides; EMPTY_OVERRIDES
} );
} catch (err) { } catch (err) {
console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err); console.error("[KeyboardShortcutOverrides] Failed to load overrides:", err);
} }
@@ -1,6 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
import { readValidatedString } from "@/storage/validated-storage";
type EditorTargetId = string; type EditorTargetId = string;
@@ -8,11 +10,7 @@ const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"]; const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> { async function loadPreferredEditor(): Promise<EditorTargetId | null> {
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY); return readValidatedString(AsyncStorage, PREFERRED_EDITOR_STORAGE_KEY, z.string().trim().min(1));
if (!stored) {
return null;
}
return stored.trim() || null;
} }
export function resolvePreferredEditorId( export function resolvePreferredEditorId(
@@ -17,6 +17,9 @@ export function createInMemoryKeyValueStorage(
async setItem(key, value) { async setItem(key, value) {
entries.set(key, value); entries.set(key, value);
}, },
async removeItem(key) {
entries.delete(key);
},
}; };
} }
@@ -15,6 +15,7 @@ import {
type SettingsDeps, type SettingsDeps,
} from "./storage"; } from "./storage";
import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes"; import { createFakeDesktopBridge, createInMemoryKeyValueStorage } from "./fakes";
import { THEME_OPTIONS } from "@/styles/theme";
const LEGACY_SETTINGS_KEY = "@paseo:settings"; const LEGACY_SETTINGS_KEY = "@paseo:settings";
@@ -42,6 +43,18 @@ describe("loadAppSettingsFromStorage", () => {
expect(result.theme).toBe("auto"); 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 () => { it("seeds storage with the client defaults when nothing is persisted", async () => {
const deps = makeDeps(); const deps = makeDeps();
@@ -382,14 +395,14 @@ describe("appearance settings", () => {
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview"); 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({ const deps = makeDeps({
storage: createInMemoryKeyValueStorage({ storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }), [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 () => { it("migrates a switched-off checks row item to the hidden checks display", async () => {
+63 -27
View File
@@ -14,6 +14,8 @@ import {
type SidebarRowItems, type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items"; } from "@/components/sidebar/display-preferences/row-items";
import { THEME_OPTIONS, type ThemePreference } from "@/styles/theme"; 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_KEY = "@paseo:app-settings";
export const APP_SETTINGS_QUERY_KEY = ["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"; export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name)); const VALID_THEMES = new Set<string>(THEME_OPTIONS.map((option) => option.name));
const ThemePreferenceSchema = z.enum(THEME_OPTIONS.map((option) => option.name));
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]); const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]); const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([ const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
@@ -74,14 +77,45 @@ export interface Settings extends AppSettings {
releaseChannel: ReleaseChannel; releaseChannel: ReleaseChannel;
} }
/** const SidebarRowItemsSchema = z.strictObject({
* `sidebarRowItems` is widened back to `unknown` because it is still read for a value the host: z.boolean().optional(),
* current shape no longer has — see `isChecksHiddenByLegacyRowItem`. changeRequest: z.boolean().optional(),
*/ services: z.boolean().optional(),
type StoredAppSettings = Partial<Omit<AppSettings, "sidebarRowItems">> & { checks: z.boolean().optional(),
compactToolCalls?: unknown; scripts: z.boolean().optional(),
sidebarRowItems?: unknown; });
};
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<typeof StoredAppSettingsSchema>;
export const DEFAULT_CLIENT_SETTINGS: AppSettings = { export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
theme: "auto", theme: "auto",
@@ -114,6 +148,7 @@ export const DEFAULT_APP_SETTINGS: Settings = {
export interface KeyValueStorage { export interface KeyValueStorage {
getItem(key: string): Promise<string | null>; getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>; setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
} }
export interface DesktopSettingsBridge { export interface DesktopSettingsBridge {
@@ -146,17 +181,20 @@ export async function saveAppSettings(input: {
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> { export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
try { try {
const stored = await deps.storage.getItem(APP_SETTINGS_KEY); const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
if (stored) { 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) { if (legacyStored) {
const legacyParsed = JSON.parse(legacyStored) as Record<string, unknown>;
const next = { const next = {
...DEFAULT_CLIENT_SETTINGS, ...DEFAULT_CLIENT_SETTINGS,
...pickAppSettingsFromLegacy(legacyParsed), ...pickAppSettingsFromLegacy(legacyStored),
} satisfies AppSettings; } satisfies AppSettings;
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next)); await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
return next; return next;
@@ -197,11 +235,11 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
} }
export function normalizeAppSettings(value: unknown): AppSettings { export function normalizeAppSettings(value: unknown): AppSettings {
const stored = const result = StoredAppSettingsSchema.safeParse(value);
typeof value === "object" && value !== null && !Array.isArray(value) return {
? (value as StoredAppSettings) ...DEFAULT_CLIENT_SETTINGS,
: {}; ...pickAppSettings(result.success ? result.data : {}),
return { ...DEFAULT_CLIENT_SETTINGS, ...pickAppSettings(stored) }; };
} }
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null { function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
@@ -334,7 +372,9 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
return result; return result;
} }
function pickAppSettingsFromLegacy(legacy: Record<string, unknown>): Partial<AppSettings> { function pickAppSettingsFromLegacy(
legacy: z.infer<typeof LegacyRendererSettingsSchema>,
): Partial<AppSettings> {
const result: Partial<AppSettings> = {}; const result: Partial<AppSettings> = {};
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") { if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
result.theme = legacy.theme; result.theme = legacy.theme;
@@ -420,15 +460,11 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
async function loadRendererSettingsPayload( async function loadRendererSettingsPayload(
storage: KeyValueStorage, storage: KeyValueStorage,
): Promise<Record<string, unknown> | null> { ): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> {
const current = await storage.getItem(APP_SETTINGS_KEY); const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema);
if (current) { if (current) {
return JSON.parse(current) as Record<string, unknown>; return current;
} }
const legacy = await storage.getItem(LEGACY_SETTINGS_KEY); return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema);
if (!legacy) {
return null;
}
return JSON.parse(legacy) as Record<string, unknown>;
} }
+8 -16
View File
@@ -1,5 +1,6 @@
import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors"; import { IDENTITY_COLOR_NAMES, type IdentityColorName } from "@/styles/identity-colors";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { z } from "zod";
export type HostColor = "none" | IdentityColorName; export type HostColor = "none" | IdentityColorName;
@@ -19,27 +20,18 @@ export interface HostAppearance {
badgeDisplay: HostBadgeDisplay | null; badgeDisplay: HostBadgeDisplay | null;
} }
export const HostAppearanceSchema: z.ZodType<HostAppearance> = z.strictObject({
color: z.enum(["none", ...IDENTITY_COLOR_NAMES]),
badgeDisplay: z.enum(["name", "icon", "hidden"]).nullable(),
});
export function defaultHostAppearance(): HostAppearance { export function defaultHostAppearance(): HostAppearance {
return { color: "none", badgeDisplay: null }; 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 { export function normalizeStoredHostAppearance(value: unknown): HostAppearance {
if (!value || typeof value !== "object" || Array.isArray(value)) { const result = HostAppearanceSchema.safeParse(value);
return defaultHostAppearance(); return result.success ? result.data : defaultHostAppearance();
}
const record = value as Record<string, unknown>;
return {
color: isHostColor(record.color) ? record.color : "none",
badgeDisplay: isHostBadgeDisplay(record.badgeDisplay) ? record.badgeDisplay : null,
};
} }
export function resolveHostBadgeDisplay(input: { export function resolveHostBadgeDisplay(input: {
@@ -2,23 +2,22 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants"; import Constants from "expo-constants";
import * as Notifications from "expo-notifications"; import * as Notifications from "expo-notifications";
import { Platform } from "react-native"; import { Platform } from "react-native";
import { z } from "zod";
import { readValidatedString } from "@/storage/validated-storage";
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types"; import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types";
const STORAGE_PREFIX = "@paseo:expo-push-token:"; const STORAGE_PREFIX = "@paseo:expo-push-token:";
const ExpoPushTokenSchema = z.string().trim().min(1);
function storageKey(serverId: string): string { function storageKey(serverId: string): string {
return `${STORAGE_PREFIX}${serverId}`; return `${STORAGE_PREFIX}${serverId}`;
} }
function getExpoProjectId(): string | null { function getExpoProjectId(): string | null {
const constants = Constants as unknown as { const fromEas = ExpoPushTokenSchema.safeParse(Constants.easConfig?.projectId);
easConfig?: { projectId?: unknown }; if (fromEas.success) return fromEas.data;
expoConfig?: { extra?: { eas?: { projectId?: unknown } } }; const fromExtra = ExpoPushTokenSchema.safeParse(Constants.expoConfig?.extra?.eas?.projectId);
}; return fromExtra.success ? fromExtra.data : null;
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;
} }
async function ensurePushPermission(): Promise<boolean> { async function ensurePushPermission(): Promise<boolean> {
@@ -31,7 +30,7 @@ async function ensurePushPermission(): Promise<boolean> {
async function resolveToken(serverId: string): Promise<string | null> { async function resolveToken(serverId: string): Promise<string | null> {
const key = storageKey(serverId); const key = storageKey(serverId);
const cached = await AsyncStorage.getItem(key); const cached = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema);
if (!(await ensurePushPermission())) { if (!(await ensurePushPermission())) {
await AsyncStorage.removeItem(key); await AsyncStorage.removeItem(key);
return null; return null;
@@ -87,7 +86,7 @@ export function startSubscription(input: StartPushNotificationsInput): () => voi
export async function revokeSubscription(input: RevokePushNotificationsInput): Promise<void> { export async function revokeSubscription(input: RevokePushNotificationsInput): Promise<void> {
const key = storageKey(input.serverId); const key = storageKey(input.serverId);
const token = await AsyncStorage.getItem(key); const token = await readValidatedString(AsyncStorage, key, ExpoPushTokenSchema);
if ( if (
token && token &&
input.client?.isConnected && input.client?.isConnected &&
+26 -39
View File
@@ -1,3 +1,5 @@
import { z } from "zod";
export type ReviewDraftMode = "uncommitted" | "base"; export type ReviewDraftMode = "uncommitted" | "base";
export type ReviewDraftSide = "old" | "new"; export type ReviewDraftSide = "old" | "new";
@@ -30,8 +32,27 @@ export interface ReviewDraftStoreState {
// Only drafts are persisted; diffModeOverrides is intentionally excluded. // Only drafts are persisted; diffModeOverrides is intentionally excluded.
export interface SerializedReviewDraftState { export interface SerializedReviewDraftState {
drafts: Record<string, ReviewDraftComment[]>; drafts: Record<string, ReviewDraftComment[]>;
activeModesByScope?: Record<string, ReviewDraftMode>;
} }
const IsoDateTimeSchema = z.string().datetime({ offset: true });
export const ReviewDraftCommentSchema: z.ZodType<ReviewDraftComment> = 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<SerializedReviewDraftState> =
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( export function setDiffModeOverrideInState(
state: ReviewDraftStoreState, state: ReviewDraftStoreState,
input: { scopeKey: string; override: DiffModeOverride }, input: { scopeKey: string; override: DiffModeOverride },
@@ -160,45 +181,11 @@ export function serializeReviewDraftState(
} }
export function normalizePersistedState(state: unknown): ReviewDraftStoreState { export function normalizePersistedState(state: unknown): ReviewDraftStoreState {
if (!state || typeof state !== "object") { const result = SerializedReviewDraftStateSchema.safeParse(state);
return { drafts: {}, diffModeOverrides: {} }; return {
} drafts: result.success ? result.data.drafts : {},
// activeModesByScope may be present in old persisted JSON — tolerate and ignore it. diffModeOverrides: {},
const persisted = state as { drafts?: unknown }; };
const drafts = persisted.drafts;
if (!drafts || typeof drafts !== "object" || Array.isArray(drafts)) {
return { drafts: {}, diffModeOverrides: {} };
}
const normalized: Record<string, ReviewDraftComment[]> = {};
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<string, unknown>;
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"
);
} }
function applyCommentUpdates( function applyCommentUpdates(
+39 -12
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import type { ParsedDiffFile } from "@/git/use-diff-query"; import type { ParsedDiffFile } from "@/git/use-diff-query";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import { import {
buildReviewAttachmentSnapshot, buildReviewAttachmentSnapshot,
buildReviewDraftKey, buildReviewDraftKey,
@@ -16,6 +18,7 @@ import {
type ReviewDraftComment, type ReviewDraftComment,
type ReviewDraftStoreState, type ReviewDraftStoreState,
serializeReviewDraftState, serializeReviewDraftState,
SerializedReviewDraftStateSchema,
setDiffModeOverrideInState, setDiffModeOverrideInState,
updateCommentInState, updateCommentInState,
} from "./state"; } from "./state";
@@ -69,6 +72,20 @@ function makeFile(): ParsedDiffFile {
}; };
} }
function createMemoryStorage(): StateStorage & { values: Map<string, string> } {
const values = new Map<string, string>();
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", () => { describe("buildReviewDraftKey", () => {
it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => { it("scopes by server, workspace-or-cwd, diff mode, base ref, and whitespace mode", () => {
const base = buildReviewDraftKey({ const base = buildReviewDraftKey({
@@ -122,7 +139,27 @@ describe("buildReviewDraftKey", () => {
}); });
describe("normalizePersistedState", () => { 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({ const normalized = normalizePersistedState({
drafts: { drafts: {
"review:key": [ "review:key": [
@@ -146,17 +183,7 @@ describe("normalizePersistedState", () => {
}); });
expect(normalized.diffModeOverrides).toEqual({}); expect(normalized.diffModeOverrides).toEqual({});
expect(normalized.drafts["review:key"]).toEqual([ expect(normalized.drafts).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",
},
]);
}); });
it("returns empty state for null, non-object, or malformed inputs", () => { it("returns empty state for null, non-object, or malformed inputs", () => {
+4 -2
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { ComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment } from "@/attachments/types";
import type { ParsedDiffFile } from "@/git/use-diff-query"; import type { ParsedDiffFile } from "@/git/use-diff-query";
import { import {
@@ -17,12 +17,14 @@ import {
type ReviewDraftSide, type ReviewDraftSide,
type ReviewDraftStoreState, type ReviewDraftStoreState,
serializeReviewDraftState, serializeReviewDraftState,
SerializedReviewDraftStateSchema,
setDiffModeOverrideInState, setDiffModeOverrideInState,
updateCommentInState, updateCommentInState,
} from "@/review/state"; } from "@/review/state";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout"; import { buildNumberedDiffHunks, type NumberedDiffLine } from "@/utils/diff-layout";
import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export type { export type {
DiffModeOverride, DiffModeOverride,
@@ -168,7 +170,7 @@ export const useReviewDraftStore = create<ReviewDraftStore>()(
{ {
name: "@paseo:review-draft-store", name: "@paseo:review-draft-store",
version: STORE_VERSION, version: STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, SerializedReviewDraftStateSchema),
partialize: (state) => serializeReviewDraftState(state), partialize: (state) => serializeReviewDraftState(state),
migrate: async (state) => normalizePersistedState(state), migrate: async (state) => normalizePersistedState(state),
}, },
@@ -445,6 +445,9 @@ function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): H
setItem: async (key, value) => { setItem: async (key, value) => {
values.set(key, value); values.set(key, value);
}, },
removeItem: async (key) => {
values.delete(key);
},
}; };
} }
+46 -43
View File
@@ -3,6 +3,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6"; import equal from "fast-deep-equal/es6";
import { import {
DaemonClient, DaemonClient,
type DaemonClientConfig,
type ConnectionState, type ConnectionState,
type FetchAgentsOptions, type FetchAgentsOptions,
} from "@getpaseo/client/internal/daemon-client"; } from "@getpaseo/client/internal/daemon-client";
@@ -11,6 +12,7 @@ import {
normalizeStoredHostProfile, normalizeStoredHostProfile,
upsertHostConnectionInProfiles, upsertHostConnectionInProfiles,
registryHasConnection, registryHasConnection,
StoredHostRegistrySchema,
type HostConnection, type HostConnection,
type HostProfile, type HostProfile,
} from "@/types/host-connection"; } from "@/types/host-connection";
@@ -28,6 +30,8 @@ import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { connectToDaemon } from "@/utils/test-daemon-connection"; import { connectToDaemon } from "@/utils/test-daemon-connection";
import { getOrCreateClientId } from "@/utils/client-id"; import { getOrCreateClientId } from "@/utils/client-id";
import { z } from "zod";
import { readValidatedJson, readValidatedString } from "@/storage/validated-storage";
import { import {
selectBestConnection, selectBestConnection,
type ConnectionCandidate, type ConnectionCandidate,
@@ -163,6 +167,7 @@ export interface HostRuntimeControllerDeps {
export interface HostRuntimeStorage { export interface HostRuntimeStorage {
getItem: (key: string) => Promise<string | null>; getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>; setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
} }
export interface HostRuntimeStartOptions { export interface HostRuntimeStartOptions {
@@ -483,12 +488,12 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
const base = { const base = {
suppressSendErrors: true, suppressSendErrors: true,
clientId, clientId,
clientType: "mobile" as const, clientType: "mobile",
appVersion: resolveAppVersion() ?? undefined, appVersion: resolveAppVersion() ?? undefined,
runtimeGeneration, runtimeGeneration,
capabilities: appCapabilities, capabilities: appCapabilities,
trace: nativePerformanceTrace, trace: nativePerformanceTrace,
}; } satisfies Omit<DaemonClientConfig, "url">;
if (connection.type === "directSocket" || connection.type === "directPipe") { if (connection.type === "directSocket" || connection.type === "directPipe") {
return new DaemonClient({ return new DaemonClient({
...base, ...base,
@@ -1288,9 +1293,10 @@ export interface InitialDaemonConnectionHint {
useTls?: boolean; useTls?: boolean;
} }
function isInitialConnectionHintRecord(value: unknown): value is Record<string, unknown> { const InitialDaemonConnectionHintSchema: z.ZodType<InitialDaemonConnectionHint> = z.object({
return Boolean(value) && typeof value === "object" && !Array.isArray(value); listen: z.string().trim().min(1),
} useTls: z.boolean().optional().default(false),
});
export function readInitialDaemonConnectionHint(input?: { export function readInitialDaemonConnectionHint(input?: {
isWebRuntime?: boolean; isWebRuntime?: boolean;
@@ -1299,18 +1305,10 @@ export function readInitialDaemonConnectionHint(input?: {
if (!isWebRuntime || typeof globalThis === "undefined") { if (!isWebRuntime || typeof globalThis === "undefined") {
return null; return null;
} }
const value = (globalThis as Record<string, unknown>)[INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY]; const result = InitialDaemonConnectionHintSchema.safeParse(
if (!isInitialConnectionHintRecord(value)) { Reflect.get(globalThis, INITIAL_DAEMON_CONNECTION_HINT_GLOBAL_KEY),
return null; );
} return result.success ? result.data : null;
const listen = typeof value.listen === "string" ? value.listen.trim() : "";
if (!listen) {
return null;
}
return {
listen,
useTls: value.useTls === true,
};
} }
function readConfiguredLocalDaemonOverride(): string | null { function readConfiguredLocalDaemonOverride(): string | null {
@@ -1419,7 +1417,7 @@ export class HostRuntimeStore {
let isE2E: string | null = null; let isE2E: string | null = null;
try { try {
isE2E = await this.storage.getItem(E2E_STORAGE_KEY); isE2E = await readValidatedString(this.storage, E2E_STORAGE_KEY, z.string().min(1));
} catch { } catch {
return; return;
} }
@@ -1452,19 +1450,27 @@ export class HostRuntimeStore {
let shouldPersistHosts = false; let shouldPersistHosts = false;
let profiles: HostProfile[] = []; let profiles: HostProfile[] = [];
try { try {
const stored = await this.storage.getItem(REGISTRY_STORAGE_KEY); const stored = await readValidatedJson(
this.storage,
REGISTRY_STORAGE_KEY,
StoredHostRegistrySchema,
);
if (stored) { if (stored) {
const parsed = JSON.parse(stored) as unknown; const normalizedProfiles: HostProfile[] = [];
if (Array.isArray(parsed)) { for (const entry of stored) {
const normalizedProfiles = parsed const profile = normalizeStoredHostProfile(entry);
.map((entry) => normalizeStoredHostProfile(entry)) if (!profile) {
.filter((entry): entry is HostProfile => entry !== null); await this.storage.removeItem(REGISTRY_STORAGE_KEY);
normalizedProfiles.length = 0;
break;
}
normalizedProfiles.push(profile);
}
profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId)); profiles = normalizedProfiles.filter((entry) => !isPlaceholderServerId(entry.serverId));
if (profiles.length !== normalizedProfiles.length) { if (profiles.length !== normalizedProfiles.length) {
shouldPersistHosts = true; shouldPersistHosts = true;
} }
} }
}
this.hosts = profiles; this.hosts = profiles;
this.replicaCache.setHosts(profiles.map((profile) => profile.serverId)); this.replicaCache.setHosts(profiles.map((profile) => profile.serverId));
await this.replicaCache.restore(); await this.replicaCache.restore();
@@ -1922,7 +1928,11 @@ export class HostRuntimeStore {
void this.persistHosts().catch((error) => void this.persistHosts().catch((error) =>
console.error("[HostRuntime] Failed to persist host registry", 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( private setHostsAndSync(
@@ -2321,25 +2331,19 @@ export class HostRuntimeStore {
let singletonHostRuntimeStore: HostRuntimeStore | null = null; let singletonHostRuntimeStore: HostRuntimeStore | null = null;
const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore"; const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore";
type HostRuntimeGlobal = typeof globalThis & {
[HOST_RUNTIME_STORE_GLOBAL_KEY]?: HostRuntimeStore;
};
export function getHostRuntimeStore(): HostRuntimeStore { export function getHostRuntimeStore(): HostRuntimeStore {
if (singletonHostRuntimeStore) { if (singletonHostRuntimeStore) {
return singletonHostRuntimeStore; return singletonHostRuntimeStore;
} }
const runtimeGlobal = globalThis as HostRuntimeGlobal; const existing = Reflect.get(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY);
if (runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY]) { if (existing instanceof HostRuntimeStore) {
singletonHostRuntimeStore = runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] ?? null; singletonHostRuntimeStore = existing;
if (singletonHostRuntimeStore) { return existing;
return singletonHostRuntimeStore;
}
} }
singletonHostRuntimeStore = new HostRuntimeStore(); singletonHostRuntimeStore = new HostRuntimeStore();
runtimeGlobal[HOST_RUNTIME_STORE_GLOBAL_KEY] = singletonHostRuntimeStore; Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore);
return singletonHostRuntimeStore; return singletonHostRuntimeStore;
} }
@@ -2396,12 +2400,11 @@ export function useHostRuntimeConnectionStatuses(
return useMemo(() => { return useMemo(() => {
// The aggregate version is the reactivity trigger; re-read snapshots on every host tick. // The aggregate version is the reactivity trigger; re-read snapshots on every host tick.
void version; void version;
return new Map( const entries: Array<[string, HostRuntimeConnectionStatus]> = serverIds.map((serverId) => [
serverIds.map( serverId,
(serverId) => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
[serverId, store.getSnapshot(serverId)?.connectionStatus ?? "connecting"] as const, ]);
), return new Map(entries);
);
}, [serverIds, store, version]); }, [serverIds, store, version]);
} }
@@ -25,6 +25,10 @@ class MemoryStorage implements ReplicaCacheStorage {
this.writes += 1; this.writes += 1;
this.values.set(key, value); this.values.set(key, value);
} }
async removeItem(key: string): Promise<void> {
this.values.delete(key);
}
} }
function workspace( function workspace(
@@ -272,7 +276,7 @@ describe("ReplicaCache", () => {
version: number; version: number;
hosts: Array<{ timeline: Record<string, unknown> | null }>; hosts: Array<{ timeline: Record<string, unknown> | null }>;
}; };
expect(persisted.version).toBe(4); expect(persisted.version).toBe(5);
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]); 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"]); 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(); const storage = new MemoryStorage();
storage.values.set( storage.values.set(
"@paseo:replica-cache", "@paseo:replica-cache",
JSON.stringify({ JSON.stringify({
version: 3, version: 4,
hosts: [ hosts: [
{ {
serverId: SERVER_ID, serverId: SERVER_ID,
@@ -357,11 +361,12 @@ describe("ReplicaCache", () => {
cache.setHosts([SERVER_ID]); cache.setHosts([SERVER_ID]);
await cache.restore(); await cache.restore();
expect(storage.values.has("@paseo:replica-cache")).toBe(false);
await cache.flush(); await cache.flush();
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({ expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
version: 4, version: 5,
hosts: [], hosts: [],
}); });
}); });
+393 -111
View File
@@ -1,19 +1,14 @@
import { Buffer } from "buffer"; import { Buffer } from "buffer";
import { z } from "zod"; import { z } from "zod";
import { import { AgentStatusSchema } from "@getpaseo/protocol/messages";
AgentSnapshotPayloadSchema, import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
ProjectPlacementPayloadSchema,
WorkspaceDescriptorPayloadSchema,
WorkspaceProjectDescriptorPayloadSchema,
type AgentSnapshotPayload,
type WorkspaceDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { import {
normalizeProjectDescriptor, normalizeProjectDescriptor,
normalizeWorkspaceDescriptor, normalizeWorkspaceDescriptor,
selectAgentTimelineState, selectAgentTimelineState,
useSessionStore, useSessionStore,
type Agent, type Agent,
type AgentTimelineState,
type SessionReplica, type SessionReplica,
type SessionState, type SessionState,
type ProjectDescriptor, type ProjectDescriptor,
@@ -23,39 +18,217 @@ import { isUnreconciledLocalUserMessage, type StreamItem } from "@/types/stream"
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const STORAGE_KEY = "@paseo:replica-cache"; const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 4; const CACHE_VERSION = 5;
const PERSIST_DELAY_MS = 750; const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50; const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 1024 * 1024; const MAX_CACHE_BYTES = 1024 * 1024;
const DATE_TAG = "__paseoDate"; const IsoDateSchema = z.iso.datetime();
const TimelinePositionSchema = z.strictObject({
const StoredAgentSchema = z.object({ epoch: z.string(),
snapshot: AgentSnapshotPayloadSchema, seq: z.number().int().nonnegative(),
projectPlacement: ProjectPlacementPayloadSchema.nullable(),
lastActivityAt: z.string(),
}); });
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(), agentId: z.string(),
items: z.unknown(), items: z.array(StoredTimelineItemSchema),
}); });
const StoredHostSchema = z.object({ const StoredHostSchema = z.strictObject({
serverId: z.string(), serverId: z.string(),
agents: z.array(StoredAgentSchema), agents: z.array(StoredAgentSchema),
workspaces: z.array(WorkspaceDescriptorPayloadSchema), workspaces: z.array(StoredWorkspaceSchema),
projects: z.array(WorkspaceProjectDescriptorPayloadSchema).optional().default([]), projects: z.array(StoredProjectSchema),
emptyProjects: z.array(WorkspaceProjectDescriptorPayloadSchema), emptyProjects: z.array(StoredProjectSchema),
timeline: StoredTimelineSchema.nullable(), timeline: StoredTimelineSchema.nullable(),
}); });
const StoredCacheSchema = z.object({ const StoredCacheSchema = z.strictObject({
version: z.literal(CACHE_VERSION), version: z.literal(CACHE_VERSION),
hosts: z.array(StoredHostSchema), hosts: z.array(StoredHostSchema),
}); });
type StoredAgent = z.infer<typeof StoredAgentSchema>; type StoredAgent = z.infer<typeof StoredAgentSchema>;
type StoredHost = z.infer<typeof StoredHostSchema>; type StoredHost = z.infer<typeof StoredHostSchema>;
type StoredTimelineItem = z.infer<typeof StoredTimelineItemSchema>;
type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>;
type StoredProject = z.infer<typeof StoredProjectSchema>;
interface ReplicaInput { interface ReplicaInput {
agent: Agent | undefined; agent: Agent | undefined;
@@ -67,104 +240,177 @@ interface ReplicaInput {
export interface ReplicaCacheStorage { export interface ReplicaCacheStorage {
getItem: (key: string) => Promise<string | null>; getItem: (key: string) => Promise<string | null>;
setItem: (key: string, value: string) => Promise<void>; setItem: (key: string, value: string) => Promise<void>;
removeItem: (key: string) => Promise<void>;
} }
interface ReplicaCacheOptions { interface ReplicaCacheOptions {
maxBytes?: number; maxBytes?: number;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function hasString(value: Record<string, unknown>, 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"] { function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] {
if (!stored) { if (!stored) {
return null; return null;
} }
const decoded = decodeDates(stored.items);
if (!Array.isArray(decoded) || !decoded.every(isStreamItem)) {
return null;
}
return { return {
agentId: stored.agentId, 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 { function serializeAgent(agent: Agent): StoredAgent {
const snapshot: AgentSnapshotPayload = { const snapshot = {
id: agent.id, id: agent.id,
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
model: agent.model, model: agent.model,
...(agent.features ? { features: agent.features } : {}),
thinkingOptionId: agent.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? null,
createdAt: agent.createdAt.toISOString(), createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(), updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
status: agent.status, 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, currentModeId: agent.currentModeId,
availableModes: agent.availableModes, availableModes: [],
pendingPermissions: [], pendingPermissions: [],
persistence: agent.persistence, persistence: null,
...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}),
...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}),
...(agent.lastError ? { lastError: agent.lastError } : {}), ...(agent.lastError ? { lastError: agent.lastError } : {}),
title: agent.title, title: agent.title,
labels: agent.labels, labels: agent.labels,
@@ -175,7 +421,7 @@ function serializeAgent(agent: Agent): StoredAgent {
}; };
return { return {
snapshot, snapshot,
projectPlacement: agent.projectPlacement ?? null, projectPlacement: null,
lastActivityAt: agent.lastActivityAt.toISOString(), 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 { return {
id: workspace.id, id: workspace.id,
projectId: workspace.projectId, projectId: workspace.projectId,
@@ -208,15 +454,25 @@ function serializeWorkspace(workspace: WorkspaceDescriptor): WorkspaceDescriptor
activityAt: null, activityAt: null,
archivingAt: workspace.archivingAt, archivingAt: workspace.archivingAt,
diffStat: workspace.diffStat, 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, gitRuntime: workspace.gitRuntime,
githubRuntime: workspace.githubRuntime,
forge: workspace.forge, forge: workspace.forge,
project: workspace.project,
}; };
} }
function serializeProject(project: ProjectDescriptor) { function serializeProject(project: ProjectDescriptor): StoredProject {
return { return {
projectId: project.projectId, projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}), ...(project.projectKey ? { projectKey: project.projectKey } : {}),
@@ -244,9 +500,9 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl
(candidate) => candidate.workspaceDirectory === agent.cwd, (candidate) => candidate.workspaceDirectory === agent.cwd,
)) ))
: undefined; : undefined;
const timeline = agentId const timeline: AgentTimelineState = agentId
? selectAgentTimelineState(session, agentId) ? selectAgentTimelineState(session, agentId)
: { status: "cold" as const }; : { status: "cold" };
return { return {
agent, agent,
workspace, workspace,
@@ -256,9 +512,10 @@ function selectReplicaInput(session: SessionState, agentId: string | null): Repl
} }
function serializeHost(serverId: string, input: ReplicaInput): StoredHost { function serializeHost(serverId: string, input: ReplicaInput): StoredHost {
const items = input.timelineItems?.filter( const items = input.timelineItems
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item))
); .map(serializeTimelineItem)
.filter((item) => item !== null);
return { return {
serverId, serverId,
agents: input.agent ? [serializeAgent(input.agent)] : [], agents: input.agent ? [serializeAgent(input.agent)] : [],
@@ -269,7 +526,7 @@ function serializeHost(serverId: string, input: ReplicaInput): StoredHost {
input.agent && items input.agent && items
? { ? {
agentId: input.agent.id, agentId: input.agent.id,
items: encodeDates(items.slice(-MAX_TIMELINE_ITEMS)), items: items.slice(-MAX_TIMELINE_ITEMS),
} }
: null, : null,
}; };
@@ -360,10 +617,14 @@ export class ReplicaCache {
try { try {
parsed = JSON.parse(raw); parsed = JSON.parse(raw);
} catch { } catch {
await this.clearInvalidCache();
return; return;
} }
const cache = StoredCacheSchema.safeParse(parsed); const cache = StoredCacheSchema.safeParse(parsed);
if (!cache.success) return; if (!cache.success) {
await this.clearInvalidCache();
return;
}
for (const host of cache.data.hosts) { for (const host of cache.data.hosts) {
if (!this.activeServerIds.has(host.serverId)) { if (!this.activeServerIds.has(host.serverId)) {
this.needsPersist = true; this.needsPersist = true;
@@ -372,7 +633,13 @@ export class ReplicaCache {
this.storedHosts.set(host.serverId, host); this.storedHosts.set(host.serverId, host);
if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); 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()) { for (const host of this.storedHosts.values()) {
useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host));
const session = useSessionStore.getState().sessions[host.serverId]; const session = useSessionStore.getState().sessions[host.serverId];
@@ -427,11 +694,15 @@ export class ReplicaCache {
this.persistTimer = null; this.persistTimer = null;
} }
this.captureSessions(); this.captureSessions();
const { payload } = this.buildBoundedPayload(); const bounded = this.buildBoundedPayload();
this.needsPersist = false; this.needsPersist = false;
if (!bounded) {
await this.clearInvalidCache();
return;
}
const write = this.writeQueue const write = this.writeQueue
.catch(() => undefined) .catch(() => undefined)
.then(() => this.storage.setItem(STORAGE_KEY, payload)); .then(() => this.storage.setItem(STORAGE_KEY, bounded.payload));
this.writeQueue = write; this.writeQueue = write;
await write.catch(() => undefined); await write.catch(() => undefined);
} }
@@ -461,24 +732,35 @@ export class ReplicaCache {
return true; return true;
} }
private buildBoundedPayload(): { payload: string; evicted: boolean } { private buildBoundedPayload(): { payload: string; evicted: boolean } | null {
let evicted = false; let evicted = false;
let payload = this.serialize(); let payload = this.serialize();
if (payload === null) return null;
while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) { while (Buffer.byteLength(payload, "utf8") > this.maxBytes && this.storedHosts.size > 0) {
const oldestServerId = this.storedHosts.keys().next().value; const oldestServerId = this.storedHosts.keys().next().value;
if (oldestServerId === undefined) break; if (oldestServerId === undefined) break;
this.storedHosts.delete(oldestServerId); this.storedHosts.delete(oldestServerId);
evicted = true; evicted = true;
payload = this.serialize(); payload = this.serialize();
if (payload === null) return null;
} }
return { payload, evicted }; return { payload, evicted };
} }
private serialize(): string { private serialize(): string | null {
return JSON.stringify({ const cache = StoredCacheSchema.safeParse({
version: CACHE_VERSION, version: CACHE_VERSION,
hosts: Array.from(this.storedHosts.values()), hosts: Array.from(this.storedHosts.values()),
}); });
return cache.success ? JSON.stringify(cache.data) : null;
}
private async clearInvalidCache(): Promise<void> {
try {
await this.storage.removeItem(STORAGE_KEY);
} catch {
// A storage failure must not turn invalid cached data into application state.
}
} }
private schedulePersist(): void { private schedulePersist(): void {
@@ -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<string, string>();
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);
});
});
@@ -0,0 +1,43 @@
import { z } from "zod";
import type { PersistStorage, StateStorage } from "zustand/middleware";
export function createValidatedPersistStorage<State>(
backingStorage: StateStorage,
stateSchema: z.ZodType<State>,
): PersistStorage<State> {
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),
};
}
@@ -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<string, string>();
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async removeItem(key: string): Promise<void> {
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);
});
});
@@ -0,0 +1,47 @@
import { z } from "zod";
export interface ValidatedStorage {
getItem(key: string): Promise<string | null>;
removeItem(key: string): Promise<void>;
}
async function clearInvalidValue(storage: ValidatedStorage, key: string): Promise<void> {
try {
await storage.removeItem(key);
} catch {
// Invalid persisted data must never cross the boundary, even when cleanup fails.
}
}
export async function readValidatedString<Value>(
storage: ValidatedStorage,
key: string,
schema: z.ZodType<Value>,
): Promise<Value | null> {
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<Value>(
storage: ValidatedStorage,
key: string,
schema: z.ZodType<Value>,
): Promise<Value | null> {
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;
}
+12 -7
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 AsyncStorage from "@react-native-async-storage/async-storage";
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types"; import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file"; import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
@@ -27,8 +27,14 @@ import {
type DraftRecord, type DraftRecord,
type DraftStoreState, type DraftStoreState,
} from "./state"; } from "./state";
import { migrateDraftInput, migratePersistedState, type MigrateLegacyImages } from "./migration"; import {
migrateDraftInput,
migratePersistedState,
type MigrateLegacyImages,
PersistedDraftStoreSchema,
} from "./migration";
import { createDraftPersistStorage } from "./persistence"; import { createDraftPersistStorage } from "./persistence";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export type { DraftInput, DraftLifecycleState } from "./state"; export type { DraftInput, DraftLifecycleState } from "./state";
@@ -58,7 +64,7 @@ type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
let gcScheduled = false; let gcScheduled = false;
const draftPersistStorage = createDraftPersistStorage( const draftPersistStorage = createDraftPersistStorage(
createJSONStorage<DraftStoreState>(() => AsyncStorage), createValidatedPersistStorage(AsyncStorage, PersistedDraftStoreSchema),
); );
export function flushDraftPersistStorage(): Promise<void> { export function flushDraftPersistStorage(): Promise<void> {
@@ -414,12 +420,11 @@ export const useDraftStore = create<DraftStore>()(
version: DRAFT_STORE_VERSION, version: DRAFT_STORE_VERSION,
storage: draftPersistStorage, storage: draftPersistStorage,
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }), partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
migrate: (persistedState) => { migrate: (state) =>
return migratePersistedState(persistedState, { migratePersistedState(state, {
migrateLegacyImages, migrateLegacyImages,
nowMs: Date.now(), nowMs: Date.now(),
}); }),
},
onRehydrateStorage: () => { onRehydrateStorage: () => {
return () => { return () => {
void migrateAllLegacyDrafts(); void migrateAllLegacyDrafts();
@@ -1,11 +1,31 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; 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"; import { isAttachmentMetadata, type DraftRecord } from "./state";
const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) => const passThroughMigrateLegacyImages: MigrateLegacyImages = async (images) =>
images.filter(isAttachmentMetadata); images.filter(isAttachmentMetadata);
function createMemoryStorage(): StateStorage & { values: Map<string, string> } {
const values = new Map<string, string>();
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( function activeDraft(
text: string, text: string,
updatedAt: number, updatedAt: number,
@@ -96,6 +116,30 @@ function workspaceReviewAttachment(): Extract<ComposerAttachment, { kind: "revie
} }
describe("draft-store migration", () => { describe("draft-store migration", () => {
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 () => { it("promotes the newest legacy New Workspace draft into the singleton surface", async () => {
const forkDraft = activeDraft("fork context", 1700000000003); const forkDraft = activeDraft("fork context", 1700000000003);
const agentDraft = activeDraft("agent prompt", 1700000000004); const agentDraft = activeDraft("agent prompt", 1700000000004);
@@ -242,9 +286,9 @@ describe("draft-store migration", () => {
}); });
}); });
it("rejects workspace review attachments from migrated draft attachments", async () => { it("drops a legacy workspace review attachment without deleting its draft", async () => {
const migrated = await migratePersistedState( const backing = createMemoryStorage();
{ const persistedState = {
drafts: { drafts: {
"agent:server:agent": { "agent:server:agent": {
input: { input: {
@@ -257,10 +301,20 @@ describe("draft-store migration", () => {
}, },
}, },
createModalDraft: null, createModalDraft: null,
}, };
{ migrateLegacyImages: passThroughMigrateLegacyImages, nowMs: 1700000000002 }, 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);
}); });
}); });
+136 -57
View File
@@ -1,11 +1,12 @@
import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types"; import type { AttachmentMetadata, UserComposerAttachment } from "@/attachments/types";
import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys"; import { isLegacyNewWorkspaceDraftKey, NEW_WORKSPACE_DRAFT_KEY } from "@/stores/draft-keys";
import { z } from "zod";
import { import {
isAttachmentMetadata, AttachmentMetadataSchema,
isLegacyDraftImage, LegacyDraftImageSchema,
isUserComposerAttachment,
normalizeAttachmentMetadata, normalizeAttachmentMetadata,
normalizeComposerAttachment, normalizeComposerAttachment,
UserComposerAttachmentSchema,
type CanonicalDraftInput, type CanonicalDraftInput,
type DraftLifecycleState, type DraftLifecycleState,
type DraftRecord, type DraftRecord,
@@ -13,29 +14,109 @@ import {
type PersistedDraftImage, type PersistedDraftImage,
} from "./state"; } 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<typeof PersistedComposerAttachmentSchema>;
const LegacyAttachmentMetadataSchema = AttachmentMetadataSchema.extend({
previewUri: z.string(),
});
type PersistedImage = PersistedDraftImage | z.infer<typeof LegacyAttachmentMetadataSchema>;
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<typeof PersistedDraftRecordSchema>;
export type MigrateLegacyImages = ( export type MigrateLegacyImages = (
images: readonly PersistedDraftImage[], images: readonly PersistedDraftImage[],
) => Promise<AttachmentMetadata[]>; ) => Promise<AttachmentMetadata[]>;
function normalizePersistedImage(value: unknown): PersistedDraftImage | null { function normalizePersistedImage(value: PersistedImage): PersistedDraftImage {
if (isAttachmentMetadata(value)) { if ("id" in value) {
return normalizeAttachmentMetadata(value); return normalizeAttachmentMetadata(value);
} }
if (isLegacyDraftImage(value)) {
return { return {
uri: value.uri, uri: value.uri,
...(value.mimeType ? { mimeType: value.mimeType } : {}), ...(value.mimeType ? { mimeType: value.mimeType } : {}),
}; };
} }
return null;
}
function normalizePersistedComposerAttachment(value: unknown): UserComposerAttachment | null {
if (!isUserComposerAttachment(value)) {
return null;
}
return normalizeComposerAttachment(value);
}
function legacyImagesToAttachments( function legacyImagesToAttachments(
images: readonly AttachmentMetadata[], images: readonly AttachmentMetadata[],
@@ -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( export async function migrateDraftInput(
input: { rawInput: unknown }, input: { rawInput: unknown },
ports: { migrateLegacyImages: MigrateLegacyImages }, ports: { migrateLegacyImages: MigrateLegacyImages },
): Promise<CanonicalDraftInput> { ): Promise<CanonicalDraftInput> {
const rawInput = const result = RawDraftInputSchema.safeParse(input.rawInput);
input.rawInput && typeof input.rawInput === "object" const rawInput = result.success ? result.data : {};
? (input.rawInput as Record<string, unknown>) const attachments = (rawInput.attachments ?? [])
: {}; .map(normalizePersistedComposerAttachment)
const attachments = Array.isArray(rawInput.attachments) .filter((attachment): attachment is UserComposerAttachment => attachment !== null);
? rawInput.attachments const legacyImages = (rawInput.images ?? []).map(normalizePersistedImage);
.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 migratedImages = await ports.migrateLegacyImages(legacyImages); const migratedImages = await ports.migrateLegacyImages(legacyImages);
return { return {
@@ -72,30 +165,29 @@ export async function migrateDraftInput(
}; };
} }
function resolvePersistedLifecycle(lifecycle: unknown): DraftLifecycleState { function resolvePersistedLifecycle(
if (lifecycle === "sent" || lifecycle === "abandoned") { lifecycle: DraftLifecycleState | undefined,
return lifecycle as DraftLifecycleState; ): DraftLifecycleState {
} return lifecycle ?? "active";
return "active";
} }
function extractRawInput(record: Record<string, unknown>): unknown { function extractRawInput(record: PersistedDraftRecord): z.infer<typeof RawDraftInputSchema> {
if ("input" in record && record.input && typeof record.input === "object") { if ("input" in record) {
return record.input; return record.input;
} }
return record; return record;
} }
async function buildMigratedDraftRecord( async function buildMigratedDraftRecord(
parsed: Record<string, unknown>, parsed: PersistedDraftRecord,
ports: { migrateLegacyImages: MigrateLegacyImages }, ports: { migrateLegacyImages: MigrateLegacyImages },
nowMs: number, nowMs: number,
): Promise<DraftRecord> { ): Promise<DraftRecord> {
return { return {
input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports), input: await migrateDraftInput({ rawInput: extractRawInput(parsed) }, ports),
lifecycle: resolvePersistedLifecycle(parsed.lifecycle), lifecycle: resolvePersistedLifecycle(parsed.lifecycle),
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : nowMs, updatedAt: parsed.updatedAt ?? nowMs,
version: typeof parsed.version === "number" ? parsed.version : 1, version: parsed.version ?? 1,
}; };
} }
@@ -143,30 +235,17 @@ export async function migratePersistedState(
state: unknown, state: unknown,
ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number }, ports: { migrateLegacyImages: MigrateLegacyImages; nowMs: number },
): Promise<DraftStoreState> { ): Promise<DraftStoreState> {
const input = (state ?? {}) as { const result = PersistedDraftStoreSchema.safeParse(state);
drafts?: Record<string, unknown>; const input = result.success ? result.data : {};
createModalDraft?: unknown;
};
const nextDrafts: Record<string, DraftRecord> = {}; const nextDrafts: Record<string, DraftRecord> = {};
for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) {
if (!rawRecord || typeof rawRecord !== "object") { nextDrafts[draftKey] = await buildMigratedDraftRecord(rawRecord, ports, ports.nowMs);
continue;
}
nextDrafts[draftKey] = await buildMigratedDraftRecord(
rawRecord as Record<string, unknown>,
ports,
ports.nowMs,
);
} }
let createModalDraft: DraftRecord | null = null; let createModalDraft: DraftRecord | null = null;
if (input.createModalDraft && typeof input.createModalDraft === "object") { if (input.createModalDraft) {
createModalDraft = await buildMigratedDraftRecord( createModalDraft = await buildMigratedDraftRecord(input.createModalDraft, ports, ports.nowMs);
input.createModalDraft as Record<string, unknown>,
ports,
ports.nowMs,
);
} }
return { return {
@@ -1,7 +1,6 @@
import type { PersistStorage } from "zustand/middleware"; import type { PersistStorage } from "zustand/middleware";
export const DRAFT_PERSIST_INTERVAL_MS = 200; export const DRAFT_PERSIST_INTERVAL_MS = 200;
export interface PersistenceScheduler { export interface PersistenceScheduler {
now: () => number; now: () => number;
schedule: (callback: () => void, delayMs: number) => unknown; schedule: (callback: () => void, delayMs: number) => unknown;
@@ -12,10 +11,28 @@ export interface DraftPersistStorage<T> extends PersistStorage<T> {
flush: () => Promise<void>; flush: () => Promise<void>;
} }
let nextSystemTimerId = 0;
const systemTimers = new Map<number, ReturnType<typeof setTimeout>>();
const systemScheduler: PersistenceScheduler = { const systemScheduler: PersistenceScheduler = {
now: Date.now, now: Date.now,
schedule: (callback, delayMs) => setTimeout(callback, delayMs), schedule: (callback, delayMs) => {
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>), 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<T>( export function createDraftPersistStorage<T>(
+88 -78
View File
@@ -3,12 +3,7 @@ import {
type AttachmentMetadata, type AttachmentMetadata,
type UserComposerAttachment, type UserComposerAttachment,
} from "@/attachments/types"; } from "@/attachments/types";
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file"; import { z } from "zod";
import {
ForgeSearchItemSchema,
GitHubSearchItemSchema,
UploadedFileAttachmentSchema,
} from "@getpaseo/protocol/messages";
export const DRAFT_STORE_VERSION = 5; export const DRAFT_STORE_VERSION = 5;
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
@@ -41,26 +36,95 @@ export interface DraftStoreState {
createModalDraft: DraftRecord | null; createModalDraft: DraftRecord | null;
} }
export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { export const AttachmentMetadataSchema = z.strictObject({
if (!value || typeof value !== "object") { id: z.string(),
return false; mimeType: z.string(),
} storageType: z.enum(["web-indexeddb", "desktop-file", "native-file"]),
const record = value as Record<string, unknown>; storageKey: z.string(),
return ( fileName: z.string().nullable().optional(),
typeof record.id === "string" && byteSize: z.number().nullable().optional(),
typeof record.mimeType === "string" && createdAt: z.number(),
typeof record.storageType === "string" && }) satisfies z.ZodType<AttachmentMetadata>;
typeof record.storageKey === "string" && export const LegacyDraftImageSchema: z.ZodType<LegacyDraftImage> = z.strictObject({
typeof record.createdAt === "number" 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<UserComposerAttachment> = 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<DraftRecord> = z.strictObject({
input: CanonicalDraftInputSchema,
lifecycle: z.enum(["active", "abandoned", "sent"]),
updatedAt: z.number(),
version: z.number().int().positive(),
});
export const DraftStoreStateSchema: z.ZodType<DraftStoreState> = z.strictObject({
drafts: z.record(z.string(), DraftRecordSchema),
createModalDraft: DraftRecordSchema.nullable(),
});
export function isAttachmentMetadata(value: unknown): value is AttachmentMetadata {
return AttachmentMetadataSchema.safeParse(value).success;
} }
export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { export function isLegacyDraftImage(value: unknown): value is LegacyDraftImage {
if (!value || typeof value !== "object") { return LegacyDraftImageSchema.safeParse(value).success;
return false;
}
const record = value as Record<string, unknown>;
return typeof record.uri === "string";
} }
export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { export function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata {
@@ -80,39 +144,7 @@ export function normalizeAttachmentMetadata(image: AttachmentMetadata): Attachme
} }
export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment { export function isUserComposerAttachment(value: unknown): value is UserComposerAttachment {
if (!value || typeof value !== "object") { return UserComposerAttachmentSchema.safeParse(value).success;
return false;
}
const record = value as Record<string, unknown>;
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
);
} }
export function normalizeComposerAttachment( export function normalizeComposerAttachment(
@@ -131,33 +163,11 @@ export function normalizeComposerAttachment(
selection: attachment.selection, 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; return attachment;
} }
export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { export function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput {
if (!value || typeof value !== "object") { return CanonicalDraftInputSchema.safeParse(value).success;
return false;
}
const input = value as Record<string, unknown>;
// 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)
);
} }
export function toDraftInputIfReady( export function toDraftInputIfReady(
@@ -20,12 +20,19 @@ class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage
this.saved = value; this.saved = value;
} }
async clear(): Promise<void> {
this.saved = null;
}
finishHydrationWith(selection: ActiveWorkspaceSelection | null) { finishHydrationWith(selection: ActiveWorkspaceSelection | null) {
this.finishRead(selection ? JSON.stringify(selection) : null); this.finishRead(selection ? JSON.stringify(selection) : null);
} }
getSavedSelection(): ActiveWorkspaceSelection | 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;
} }
} }
@@ -1,3 +1,5 @@
import { z } from "zod";
export interface ActiveWorkspaceSelection { export interface ActiveWorkspaceSelection {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -8,19 +10,17 @@ export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-
export interface LastWorkspaceSelectionStorage { export interface LastWorkspaceSelectionStorage {
read(): Promise<string | null>; read(): Promise<string | null>;
write(value: string): Promise<void>; write(value: string): Promise<void>;
clear(): Promise<void>;
} }
const ActiveWorkspaceSelectionSchema: z.ZodType<ActiveWorkspaceSelection> = z.strictObject({
serverId: z.string().trim().min(1),
workspaceId: z.string().trim().min(1),
});
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null { function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
if (!input || typeof input !== "object" || Array.isArray(input)) { const result = ActiveWorkspaceSelectionSchema.safeParse(input);
return null; return result.success ? result.data : null;
}
const record = input as Record<string, unknown>;
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 };
} }
function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null { function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null {
@@ -75,6 +75,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
.then((stored) => { .then((stored) => {
if (revision === hydrationRevision) { if (revision === hydrationRevision) {
selection = parseStoredWorkspaceSelection(stored); selection = parseStoredWorkspaceSelection(stored);
if (stored !== null && selection === null) {
void storage.clear().catch(() => {});
}
} }
return undefined; return undefined;
}) })
@@ -25,6 +25,7 @@ export type { NavigateToWorkspaceInput } from "./navigation";
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = { const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY), read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value), write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
clear: () => AsyncStorage.removeItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
}; };
const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore( const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
+5 -4
View File
@@ -1,5 +1,5 @@
import { create } from "zustand"; 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 AsyncStorage from "@react-native-async-storage/async-storage";
import { import {
buildExplorerCheckoutKey, buildExplorerCheckoutKey,
@@ -24,6 +24,7 @@ import {
MIN_EXPLORER_SIDEBAR_WIDTH, MIN_EXPLORER_SIDEBAR_WIDTH,
MIN_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH,
migratePanelState, migratePanelState,
PanelPersistedStateSchema,
selectIsAgentListOpen, selectIsAgentListOpen,
selectIsFileExplorerOpen, selectIsFileExplorerOpen,
setMobilePanelTarget, setMobilePanelTarget,
@@ -37,6 +38,7 @@ import {
type SortOption, type SortOption,
} from "./state"; } from "./state";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export type { ExplorerTab } from "../explorer-tab-memory"; export type { ExplorerTab } from "../explorer-tab-memory";
export type { ExplorerCheckoutContext } from "../explorer-checkout-context"; export type { ExplorerCheckoutContext } from "../explorer-checkout-context";
export type { export type {
@@ -311,9 +313,8 @@ export const usePanelStore = create<PanelState>()(
{ {
name: "panel-state", name: "panel-state",
version: 12, version: 12,
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, PanelPersistedStateSchema),
migrate: (persistedState, version) => migrate: (persistedState, version) => migratePanelState(persistedState, version, { isWeb }),
migratePanelState(persistedState, version, { isWeb }) as unknown as PanelState,
partialize: (state) => ({ partialize: (state) => ({
desktop: state.desktop, desktop: state.desktop,
explorerTab: state.explorerTab, explorerTab: state.explorerTab,
+36 -4
View File
@@ -5,6 +5,7 @@ import {
type ExplorerTab, type ExplorerTab,
} from "../explorer-tab-memory"; } from "../explorer-tab-memory";
import { type ExplorerCheckoutContext } from "../explorer-checkout-context"; import { type ExplorerCheckoutContext } from "../explorer-checkout-context";
import { z } from "zod";
export type MobilePanelView = "agent" | "agent-list" | "file-explorer"; export type MobilePanelView = "agent" | "agent-list" | "file-explorer";
@@ -161,7 +162,37 @@ export function buildToggleFileExplorerPatch(
return { desktop: { ...state.desktop, fileExplorerOpen: false } }; return { desktop: { ...state.desktop, fileExplorerOpen: false } };
} }
type MigratablePanelState = Record<string, unknown>; 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<typeof PanelPersistedStateSchema>;
function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void { function migratePanelV2Explorer(state: MigratablePanelState, isWeb: boolean): void {
if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) { if (isWeb && typeof state.explorerWidth === "number" && state.explorerWidth === 400) {
@@ -193,7 +224,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version:
state.explorerTabByCheckout = {}; state.explorerTabByCheckout = {};
return; return;
} }
const entries = Object.entries(state.explorerTabByCheckout as Record<string, unknown>); const entries = Object.entries(state.explorerTabByCheckout);
const next: Record<string, ExplorerTab> = {}; const next: Record<string, ExplorerTab> = {};
for (const [key, value] of entries) { for (const [key, value] of entries) {
if (!isExplorerTab(value)) { if (!isExplorerTab(value)) {
@@ -205,7 +236,7 @@ function migratePanelExplorerTabByCheckout(state: MigratablePanelState, version:
} }
function migratePanelDesktopFocusMode(state: MigratablePanelState): void { function migratePanelDesktopFocusMode(state: MigratablePanelState): void {
const desktop = state.desktop as Record<string, unknown> | undefined; const desktop = state.desktop;
if (!desktop) { if (!desktop) {
return; return;
} }
@@ -227,7 +258,8 @@ export function migratePanelState(
version: number, version: number,
options: { isWeb: boolean }, options: { isWeb: boolean },
): MigratablePanelState { ): MigratablePanelState {
const state = (persistedState ?? {}) as MigratablePanelState; const result = PanelPersistedStateSchema.safeParse(persistedState);
const state: MigratablePanelState = result.success ? result.data : {};
const { isWeb } = options; const { isWeb } = options;
if (version < 2) { if (version < 2) {
@@ -1,9 +1,12 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import { import {
type CollapsedProjectsState, type CollapsedProjectsState,
type PersistedCollapsedProjects,
mergePersistedCollapsedProjects, mergePersistedCollapsedProjects,
PersistedCollapsedProjectsSchema,
serializeCollapsedProjects, serializeCollapsedProjects,
setProjectCollapsed, setProjectCollapsed,
togglePinnedCollapsed, togglePinnedCollapsed,
@@ -19,7 +22,7 @@ interface SidebarCollapsedSectionsState extends CollapsedProjectsState {
} }
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()( export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>()(
persist( persist<SidebarCollapsedSectionsState, [], [], PersistedCollapsedProjects>(
(set) => ({ (set) => ({
collapsedProjectKeys: new Set(), collapsedProjectKeys: new Set(),
collapsedStatusGroupKeys: new Set(), collapsedStatusGroupKeys: new Set(),
@@ -34,13 +37,10 @@ export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsS
}), }),
{ {
name: "sidebar-collapsed-sections", name: "sidebar-collapsed-sections",
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, PersistedCollapsedProjectsSchema),
partialize: (state) => serializeCollapsedProjects(state), partialize: (state) => serializeCollapsedProjects(state),
merge: (persistedState, currentState) => merge: (persistedState, currentState) =>
mergePersistedCollapsedProjects( mergePersistedCollapsedProjects(persistedState, currentState),
persistedState as { collapsedProjectKeys?: unknown } | undefined,
currentState,
),
}, },
), ),
); );
@@ -52,13 +52,13 @@ describe("sidebar collapsed projects transitions", () => {
expect(restored.collapsedPinned).toBe(true); 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( const restored = mergePersistedCollapsedProjects(
{ collapsedProjectKeys: ["project-a", "project-b", 42] }, { collapsedProjectKeys: ["project-a", "project-b", 42] },
emptyState(), emptyState(),
); );
expect(Array.from(restored.collapsedProjectKeys)).toEqual(["project-a", "project-b"]); expect(Array.from(restored.collapsedProjectKeys)).toEqual([]);
expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]); expect(Array.from(restored.collapsedStatusGroupKeys)).toEqual([]);
}); });
@@ -1,3 +1,5 @@
import { z } from "zod";
export interface CollapsedProjectsState { export interface CollapsedProjectsState {
collapsedProjectKeys: Set<string>; collapsedProjectKeys: Set<string>;
collapsedStatusGroupKeys: Set<string>; collapsedStatusGroupKeys: Set<string>;
@@ -5,11 +7,18 @@ export interface CollapsedProjectsState {
} }
export interface PersistedCollapsedProjects { export interface PersistedCollapsedProjects {
collapsedProjectKeys?: unknown; collapsedProjectKeys?: string[];
collapsedStatusGroupKeys?: unknown; collapsedStatusGroupKeys?: string[];
collapsedPinned?: unknown; collapsedPinned?: boolean;
} }
export const PersistedCollapsedProjectsSchema: z.ZodType<PersistedCollapsedProjects> =
z.strictObject({
collapsedProjectKeys: z.array(z.string()).optional(),
collapsedStatusGroupKeys: z.array(z.string()).optional(),
collapsedPinned: z.boolean().optional(),
});
export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState { export function togglePinnedCollapsed(state: CollapsedProjectsState): CollapsedProjectsState {
return { ...state, collapsedPinned: !state.collapsedPinned }; return { ...state, collapsedPinned: !state.collapsedPinned };
} }
@@ -67,22 +76,21 @@ export function serializeCollapsedProjects(state: CollapsedProjectsState): {
} }
export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>( export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState>(
persisted: PersistedCollapsedProjects | undefined, persistedValue: unknown,
current: S, current: S,
): S { ): S {
if ( const result = PersistedCollapsedProjectsSchema.safeParse(persistedValue);
!persisted?.collapsedProjectKeys && if (!result.success) {
!persisted?.collapsedStatusGroupKeys &&
persisted?.collapsedPinned === undefined
) {
return current; return current;
} }
const restoredProjects = deserializeCollapsedKeys(persisted.collapsedProjectKeys); const persisted = result.data;
const restoredStatusGroups = deserializeCollapsedKeys(persisted.collapsedStatusGroupKeys); const restoredProjects = deserializeCollapsedKeys(
const restoredPinned = persisted.collapsedProjectKeys ?? Array.from(current.collapsedProjectKeys),
typeof persisted.collapsedPinned === "boolean" );
? persisted.collapsedPinned const restoredStatusGroups = deserializeCollapsedKeys(
: current.collapsedPinned; persisted.collapsedStatusGroupKeys ?? Array.from(current.collapsedStatusGroupKeys),
);
const restoredPinned = persisted.collapsedPinned ?? current.collapsedPinned;
if ( if (
areSetsEqual(current.collapsedProjectKeys, restoredProjects) && areSetsEqual(current.collapsedProjectKeys, restoredProjects) &&
areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) && areSetsEqual(current.collapsedStatusGroupKeys, restoredStatusGroups) &&
@@ -98,11 +106,8 @@ export function mergePersistedCollapsedProjects<S extends CollapsedProjectsState
}; };
} }
function deserializeCollapsedKeys(value: unknown): Set<string> { function deserializeCollapsedKeys(value: string[]): Set<string> {
if (!Array.isArray(value)) { return new Set(value);
return new Set();
}
return new Set(value.filter((key): key is string => typeof key === "string"));
} }
function areSetsEqual(left: Set<string>, right: Set<string>): boolean { function areSetsEqual(left: Set<string>, right: Set<string>): boolean {
+15 -5
View File
@@ -1,6 +1,8 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; 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 { interface SidebarOrderStoreState {
projectOrder: string[]; projectOrder: string[];
@@ -18,6 +20,14 @@ interface SidebarOrderPersistedState {
workspaceOrderByServerAndProject?: Record<string, string[]>; workspaceOrderByServerAndProject?: Record<string, string[]>;
} }
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 { interface SidebarWorkspaceOrderScope {
serverId: string; serverId: string;
projectViewKey: string; projectViewKey: string;
@@ -71,11 +81,11 @@ export function migrateSidebarOrderState(persistedState: unknown): {
projectOrder: string[]; projectOrder: string[];
workspaceOrderByProject: Record<string, string[]>; workspaceOrderByProject: Record<string, string[]>;
} { } {
const state = persistedState as SidebarOrderPersistedState | undefined; const result = SidebarOrderPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
if (!state) {
return { projectOrder: [], workspaceOrderByProject: {} }; return { projectOrder: [], workspaceOrderByProject: {} };
} }
const state: SidebarOrderPersistedState = result.data;
const projectOrder = normalizeKeys(state.projectOrder ?? []); const projectOrder = normalizeKeys(state.projectOrder ?? []);
const seenProjects = new Set(projectOrder); const seenProjects = new Set(projectOrder);
@@ -135,7 +145,7 @@ export const useSidebarOrderStore = create<SidebarOrderStoreState>()(
}), }),
{ {
name: "sidebar-project-workspace-order", name: "sidebar-project-workspace-order",
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, SidebarOrderPersistedStateSchema),
partialize: (state) => ({ partialize: (state) => ({
projectOrder: state.projectOrder, projectOrder: state.projectOrder,
workspaceOrderByProject: state.workspaceOrderByProject, workspaceOrderByProject: state.workspaceOrderByProject,
+28 -19
View File
@@ -1,6 +1,8 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; 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"; export type SidebarGroupMode = "project" | "status";
@@ -23,51 +25,55 @@ interface SidebarViewPersistedState {
hostFilters: string[]; hostFilters: string[];
} }
function isSidebarGroupMode(value: unknown): value is SidebarGroupMode { const SidebarGroupModeSchema = z.enum(["project", "status"]);
return value === "project" || value === "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<string, unknown> { type SidebarViewStorageState = z.infer<typeof SidebarViewPersistedStateSchema>;
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readLegacyGroupMode(persistedState: Record<string, unknown>): SidebarGroupMode | null { function readLegacyGroupMode(persistedState: SidebarViewStorageState): SidebarGroupMode | null {
const groupModeByServerId = persistedState.groupModeByServerId; const groupModeByServerId = persistedState.groupModeByServerId;
if (!isRecord(groupModeByServerId)) { if (!groupModeByServerId) {
return null; return null;
} }
const modes = Object.values(groupModeByServerId).filter(isSidebarGroupMode); const modes = Object.values(groupModeByServerId);
if (modes.length === 0) return null; if (modes.length === 0) return null;
return modes.includes("status") ? "status" : "project"; return modes.includes("status") ? "status" : "project";
} }
// Reads the host filter from any persisted shape: the current `hostFilters` array, or the // Reads the host filter from any persisted shape: the current `hostFilters` array, or the
// pre-v2 single `hostFilter` string (null/absent meant "all hosts"). // pre-v2 single `hostFilter` string (null/absent meant "all hosts").
function readHostFilters(persistedState: Record<string, unknown>): string[] { function readHostFilters(persistedState: SidebarViewStorageState): string[] {
const hostFilters = persistedState.hostFilters; const hostFilters = persistedState.hostFilters;
if (Array.isArray(hostFilters)) { if (hostFilters) {
return hostFilters.filter((value): value is string => typeof value === "string"); return hostFilters;
} }
// COMPAT(sidebarHostFilters): added in v0.1.102, remove after 2026-12-30 once pre-v2 persisted // 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. // sidebar state (a single `hostFilter` string) has aged out.
const legacyHostFilter = persistedState.hostFilter; const legacyHostFilter = persistedState.hostFilter;
return typeof legacyHostFilter === "string" ? [legacyHostFilter] : []; return legacyHostFilter ? [legacyHostFilter] : [];
} }
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState { export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
if (!isRecord(persistedState)) { const result = SidebarViewPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return { groupMode: "project", hostFilters: [] }; return { groupMode: "project", hostFilters: [] };
} }
const state = result.data;
const legacyGroupMode = readLegacyGroupMode(persistedState); const legacyGroupMode = readLegacyGroupMode(state);
if (legacyGroupMode) { if (legacyGroupMode) {
return { groupMode: legacyGroupMode, hostFilters: [] }; return { groupMode: legacyGroupMode, hostFilters: [] };
} }
return { return {
groupMode: isSidebarGroupMode(persistedState.groupMode) ? persistedState.groupMode : "project", groupMode: state.groupMode ?? "project",
hostFilters: readHostFilters(persistedState), hostFilters: readHostFilters(state),
}; };
} }
@@ -116,7 +122,10 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
{ {
name: SIDEBAR_VIEW_STORAGE_KEY, name: SIDEBAR_VIEW_STORAGE_KEY,
version: SIDEBAR_VIEW_STORE_VERSION, version: SIDEBAR_VIEW_STORE_VERSION,
storage: createJSONStorage(createSidebarViewStorage), storage: createValidatedPersistStorage(
createSidebarViewStorage(),
SidebarViewPersistedStateSchema,
),
partialize: (state) => ({ partialize: (state) => ({
groupMode: state.groupMode, groupMode: state.groupMode,
hostFilters: state.hostFilters, hostFilters: state.hostFilters,
@@ -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 { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
import { import {
collectAllPanes, 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", () => { it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();
@@ -1,7 +1,8 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { create } from "zustand"; 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 type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import { import {
defaultWorkspaceLayoutIds, defaultWorkspaceLayoutIds,
@@ -42,6 +43,7 @@ import {
type WorkspaceLayout, type WorkspaceLayout,
} from "@/stores/workspace-layout-actions"; } from "@/stores/workspace-layout-actions";
import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity"; import { normalizeWorkspaceTabTarget } from "@/workspace-tabs/identity";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
export { export {
collectAllPanes, collectAllPanes,
@@ -123,6 +125,83 @@ interface WorkspaceFocusRestorationState {
const MAX_TREE_DEPTH = 4; 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<SplitNode> = 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<WorkspaceLayout> = 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 { function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") { if (typeof value !== "string") {
return null; return null;
@@ -942,7 +1021,7 @@ export function createWorkspaceLayoutStore(
{ {
name: "workspace-layout-state", name: "workspace-layout-state",
version: 1, version: 1,
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, WorkspaceLayoutPersistedStateSchema),
partialize: (state) => { partialize: (state) => {
const layoutByWorkspace: Record<string, WorkspaceLayout> = {}; const layoutByWorkspace: Record<string, WorkspaceLayout> = {};
for (const key in state.layoutByWorkspace) { for (const key in state.layoutByWorkspace) {
@@ -957,6 +1036,17 @@ export function createWorkspaceLayoutStore(
splitSizesByWorkspace: state.splitSizesByWorkspace, 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 ?? {},
};
},
}, },
), ),
); );
+64 -48
View File
@@ -9,8 +9,9 @@ import {
import { import {
type HostAppearance, type HostAppearance,
defaultHostAppearance, defaultHostAppearance,
normalizeStoredHostAppearance, HostAppearanceSchema,
} from "@/hosts/appearance"; } from "@/hosts/appearance";
import { z } from "zod";
export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection }; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection };
@@ -293,54 +294,74 @@ export function connectionFromListen(listen: string): HostConnection | null {
} }
} }
function isPlainRecord(value: unknown): value is Record<string, unknown> { const StoredHostConnectionSchema = z.discriminatedUnion("type", [
return Boolean(value) && typeof value === "object" && !Array.isArray(value); 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<typeof StoredHostConnectionSchema>;
function toObjectRecord(value: unknown): Record<string, unknown> | undefined { function normalizeStoredConnection(connection: StoredHostConnection): HostConnection | null {
return isPlainRecord(value) ? value : undefined; if (connection.type === "directTcp") {
}
function normalizeStoredConnection(connection: unknown): HostConnection | null {
const record = toObjectRecord(connection);
if (!record) {
return null;
}
const type = record.type;
if (type === "directTcp") {
try { try {
const endpoint = normalizeLoopbackToLocalhost( const endpoint = normalizeLoopbackToLocalhost(normalizeHostPort(connection.endpoint));
normalizeHostPort(typeof record.endpoint === "string" ? record.endpoint : ""),
);
return DirectTcpHostConnectionSchema.parse({ return DirectTcpHostConnectionSchema.parse({
id: `direct:${endpoint}`, id: `direct:${endpoint}`,
type: "directTcp", type: "directTcp",
endpoint, endpoint,
useTls: record.useTls, useTls: connection.useTls,
...(typeof record.password === "string" ? { password: record.password } : {}), ...(connection.password !== undefined ? { password: connection.password } : {}),
}); });
} catch { } catch {
return null; return null;
} }
} }
if (type === "directSocket") { if (connection.type === "directSocket") {
const path = (typeof record.path === "string" ? record.path : "").trim(); const path = connection.path.trim();
return path ? { id: `socket:${path}`, type: "directSocket", path } : null; return path ? { id: `socket:${path}`, type: "directSocket", path } : null;
} }
if (type === "directPipe") { if (connection.type === "directPipe") {
const path = (typeof record.path === "string" ? record.path : "").trim(); const path = connection.path.trim();
return path ? { id: `pipe:${path}`, type: "directPipe", path } : null; return path ? { id: `pipe:${path}`, type: "directPipe", path } : null;
} }
if (type === "relay") { if (connection.type === "relay") {
try { try {
const relayEndpoint = normalizeHostPort( const relayEndpoint = normalizeHostPort(connection.relayEndpoint);
typeof record.relayEndpoint === "string" ? record.relayEndpoint : "", const daemonPublicKeyB64 = connection.daemonPublicKeyB64.trim();
);
const daemonPublicKeyB64 = (
typeof record.daemonPublicKeyB64 === "string" ? record.daemonPublicKeyB64 : ""
).trim();
if (!daemonPublicKeyB64) return null; if (!daemonPublicKeyB64) return null;
const useTls = typeof record.useTls === "boolean" ? record.useTls : undefined; const useTls = connection.useTls;
return { return {
id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`, id: useTls === true ? `relay:wss:${relayEndpoint}` : `relay:${relayEndpoint}`,
type: "relay", type: "relay",
@@ -357,17 +378,14 @@ function normalizeStoredConnection(connection: unknown): HostConnection | null {
} }
export function normalizeStoredHostProfile(entry: unknown): HostProfile | null { export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
const record = toObjectRecord(entry); const result = StoredHostProfileSchema.safeParse(entry);
if (!record) { if (!result.success) {
return null;
}
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
if (!serverId) {
return null; return null;
} }
const record = result.data;
const serverId = record.serverId;
const rawConnections = Array.isArray(record.connections) ? record.connections : []; const connections = record.connections
const connections = rawConnections
.map((connection) => normalizeStoredConnection(connection)) .map((connection) => normalizeStoredConnection(connection))
.filter((connection): connection is HostConnection => connection !== null); .filter((connection): connection is HostConnection => connection !== null);
if (connections.length === 0) { if (connections.length === 0) {
@@ -375,12 +393,10 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
const label = normalizeHostLabel( const label = normalizeHostLabel(record.label, serverId);
typeof record.label === "string" ? record.label : null,
serverId,
);
const preferredConnectionId = const preferredConnectionId =
typeof record.preferredConnectionId === "string" && record.preferredConnectionId !== null &&
record.preferredConnectionId !== undefined &&
connections.some((connection) => connection.id === record.preferredConnectionId) connections.some((connection) => connection.id === record.preferredConnectionId)
? record.preferredConnectionId ? record.preferredConnectionId
: (connections[0]?.id ?? null); : (connections[0]?.id ?? null);
@@ -388,12 +404,12 @@ export function normalizeStoredHostProfile(entry: unknown): HostProfile | null {
return { return {
serverId, serverId,
label, label,
appearance: normalizeStoredHostAppearance(record.appearance), appearance: record.appearance ?? defaultHostAppearance(),
lifecycle: defaultLifecycle(), lifecycle: defaultLifecycle(),
connections, connections,
preferredConnectionId, preferredConnectionId,
createdAt: typeof record.createdAt === "string" ? record.createdAt : now, createdAt: record.createdAt ?? now,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : now, updatedAt: record.updatedAt ?? now,
}; };
} }
+3
View File
@@ -18,6 +18,9 @@ function inMemoryStorage(initial: Record<string, string> = {}): InMemoryStorage
storage.setCallCount += 1; storage.setCallCount += 1;
items.set(key, value); items.set(key, value);
}, },
async removeItem(key) {
items.delete(key);
},
}; };
return storage; return storage;
} }
+6 -10
View File
@@ -1,23 +1,20 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; 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"; const CLIENT_ID_STORAGE_KEY = "@paseo:client-id-v1";
export interface ClientIdStorage { export interface ClientIdStorage {
getItem(key: string): Promise<string | null>; getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>; setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
} }
export interface ClientIdResolver { export interface ClientIdResolver {
getOrCreate(): Promise<string>; getOrCreate(): Promise<string>;
} }
function normalizeStoredClientId(value: unknown): string | null { const ClientIdSchema = z.string().trim().min(1);
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function createClientIdResolver(deps: { export function createClientIdResolver(deps: {
storage: ClientIdStorage; storage: ClientIdStorage;
@@ -38,8 +35,7 @@ export function createClientIdResolver(deps: {
} }
inFlight = (async () => { inFlight = (async () => {
const stored = await deps.storage.getItem(storageKey); const existing = await readValidatedString(deps.storage, storageKey, ClientIdSchema);
const existing = normalizeStoredClientId(stored);
if (existing) { if (existing) {
cached = existing; cached = existing;
return existing; return existing;
@@ -61,7 +57,7 @@ export function createClientIdResolver(deps: {
} }
function generateUuidFromGlobalCrypto(): string { function generateUuidFromGlobalCrypto(): string {
const cryptoObj = globalThis.crypto as { randomUUID?: () => string } | undefined; const cryptoObj = globalThis.crypto;
if (cryptoObj && typeof cryptoObj.randomUUID === "function") { if (cryptoObj && typeof cryptoObj.randomUUID === "function") {
return cryptoObj.randomUUID().replace(/-/g, ""); return cryptoObj.randomUUID().replace(/-/g, "");
} }
+19 -8
View File
@@ -1,7 +1,9 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; 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 { isTargetPinned, togglePinnedTarget, type PinnedTabTarget } from "@/workspace-pins/target";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
interface PinnedTargetsState { interface PinnedTargetsState {
pinned: PinnedTabTarget[]; pinned: PinnedTabTarget[];
@@ -10,6 +12,15 @@ interface PinnedTargetsState {
} }
const DEFAULT_PINNED_TARGETS: PinnedTabTarget[] = [{ kind: "terminal" }, { kind: "browser" }]; 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[] { function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[] {
const next = [...DEFAULT_PINNED_TARGETS]; const next = [...DEFAULT_PINNED_TARGETS];
@@ -22,7 +33,7 @@ function applyDefaultPinnedTargets(pinned: PinnedTabTarget[]): PinnedTabTarget[]
} }
export const usePinnedTargetsStore = create<PinnedTargetsState>()( export const usePinnedTargetsStore = create<PinnedTargetsState>()(
persist( persist<PinnedTargetsState, [], [], z.infer<typeof PinnedTargetsPersistedStateSchema>>(
(set, get) => ({ (set, get) => ({
pinned: [], pinned: [],
toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })), toggle: (target) => set((state) => ({ pinned: togglePinnedTarget(state.pinned, target) })),
@@ -32,21 +43,21 @@ export const usePinnedTargetsStore = create<PinnedTargetsState>()(
name: "pinned-tab-targets", name: "pinned-tab-targets",
version: 1, version: 1,
merge: (persistedState, currentState) => { merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<PinnedTargetsState> | null; const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState);
return { return {
...currentState, ...currentState,
...persisted, pinned: result.success ? result.data.pinned : applyDefaultPinnedTargets([]),
pinned: persisted?.pinned ?? applyDefaultPinnedTargets([]),
}; };
}, },
storage: createJSONStorage(() => AsyncStorage), storage: createValidatedPersistStorage(AsyncStorage, PinnedTargetsPersistedStateSchema),
partialize: (state) => ({ pinned: state.pinned }), partialize: (state) => ({ pinned: state.pinned }),
migrate: (persistedState, version) => { migrate: (persistedState, version) => {
const result = PinnedTargetsPersistedStateSchema.safeParse(persistedState);
const pinned = result.success ? result.data.pinned : [];
if (version === 0) { if (version === 0) {
const pinned = (persistedState as { pinned?: PinnedTabTarget[] } | null)?.pinned ?? [];
return { pinned: applyDefaultPinnedTargets(pinned) }; return { pinned: applyDefaultPinnedTargets(pinned) };
} }
return persistedState as PinnedTargetsState; return { pinned };
}, },
}, },
), ),
@@ -32,7 +32,7 @@ describe("workspace service route preferences", () => {
expect(restored.getState().byServerId).toEqual({ desktop: "direct", devbox: "public" }); 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({ const storage = createMemoryStorage({
"workspace-service-route-preferences": JSON.stringify({ "workspace-service-route-preferences": JSON.stringify({
state: { byServerId: { desktop: "direct", broken: "unknown" } }, state: { byServerId: { desktop: "direct", broken: "unknown" } },
@@ -42,6 +42,7 @@ describe("workspace service route preferences", () => {
const store = createWorkspaceServiceRoutePreferencesStore(storage); const store = createWorkspaceServiceRoutePreferencesStore(storage);
await store.persist.rehydrate(); 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);
}); });
}); });
@@ -1,6 +1,8 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import { create } from "zustand"; 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"; import type { WorkspaceScriptLinkKind } from "@/utils/workspace-script-links";
interface WorkspaceServiceRoutePreferencesState { interface WorkspaceServiceRoutePreferencesState {
@@ -8,25 +10,19 @@ interface WorkspaceServiceRoutePreferencesState {
setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void; setPreferredRoute: (serverId: string, kind: WorkspaceScriptLinkKind) => void;
} }
function isWorkspaceScriptLinkKind(value: unknown): value is WorkspaceScriptLinkKind { const WorkspaceScriptLinkKindSchema = z.enum(["public", "paseo", "direct"]);
return value === "public" || value === "paseo" || value === "direct"; const WorkspaceServiceRoutePreferencesSchema = z.strictObject({
} byServerId: z.record(z.string(), WorkspaceScriptLinkKindSchema),
});
function sanitizePreferences(value: unknown): Record<string, WorkspaceScriptLinkKind> {
if (!value || typeof value !== "object") return {};
const byServerId = (value as { byServerId?: unknown }).byServerId;
if (!byServerId || typeof byServerId !== "object") return {};
const result: Record<string, WorkspaceScriptLinkKind> = {};
for (const [serverId, kind] of Object.entries(byServerId)) {
if (isWorkspaceScriptLinkKind(kind)) result[serverId] = kind;
}
return result;
}
export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) { export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorage) {
return create<WorkspaceServiceRoutePreferencesState>()( return create<WorkspaceServiceRoutePreferencesState>()(
persist( persist<
WorkspaceServiceRoutePreferencesState,
[],
[],
z.infer<typeof WorkspaceServiceRoutePreferencesSchema>
>(
(set) => ({ (set) => ({
byServerId: {}, byServerId: {},
setPreferredRoute: (serverId, kind) => setPreferredRoute: (serverId, kind) =>
@@ -35,12 +31,15 @@ export function createWorkspaceServiceRoutePreferencesStore(storage: StateStorag
{ {
name: "workspace-service-route-preferences", name: "workspace-service-route-preferences",
version: 1, version: 1,
storage: createJSONStorage(() => storage), storage: createValidatedPersistStorage(storage, WorkspaceServiceRoutePreferencesSchema),
partialize: (state) => ({ byServerId: state.byServerId }), partialize: (state) => ({ byServerId: state.byServerId }),
merge: (persistedState, currentState) => ({ merge: (persistedState, currentState) => {
const result = WorkspaceServiceRoutePreferencesSchema.safeParse(persistedState);
return {
...currentState, ...currentState,
byServerId: sanitizePreferences(persistedState), byServerId: result.success ? result.data.byServerId : {},
}), };
},
}, },
), ),
); );