Restore host directories instantly before reconnect (#3259)

* feat(sync): restore host directories before reconnect

Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.

* fix(sync): preserve active turn identity during catch-up

Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.

* test(e2e): inspect the real IndexedDB replica

Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.

* fix warm reconnect cache paths

* feat(sync): persist project icons and deepen directory sync

* fix(sync): preserve project update order

* fix(cache): preserve validated replica persistence

* fix(sync): retain cached agents during catch-up
This commit is contained in:
Mohamed Boudra
2026-08-13 23:10:44 +02:00
committed by GitHub
parent 599846650c
commit 373a98c64d
51 changed files with 2428 additions and 400 deletions
@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { DirectorySyncService } from "./index.js";
function project(projectId: string, name: string) {
return {
projectId,
projectDisplayName: name,
projectRootPath: `/${projectId}`,
projectKind: "git" as const,
};
}
describe("DirectorySyncService", () => {
test("owns sequencing and returns wire-ready latest project changes", () => {
const service = new DirectorySyncService("generation");
expect(service.synchronizeProjects([project("one", "One")], {}).sync).toMatchObject({
mode: "snapshot",
headSeq: 1,
});
service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Renamed") }, true);
service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Latest") }, true);
expect(
service.synchronizeProjects([project("one", "Latest")], {
generation: "generation",
afterSeq: 1,
}),
).toEqual({
projects: [{ ...project("one", "Latest"), syncSeq: 3 }],
sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] },
});
});
test("sequences a hard project removal once", () => {
const service = new DirectorySyncService("generation");
service.synchronizeProjects([project("one", "One")], {});
const update = { kind: "remove" as const, projectId: "one" };
expect(service.sequenceProjectUpdate(update, true)).toEqual({
...update,
generation: "generation",
seq: 2,
});
expect(service.sequenceProjectUpdate(update, true)).toEqual({
...update,
generation: "generation",
seq: 2,
});
expect(
service.synchronizeProjects([], { generation: "generation", afterSeq: 1 }).sync.removals,
).toEqual([{ id: "one", seq: 2 }]);
});
test("keeps one daemon-wide version for repeated observations", () => {
const service = new DirectorySyncService("generation");
const update = { kind: "upsert" as const, project: project("one", "One") };
const sequenced = { ...update, generation: "generation", seq: 1 };
expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced);
expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced);
});
test("sequences only the project whose effective icon changed", () => {
const service = new DirectorySyncService("generation");
const one = { ...project("one", "One"), projectIconRevision: "icon-a" };
const two = { ...project("two", "Two"), projectIconRevision: "icon-a" };
service.synchronizeProjects([one, two], {});
const changed = { ...one, projectIconRevision: "icon-b" };
service.sequenceProjectUpdate({ kind: "upsert", project: changed }, true);
expect(
service.synchronizeProjects([changed, two], {
generation: "generation",
afterSeq: 2,
}),
).toEqual({
projects: [{ ...changed, syncSeq: 3 }],
sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] },
});
});
test("falls back to a snapshot when the daemon generation changes", () => {
const service = new DirectorySyncService("new-generation");
const read = service.synchronizeProjects([project("one", "One")], {
generation: "old-generation",
afterSeq: 10,
});
expect(read.sync).toMatchObject({
mode: "snapshot",
reason: "generation_changed",
headSeq: 1,
});
});
});
@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import type {
AgentSnapshotPayload,
ProjectPlacementPayload,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js";
interface AgentDirectoryEntry {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
}
interface DirectorySyncCursor {
generation?: string;
afterSeq?: number;
}
interface DirectoryVersion {
generation: string;
seq: number;
}
type ProjectUpdate = Extract<SessionOutboundMessage, { type: "project.update" }>;
type ProjectListResponse = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
>["payload"];
type FetchWorkspacesResponse = Extract<
SessionOutboundMessage,
{ type: "fetch_workspaces_response" }
>["payload"];
type FetchAgentsResponse = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
>["payload"];
/** Daemon-global latest-state sequence owner for the active app directory. */
export class DirectorySyncService {
private readonly generation: string;
private readonly projects = new VersionedCollection<WorkspaceProjectDescriptorPayload>({
getId: (project) => project.projectId,
});
private readonly workspaces = new VersionedCollection<WorkspaceDescriptorPayload>({
getId: (workspace) => workspace.id,
});
private readonly agents = new VersionedCollection<AgentDirectoryEntry>({
getId: (entry) => entry.agent.id,
});
constructor(generation = randomUUID()) {
this.generation = generation;
}
synchronizeProjects(
snapshot: Iterable<WorkspaceProjectDescriptorPayload>,
cursor: DirectorySyncCursor,
): Pick<ProjectListResponse, "projects" | "sync"> {
this.projects.replaceAll(snapshot);
const read = this.read(this.projects, cursor);
return {
projects: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
sync: this.metadata(read),
};
}
synchronizeWorkspaces(
snapshot: Iterable<WorkspaceDescriptorPayload>,
cursor: DirectorySyncCursor,
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
this.workspaces.replaceAll(snapshot);
const read = this.read(this.workspaces, cursor);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
};
}
synchronizeAgents(
snapshot: Iterable<AgentDirectoryEntry>,
cursor: DirectorySyncCursor,
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
this.agents.replaceAll(snapshot);
const read = this.read(this.agents, cursor);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
};
}
sequenceProjectUpdate(
update: ProjectUpdate["payload"],
includeSequence: boolean,
): ProjectUpdate["payload"] {
if (update.kind === "upsert") {
this.projects.replace(update.project);
} else {
this.projects.remove(update.projectId);
}
const id = update.kind === "upsert" ? update.project.projectId : update.projectId;
return this.withVersion(update, includeSequence, this.version(this.projects, id));
}
sequenceWorkspaceUpdate<T extends object>(
payload: T,
value: WorkspaceDescriptorPayload | null,
id: string,
includeSequence: boolean,
): T & Partial<DirectoryVersion> {
if (value) {
this.workspaces.replace(value);
} else {
this.workspaces.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.workspaces, id));
}
sequenceAgentUpdate<T extends object>(
payload: T,
value: AgentDirectoryEntry | null,
id: string,
includeSequence: boolean,
): T & Partial<DirectoryVersion> {
if (value) {
this.agents.replace(value);
} else {
this.agents.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.agents, id));
}
private metadata(read: CollectionRead<unknown>) {
return {
generation: this.generation,
headSeq: read.headSeq,
mode: read.mode,
...(read.reason ? { reason: read.reason } : {}),
removals: read.removals,
};
}
private withVersion<T extends object>(
payload: T,
includeSequence: boolean,
version: DirectoryVersion | null,
): T & Partial<DirectoryVersion> {
return version && includeSequence ? { ...payload, ...version } : payload;
}
private read<T>(
collection: VersionedCollection<T>,
cursor: DirectorySyncCursor,
): CollectionRead<T> {
if (cursor.generation !== this.generation) {
return collection.readSnapshot(
cursor.generation === undefined ? "no_cursor" : "generation_changed",
);
}
if (cursor.afterSeq === undefined) {
return collection.readSnapshot("no_cursor");
}
return collection.readAfter(cursor.afterSeq);
}
private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null {
const seq = collection.sequenceFor(id);
return seq === null ? null : { generation: this.generation, seq };
}
}
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { VersionedCollection } from "./versioned-collection.js";
describe("VersionedCollection", () => {
test("returns only each entity's latest visible snapshot after a sequence", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
collection.replace({ id: "one", title: "first" });
const cursor = collection.headSeq;
collection.replace({ id: "one", title: "second" });
collection.replace({ id: "one", title: "latest" });
collection.replace({ id: "two", title: "other" });
expect(collection.readAfter(cursor)).toEqual({
mode: "changes",
headSeq: 4,
values: [
{ seq: 3, value: { id: "one", title: "latest" } },
{ seq: 4, value: { id: "two", title: "other" } },
],
removals: [],
});
});
test("does not advance for an unchanged projection or repeated removal", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
expect(collection.replace({ id: "one", title: "same" })?.seq).toBe(1);
expect(collection.replace({ id: "one", title: "same" })).toBeNull();
expect(collection.remove("one")?.seq).toBe(2);
expect(collection.remove("one")).toBeNull();
expect(collection.headSeq).toBe(2);
});
test("returns tombstones and lets a later value replace them", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
collection.replace({ id: "one", title: "before" });
collection.remove("one");
expect(collection.readAfter(1)).toEqual({
mode: "changes",
headSeq: 2,
values: [],
removals: [{ id: "one", seq: 2 }],
});
collection.replace({ id: "one", title: "after" });
expect(collection.readAfter(2)).toEqual({
mode: "changes",
headSeq: 3,
values: [{ seq: 3, value: { id: "one", title: "after" } }],
removals: [],
});
});
test("falls back to a latest snapshot when a required tombstone expired", () => {
const collection = new VersionedCollection<{ id: string }>({
getId: (value) => value.id,
maxTombstones: 1,
});
collection.replace({ id: "one" });
collection.replace({ id: "two" });
collection.remove("one");
collection.remove("two");
expect(collection.readAfter(2)).toEqual({
mode: "snapshot",
reason: "cursor_expired",
headSeq: 4,
values: [],
removals: [],
});
});
});
@@ -0,0 +1,141 @@
export interface VersionedValue<T> {
seq: number;
value: T;
}
export interface VersionedRemoval {
id: string;
seq: number;
}
export interface CollectionRead<T> {
mode: "changes" | "snapshot";
reason?: "no_cursor" | "generation_changed" | "cursor_expired";
headSeq: number;
values: VersionedValue<T>[];
removals: VersionedRemoval[];
}
export type PublishedChange<T> =
| { kind: "upsert"; seq: number; value: T }
| { kind: "remove"; seq: number; id: string };
interface StoredValue<T> extends VersionedValue<T> {
fingerprint: string;
}
interface VersionedCollectionOptions<T> {
getId(value: T): string;
fingerprint?(value: T): string;
maxTombstones?: number;
}
/** Owns latest-state sequencing for one directory entity type. */
export class VersionedCollection<T> {
private readonly valuesById = new Map<string, StoredValue<T>>();
private readonly tombstonesById = new Map<string, number>();
private readonly getId: (value: T) => string;
private readonly fingerprint: (value: T) => string;
private readonly maxTombstones: number;
private expiredThroughSeq = 0;
private nextSeq = 0;
constructor(options: VersionedCollectionOptions<T>) {
this.getId = options.getId;
this.fingerprint = options.fingerprint ?? ((value) => JSON.stringify(value));
this.maxTombstones = options.maxTombstones ?? 5_000;
}
get headSeq(): number {
return this.nextSeq;
}
sequenceFor(id: string): number | null {
return this.valuesById.get(id)?.seq ?? this.tombstonesById.get(id) ?? null;
}
replace(value: T): Extract<PublishedChange<T>, { kind: "upsert" }> | null {
const id = this.getId(value);
const fingerprint = this.fingerprint(value);
const previous = this.valuesById.get(id);
if (previous?.fingerprint === fingerprint) return null;
const seq = this.advance();
this.valuesById.set(id, { seq, fingerprint, value });
this.tombstonesById.delete(id);
return { kind: "upsert", seq, value };
}
remove(id: string): Extract<PublishedChange<T>, { kind: "remove" }> | null {
if (!this.valuesById.has(id) && this.tombstonesById.has(id)) return null;
if (!this.valuesById.delete(id) && !this.tombstonesById.has(id)) return null;
const seq = this.advance();
this.tombstonesById.delete(id);
this.tombstonesById.set(id, seq);
this.trimTombstones();
return { kind: "remove", seq, id };
}
replaceAll(values: Iterable<T>): void {
const retainedIds = new Set<string>();
for (const value of values) {
const id = this.getId(value);
retainedIds.add(id);
this.replace(value);
}
for (const id of this.valuesById.keys()) {
if (!retainedIds.has(id)) this.remove(id);
}
}
readSnapshot(
reason: "no_cursor" | "generation_changed" | "cursor_expired" = "no_cursor",
): CollectionRead<T> {
return {
mode: "snapshot",
reason,
headSeq: this.headSeq,
values: this.sortedValues(),
removals: [],
};
}
readAfter(afterSeq: number): CollectionRead<T> {
if (afterSeq < this.expiredThroughSeq || afterSeq > this.headSeq) {
return this.readSnapshot("cursor_expired");
}
return {
mode: "changes",
headSeq: this.headSeq,
values: this.sortedValues().filter((entry) => entry.seq > afterSeq),
removals: Array.from(this.tombstonesById, ([id, seq]) => ({ id, seq }))
.filter((entry) => entry.seq > afterSeq)
.sort((left, right) => left.seq - right.seq || left.id.localeCompare(right.id)),
};
}
private advance(): number {
this.nextSeq += 1;
return this.nextSeq;
}
private sortedValues(): VersionedValue<T>[] {
return Array.from(this.valuesById.values())
.map(({ seq, value }) => ({ seq, value }))
.sort(
(left, right) =>
left.seq - right.seq || this.getId(left.value).localeCompare(this.getId(right.value)),
);
}
private trimTombstones(): void {
while (this.tombstonesById.size > this.maxTombstones) {
const oldest = this.tombstonesById.entries().next().value;
if (!oldest) return;
const [id, seq] = oldest;
this.tombstonesById.delete(id);
this.expiredThroughSeq = Math.max(this.expiredThroughSeq, seq);
}
}
}
+4 -2
View File
@@ -710,6 +710,7 @@ describe("project command-center RPCs", () => {
projectDisplayName: "new-project",
projectCustomName: null,
projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: directoryPath,
projectKind: "non_git",
},
@@ -4967,7 +4968,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha
]);
});
test("sends project updates only to capable sockets in a retained session", () => {
test("sends project updates only to capable sockets in a retained session", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const session = createSessionForTest({ messages, targetedMessages });
@@ -4976,7 +4977,7 @@ test("sends project updates only to capable sockets in a retained session", () =
session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket);
session.emitProjectUpdate({
await session.emitProjectUpdate({
kind: "upsert",
project: createPersistedProjectRecord({
projectId: "project-capable-socket",
@@ -5039,6 +5040,7 @@ test("project.list returns every active project descriptor", async () => {
projectDisplayName: "acme/app",
projectCustomName: null,
projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: "/tmp/project-active",
projectKind: "git",
},
+119 -29
View File
@@ -70,6 +70,7 @@ import {
CLIENT_SHUTDOWN_RPC_REASON,
normalizeClientRestartRpcReason,
} from "./lifecycle-reasons.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js";
import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js";
@@ -144,7 +145,7 @@ import {
import { wrapSpokenInput } from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
readProjectIcon,
ProjectIconReader,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "../utils/project-custom-icon.js";
@@ -394,6 +395,7 @@ type WorkspaceUpdatePayload = Extract<
>["payload"];
interface WorkspaceUpdatesSubscriptionState {
subscriptionId: string;
syncEnabled?: boolean;
filter?: WorkspaceUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
@@ -446,6 +448,7 @@ export interface SessionOptions {
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
directorySync?: DirectorySyncService;
filesystem?: SessionFileSystem;
scheduleService: ScheduleService;
checkoutDiffManager: CheckoutDiffManager;
@@ -579,6 +582,10 @@ interface WorkspaceUpdateOptions {
optimisticStatus?: WorkspaceDescriptorPayload["status"];
}
function resolveDirectorySync(service: DirectorySyncService | undefined): DirectorySyncService {
return service ?? new DirectorySyncService();
}
function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition {
if (!record) {
return "created";
@@ -612,12 +619,14 @@ export class Session {
| null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
private readonly projectIcons: ProjectIconReader;
private readonly worktreesRoot: string | undefined;
private agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly directorySync: DirectorySyncService;
private readonly filesystem: SessionFileSystem;
private readonly github: ForgeService;
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
@@ -632,6 +641,7 @@ export class Session {
private unsubscribeProjectMutations: (() => void) | null = null;
private unsubscribeWorkspaceMutations: (() => void) | null = null;
private registryMutationQueue: Promise<void> = Promise.resolve();
private projectUpdateQueue: Promise<void> = Promise.resolve();
private isCleanedUp = false;
private viewedTimelineAgentIds = new Set<string>();
private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>();
@@ -640,6 +650,7 @@ export class Session {
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
private readonly agentUpdates: AgentUpdatesService;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
private projectSyncEnabled = false;
private readonly workspaceUpdateTails = new Map<string, Promise<void>>();
private clientActivity: {
deviceType: "web" | "mobile";
@@ -699,6 +710,7 @@ export class Session {
agentStorage,
projectRegistry,
workspaceRegistry,
directorySync,
filesystem,
scheduleService,
checkoutDiffManager,
@@ -744,6 +756,7 @@ export class Session {
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushNotifications = pushNotifications;
this.paseoHome = paseoHome;
this.projectIcons = new ProjectIconReader(paseoHome);
this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({
module: "session",
@@ -764,6 +777,7 @@ export class Session {
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.directorySync = resolveDirectorySync(directorySync);
this.filesystem = filesystem ?? nodeSessionFileSystem;
this.github = github ?? createGitHubService();
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
@@ -922,6 +936,13 @@ export class Session {
this.buildProjectPlacementForWorkspaceId(workspaceId),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
this.directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
),
logger: this.sessionLogger,
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
@@ -1141,13 +1162,22 @@ export class Session {
);
}
emitProjectUpdate(update: ProjectUpdate): void {
emitProjectUpdate(update: ProjectUpdate): Promise<void> {
const queued = this.projectUpdateQueue
.catch(() => undefined)
.then(() => this.publishProjectUpdate(update));
this.projectUpdateQueue = queued;
return queued;
}
private async publishProjectUpdate(update: ProjectUpdate): Promise<void> {
const projectedPayload =
update.kind === "upsert"
? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) }
: update;
const message: SessionOutboundMessage = {
type: "project.update",
payload:
update.kind === "upsert"
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
: update,
payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled),
};
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message);
@@ -2121,7 +2151,7 @@ export class Session {
case "fetch_workspaces_request":
return this.handleFetchWorkspacesRequest(msg);
case "project.list.request":
return this.handleProjectListRequest(msg.requestId);
return this.handleProjectListRequest(msg);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
@@ -2692,7 +2722,7 @@ export class Session {
// Emit a project.update so clients that track the project as an empty
// project (no workspaces yet) receive the resolved name immediately.
this.emitProjectUpdate({ kind: "upsert", project: updated });
await this.emitProjectUpdate({ kind: "upsert", project: updated });
// Re-emit descriptors for every workspace under this project so the new
// resolved name lands in the UI immediately.
@@ -2746,7 +2776,7 @@ export class Session {
type: "project.icon.set.response",
payload: { requestId, projectId, accepted: true, error: null },
});
this.emitProjectUpdate({ kind: "upsert", project: updated });
await this.emitProjectUpdate({ kind: "upsert", project: updated });
const affectedWorkspaceIds = (await this.workspaceRegistry.list())
.filter((workspace) => workspace.projectId === projectId)
@@ -2772,7 +2802,7 @@ export class Session {
const project = await this.projectRegistry.get(projectId);
if (!project) throw new Error("Project not found");
const icon = await readProjectIcon({ paseoHome: this.paseoHome, project });
const icon = await this.projectIcons.read(project);
this.emit({
type: "project.icon.get.response",
payload: { projectId, icon, error: null, requestId },
@@ -4684,15 +4714,17 @@ export class Session {
}
}
private buildProjectDescriptor(
private async buildProjectDescriptor(
project: PersistedProjectRecord,
): WorkspaceProjectDescriptorPayload {
): Promise<WorkspaceProjectDescriptorPayload> {
const icon = await this.projectIcons.snapshot(project);
return {
projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectCustomIconRevision: project.customIconRevision ?? null,
projectIconRevision: icon.revision,
projectRootPath: project.rootPath,
projectKind: project.kind,
};
@@ -4890,7 +4922,6 @@ export class Session {
continue;
}
this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) {
if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) {
continue;
@@ -4899,17 +4930,28 @@ export class Session {
return;
}
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
const removePayload = await this.buildWorkspaceRemoveUpdatePayload(
workspaceId,
options?.removedProjectId,
);
this.bufferOrEmitWorkspaceUpdate(
subscription,
await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId),
this.directorySync.sequenceWorkspaceUpdate(
removePayload,
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
),
);
continue;
}
const nextPayload: WorkspaceUpdatePayload = {
kind: "upsert",
workspace: nextWorkspace,
};
const nextPayload: WorkspaceUpdatePayload = this.directorySync.sequenceWorkspaceUpdate(
{ kind: "upsert", workspace: nextWorkspace },
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
);
if (
lastEmitted &&
@@ -5013,10 +5055,13 @@ export class Session {
this.agentUpdates.beginSubscription({
subscriptionId,
filter: request.filter,
syncEnabled: Boolean(request.sync),
});
}
const payload = await this.listFetchAgentsEntries(request);
const payload = request.sync
? await this.readAgentDirectorySync(request)
: await this.listFetchAgentsEntries(request);
const snapshotUpdatedAtByAgentId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
@@ -5147,6 +5192,7 @@ export class Session {
if (subscriptionId) {
this.workspaceUpdatesSubscription = {
subscriptionId,
syncEnabled: Boolean(request.sync),
filter: request.filter,
isBootstrapping: true,
pendingUpdatesByWorkspaceId: new Map(),
@@ -5155,7 +5201,9 @@ export class Session {
};
}
const payload = await this.listFetchWorkspacesEntries(request);
const payload = request.sync
? await this.readWorkspaceDirectorySync(request)
: await this.listFetchWorkspacesEntries(request);
this.workspaceGitObserver.syncObservers(payload.entries);
this.sessionLogger.debug(
{
@@ -5205,21 +5253,32 @@ export class Session {
}
}
private async handleProjectListRequest(requestId: string): Promise<void> {
private async handleProjectListRequest(
request: Extract<SessionInboundMessage, { type: "project.list.request" }>,
): Promise<void> {
try {
const projects = (await this.projectRegistry.list())
.filter((project) => !project.archivedAt)
.map((project) => this.buildProjectDescriptor(project));
const projects = await Promise.all(
(await this.projectRegistry.list())
.filter((project) => !project.archivedAt)
.map((project) => this.buildProjectDescriptor(project)),
);
const synchronized = request.sync
? this.directorySync.synchronizeProjects(projects, request.sync)
: null;
if (synchronized) this.projectSyncEnabled = true;
this.emit({
type: "project.list.response",
payload: { requestId, projects },
payload: {
requestId: request.requestId,
...(synchronized ?? { projects }),
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.list.request");
this.emit({
type: "rpc_error",
payload: {
requestId,
requestId: request.requestId,
requestType: "project.list.request",
error: error instanceof Error ? error.message : "Failed to list projects",
code: "project_list_failed",
@@ -5228,6 +5287,37 @@ export class Session {
}
}
private async readAgentDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
) {
if (request.scope !== "active" || request.filter) {
throw new SessionRequestError(
"invalid_request",
"Sequenced agent directory reads require scope=active and no filter.",
);
}
const snapshot = await this.listFetchAgentsEntries({
...request,
page: { limit: Number.MAX_SAFE_INTEGER },
});
return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {});
}
private async readWorkspaceDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
) {
if (request.filter) {
throw new SessionRequestError(
"invalid_request",
"Sequenced workspace directory reads do not support filters.",
);
}
return this.directorySync.synchronizeWorkspaces(
await this.workspaceDirectory.listDescriptors(),
request.sync ?? {},
);
}
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
// to decide which pending updates to drop. Captures the status,
// statusEnteredAt, and activityAt (parsed to ms) for each workspace entry
@@ -5580,7 +5670,7 @@ export class Session {
type: "project.add.response",
payload: {
requestId: request.requestId,
project: this.buildProjectDescriptor(project),
project: await this.buildProjectDescriptor(project),
error: null,
},
});
@@ -5614,7 +5704,7 @@ export class Session {
payload: {
requestId: request.requestId,
directoryPath: result.directoryPath,
project: this.buildProjectDescriptor(result.project),
project: await this.buildProjectDescriptor(result.project),
error: null,
errorCode: null,
},
@@ -5779,7 +5869,7 @@ export class Session {
requestId: request.requestId,
repo: repo.displayName,
checkoutPath,
project: this.buildProjectDescriptor(project),
project: await this.buildProjectDescriptor(project),
error: null,
},
});
@@ -736,6 +736,55 @@ function createSessionForWorkspaceTests(
return session;
}
test("project.list.request catches up by sequence on the existing RPC", async () => {
const emitted: SessionOutboundMessage[] = [];
let project = createPersistedProjectRecord({
projectId: "project-sequenced",
rootPath: "/repo/sequenced",
kind: "git",
displayName: "Before",
createdAt: "2026-08-12T08:00:00.000Z",
updatedAt: "2026-08-12T08:00:00.000Z",
});
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [project],
get: async () => project,
getOrCreateActiveByRoot: async () => project,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
},
});
await session.handleMessage({
type: "project.list.request",
requestId: "initial",
sync: {},
});
const initial = findByType(emitted, "project.list.response")?.payload;
expect(initial?.sync).toMatchObject({ mode: "snapshot", headSeq: 1 });
project = { ...project, displayName: "After", updatedAt: "2026-08-12T08:01:00.000Z" };
await session.handleMessage({
type: "project.list.request",
requestId: "catch-up",
sync: {
generation: initial?.sync?.generation,
afterSeq: initial?.sync?.headSeq,
},
});
const responses = filterByType(emitted, "project.list.response");
expect(responses.at(-1)?.payload).toMatchObject({
requestId: "catch-up",
projects: [{ projectId: "project-sequenced", projectDisplayName: "After", syncSeq: 2 }],
sync: { mode: "changes", headSeq: 2, removals: [] },
});
});
test("agent updates preserve queued live transitions across stored metadata reads", async () => {
const running = makeManagedAgent({
id: "agent-coherent",
@@ -7754,15 +7803,17 @@ const ICON_PNG_1X1 = Buffer.from([
test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => {
const emitted: SessionOutboundMessage[] = [];
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const session = asTestSession(
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
createSessionForWorkspaceTests({
paseoHome: path.join(tempDir, "paseo-home"),
onMessage: (message) => emitted.push(message),
}),
);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const projectRoot = path.join(tempDir, "project-without-icons");
mkdirSync(projectRoot, { recursive: true });
session.paseoHome = path.join(tempDir, "paseo-home");
const project = createPersistedProjectRecord({
projectId: "prj_icon",
@@ -8,6 +8,7 @@ import type {
} from "../../messages.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
import { DirectorySyncService } from "../../directory-sync/index.js";
// No mocks — every dependency is an injected in-memory fake. The agent payloads
// are supplied through the fake builders, so each test fully controls the
@@ -100,6 +101,7 @@ function buildHarness() {
let providerVisible: (provider: string) => boolean = () => true;
let buildAgentPayloadError: Error | null = null;
let enrichProjectedPayload = false;
const directorySync = new DirectorySyncService("test-generation");
const service = createAgentUpdatesService({
emit: (message) => emitted.push(message),
@@ -133,6 +135,13 @@ function buildHarness() {
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
workspaceUpdates.push(workspaceId);
},
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
),
logger: { error: (...args: unknown[]) => loggedErrors.push(args) } as unknown as pino.Logger,
});
@@ -16,6 +16,7 @@ type AgentUpdatesFilter = NonNullable<
interface AgentUpdatesSubscriptionState {
subscriptionId: string;
syncEnabled?: boolean;
filter?: AgentUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
@@ -37,7 +38,11 @@ interface AgentUpdatesSubscriptionState {
* stay consistent.
*/
export interface AgentUpdatesService {
beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void;
beginSubscription(input: {
subscriptionId: string;
filter?: AgentUpdatesFilter;
syncEnabled?: boolean;
}): void;
flushBootstrapped(
subscriptionId: string,
options?: { snapshotUpdatedAtByAgentId?: Map<string, number> },
@@ -57,6 +62,13 @@ export interface AgentUpdatesServiceDeps {
isProviderVisibleToClient(provider: string): boolean;
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>;
emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>;
sequenceAgentUpdate<T extends AgentUpdatePayload>(
payload: T,
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
includeSequence: boolean,
): T;
logger: pino.Logger;
}
@@ -151,6 +163,13 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string {
export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService {
let subscription: AgentUpdatesSubscriptionState | null = null;
const liveAgentUpdateTails = new Map<string, Promise<void>>();
const sequence = <T extends AgentUpdatePayload>(
sub: AgentUpdatesSubscriptionState,
payload: T,
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true);
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) {
@@ -167,9 +186,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
});
}
function beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void {
function beginSubscription(input: {
subscriptionId: string;
filter?: AgentUpdatesFilter;
syncEnabled?: boolean;
}): void {
subscription = {
subscriptionId: input.subscriptionId,
syncEnabled: input.syncEnabled,
filter: input.filter,
isBootstrapping: true,
pendingUpdatesByAgentId: new Map(),
@@ -230,10 +254,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (!project) {
bufferOrEmit(sub, {
kind: "remove",
agentId: payload.id,
});
bufferOrEmit(
sub,
sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id),
);
return payload;
}
@@ -244,16 +268,22 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
});
bufferOrEmit(
sub,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId: payload.id,
},
sequence(
sub,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId: payload.id,
},
payload,
project,
payload.id,
),
);
return payload;
}
@@ -267,10 +297,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (!project) {
bufferOrEmit(sub, {
kind: "remove",
agentId: payload.id,
});
bufferOrEmit(
sub,
sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id),
);
} else {
const matches = matchesAgentUpdatesFilter({
agent: payload,
@@ -279,16 +309,34 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
});
if (matches) {
bufferOrEmit(sub, {
kind: "upsert",
agent: payload,
project,
});
bufferOrEmit(
sub,
sequence(
sub,
{
kind: "upsert",
agent: payload,
project,
},
payload,
project,
payload.id,
),
);
} else {
bufferOrEmit(sub, {
kind: "remove",
agentId: payload.id,
});
bufferOrEmit(
sub,
sequence(
sub,
{
kind: "remove",
agentId: payload.id,
},
payload,
project,
payload.id,
),
);
}
}
}
@@ -326,7 +374,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
function removeAgent(agentId: string): Promise<void> {
return enqueueAgentUpdate(agentId, () => {
if (subscription) {
bufferOrEmit(subscription, { kind: "remove", agentId });
bufferOrEmit(
subscription,
sequence(subscription, { kind: "remove", agentId }, null, null, agentId),
);
}
});
}
@@ -645,6 +645,16 @@ describe("CheckoutSession", () => {
},
]);
});
it("does not emit the same checkout status twice", () => {
const { checkout, emitted } = makeCheckoutSession();
const snapshot = createGitSnapshot("/repo", "main");
checkout.emitStatusUpdate("/repo", snapshot);
checkout.emitStatusUpdate("/repo", snapshot);
expect(emitted).toHaveLength(1);
});
});
describe("switch branch", () => {
@@ -153,6 +153,7 @@ export class CheckoutSession {
private readonly worktreesRoot: string | undefined;
private readonly logger: pino.Logger;
private readonly diffSubscriptions = new Map<string, () => void>();
private readonly statusUpdateFingerprints = new Map<string, string>();
constructor(options: CheckoutSessionOptions) {
this.host = options.host;
@@ -480,20 +481,24 @@ export class CheckoutSession {
emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
try {
const requestId = `subscription:${cwd}`;
const payload = {
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
};
const fingerprint = JSON.stringify(payload);
if (this.statusUpdateFingerprints.get(cwd) === fingerprint) return;
this.statusUpdateFingerprints.set(cwd, fingerprint);
this.host.emit({
type: "checkout_status_update",
payload: {
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
},
payload,
});
} catch (error) {
this.logger.warn({ err: error, cwd }, "Failed to emit workspace checkout status update");
@@ -1411,6 +1416,7 @@ export class CheckoutSession {
unsubscribe();
}
this.diffSubscriptions.clear();
this.statusUpdateFingerprints.clear();
}
}
+10 -1
View File
@@ -91,6 +91,7 @@ import {
} from "@getpaseo/protocol/browser-automation/capabilities";
import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import {
APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS,
ApplicationSocketLease,
@@ -577,6 +578,7 @@ export class VoiceAssistantWebSocketServer {
private acceptingConnections = true;
private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean;
private readonly directorySync = new DirectorySyncService();
constructor(
server: HTTPServer,
@@ -900,7 +902,11 @@ export class VoiceAssistantWebSocketServer {
}
public publishProjectUpdate(update: ProjectUpdate): void {
for (const session of this.listTrustedSessions()) session.emitProjectUpdate(update);
for (const session of this.listTrustedSessions()) {
void session
.emitProjectUpdate(update)
.catch((error) => this.logger.warn({ err: error }, "Failed to publish project update"));
}
}
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
@@ -1344,6 +1350,7 @@ export class VoiceAssistantWebSocketServer {
agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
directorySync: this.directorySync,
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
github: this.github,
@@ -1529,6 +1536,8 @@ export class VoiceAssistantWebSocketServer {
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
features: {
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
directorySync: true,
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true,
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
+55 -5
View File
@@ -11,6 +11,7 @@ import {
type SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import { Session, type SessionOptions } from "./session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { AgentTimelineRow } from "./agent/agent-manager.js";
import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js";
@@ -180,6 +181,7 @@ class InMemoryWorktreeWorkflow {
function createSessionForWireCompatTest(options?: {
clientCapabilities?: Record<string, unknown> | null;
directorySync?: DirectorySyncService;
messages?: SessionOutboundMessage[];
rows?: AgentTimelineRow[];
}): Session {
@@ -218,6 +220,7 @@ function createSessionForWireCompatTest(options?: {
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
workspaceRegistry:
new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"],
directorySync: options?.directorySync,
scheduleService: {} as SessionOptions["scheduleService"],
checkoutDiffManager: {
scheduleRefreshForCwd() {},
@@ -304,7 +307,7 @@ async function emitTimelineResponse(options?: {
}
describe("wire compatibility", () => {
test("sends project updates only to clients that declare support", () => {
test("sends project updates only to clients that declare support", async () => {
const project = createPersistedProjectRecord({
projectId: "project-1",
rootPath: "/tmp/project",
@@ -322,10 +325,12 @@ describe("wire compatibility", () => {
messages: capableMessages,
});
legacy.emitProjectUpdate({ kind: "upsert", project });
legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
capable.emitProjectUpdate({ kind: "upsert", project });
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId });
await Promise.all([
legacy.emitProjectUpdate({ kind: "upsert", project }),
legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }),
capable.emitProjectUpdate({ kind: "upsert", project }),
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }),
]);
expect(legacyMessages).toEqual([]);
expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([
@@ -338,6 +343,7 @@ describe("wire compatibility", () => {
projectDisplayName: "Favorite project",
projectCustomName: "Favorite project",
projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: "/tmp/project",
projectKind: "git",
},
@@ -350,6 +356,50 @@ describe("wire compatibility", () => {
]);
});
test("publishes rapid project mutations in order before incremental reconciliation", async () => {
const directorySync = new DirectorySyncService("generation");
const initial = directorySync.synchronizeProjects([], {});
const messages: SessionOutboundMessage[] = [];
const session = createSessionForWireCompatTest({
clientCapabilities: { [CLIENT_CAPS.projectUpdates]: true },
directorySync,
messages,
});
const project = createPersistedProjectRecord({
projectId: "project-ordered",
rootPath: "/tmp/project-ordered",
kind: "git",
displayName: "Ordered project",
createdAt: "2026-07-15T00:00:00.000Z",
updatedAt: "2026-07-15T00:00:00.000Z",
});
await Promise.all([
session.emitProjectUpdate({ kind: "upsert", project }),
session.emitProjectUpdate({ kind: "remove", projectId: project.projectId }),
]);
expect(
messages.flatMap((message) =>
message.type === "project.update" ? [message.payload.kind] : [],
),
).toEqual(["upsert", "remove"]);
expect(
directorySync.synchronizeProjects([], {
generation: initial.sync.generation,
afterSeq: initial.sync.headSeq,
}),
).toEqual({
projects: [],
sync: {
generation: "generation",
mode: "changes",
headSeq: 2,
removals: [{ id: "project-ordered", seq: 2 }],
},
});
});
test("downgrades reasoning_merge for clients that do not declare the capability", async () => {
const response = await emitTimelineResponse();
@@ -10,7 +10,9 @@ import {
type ProjectRegistry,
} from "../server/workspace-registry.js";
import {
ProjectIconReader,
readProjectIcon,
readProjectIconSnapshot,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "./project-custom-icon.js";
@@ -52,6 +54,7 @@ async function project() {
return record;
},
} as unknown as ProjectRegistry;
const reader = new ProjectIconReader(paseoHome);
return {
paseoHome,
@@ -59,6 +62,9 @@ async function project() {
set: (source: ProjectIconSource) =>
setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }),
read: () => readProjectIcon({ paseoHome, project: record }),
snapshot: () => readProjectIconSnapshot({ paseoHome, project: record }),
advertisedSnapshot: () => reader.snapshot(record),
readAdvertised: () => reader.read(record),
revision: () => record.customIconRevision,
remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }),
};
@@ -88,6 +94,40 @@ describe("project custom icon", () => {
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("fingerprints automatic icons and explicit no-icon results", async () => {
const target = await project();
const missing = await target.snapshot();
expect(missing).toEqual({ icon: null, revision: "automatic:none:v1" });
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const first = await target.snapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
const changed = await target.snapshot();
expect(first.icon).toEqual(PNG_1X1_ICON);
expect(first.revision).toMatch(/^automatic:[a-f0-9]{64}$/);
expect(changed.revision).not.toBe(first.revision);
});
it("serves the bytes that match the advertised revision", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const advertised = await target.advertisedSnapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
await expect(target.readAdvertised()).resolves.toEqual(advertised.icon);
});
it("drops the stored image when the project goes back to automatic", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
@@ -57,12 +57,63 @@ export async function readProjectIcon(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIcon | null> {
if (!input.project.customIconRevision) return getProjectIcon(input.project.rootPath);
try {
return validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
return null;
return (await readProjectIconSnapshot(input)).icon;
}
export interface ProjectIconSnapshot {
icon: ProjectIcon | null;
revision: string;
}
/** Keeps the icon bytes served by a session aligned with its advertised revision. */
export class ProjectIconReader {
private readonly snapshots = new Map<string, ProjectIconSnapshot>();
constructor(private readonly paseoHome: string) {}
async snapshot(project: PersistedProjectRecord): Promise<ProjectIconSnapshot> {
const snapshot = await readProjectIconSnapshot({ paseoHome: this.paseoHome, project });
this.snapshots.set(project.projectId, snapshot);
return snapshot;
}
async read(project: PersistedProjectRecord): Promise<ProjectIcon | null> {
return (this.snapshots.get(project.projectId) ?? (await this.snapshot(project))).icon;
}
}
/** Reads the effective icon and gives that exact result a stable identity. */
export async function readProjectIconSnapshot(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIconSnapshot> {
if (input.project.customIconRevision) {
let icon: ProjectIcon | null = null;
try {
icon = validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
// A missing/corrupt custom icon is itself a cacheable result until the
// registry revision changes.
}
return {
icon,
revision: icon
? iconRevision("custom", icon)
: `custom:none:${input.project.customIconRevision}`,
};
}
const icon = await getProjectIcon(input.project.rootPath);
if (!icon) return { icon: null, revision: "automatic:none:v1" };
return { icon, revision: iconRevision("automatic", icon) };
}
function iconRevision(source: "automatic" | "custom", icon: ProjectIcon): string {
const fingerprint = createHash("sha256")
.update(icon.mimeType)
.update("\0")
.update(icon.data)
.digest("hex");
return `${source}:${fingerprint}`;
}
export async function removeProjectCustomIcon(input: {