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
@@ -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 {