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