mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
* feat(workspaces): record workspace creation parentage Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle. * fix(app): preserve workspace parentage in replica cache
1630 lines
54 KiB
TypeScript
1630 lines
54 KiB
TypeScript
import { findMountedWindowStart } from "@/agent-stream/history-window";
|
|
import { z } from "zod";
|
|
import {
|
|
AgentStatusSchema,
|
|
AgentTimelineItemPayloadSchema,
|
|
WorkspaceGitHubRuntimePayloadSchema,
|
|
} from "@getpaseo/protocol/messages";
|
|
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
|
|
import type { PluginTimelineData } from "@getpaseo/plugin";
|
|
import {
|
|
normalizeProjectDescriptor,
|
|
normalizeWorkspaceDescriptor,
|
|
type Agent,
|
|
type AgentTimelineCursorState,
|
|
type ProjectDescriptor,
|
|
type WorkspaceDescriptor,
|
|
} from "@/stores/session-store";
|
|
import {
|
|
isUnreconciledLocalUserMessage,
|
|
type AgentToolCallData,
|
|
type StreamItem,
|
|
} from "@/types/stream";
|
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|
import { clearLegacyReplicaCache } from "./legacy-cleanup";
|
|
import {
|
|
REPLICA_SINGLETON_ROW_ID,
|
|
type ReplicaRow,
|
|
type ReplicaRowChanges,
|
|
type ReplicaRowKey,
|
|
type ReplicaRowKind,
|
|
type ReplicaRowStore,
|
|
} from "./row-store";
|
|
|
|
export type DirectoryReplicaMutation =
|
|
| { kind: "agent"; type: "upsert"; id: string; value: Agent }
|
|
| { kind: "agent"; type: "delete"; id: string }
|
|
| { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor }
|
|
| { kind: "workspace"; type: "delete"; id: string }
|
|
| { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor }
|
|
| { kind: "project"; type: "delete"; id: string };
|
|
|
|
export interface CachedDirectory {
|
|
agents: Map<string, Agent>;
|
|
workspaces: Map<string, WorkspaceDescriptor>;
|
|
projects: Map<string, ProjectDescriptor>;
|
|
checkpoint?: DirectoryCheckpoint;
|
|
}
|
|
|
|
export interface CachedWorkspace {
|
|
workspace: WorkspaceDescriptor;
|
|
project?: ProjectDescriptor;
|
|
}
|
|
|
|
export interface DirectoryCursor {
|
|
generation: string;
|
|
afterSeq: number;
|
|
}
|
|
|
|
export interface DirectoryCheckpoint {
|
|
projects?: DirectoryCursor;
|
|
workspaces?: DirectoryCursor;
|
|
agents?: DirectoryCursor;
|
|
}
|
|
|
|
export interface CachedTimeline {
|
|
agentId: string;
|
|
items: StreamItem[];
|
|
range: AgentTimelineCursorState | null;
|
|
hasOlder: boolean;
|
|
}
|
|
|
|
const PERSIST_DELAY_MS = 1_000;
|
|
const MAX_TIMELINE_ITEMS = 50;
|
|
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
|
const IsoDateSchema = z.iso.datetime();
|
|
const TimelinePositionSchema = z.strictObject({
|
|
epoch: z.string(),
|
|
seq: z.number().int().nonnegative(),
|
|
});
|
|
const PluginTimelineDataSchema: z.ZodType<PluginTimelineData> = z.lazy(() =>
|
|
z.union([
|
|
z.null(),
|
|
z.boolean(),
|
|
z.number(),
|
|
z.string(),
|
|
z.array(PluginTimelineDataSchema),
|
|
z.record(z.string(), PluginTimelineDataSchema),
|
|
]),
|
|
);
|
|
|
|
const TimelineItemBaseShape = {
|
|
id: z.string(),
|
|
timelineCursor: TimelinePositionSchema.optional(),
|
|
// COMPAT(active-turn-membership): absent on caches written before turn membership.
|
|
turnId: z.string().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"]),
|
|
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(),
|
|
// Reject old caches containing display fragments; refetch the complete source text.
|
|
}),
|
|
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("notification"),
|
|
sourceType: z.enum(["error", "notification"]),
|
|
level: z.enum(["info", "warning", "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(),
|
|
}),
|
|
z.strictObject({
|
|
...TimelineItemBaseShape,
|
|
kind: z.literal("tool_call"),
|
|
provider: AgentProviderSchema,
|
|
item: AgentTimelineItemPayloadSchema.refine((item) => item.type === "tool_call"),
|
|
}),
|
|
z.strictObject({
|
|
...TimelineItemBaseShape,
|
|
kind: z.literal("plugin"),
|
|
pluginId: z.string(),
|
|
pluginItemId: z.string(),
|
|
itemKind: z.string(),
|
|
version: z.number().int().positive(),
|
|
data: PluginTimelineDataSchema,
|
|
}),
|
|
]);
|
|
|
|
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 StoredProjectCheckoutSchema = z.union([
|
|
z.strictObject({
|
|
cwd: z.string(),
|
|
isGit: z.literal(false),
|
|
currentBranch: z.null(),
|
|
remoteUrl: z.null(),
|
|
worktreeRoot: z.null(),
|
|
isPaseoOwnedWorktree: z.literal(false),
|
|
mainRepoRoot: z.null(),
|
|
}),
|
|
z.strictObject({
|
|
cwd: z.string(),
|
|
isGit: z.literal(true),
|
|
currentBranch: z.string().nullable(),
|
|
remoteUrl: z.string().nullable(),
|
|
worktreeRoot: z.string(),
|
|
isPaseoOwnedWorktree: z.literal(false),
|
|
mainRepoRoot: z.string().nullable(),
|
|
}),
|
|
z.strictObject({
|
|
cwd: z.string(),
|
|
isGit: z.literal(true),
|
|
currentBranch: z.string().nullable(),
|
|
remoteUrl: z.string().nullable(),
|
|
worktreeRoot: z.string(),
|
|
isPaseoOwnedWorktree: z.literal(true),
|
|
mainRepoRoot: z.string(),
|
|
}),
|
|
]);
|
|
|
|
const StoredProjectPlacementSchema = z.strictObject({
|
|
projectKey: z.string(),
|
|
projectName: z.string(),
|
|
workspaceName: z.string().nullable().optional(),
|
|
checkout: StoredProjectCheckoutSchema,
|
|
});
|
|
|
|
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()),
|
|
// Optional: clients without background_agents never received a background agent to cache.
|
|
background: z.boolean().optional(),
|
|
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,
|
|
turn: z
|
|
.discriminatedUnion("phase", [
|
|
z.strictObject({ phase: z.literal("idle") }),
|
|
z.strictObject({
|
|
phase: z.literal("open"),
|
|
turnId: z.string().nullable(),
|
|
startedAt: IsoDateSchema.nullable(),
|
|
}),
|
|
])
|
|
.optional(),
|
|
projectPlacement: StoredProjectPlacementSchema.nullable(),
|
|
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({
|
|
parentWorkspaceId: z.string().optional(),
|
|
// Require both keys: a cursor without these values would preserve the wrong sort on reconnect.
|
|
createdAt: IsoDateSchema.nullable(),
|
|
lastUserMessageAt: IsoDateSchema.nullable(),
|
|
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"]),
|
|
// Require the flag so older rows that lost background visibility invalidate the workspace cursor.
|
|
background: z.boolean(),
|
|
name: z.string(),
|
|
title: z.string().nullable(),
|
|
pinnedAt: z.string().nullable(),
|
|
// Optional because entries written before labels existed have none. A cached workspace that
|
|
// dropped them painted its row without its chips and stayed that way: the directory cursor is
|
|
// current on reconnect, so the daemon has nothing newer to send back.
|
|
labels: z.array(z.string()).optional(),
|
|
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,
|
|
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
|
forge: z.string().optional(),
|
|
});
|
|
|
|
const StoredProjectSchema = z.strictObject({
|
|
projectId: z.string(),
|
|
projectKey: z.string().optional(),
|
|
projectDisplayName: z.string(),
|
|
projectCustomName: z.string().nullable(),
|
|
projectCustomIconRevision: z.string().nullable(),
|
|
projectIconRevision: z.string().optional(),
|
|
projectRootPath: z.string(),
|
|
projectKind: z.enum(["git", "non_git", "directory"]),
|
|
});
|
|
|
|
const StoredTimelineSchema = z.strictObject({
|
|
agentId: z.string(),
|
|
items: z.array(StoredTimelineItemSchema),
|
|
range: z
|
|
.strictObject({
|
|
epoch: z.string(),
|
|
startSeq: z.number().int().nonnegative(),
|
|
endSeq: z.number().int().nonnegative(),
|
|
})
|
|
.nullable(),
|
|
hasOlder: z.boolean(),
|
|
});
|
|
|
|
type StoredAgent = z.infer<typeof StoredAgentSchema>;
|
|
type StoredTimeline = z.infer<typeof StoredTimelineSchema>;
|
|
type StoredTimelineItem = z.infer<typeof StoredTimelineItemSchema>;
|
|
type StoredToolCall = Extract<StoredTimelineItem, { kind: "tool_call" }>["item"];
|
|
type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>;
|
|
type StoredProject = z.infer<typeof StoredProjectSchema>;
|
|
|
|
interface ReplicaCacheOptions {
|
|
maxBytes?: number;
|
|
clearLegacyCache?: () => Promise<void>;
|
|
}
|
|
|
|
type StructuredReplicaUpsert =
|
|
| { serverId: string; kind: "agent"; id: string; value: Agent }
|
|
| { serverId: string; kind: "workspace"; id: string; value: WorkspaceDescriptor }
|
|
| { serverId: string; kind: "project"; id: string; value: ProjectDescriptor }
|
|
| { serverId: string; kind: "timeline"; id: string; value: CachedTimeline }
|
|
| { serverId: string; kind: "checkpoint"; id: string; value: DirectoryCheckpoint };
|
|
|
|
const DirectoryCursorSchema = z.strictObject({
|
|
generation: z.string(),
|
|
afterSeq: z.number().int().nonnegative(),
|
|
});
|
|
|
|
const DirectoryCheckpointSchema = z.strictObject({
|
|
projects: DirectoryCursorSchema.optional(),
|
|
workspaces: DirectoryCursorSchema.optional(),
|
|
agents: DirectoryCursorSchema.optional(),
|
|
});
|
|
|
|
// Old checkpoints could describe a baseline that an overlapping refresh had discarded.
|
|
// Revalidate directory contents once without evicting cached rows or timelines.
|
|
const StoredDirectoryCheckpointSchema = z.strictObject({
|
|
version: z.literal(1),
|
|
cursors: DirectoryCheckpointSchema,
|
|
});
|
|
|
|
function serializeDirectoryCheckpoint(cursors: DirectoryCheckpoint) {
|
|
return { version: 1 as const, cursors };
|
|
}
|
|
|
|
function deserializeDirectoryCheckpoint(payload: string): DirectoryCheckpoint {
|
|
return parseStoredPayload(StoredDirectoryCheckpointSchema, payload).cursors;
|
|
}
|
|
|
|
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
|
|
if (!stored) {
|
|
return null;
|
|
}
|
|
return {
|
|
agentId: stored.agentId,
|
|
items: stored.items.map(deserializeTimelineItem),
|
|
range: stored.range,
|
|
hasOlder: stored.hasOlder,
|
|
};
|
|
}
|
|
|
|
function timelineBase(item: StreamItem) {
|
|
return {
|
|
id: item.id,
|
|
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
|
...(item.turnId ? { turnId: item.turnId } : {}),
|
|
timestamp: item.timestamp.toISOString(),
|
|
};
|
|
}
|
|
|
|
function serializeAgentToolCall(data: AgentToolCallData): StoredToolCall {
|
|
const base = {
|
|
type: "tool_call" as const,
|
|
callId: data.callId,
|
|
name: data.name,
|
|
detail: data.detail,
|
|
...(data.metadata ? { metadata: data.metadata } : {}),
|
|
...(data.agentMessage ? { agentMessage: data.agentMessage } : {}),
|
|
};
|
|
switch (data.status) {
|
|
case "running":
|
|
case "completed":
|
|
case "canceled":
|
|
return { ...base, status: data.status, error: null };
|
|
case "failed":
|
|
return { ...base, status: data.status, error: data.error };
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
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 "notification":
|
|
return {
|
|
...base,
|
|
kind: item.kind,
|
|
sourceType: item.sourceType,
|
|
level: item.level,
|
|
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":
|
|
if (item.payload.source !== "agent") return null;
|
|
return {
|
|
...base,
|
|
kind: item.kind,
|
|
provider: item.payload.data.provider,
|
|
item: serializeAgentToolCall(item.payload.data),
|
|
};
|
|
case "plugin":
|
|
return {
|
|
...base,
|
|
kind: item.kind,
|
|
pluginId: item.pluginId,
|
|
pluginItemId: item.pluginItemId,
|
|
itemKind: item.itemKind,
|
|
version: item.version,
|
|
data: item.data,
|
|
};
|
|
}
|
|
}
|
|
|
|
function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
|
|
if (item.kind === "plugin") {
|
|
return {
|
|
id: item.id,
|
|
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
|
...(item.turnId ? { turnId: item.turnId } : {}),
|
|
timestamp: new Date(item.timestamp),
|
|
kind: item.kind,
|
|
pluginId: item.pluginId,
|
|
pluginItemId: item.pluginItemId,
|
|
itemKind: item.itemKind,
|
|
version: item.version,
|
|
data: item.data,
|
|
};
|
|
}
|
|
return deserializeBuiltinTimelineItem(item);
|
|
}
|
|
|
|
function deserializeBuiltinTimelineItem(
|
|
item: Exclude<StoredTimelineItem, { kind: "plugin" }>,
|
|
): StreamItem {
|
|
const base = {
|
|
id: item.id,
|
|
...(item.timelineCursor ? { timelineCursor: item.timelineCursor } : {}),
|
|
...(item.turnId ? { turnId: item.turnId } : {}),
|
|
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,
|
|
};
|
|
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 "notification":
|
|
return {
|
|
...base,
|
|
kind: item.kind,
|
|
sourceType: item.sourceType,
|
|
level: item.level,
|
|
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": {
|
|
const tool = item.item;
|
|
if (tool.type !== "tool_call") {
|
|
throw new Error("Stored tool call contains a non-tool timeline item");
|
|
}
|
|
return {
|
|
...base,
|
|
kind: item.kind,
|
|
payload: {
|
|
source: "agent",
|
|
data: {
|
|
provider: item.provider,
|
|
callId: tool.callId,
|
|
name: tool.name,
|
|
status: tool.status,
|
|
error: tool.error,
|
|
detail: tool.detail,
|
|
...(tool.metadata ? { metadata: tool.metadata } : {}),
|
|
...(tool.agentMessage ? { agentMessage: tool.agentMessage } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement"] {
|
|
return agent.projectPlacement ?? null;
|
|
}
|
|
|
|
function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
|
|
if (agent.turn.phase === "idle") return { phase: "idle" };
|
|
return {
|
|
phase: "open",
|
|
turnId: agent.turn.turnId,
|
|
startedAt: agent.turn.startedAt?.toISOString() ?? null,
|
|
};
|
|
}
|
|
|
|
function serializeAgentCapabilities(
|
|
capabilities: Agent["capabilities"],
|
|
): StoredAgent["snapshot"]["capabilities"] {
|
|
return {
|
|
supportsStreaming: capabilities.supportsStreaming,
|
|
supportsSessionPersistence: capabilities.supportsSessionPersistence,
|
|
...(capabilities.supportsSessionListing !== undefined
|
|
? { supportsSessionListing: capabilities.supportsSessionListing }
|
|
: {}),
|
|
supportsDynamicModes: capabilities.supportsDynamicModes,
|
|
supportsMcpServers: capabilities.supportsMcpServers,
|
|
supportsReasoningStream: capabilities.supportsReasoningStream,
|
|
supportsToolInvocations: capabilities.supportsToolInvocations,
|
|
...(capabilities.supportsRewindConversation !== undefined
|
|
? { supportsRewindConversation: capabilities.supportsRewindConversation }
|
|
: {}),
|
|
...(capabilities.supportsRewindFiles !== undefined
|
|
? { supportsRewindFiles: capabilities.supportsRewindFiles }
|
|
: {}),
|
|
...(capabilities.supportsRewindBoth !== undefined
|
|
? { supportsRewindBoth: capabilities.supportsRewindBoth }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
function serializeAgent(agent: Agent): StoredAgent {
|
|
const snapshot = {
|
|
id: agent.id,
|
|
provider: agent.provider,
|
|
cwd: agent.cwd,
|
|
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
|
|
model: agent.model,
|
|
thinkingOptionId: agent.thinkingOptionId ?? null,
|
|
createdAt: agent.createdAt.toISOString(),
|
|
updatedAt: agent.updatedAt.toISOString(),
|
|
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
|
|
status: agent.status,
|
|
...(agent.turn.phase === "open" && agent.turn.turnId
|
|
? {
|
|
activeTurn: {
|
|
turnId: agent.turn.turnId,
|
|
startedAt: agent.turn.startedAt?.toISOString() ?? null,
|
|
},
|
|
}
|
|
: {}),
|
|
capabilities: serializeAgentCapabilities(agent.capabilities),
|
|
currentModeId: agent.currentModeId,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
|
title: agent.title,
|
|
labels: agent.labels,
|
|
...(agent.background ? { background: true } : {}),
|
|
requiresAttention: agent.requiresAttention ?? false,
|
|
attentionReason: agent.attentionReason ?? null,
|
|
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
|
archivedAt: agent.archivedAt?.toISOString() ?? null,
|
|
};
|
|
return {
|
|
snapshot,
|
|
turn: serializeAgentTurn(agent),
|
|
projectPlacement: serializeProjectPlacement(agent),
|
|
lastActivityAt: agent.lastActivityAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
function deserializeAgent(serverId: string, stored: StoredAgent): Agent {
|
|
const normalized = normalizeAgentSnapshot(stored.snapshot, serverId);
|
|
let turn = normalized.turn;
|
|
if (stored.turn?.phase === "idle") turn = { phase: "idle", cancellationRequestId: null };
|
|
if (stored.turn?.phase === "open") {
|
|
turn = {
|
|
phase: "open",
|
|
turnId: stored.turn.turnId,
|
|
startedAt: stored.turn.startedAt ? new Date(stored.turn.startedAt) : null,
|
|
cancellationRequestId: null,
|
|
};
|
|
}
|
|
return {
|
|
...normalized,
|
|
turn,
|
|
lastActivityAt: new Date(stored.lastActivityAt),
|
|
projectPlacement: stored.projectPlacement,
|
|
};
|
|
}
|
|
|
|
function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor {
|
|
return normalizeWorkspaceDescriptor({ ...stored, createdAt: stored.createdAt ?? undefined });
|
|
}
|
|
|
|
function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace {
|
|
return {
|
|
parentWorkspaceId: workspace.parentWorkspaceId,
|
|
createdAt: workspace.createdAt?.toISOString() ?? null,
|
|
lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null,
|
|
id: workspace.id,
|
|
projectId: workspace.projectId,
|
|
projectDisplayName: workspace.projectDisplayName,
|
|
projectCustomName: workspace.projectCustomName ?? null,
|
|
projectCustomIconRevision: workspace.projectCustomIconRevision ?? null,
|
|
projectRootPath: workspace.projectRootPath,
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
worktreeSlug: workspace.worktreeSlug,
|
|
projectKind: workspace.projectKind,
|
|
workspaceKind: workspace.workspaceKind,
|
|
background: workspace.background ?? false,
|
|
name: workspace.name,
|
|
title: workspace.title ?? null,
|
|
pinnedAt: workspace.pinnedAt ?? null,
|
|
labels: workspace.labels,
|
|
status: workspace.status,
|
|
statusEnteredAt: workspace.statusEnteredAt?.toISOString() ?? null,
|
|
activityAt: null,
|
|
archivingAt: workspace.archivingAt,
|
|
diffStat: workspace.diffStat,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function serializeProject(project: ProjectDescriptor): StoredProject {
|
|
return {
|
|
projectId: project.projectId,
|
|
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
|
projectDisplayName: project.projectDisplayName,
|
|
projectCustomName: project.projectCustomName,
|
|
projectCustomIconRevision: project.projectCustomIconRevision ?? null,
|
|
projectIconRevision: project.projectIconRevision,
|
|
projectRootPath: project.projectRootPath,
|
|
projectKind: project.projectKind,
|
|
};
|
|
}
|
|
|
|
function isTimelineItemStoredLosslessly(item: StreamItem): boolean {
|
|
switch (item.kind) {
|
|
case "user_message":
|
|
return (item.images?.length ?? 0) === 0 && (item.attachments?.length ?? 0) === 0;
|
|
case "tool_call":
|
|
return item.payload.source === "agent";
|
|
default:
|
|
return true;
|
|
}
|
|
}
|
|
|
|
function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null {
|
|
const canonicalItems = timeline.items.filter(
|
|
(item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item),
|
|
);
|
|
const start = findMountedWindowStart({
|
|
items: canonicalItems,
|
|
minMountedCount: MAX_TIMELINE_ITEMS,
|
|
});
|
|
const items = canonicalItems
|
|
.slice(start)
|
|
.map(serializeTimelineItem)
|
|
.filter((item) => item !== null);
|
|
const range = timeline.range;
|
|
const canPersistCoverage =
|
|
range !== null &&
|
|
range.retainedRanges === undefined &&
|
|
canonicalItems.length <= MAX_TIMELINE_ITEMS &&
|
|
items.length === canonicalItems.length &&
|
|
canonicalItems.every(
|
|
(item) =>
|
|
isTimelineItemStoredLosslessly(item) &&
|
|
item.timelineCursor?.epoch === range.epoch &&
|
|
item.timelineCursor.seq >= range.startSeq &&
|
|
item.timelineCursor.seq <= range.endSeq,
|
|
) &&
|
|
canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq);
|
|
return {
|
|
agentId: timeline.agentId,
|
|
items,
|
|
range: canPersistCoverage
|
|
? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq }
|
|
: null,
|
|
hasOlder: canPersistCoverage ? timeline.hasOlder : false,
|
|
};
|
|
}
|
|
|
|
function rowKey(key: Pick<ReplicaRowKey, "kind" | "id">): string {
|
|
return `${key.kind}\u0000${key.id}`;
|
|
}
|
|
|
|
function pendingRowKey(key: ReplicaRowKey): string {
|
|
return `${key.serverId}\u0000${rowKey(key)}`;
|
|
}
|
|
|
|
// Budget accounting runs over every stored row of a touched host on each persist. Rows are
|
|
// immutable once stored, so their size is computed once. The count itself avoids the JS Buffer
|
|
// polyfill, which materialises the whole byte array just to measure it.
|
|
const rowBytesCache = new WeakMap<ReplicaRow, number>();
|
|
|
|
function utf8ByteLength(text: string): number {
|
|
let bytes = 0;
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const code = text.charCodeAt(index);
|
|
if (code < 0x80) bytes += 1;
|
|
else if (code < 0x800) bytes += 2;
|
|
else if (code >= 0xd800 && code <= 0xdbff) {
|
|
bytes += 4;
|
|
index += 1;
|
|
} else bytes += 3;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function rowBytes(row: ReplicaRow): number {
|
|
let bytes = rowBytesCache.get(row);
|
|
if (bytes === undefined) {
|
|
bytes = utf8ByteLength(row.payload);
|
|
rowBytesCache.set(row, bytes);
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function parseJsonPayload(payload: string): unknown {
|
|
return JSON.parse(payload);
|
|
}
|
|
|
|
function parseStoredPayload<Value>(schema: z.ZodType<Value>, payload: string): Value {
|
|
const parsed = schema.safeParse(parseJsonPayload(payload));
|
|
if (!parsed.success) throw new Error("Invalid replica row payload");
|
|
return parsed.data;
|
|
}
|
|
|
|
interface DirectoryReadAccumulator {
|
|
agents: Map<string, Agent>;
|
|
workspaces: Map<string, WorkspaceDescriptor>;
|
|
projects: Map<string, ProjectDescriptor>;
|
|
checkpoint?: DirectoryCheckpoint;
|
|
}
|
|
|
|
interface PendingReplicaChanges {
|
|
upserts: StructuredReplicaUpsert[];
|
|
deletes: ReplicaRowKey[];
|
|
baselines: Set<string>;
|
|
}
|
|
|
|
function applyDirectoryRow(
|
|
serverId: string,
|
|
row: ReplicaRow,
|
|
result: DirectoryReadAccumulator,
|
|
): void {
|
|
switch (row.kind) {
|
|
case "agent": {
|
|
const stored = parseStoredPayload(StoredAgentSchema, row.payload);
|
|
if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch");
|
|
result.agents.set(row.id, deserializeAgent(serverId, stored));
|
|
return;
|
|
}
|
|
case "workspace": {
|
|
const stored = parseStoredPayload(StoredWorkspaceSchema, row.payload);
|
|
if (stored.id !== row.id) throw new Error("Replica workspace row id mismatch");
|
|
result.workspaces.set(row.id, restoreWorkspace(stored));
|
|
return;
|
|
}
|
|
case "project": {
|
|
const stored = parseStoredPayload(StoredProjectSchema, row.payload);
|
|
if (stored.projectId !== row.id) throw new Error("Replica project row id mismatch");
|
|
result.projects.set(row.id, normalizeProjectDescriptor(stored));
|
|
return;
|
|
}
|
|
case "checkpoint":
|
|
if (row.id !== REPLICA_SINGLETON_ROW_ID) {
|
|
throw new Error("Replica checkpoint row id mismatch");
|
|
}
|
|
result.checkpoint = deserializeDirectoryCheckpoint(row.payload);
|
|
return;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | undefined {
|
|
if (row.kind === "agent") return "agents";
|
|
if (row.kind === "workspace") return "workspaces";
|
|
if (row.kind === "project") return "projects";
|
|
return undefined;
|
|
}
|
|
|
|
interface ReplicaRead {
|
|
serverId: string;
|
|
kinds: readonly ReplicaRowKind[];
|
|
ids?: readonly string[];
|
|
invalidated: boolean;
|
|
}
|
|
|
|
function matchesReplicaRead(read: ReplicaRead, row: ReplicaRowKey): boolean {
|
|
return (
|
|
read.serverId === row.serverId &&
|
|
read.kinds.includes(row.kind) &&
|
|
(!read.ids || read.ids.includes(row.id))
|
|
);
|
|
}
|
|
|
|
export class ReplicaCache {
|
|
private readonly activeServerIds = new Set<string>();
|
|
private readonly activeReads = new Set<ReplicaRead>();
|
|
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
|
|
private readonly hostBytes = new Map<string, number>();
|
|
private readonly hostWriteOrder = new Map<string, true>();
|
|
private pendingUpserts = new Map<string, StructuredReplicaUpsert>();
|
|
private pendingDeletes = new Map<string, ReplicaRowKey>();
|
|
private pendingBaselines = new Set<string>();
|
|
private readonly invalidatedHosts = new Set<string>();
|
|
private readonly maxBytes: number;
|
|
private totalBytes = 0;
|
|
private persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private writeQueue: Promise<void> = Promise.resolve();
|
|
private preparePromise: Promise<void> | null = null;
|
|
private storedIndexPromise: Promise<void> | null = null;
|
|
|
|
constructor(
|
|
private readonly rowStore: ReplicaRowStore,
|
|
options: ReplicaCacheOptions = {},
|
|
) {
|
|
this.maxBytes = Math.max(options.maxBytes ?? MAX_CACHE_BYTES, 0);
|
|
this.clearLegacyCache = options.clearLegacyCache ?? clearLegacyReplicaCache;
|
|
}
|
|
|
|
private readonly clearLegacyCache: () => Promise<void>;
|
|
|
|
async readAgent(serverId: string, agentId: string): Promise<Agent | undefined> {
|
|
const rows = await this.readRows(serverId, ["agent"], [agentId]);
|
|
const row = rows[0];
|
|
if (!row) return undefined;
|
|
try {
|
|
const stored = parseStoredPayload(StoredAgentSchema, row.payload);
|
|
if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch");
|
|
return deserializeAgent(serverId, stored);
|
|
} catch {
|
|
await this.deleteInvalidRow(row);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined> {
|
|
const workspaceRow = (await this.readRows(serverId, ["workspace"], [workspaceId]))[0];
|
|
if (!workspaceRow) return undefined;
|
|
let workspace: WorkspaceDescriptor;
|
|
try {
|
|
const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload);
|
|
if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch");
|
|
workspace = restoreWorkspace(stored);
|
|
} catch {
|
|
await this.deleteInvalidRow(workspaceRow);
|
|
return undefined;
|
|
}
|
|
|
|
let project: ProjectDescriptor | undefined;
|
|
const projectRow = (await this.readRows(serverId, ["project"], [workspace.projectId]))[0];
|
|
if (projectRow) {
|
|
try {
|
|
const stored = parseStoredPayload(StoredProjectSchema, projectRow.payload);
|
|
if (stored.projectId !== projectRow.id) throw new Error("Replica project row id mismatch");
|
|
project = normalizeProjectDescriptor(stored);
|
|
} catch {
|
|
await this.deleteInvalidRow(projectRow);
|
|
}
|
|
}
|
|
|
|
return { workspace, ...(project ? { project } : {}) };
|
|
}
|
|
|
|
async readDirectory(serverId: string): Promise<CachedDirectory> {
|
|
const rows = await this.readRows(serverId, ["agent", "workspace", "project", "checkpoint"]);
|
|
const result: DirectoryReadAccumulator = {
|
|
agents: new Map(),
|
|
workspaces: new Map(),
|
|
projects: new Map(),
|
|
};
|
|
const invalidEntities = new Set<keyof DirectoryCheckpoint>();
|
|
const invalidRows: ReplicaRow[] = [];
|
|
for (const row of rows) {
|
|
try {
|
|
applyDirectoryRow(serverId, row, result);
|
|
} catch {
|
|
const invalidEntity = directoryEntityForRow(row);
|
|
if (invalidEntity) invalidEntities.add(invalidEntity);
|
|
invalidRows.push(row);
|
|
}
|
|
}
|
|
if (result.checkpoint && invalidEntities.size > 0) {
|
|
result.checkpoint = { ...result.checkpoint };
|
|
for (const entity of invalidEntities) delete result.checkpoint[entity];
|
|
}
|
|
if (invalidRows.length > 0) {
|
|
await this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined> {
|
|
const rows = await this.readRows(serverId, ["timeline"], [agentId]);
|
|
const row = rows[0];
|
|
if (!row) return undefined;
|
|
try {
|
|
const stored = parseStoredPayload(StoredTimelineSchema, row.payload);
|
|
if (stored.agentId !== agentId) return undefined;
|
|
return deserializeTimeline(stored) ?? undefined;
|
|
} catch {
|
|
await this.deleteInvalidRow(row);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private async readRows(
|
|
serverId: string,
|
|
kinds: readonly ReplicaRowKind[],
|
|
ids?: readonly string[],
|
|
): Promise<ReplicaRow[]> {
|
|
if (!this.activeServerIds.has(serverId)) return [];
|
|
try {
|
|
await this.prepareStore();
|
|
while (this.activeServerIds.has(serverId)) {
|
|
// A read may only answer from rows the store already holds, so it waits for this host's
|
|
// accepted commits to land. A store that rejects them will keep rejecting them: fail closed
|
|
// rather than re-attempt the write on every pass. A write rejected for another host leaves
|
|
// this host's stored rows readable.
|
|
const read: ReplicaRead = { serverId, kinds, ids, invalidated: false };
|
|
this.activeReads.add(read);
|
|
try {
|
|
if (!(await this.syncPending()) && this.hasPendingHostChanges(serverId)) return [];
|
|
if (read.invalidated || this.hasPendingReadChanges(read)) continue;
|
|
const rows = await this.rowStore.read(serverId, kinds, ids);
|
|
if (
|
|
this.activeServerIds.has(serverId) &&
|
|
!read.invalidated &&
|
|
!this.hasPendingReadChanges(read)
|
|
) {
|
|
return rows;
|
|
}
|
|
} finally {
|
|
this.activeReads.delete(read);
|
|
}
|
|
}
|
|
return [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private async deleteInvalidRow(row: ReplicaRow): Promise<void> {
|
|
const invalidEntity = directoryEntityForRow(row);
|
|
if (!invalidEntity) {
|
|
const changes = { upserts: [], deletes: [row] } satisfies ReplicaRowChanges;
|
|
await this.queueOperation(async () => {
|
|
await this.rowStore.apply(changes);
|
|
this.applyStoredChanges(changes);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const checkpointRow = (
|
|
await this.readRows(row.serverId, ["checkpoint"], [REPLICA_SINGLETON_ROW_ID])
|
|
)[0];
|
|
let checkpoint: DirectoryCheckpoint | undefined;
|
|
if (checkpointRow) {
|
|
try {
|
|
checkpoint = deserializeDirectoryCheckpoint(checkpointRow.payload);
|
|
checkpoint = { ...checkpoint };
|
|
delete checkpoint[invalidEntity];
|
|
} catch {
|
|
checkpoint = undefined;
|
|
}
|
|
}
|
|
const changes: ReplicaRowChanges = {
|
|
deletes: [row, ...(checkpointRow && !checkpoint ? [checkpointRow] : [])],
|
|
upserts:
|
|
checkpointRow && checkpoint
|
|
? [
|
|
{
|
|
serverId: row.serverId,
|
|
kind: "checkpoint",
|
|
id: REPLICA_SINGLETON_ROW_ID,
|
|
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
await this.queueOperation(async () => {
|
|
await this.rowStore.apply(changes);
|
|
this.applyStoredChanges(changes);
|
|
});
|
|
}
|
|
|
|
private async repairInvalidDirectoryRows(
|
|
serverId: string,
|
|
invalidRows: ReplicaRow[],
|
|
checkpoint: DirectoryCheckpoint | undefined,
|
|
): Promise<void> {
|
|
const checkpointWasInvalid = invalidRows.some((row) => row.kind === "checkpoint");
|
|
const changes: ReplicaRowChanges = {
|
|
deletes: invalidRows,
|
|
upserts:
|
|
checkpoint !== undefined && !checkpointWasInvalid
|
|
? [
|
|
{
|
|
serverId,
|
|
kind: "checkpoint",
|
|
id: REPLICA_SINGLETON_ROW_ID,
|
|
payload: JSON.stringify(serializeDirectoryCheckpoint(checkpoint)),
|
|
},
|
|
]
|
|
: [],
|
|
};
|
|
await this.queueOperation(async () => {
|
|
await this.rowStore.apply(changes);
|
|
this.applyStoredChanges(changes);
|
|
});
|
|
}
|
|
|
|
commitDirectoryMutations(
|
|
serverId: string,
|
|
mutations: readonly DirectoryReplicaMutation[],
|
|
checkpoint?: DirectoryCheckpoint,
|
|
): void {
|
|
if (!this.activeServerIds.has(serverId)) return;
|
|
if (this.invalidatedHosts.has(serverId)) return;
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === "delete") {
|
|
this.queueEntityDelete(serverId, mutation.kind, mutation.id);
|
|
} else {
|
|
this.queueUpsert({
|
|
serverId,
|
|
kind: mutation.kind,
|
|
id: mutation.id,
|
|
value: mutation.value,
|
|
} as StructuredReplicaUpsert);
|
|
}
|
|
}
|
|
if (checkpoint) {
|
|
this.queueUpsert({
|
|
serverId,
|
|
kind: "checkpoint",
|
|
id: REPLICA_SINGLETON_ROW_ID,
|
|
value: checkpoint,
|
|
});
|
|
}
|
|
this.schedulePersist();
|
|
}
|
|
|
|
replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void {
|
|
if (!this.activeServerIds.has(serverId)) return;
|
|
this.invalidateHostReads(serverId, ["agent", "workspace", "project", "checkpoint"]);
|
|
// Replacing the directory must not discard independently accepted timeline changes.
|
|
for (const [key, row] of this.pendingUpserts) {
|
|
if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key);
|
|
}
|
|
for (const [key, row] of this.pendingDeletes) {
|
|
if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key);
|
|
}
|
|
this.pendingBaselines.add(serverId);
|
|
for (const [id, value] of directory.agents) {
|
|
this.queueUpsert({ serverId, kind: "agent", id, value });
|
|
}
|
|
for (const [id, value] of directory.workspaces) {
|
|
this.queueUpsert({ serverId, kind: "workspace", id, value });
|
|
}
|
|
for (const [id, value] of directory.projects) {
|
|
this.queueUpsert({ serverId, kind: "project", id, value });
|
|
}
|
|
if (directory.checkpoint) {
|
|
this.queueUpsert({
|
|
serverId,
|
|
kind: "checkpoint",
|
|
id: REPLICA_SINGLETON_ROW_ID,
|
|
value: directory.checkpoint,
|
|
});
|
|
}
|
|
this.schedulePersist();
|
|
}
|
|
|
|
commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void {
|
|
if (!this.activeServerIds.has(serverId)) return;
|
|
if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload");
|
|
this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline });
|
|
this.schedulePersist();
|
|
}
|
|
|
|
setHosts(serverIds: Iterable<string>): void {
|
|
const next = new Set(serverIds);
|
|
const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId));
|
|
const added = [...next].filter((serverId) => !this.activeServerIds.has(serverId));
|
|
for (const serverId of [...removed, ...added]) this.invalidateHostReads(serverId);
|
|
this.activeServerIds.clear();
|
|
for (const serverId of next) this.activeServerIds.add(serverId);
|
|
for (const serverId of removed) {
|
|
this.removeStoredHost(serverId);
|
|
this.dropPendingHostChanges(serverId);
|
|
this.invalidatedHosts.delete(serverId);
|
|
this.queueOperation(() => this.rowStore.deleteHost(serverId));
|
|
}
|
|
}
|
|
|
|
reconcileServerId(oldServerId: string, newServerId: string): void {
|
|
this.invalidateHostReads(oldServerId);
|
|
this.invalidateHostReads(newServerId);
|
|
const rows = this.storedRows.get(oldServerId);
|
|
if (rows) {
|
|
const newRows = this.storedRows.get(newServerId) ?? new Map<string, ReplicaRow>();
|
|
this.totalBytes -=
|
|
(this.hostBytes.get(oldServerId) ?? 0) + (this.hostBytes.get(newServerId) ?? 0);
|
|
this.storedRows.delete(oldServerId);
|
|
for (const [key, row] of rows) newRows.set(key, { ...row, serverId: newServerId });
|
|
this.storedRows.set(newServerId, newRows);
|
|
const bytes = [...newRows.values()].reduce((sum, row) => sum + rowBytes(row), 0);
|
|
this.hostBytes.delete(oldServerId);
|
|
this.hostBytes.set(newServerId, bytes);
|
|
this.totalBytes += bytes;
|
|
this.hostWriteOrder.delete(oldServerId);
|
|
this.touchHost(newServerId);
|
|
}
|
|
this.renamePendingHostChanges(oldServerId, newServerId);
|
|
if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId);
|
|
this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId));
|
|
}
|
|
|
|
async flush(): Promise<void> {
|
|
await this.syncPending();
|
|
}
|
|
|
|
/** Resolves to whether every pending change reached the store. */
|
|
private async syncPending(): Promise<boolean> {
|
|
const persisted = await this.persist();
|
|
await this.writeQueue.catch(() => undefined);
|
|
return persisted;
|
|
}
|
|
|
|
private async flushPending(): Promise<void> {
|
|
await this.persist();
|
|
}
|
|
|
|
private async persist(): Promise<boolean> {
|
|
if (this.persistTimer) {
|
|
clearTimeout(this.persistTimer);
|
|
this.persistTimer = null;
|
|
}
|
|
if (!this.hasPendingChanges()) return true;
|
|
const write = this.writeQueue
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
const pending = this.drainPendingChanges();
|
|
try {
|
|
await this.prepareStore();
|
|
await this.ensureStoredIndex();
|
|
const changes = this.materializePendingChanges(pending);
|
|
const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes);
|
|
if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) {
|
|
await this.rowStore.apply(boundedChanges);
|
|
this.applyStoredChanges(boundedChanges);
|
|
}
|
|
for (const serverId of pending.baselines) {
|
|
if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId);
|
|
}
|
|
} catch {
|
|
this.restorePendingChanges(pending);
|
|
if (this.hasPendingChanges()) this.schedulePersist();
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
// The queue only sequences writes; every consumer decides for itself what a failure means.
|
|
this.writeQueue = write.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
return write;
|
|
}
|
|
|
|
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
|
|
this.queueDelete({ serverId, kind, id });
|
|
}
|
|
|
|
private queueUpsert(row: StructuredReplicaUpsert): void {
|
|
this.invalidateRowReads(row);
|
|
const key = pendingRowKey(row);
|
|
this.pendingDeletes.delete(key);
|
|
this.pendingUpserts.set(key, row);
|
|
}
|
|
|
|
private queueDelete(key: ReplicaRowKey): void {
|
|
this.invalidateRowReads(key);
|
|
const pendingKey = pendingRowKey(key);
|
|
this.pendingUpserts.delete(pendingKey);
|
|
this.pendingDeletes.set(pendingKey, key);
|
|
}
|
|
|
|
private hasPendingChanges(): boolean {
|
|
return (
|
|
this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0
|
|
);
|
|
}
|
|
|
|
private hasPendingReadChanges(read: ReplicaRead): boolean {
|
|
if (
|
|
this.pendingBaselines.has(read.serverId) &&
|
|
read.kinds.some((kind) => kind !== "timeline")
|
|
) {
|
|
return true;
|
|
}
|
|
return (
|
|
[...this.pendingUpserts.values()].some((row) => matchesReplicaRead(read, row)) ||
|
|
[...this.pendingDeletes.values()].some((row) => matchesReplicaRead(read, row))
|
|
);
|
|
}
|
|
|
|
private invalidateRowReads(row: ReplicaRowKey): void {
|
|
for (const read of this.activeReads) {
|
|
if (matchesReplicaRead(read, row)) read.invalidated = true;
|
|
}
|
|
}
|
|
|
|
private invalidateHostReads(serverId: string, kinds?: readonly ReplicaRowKind[]): void {
|
|
for (const read of this.activeReads) {
|
|
if (
|
|
read.serverId === serverId &&
|
|
(!kinds || kinds.some((kind) => read.kinds.includes(kind)))
|
|
) {
|
|
read.invalidated = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private hasPendingHostChanges(serverId: string): boolean {
|
|
if (this.pendingBaselines.has(serverId)) return true;
|
|
for (const row of this.pendingUpserts.values()) {
|
|
if (row.serverId === serverId) return true;
|
|
}
|
|
for (const row of this.pendingDeletes.values()) {
|
|
if (row.serverId === serverId) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private drainPendingChanges(): PendingReplicaChanges {
|
|
const changes = {
|
|
upserts: [...this.pendingUpserts.values()],
|
|
deletes: [...this.pendingDeletes.values()],
|
|
baselines: this.pendingBaselines,
|
|
};
|
|
this.pendingUpserts = new Map();
|
|
this.pendingDeletes = new Map();
|
|
this.pendingBaselines = new Set();
|
|
return changes;
|
|
}
|
|
|
|
private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges {
|
|
const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row]));
|
|
const upserts: ReplicaRow[] = [];
|
|
for (const upsert of pending.upserts) {
|
|
const row = this.materializeUpsert(upsert);
|
|
if (row) upserts.push(row);
|
|
else deletes.set(pendingRowKey(upsert), upsert);
|
|
}
|
|
for (const serverId of pending.baselines) {
|
|
const replacementKeys = new Set(
|
|
upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey),
|
|
);
|
|
for (const row of this.storedRows.get(serverId)?.values() ?? []) {
|
|
if (row.kind !== "timeline" && !replacementKeys.has(rowKey(row))) {
|
|
deletes.set(pendingRowKey(row), row);
|
|
}
|
|
}
|
|
}
|
|
return { upserts, deletes: [...deletes.values()] };
|
|
}
|
|
|
|
private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null {
|
|
let value: unknown;
|
|
switch (upsert.kind) {
|
|
case "agent":
|
|
value = serializeAgent(upsert.value);
|
|
break;
|
|
case "workspace":
|
|
value = serializeWorkspace(upsert.value);
|
|
break;
|
|
case "project":
|
|
value = serializeProject(upsert.value);
|
|
break;
|
|
case "timeline":
|
|
value = serializeTimeline(upsert.value);
|
|
if (!value) return null;
|
|
break;
|
|
case "checkpoint":
|
|
value = serializeDirectoryCheckpoint(upsert.value);
|
|
break;
|
|
}
|
|
return {
|
|
serverId: upsert.serverId,
|
|
kind: upsert.kind,
|
|
id: upsert.id,
|
|
payload: JSON.stringify(value),
|
|
};
|
|
}
|
|
|
|
private restorePendingChanges(changes: PendingReplicaChanges): void {
|
|
for (const serverId of changes.baselines) {
|
|
if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId);
|
|
}
|
|
for (const key of changes.deletes) {
|
|
const pendingKey = pendingRowKey(key);
|
|
if (
|
|
this.activeServerIds.has(key.serverId) &&
|
|
!this.pendingUpserts.has(pendingKey) &&
|
|
!this.pendingDeletes.has(pendingKey)
|
|
) {
|
|
this.queueDelete(key);
|
|
}
|
|
}
|
|
for (const row of changes.upserts) {
|
|
const pendingKey = pendingRowKey(row);
|
|
if (
|
|
this.activeServerIds.has(row.serverId) &&
|
|
!this.pendingUpserts.has(pendingKey) &&
|
|
!this.pendingDeletes.has(pendingKey)
|
|
) {
|
|
this.queueUpsert(row);
|
|
}
|
|
}
|
|
}
|
|
|
|
private applyStoredChanges(changes: ReplicaRowChanges): void {
|
|
const touchedServerIds = new Set<string>();
|
|
for (const key of changes.deletes) {
|
|
const rows = this.storedRows.get(key.serverId);
|
|
const previous = rows?.get(rowKey(key));
|
|
if (previous) {
|
|
rows?.delete(rowKey(key));
|
|
this.adjustHostBytes(key.serverId, -rowBytes(previous));
|
|
}
|
|
touchedServerIds.add(key.serverId);
|
|
}
|
|
for (const row of changes.upserts) {
|
|
const rows = this.storedRows.get(row.serverId) ?? new Map<string, ReplicaRow>();
|
|
const previous = rows.get(rowKey(row));
|
|
const previousBytes = previous ? rowBytes(previous) : 0;
|
|
rows.set(rowKey(row), row);
|
|
this.storedRows.set(row.serverId, rows);
|
|
this.adjustHostBytes(row.serverId, rowBytes(row) - previousBytes);
|
|
touchedServerIds.add(row.serverId);
|
|
}
|
|
for (const serverId of touchedServerIds) {
|
|
if ((this.storedRows.get(serverId)?.size ?? 0) === 0) {
|
|
this.removeStoredHost(serverId);
|
|
} else {
|
|
this.touchHost(serverId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private adjustHostBytes(serverId: string, delta: number): void {
|
|
this.hostBytes.set(serverId, (this.hostBytes.get(serverId) ?? 0) + delta);
|
|
this.totalBytes += delta;
|
|
}
|
|
|
|
private touchHost(serverId: string): void {
|
|
this.hostWriteOrder.delete(serverId);
|
|
this.hostWriteOrder.set(serverId, true);
|
|
}
|
|
|
|
private async fitChangesToBudget(
|
|
changes: ReplicaRowChanges,
|
|
): Promise<{ changes: ReplicaRowChanges; evicted: Set<string> }> {
|
|
const touchedServerIds = new Set<string>();
|
|
for (const key of changes.deletes) touchedServerIds.add(key.serverId);
|
|
for (const row of changes.upserts) touchedServerIds.add(row.serverId);
|
|
|
|
const projectedRows = new Map<string, Map<string, ReplicaRow>>();
|
|
const projectedBytes = new Map(this.hostBytes);
|
|
for (const serverId of touchedServerIds) {
|
|
projectedRows.set(serverId, new Map(this.storedRows.get(serverId)));
|
|
}
|
|
for (const key of changes.deletes) {
|
|
projectedRows.get(key.serverId)?.delete(rowKey(key));
|
|
}
|
|
for (const row of changes.upserts) {
|
|
projectedRows.get(row.serverId)?.set(rowKey(row), row);
|
|
}
|
|
for (const [serverId, rows] of projectedRows) {
|
|
projectedBytes.set(
|
|
serverId,
|
|
[...rows.values()].reduce((sum, row) => sum + rowBytes(row), 0),
|
|
);
|
|
}
|
|
|
|
const writeOrder = [...this.hostWriteOrder.keys()].filter(
|
|
(serverId) => !touchedServerIds.has(serverId),
|
|
);
|
|
writeOrder.push(...touchedServerIds);
|
|
let projectedTotal = [...projectedBytes.values()].reduce((sum, bytes) => sum + bytes, 0);
|
|
const evicted = new Set<string>();
|
|
while (projectedTotal > this.maxBytes) {
|
|
const serverId = writeOrder.shift();
|
|
if (serverId === undefined) break;
|
|
const bytes = projectedBytes.get(serverId) ?? 0;
|
|
projectedTotal -= bytes;
|
|
projectedBytes.delete(serverId);
|
|
evicted.add(serverId);
|
|
this.invalidatedHosts.add(serverId);
|
|
if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId);
|
|
this.removeStoredHost(serverId);
|
|
}
|
|
|
|
return {
|
|
changes: {
|
|
upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)),
|
|
deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)),
|
|
},
|
|
evicted,
|
|
};
|
|
}
|
|
|
|
private removeStoredHost(serverId: string): void {
|
|
this.totalBytes -= this.hostBytes.get(serverId) ?? 0;
|
|
this.storedRows.delete(serverId);
|
|
this.hostBytes.delete(serverId);
|
|
this.hostWriteOrder.delete(serverId);
|
|
}
|
|
|
|
private dropPendingHostChanges(serverId: string): void {
|
|
this.pendingBaselines.delete(serverId);
|
|
for (const [key, row] of this.pendingUpserts) {
|
|
if (row.serverId === serverId) this.pendingUpserts.delete(key);
|
|
}
|
|
for (const [key, row] of this.pendingDeletes) {
|
|
if (row.serverId === serverId) this.pendingDeletes.delete(key);
|
|
}
|
|
}
|
|
|
|
private renamePendingHostChanges(oldServerId: string, newServerId: string): void {
|
|
const changes = this.drainPendingChanges();
|
|
for (const key of changes.deletes) {
|
|
this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key);
|
|
}
|
|
for (const row of changes.upserts) {
|
|
this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row);
|
|
}
|
|
for (const serverId of changes.baselines) {
|
|
this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId);
|
|
}
|
|
if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId);
|
|
}
|
|
|
|
private prepareStore(): Promise<void> {
|
|
this.preparePromise ??= (async () => {
|
|
await this.rowStore.open();
|
|
// COMPAT(replica-blob-cache): remove after 2026-11
|
|
await this.clearLegacyCache().catch(() => undefined);
|
|
})();
|
|
return this.preparePromise;
|
|
}
|
|
|
|
private ensureStoredIndex(): Promise<void> {
|
|
this.storedIndexPromise ??= this.rowStore.readAll().then(async (hosts) => {
|
|
this.storedRows.clear();
|
|
this.hostBytes.clear();
|
|
this.hostWriteOrder.clear();
|
|
this.totalBytes = 0;
|
|
for (const host of hosts) {
|
|
if (!this.activeServerIds.has(host.serverId)) {
|
|
await this.rowStore.deleteHost(host.serverId);
|
|
continue;
|
|
}
|
|
const rows = new Map(host.rows.map((row) => [rowKey(row), row]));
|
|
const bytes = host.rows.reduce((sum, row) => sum + rowBytes(row), 0);
|
|
this.storedRows.set(host.serverId, rows);
|
|
this.hostBytes.set(host.serverId, bytes);
|
|
this.totalBytes += bytes;
|
|
this.touchHost(host.serverId);
|
|
}
|
|
return undefined;
|
|
});
|
|
return this.storedIndexPromise;
|
|
}
|
|
|
|
private queueOperation(operation: () => Promise<void>): Promise<void> {
|
|
this.writeQueue = this.writeQueue
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
await this.prepareStore();
|
|
await operation();
|
|
return undefined;
|
|
})
|
|
.catch(() => undefined);
|
|
return this.writeQueue;
|
|
}
|
|
|
|
private schedulePersist(): void {
|
|
if (this.persistTimer) return;
|
|
this.persistTimer = setTimeout(() => {
|
|
this.persistTimer = null;
|
|
void this.flushPending();
|
|
}, PERSIST_DELAY_MS);
|
|
}
|
|
}
|