Files
paseo/packages/app/src/runtime/directory-sync/index.test.ts
T
0009a7e717 feat(workspaces): add background visibility (#5039)
* feat(server): let clients create internal agents

The daemon's own branch-name and commit-message generators run as
internal agents: never persisted, listed, flagged, announced, or seen by
plugin lifecycle hooks. Nothing outside the daemon could ask for one,
because the wire config schema strips `internal` and the agent.create
transform re-pins it. A plugin that runs a one-turn helper per event
therefore left an archived session in History every time.

Add an optional `internal` flag to `create_agent_request` (inherited by
`agent.create.request`), copy it onto the config in the session's create
path, and skip provider session persistence for it like the generators
do. The client SDK forwards the flag on both creation paths and gates it
once on `server_info.features.internalAgents`.

Two daemon paths assumed every agent is public and had to follow:
`resolveAgentIdentifier` now accepts a live agent by exact id, since the
id set it matched against came from listings that exclude internal
agents and left `wait_for_finish` answering "Agent not found"; and
archiving an internal agent now closes it without writing the record
that `registry.applySnapshot` used to leave behind, so `autoArchive`
does not accumulate hidden files on disk.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): keep internal agents whole through failure, archive, and wait

Review follow-ups for internal agents:

- A create that fails after the runtime is registered now closes the
  internal agent before rethrowing, so a hidden runtime cannot outlive
  the failed request. Public agents keep today's behaviour.
- Archiving an internal agent cascades to attached children like any
  parent. cascadeArchiveChildren takes the parent record instead of
  reading it back from storage, where an internal agent never is.
- The archived snapshot and last message of an internal agent stay
  readable by exact id for ten minutes, so create-then-waitForFinish
  returns the finished turn whether the wait lands before or after an
  auto-archive. Storage plays that role for public agents.
- The internal-agent e2e scenarios live in the existing
  create_agent_request lifecycle suite rather than a new file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes

Internal children never reach storage, so the parent archive cascade scans
live agents for them as well. A failed internal create now clears the
created id only after the runtime is archived, so worktree cleanup cannot
delete the directory under a runtime that would not close. Stored agent
payloads carry lastError, so a retained failed helper reports its error
through wait_for_finish instead of the generic message.

* feat(cli,mcp): expose internal agents and document --no-wait

paseo run gains --internal and create_agent gains internal, both forwarded
to the create request. --no-wait becomes the documented spelling for
returning immediately, matching paseo send; --background and --detach stay
as hidden aliases under COMPAT tags.

* test: follow the --no-wait rename and stub getRetiredInternalAgent

* feat: list internal agents on request and add internal workspaces

Internal agents stay hidden by default, but a caller can now ask for
them: `fetch_agents_request` takes `filter.includeInternal`, snapshots
carry `internal: true`, and `paseo ls --internal` shows them in an
INTERNAL column. The app never passes the flag.

Workspaces get the same flag. `workspace.create.request` with
`internal: true` persists a hidden workspace: listings, update
subscriptions, sync reads, empty-project counting, open-by-path, MCP
`list_workspaces`, and plugin workspace hooks all skip it unless the
caller opts in with `filter.includeInternal`. Every agent created inside
an internal workspace is internal and unpersisted; AgentManager applies
that from the workspace id before the plugin hook so creators cannot
opt out. Archive and shutdown enumerate internal agents so nothing
inside an internal workspace outlives it.

Surfaces: `paseo workspace create --internal`, `paseo workspace ls
--internal`, `paseo run --internal --new-workspace`, MCP
`create_workspace({ internal })`, SDK `workspaces.create({ internal })`
and `list({ filter: { includeInternal } })`. Clients gate on
`features.internalWorkspaces`.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(workspaces): add background visibility with inherited creation defaults

* fix(cli): resolve workspace callers on the selected daemon

* test(workspaces): keep caller integration coverage in server suite

* test(cli): exercise workspace callers through CLI integration harness

* test(mcp): remove obsolete execution flags from browser fixtures

---------

Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-10-09 09:31:50 +01:00

1760 lines
58 KiB
TypeScript

import { DatabaseSync } from "node:sqlite";
import { ReplicaCache } from "@/runtime/replica-cache";
import {
createSqliteReplicaRowStore,
type ReplicaSqliteConnection,
type SqliteValue,
} from "@/runtime/replica-cache/row-store-sqlite";
import { afterEach, describe, expect, it } from "vitest";
import type {
DaemonClient,
WorkspaceLabelListPayload,
} from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
import { useWorkspaceLabels } from "@/workspace-labels";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import {
DirectoryRefreshSupersededError,
DirectorySync,
type DirectoryCheckpointStorage,
} from "./index";
import { subscriptionFixture } from "../subscription-fixture";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
type AgentFetchResult = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
class FakeDirectoryClient {
supportsBackgroundWorkspaces(): boolean {
return false;
}
supportsWorkspaceLabels = false;
listWorkspaceLabelsCalls = 0;
fetchAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0;
lastWorkspaceOptions: unknown;
listProjectsCalls = 0;
lastProjectOptions: unknown;
projectResult: ProjectListResult | null = null;
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
Set<(message: SessionOutboundMessage) => void>
>();
on<TType extends SessionOutboundMessage["type"]>(
type: TType,
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
): () => void {
const handlers = this.handlers.get(type) ?? new Set();
const registered = handler as unknown as (message: SessionOutboundMessage) => void;
handlers.add(registered);
this.handlers.set(type, handlers);
return () => handlers.delete(registered);
}
emit<TType extends SessionOutboundMessage["type"]>(
message: Extract<SessionOutboundMessage, { type: TType }>,
): void {
for (const handler of this.handlers.get(message.type) ?? []) handler(message);
}
holdWorkspaceFetch(): (result: WorkspaceFetchResult) => void {
let complete!: (result: WorkspaceFetchResult) => void;
this.pendingWorkspaceFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
holdAgentFetch(): (result: AgentFetchResult) => void {
let complete!: (result: AgentFetchResult) => void;
this.pendingAgentFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
holdProjectFetch(): (result: ProjectListResult) => void {
let complete!: (result: ProjectListResult) => void;
this.pendingProjectFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls += 1;
this.lastAgentOptions = options;
if (this.pendingAgentFetch) {
const pending = this.pendingAgentFetch;
this.pendingAgentFetch = null;
return pending;
}
return {
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
}
async fetchWorkspaces(options?: unknown): Promise<WorkspaceFetchResult> {
this.fetchWorkspacesCalls += 1;
this.lastWorkspaceOptions = options;
if (this.pendingWorkspaceFetch) {
const pending = this.pendingWorkspaceFetch;
this.pendingWorkspaceFetch = null;
return pending;
}
return {
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
};
}
async listProjects(options?: unknown): Promise<ProjectListResult> {
this.listProjectsCalls += 1;
this.lastProjectOptions = options;
if (this.pendingProjectFetch) {
const pending = this.pendingProjectFetch;
this.pendingProjectFetch = null;
return pending;
}
if (this.projectResult) return this.projectResult;
return {
requestId: "projects",
projects: [
{
projectId: "project-1",
projectKey: "remote:github.com/acme/app",
projectDisplayName: "acme/app",
projectRootPath: "/repo/app",
projectKind: "git",
},
],
};
}
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
this.on("agent_update", receive),
);
}
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[0]) {
return subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
this.on("workspace_update", receive),
);
}
observeEvents(events: readonly SessionOutboundMessage["type"][]) {
return subscriptionFixture(Promise.resolve({ events }), (receive) => {
const stops = events.map((event) => this.on(event, receive));
return () => stops.forEach((stop) => stop());
});
}
observeWorkspaceLabels() {
return subscriptionFixture(this.listWorkspaceLabels(), () => () => {});
}
async listWorkspaceLabels(): Promise<WorkspaceLabelListPayload> {
this.listWorkspaceLabelsCalls += 1;
return {
requestId: "workspace-labels",
labels: [{ name: "Urgent", color: "red" }],
sync: { mode: "snapshot", removals: [], generation: "generation-1", headSeq: 0 },
};
}
getLastServerInfoMessage() {
return this.supportsWorkspaceLabels ? { features: { workspaceLabels: true } } : null;
}
}
const serverIds = new Set<string>();
function createDirectory(
serverId: string,
options?: { workspaceLabels?: boolean },
): {
client: FakeDirectoryClient;
directory: DirectorySync;
} {
serverIds.add(serverId);
const client = new FakeDirectoryClient();
client.supportsWorkspaceLabels = options?.workspaceLabels === true;
const directory = new DirectorySync(serverId, {
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
});
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
return { client, directory };
}
function createAgent(serverId: string, id: string) {
return {
...normalizeAgentSnapshot(
{
id,
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-08-26T00:00:00.000Z",
updatedAt: "2026-08-26T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Cached",
labels: {},
},
serverId,
),
projectPlacement: null,
};
}
function createWorkspaceEntry(id: string): WorkspaceFetchResult["entries"][number] {
return {
id,
projectId: "snapshot-project",
projectDisplayName: "Snapshot project",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
afterEach(() => {
for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId);
serverIds.clear();
useWorkspaceLabels.setState({ hosts: {} });
});
describe("DirectorySync session readiness", () => {
it("restores the cached directory before network demand", async () => {
const serverId = "offline-cached-directory";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "cached-workspace",
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
workspaceDirectory: "/repo/cached",
projectKind: "git",
workspaceKind: "local_checkout",
name: "cached",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const cachedProject = normalizeProjectDescriptor({
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
projectKind: "git",
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([[cachedProject.projectId, cachedProject]]),
}),
commitDirectoryMutations: () => undefined,
},
);
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
await directory.restoreCachedDirectory();
expect(
useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id),
).toEqual(cachedWorkspace);
expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([
serverId,
]);
expect(client.fetchAgentsCalls).toBe(0);
expect(client.fetchWorkspacesCalls).toBe(0);
directory.dispose();
});
it("prepares a cached agent workspace route without sidebar demand", async () => {
const serverId = "cached-agent-route";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "agent-1");
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
cachedAgent.workspaceId = cachedWorkspace.id;
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => cachedAgent,
readWorkspace: async () => ({ workspace: cachedWorkspace }),
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectoryMutations: () => undefined,
},
);
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
await directory.prepareAgentRoute(cachedAgent.id);
const session = useSessionStore.getState().sessions[serverId];
expect(session?.hasHydratedAgents).toBe(false);
expect(session?.hasWorkspaceDirectorySnapshot).toBe(false);
expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent);
expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace);
expect(client.fetchAgentsCalls).toBe(0);
expect(client.fetchWorkspacesCalls).toBe(0);
directory.dispose();
});
it("persists accepted script status updates through the directory owner", async () => {
const serverId = "script-status-owner";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const commits: DirectoryReplicaMutation[][] = [];
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectoryMutations: (_serverId, mutations) => commits.push([...mutations]),
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const workspace = normalizeWorkspaceDescriptor({
id: "workspace-1",
projectId: "project-1",
projectDisplayName: "Paseo",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
directory.setDemand({}, true);
await directory.refreshDemand();
directory.acceptWorkspaces([workspace]);
commits.length = 0;
client.emit({
type: "script_status_update",
payload: {
workspaceId: workspace.id,
scripts: [
{
scriptName: "web",
type: "service",
hostname: "web.paseo.localhost",
port: 3000,
proxyUrl: "http://web.paseo.localhost:6767",
lifecycle: "running",
health: "healthy",
exitCode: null,
terminalId: null,
},
],
},
});
expect(
useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0]
?.lifecycle,
).toBe("running");
const persisted = commits
.flat()
.find(
(
mutation,
): mutation is Extract<DirectoryReplicaMutation, { kind: "workspace"; type: "upsert" }> =>
mutation.kind === "workspace" &&
mutation.type === "upsert" &&
mutation.id === workspace.id,
);
expect(persisted?.value.scripts[0]?.lifecycle).toBe("running");
directory.dispose();
});
it("subscribes to live agent updates when the directory is demanded", async () => {
const serverId = "demanded-directory-subscription";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
await directory.refreshAll();
expect(client.lastAgentOptions).toMatchObject({ subscribe: {} });
directory.dispose();
});
it("coalesces overlapping route and full-directory demand", async () => {
const serverId = "coalesced-directory-demand";
const { client, directory } = createDirectory(serverId);
const releaseAgents = client.holdAgentFetch();
const releaseWorkspaces = client.holdWorkspaceFetch();
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
directory.setAgentRouteDemand(["agent-1"]);
directory.setDemand({}, true);
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
releaseAgents({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
releaseWorkspaces({
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await directory.refreshDemand();
expect(client.fetchAgentsCalls).toBe(1);
expect(client.fetchWorkspacesCalls).toBe(1);
directory.dispose();
});
it("publishes cached directory data before the authoritative request completes", async () => {
const serverId = "cached-directory";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const releaseNetwork = client.holdWorkspaceFetch();
let cacheReads = 0;
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "cached-workspace",
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
workspaceDirectory: "/repo/cached",
projectKind: "git",
workspaceKind: "local_checkout",
name: "cached",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const cachedProject = normalizeProjectDescriptor({
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
projectKind: "git",
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => {
cacheReads += 1;
return {
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([[cachedProject.projectId, cachedProject]]),
};
},
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
const refresh = directory.refreshWorkspaces();
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
expect(cacheReads).toBe(1);
expect(
useSessionStore.getState().sessions[serverId]?.workspaces.get("cached-workspace")?.name,
).toBe("cached");
releaseNetwork({
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await refresh;
expect(useSessionStore.getState().sessions[serverId]?.workspaces.size).toBe(0);
directory.dispose();
});
it("reconciles workspace changes on top of the accepted cached baseline", async () => {
const serverId = "cached-workspace-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedWorkspace = normalizeWorkspaceDescriptor({
id: "cached-workspace",
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
workspaceDirectory: "/repo/cached",
projectKind: "git",
workspaceKind: "local_checkout",
name: "cached",
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
});
const cachedProject = normalizeProjectDescriptor({
projectId: "cached-project",
projectDisplayName: "Cached project",
projectRootPath: "/repo/cached",
projectKind: "git",
});
const releaseNetwork = client.holdWorkspaceFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([[cachedProject.projectId, cachedProject]]),
checkpoint: { workspaces: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshWorkspaces();
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
releaseNetwork({
requestId: "workspaces",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastWorkspaceOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cachedWorkspace.id)).toBe(
true,
);
directory.dispose();
});
it("reconciles agent changes on top of the accepted cached baseline", async () => {
const serverId = "cached-agent-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "cached-agent");
const releaseNetwork = client.holdAgentFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map([[cachedAgent.id, cachedAgent]]),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshAgents();
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
releaseNetwork({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastAgentOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
let releaseCache!: (
value: Awaited<ReturnType<DirectoryCheckpointStorage["readDirectory"]>>,
) => void;
const cacheRead = new Promise<Awaited<ReturnType<DirectoryCheckpointStorage["readDirectory"]>>>(
(resolve) => {
releaseCache = resolve;
},
);
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: () => cacheRead,
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
directory.setAgentRouteDemand(["agent-1"]);
await directory.refreshDemand();
const refresh = directory.refreshWorkspaces();
await Promise.resolve();
client.emit({
type: "workspace_update",
payload: { kind: "remove", id: "deleted-live" },
});
releaseCache({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } },
});
await refresh;
expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation");
directory.dispose();
});
it("does not resurrect an agent deleted while its targeted cache row is loading", async () => {
const serverId = "late-agent-cache";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = {
...normalizeAgentSnapshot(
{
id: "agent-1",
provider: "codex",
cwd: "/repo",
model: null,
createdAt: "2026-08-26T00:00:00.000Z",
updatedAt: "2026-08-26T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: "Cached",
labels: {},
},
serverId,
),
projectPlacement: null,
};
let releaseAgent!: (agent: typeof cachedAgent) => void;
const agentRead = new Promise<typeof cachedAgent>((resolve) => {
releaseAgent = resolve;
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: () => agentRead,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
const load = directory.loadCachedAgent(cachedAgent.id);
directory.removeAgent(cachedAgent.id);
releaseAgent(cachedAgent);
await load;
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false);
directory.dispose();
});
it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => {
const serverId = "late-agent-authoritative-snapshot";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "agent-1");
const releaseNetwork = client.holdAgentFetch();
let releaseAgent!: (agent: typeof cachedAgent) => void;
const agentRead = new Promise<typeof cachedAgent>((resolve) => {
releaseAgent = resolve;
});
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: () => agentRead,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { directorySync: true, workspaceMultiplicity: true },
});
const refresh = directory.refreshAgents();
const load = directory.loadCachedAgent(cachedAgent.id);
releaseNetwork({
requestId: "authoritative-snapshot",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await refresh;
releaseAgent(cachedAgent);
await load;
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false);
directory.dispose();
});
it("uses the saved agent sequence while bounding the bootstrap page", async () => {
const serverId = "agent-list-sequence-page";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "generation", afterSeq: 12 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { directorySync: true, workspaceMultiplicity: true },
});
await directory.refreshAgents({
subscribe: {},
page: { limit: 200 },
});
expect(client.lastAgentOptions).toEqual({
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: {},
page: { limit: 200 },
sync: { generation: "generation", afterSeq: 12 },
});
directory.dispose();
});
it("waits for workspace capability metadata before choosing the workspace protocol", async () => {
const serverId = "workspace-metadata";
const { client, directory } = createDirectory(serverId);
const refresh = directory.refreshWorkspaces({ subscribe: true });
await Promise.resolve();
expect(client.fetchWorkspacesCalls).toBe(0);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
await Promise.resolve();
expect(client.fetchWorkspacesCalls).toBe(0);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
await refresh;
expect(client.fetchWorkspacesCalls).toBe(1);
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
directory.dispose();
});
it("fetches the project descriptor channel when the daemon advertises it", async () => {
const serverId = "project-list";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true },
});
await directory.refreshWorkspaces();
expect(client.listProjectsCalls).toBe(1);
expect(useSessionStore.getState().sessions[serverId]?.projects.get("project-1")).toMatchObject({
projectId: "project-1",
projectKey: "remote:github.com/acme/app",
});
directory.dispose();
});
it("merges project changes from the existing list RPC and advances its cursor", async () => {
const serverId = "project-list-sequence";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const writes: Array<{
mutations: readonly DirectoryReplicaMutation[];
checkpoint: unknown;
}> = [];
const cachedProjects = [
normalizeProjectDescriptor({
projectId: "project-1",
projectDisplayName: "Old name",
projectRootPath: "/repo/one",
projectKind: "git",
}),
normalizeProjectDescriptor({
projectId: "project-2",
projectDisplayName: "Removed",
projectRootPath: "/repo/two",
projectKind: "git",
}),
normalizeProjectDescriptor({
projectId: "untouched-project",
projectDisplayName: "Untouched",
projectRootPath: "/repo/untouched",
projectKind: "git",
}),
];
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map(),
workspaces: new Map(),
projects: new Map(cachedProjects.map((project) => [project.projectId, project])),
checkpoint: { projects: { generation: "generation", afterSeq: 4 } },
}),
commitDirectoryMutations: (_serverId, mutations, checkpoint) =>
writes.push({ mutations: [...mutations], checkpoint }),
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true, directorySync: true },
});
client.projectResult = {
requestId: "projects",
projects: [
{
projectId: "project-1",
projectDisplayName: "New name",
projectRootPath: "/repo/one",
projectKind: "git",
syncSeq: 5,
},
],
sync: {
generation: "generation",
headSeq: 6,
mode: "changes",
removals: [{ id: "project-2", seq: 6 }],
},
};
await directory.refreshWorkspaces();
expect(client.lastProjectOptions).toEqual({
sync: { generation: "generation", afterSeq: 4 },
});
const projects = useSessionStore.getState().sessions[serverId]?.projects;
expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1", "untouched-project"]);
expect(projects?.get("project-1")?.projectDisplayName).toBe("New name");
expect(writes.map(({ checkpoint }) => checkpoint)).toContainEqual({
projects: { generation: "generation", afterSeq: 6 },
});
const writtenProjectIds = writes
.flatMap(({ mutations }) => mutations)
.filter((mutation) => mutation.kind === "project")
.map((mutation) => mutation.id);
expect(writtenProjectIds).toEqual(["project-1", "project-2"]);
expect(writtenProjectIds).not.toContain("untouched-project");
directory.dispose();
});
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
const serverId = "superseded-workspace-baseline";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
directory.acceptWorkspaces([normalizeWorkspaceDescriptor(createWorkspaceEntry("retained"))]);
const release = client.holdWorkspaceFetch();
const refresh = directory.refreshWorkspaces();
const rejected = expect(refresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
release({
requestId: "old-empty-snapshot",
entries: [],
emptyProjects: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
});
await rejected;
expect([...useSessionStore.getState().sessions[serverId].workspaces.keys()]).toEqual([
"retained",
]);
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId);
const staleRefresh = directory.refreshAgents();
await Promise.resolve();
directory.connectionChanged({
client: null,
status: "offline",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
await expect(staleRefresh).rejects.toBeInstanceOf(DirectoryRefreshSupersededError);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
const currentRefresh = directory.refreshAgents();
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
await currentRefresh;
expect(client.fetchAgentsCalls).toBe(1);
directory.dispose();
});
it("buffers workspace and project updates in the same hydration transaction", async () => {
const serverId = "workspace-project-transaction";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
const completeFetch = client.holdWorkspaceFetch();
directory.setDemand({}, true);
const refresh = directory.refreshDemand();
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
client.emit({
type: "workspace_update",
payload: {
kind: "remove",
id: "removed-workspace",
emptyProject: {
projectId: "workspace-project",
projectDisplayName: "Project from workspace update",
projectRootPath: "/repo/workspace-project",
projectKind: "git",
},
},
});
client.emit({
type: "project.update",
payload: {
kind: "upsert",
project: {
projectId: "snapshot-project",
projectDisplayName: "Renamed during hydration",
projectRootPath: "/moved/snapshot-project",
projectKind: "directory",
},
},
});
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...createWorkspaceEntry("updated-workspace"), name: "Live name" },
},
});
completeFetch({
requestId: "workspaces",
entries: [
createWorkspaceEntry("removed-workspace"),
createWorkspaceEntry("updated-workspace"),
],
emptyProjects: [
{
projectId: "snapshot-project",
projectDisplayName: "Stale snapshot project",
projectRootPath: "/repo/snapshot-project",
projectKind: "git",
},
],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await refresh;
const projects = useSessionStore.getState().sessions[serverId]?.projects;
expect(Array.from(projects?.keys() ?? [])).toEqual(["snapshot-project", "workspace-project"]);
expect(projects?.get("snapshot-project")).toMatchObject({
projectDisplayName: "Renamed during hydration",
projectRootPath: "/moved/snapshot-project",
projectKind: "directory",
});
expect(projects?.get("workspace-project")).toMatchObject({
projectDisplayName: "Project from workspace update",
});
expect(
[...useSessionStore.getState().sessions[serverId].workspaces.values()].map(
({ id, name }) => ({ id, name }),
),
).toEqual([{ id: "updated-workspace", name: "Live name" }]);
directory.dispose();
});
it("ignores passive project events before directory demand", async () => {
const serverId = "project-before-workspace-hydration";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
client.emit({
type: "project.update",
payload: {
kind: "upsert",
project: {
projectId: "early-project",
projectDisplayName: "Early project",
projectRootPath: "/repo/early-project",
projectKind: "git",
},
},
});
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(false);
await directory.refreshWorkspaces({ subscribe: true });
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
expect(useSessionStore.getState().sessions[serverId]?.projects.has("early-project")).toBe(
false,
);
directory.dispose();
});
});
function createSqliteCache() {
const database = new DatabaseSync(":memory:");
let beforeRead = async () => {};
const connection: ReplicaSqliteConnection = {
async exec(sql) {
database.exec(sql);
},
async run(sql, params = []) {
database.prepare(sql).run(...params);
},
async all<Row>(sql: string, params: readonly SqliteValue[] = []) {
const rows = database.prepare(sql).all(...params) as Row[];
if (sql.includes("FROM rows") && sql.includes("WHERE")) await beforeRead();
return rows;
},
async transaction(operation) {
database.exec("BEGIN IMMEDIATE");
try {
await operation(connection);
database.exec("COMMIT");
} catch (error) {
database.exec("ROLLBACK");
throw error;
}
},
};
const storage = createSqliteReplicaRowStore({ open: async () => connection }, 1);
const cache = new ReplicaCache(storage, { clearLegacyCache: async () => {} });
return {
cache,
database,
storage,
reopen: () => new ReplicaCache(storage, { clearLegacyCache: async () => {} }),
holdRead: (operation: () => Promise<void>) => {
beforeRead = operation;
},
};
}
it("fills every cached workspace beneath live updates received during the SQLite read", async () => {
const serverId = "sqlite-directory-race";
serverIds.add(serverId);
const { cache, database, holdRead } = createSqliteCache();
cache.setHosts([serverId]);
const project = normalizeProjectDescriptor({
projectId: "project",
projectDisplayName: "Cached",
projectRootPath: "/repo",
projectKind: "git",
});
const workspaces = ["first", "second", "third"].map((id) =>
normalizeWorkspaceDescriptor({
id,
projectId: "project",
projectDisplayName: "Cached",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
}),
);
cache.replaceDirectoryBaseline(serverId, {
agents: new Map([["agent", createAgent(serverId, "agent")]]),
workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])),
projects: new Map([[project.projectId, project]]),
});
await cache.flush();
const client = new FakeDirectoryClient();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => {},
markAgentLoading: () => {},
markAgentReady: () => {},
markAgentError: () => {},
},
cache,
);
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
directory.setAgentRouteDemand(["agent"]);
await directory.refreshDemand();
let updated = false;
holdRead(async () => {
if (updated) return;
updated = true;
client.emit({
type: "workspace_update",
payload: {
kind: "upsert",
workspace: { ...workspaces[0], name: "Live", activityAt: null, statusEnteredAt: null },
},
});
});
await directory.restoreCachedDirectory();
const session = useSessionStore.getState().sessions[serverId];
expect(
[...session.workspaces.values()]
.map(({ id, name }) => ({ id, name }))
.sort((a, b) => a.id.localeCompare(b.id)),
).toEqual([
{ id: "first", name: "Live" },
{ id: "second", name: "second" },
{ id: "third", name: "third" },
]);
expect(session.agents.get("agent")?.title).toBe("Cached");
expect(session.hasWorkspaceDirectorySnapshot).toBe(true);
directory.dispose();
await cache.flush();
database.close();
});
function createWorkspaceLabelDirectory(serverId: string) {
const { client, directory } = createDirectory(serverId, { workspaceLabels: true });
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: {
directorySync: true,
projectList: true,
workspaceLabels: true,
workspaceMultiplicity: true,
},
});
return { client, directory };
}
async function flushWorkspaceLabels(): Promise<void> {
for (let index = 0; index < 4; index += 1) await Promise.resolve();
}
describe("DirectorySync workspace labels", () => {
it("loads the catalog when an agent route is open before the sidebar", async () => {
const serverId = "workspace-labels-route-then-full";
const { directory } = createWorkspaceLabelDirectory(serverId);
directory.setAgentRouteDemand(["agent-1"]);
await directory.refreshDemand();
directory.setDemand({}, true);
await flushWorkspaceLabels();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
it("loads the catalog when the sidebar demands the directory first", async () => {
const serverId = "workspace-labels-full-first";
const { directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
it("keeps the picker online during a full refresh", async () => {
const serverId = "workspace-labels-refresh-no-flicker";
const { client, directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
const callsBeforeRefresh = client.listWorkspaceLabelsCalls;
const statuses: string[] = [];
const unsubscribe = useWorkspaceLabels.subscribe((state) => {
const status = state.hosts[serverId]?.status;
if (status) statuses.push(status);
});
await directory.refreshDemand();
expect(client.listWorkspaceLabelsCalls).toBeGreaterThan(callsBeforeRefresh);
expect(statuses).not.toContain("offline");
unsubscribe();
directory.dispose();
});
it("restores the catalog after reconnecting", async () => {
const serverId = "workspace-labels-reconnect";
const { client, directory } = createWorkspaceLabelDirectory(serverId);
directory.setDemand({}, true);
await directory.refreshDemand();
directory.connectionChanged({
client: null,
status: "offline",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
expect(useWorkspaceLabels.getState().hosts[serverId]?.status).toBe("offline");
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 2 },
});
await flushWorkspaceLabels();
expect(useWorkspaceLabels.getState().hosts[serverId]).toMatchObject({
status: "online",
labels: [{ name: "Urgent", color: "red" }],
});
directory.dispose();
});
});
it.each(["before", "during", "before-metadata", "damaged"] as const)(
"retains unchanged workspaces across route/full demand and restart (cache: %s)",
async (timing) => {
const cacheFirst = timing === "before";
const damaged = timing === "damaged";
const serverId = `cache-route-overlap-${timing}`;
serverIds.add(serverId);
const { cache, database, reopen, storage } = createSqliteCache();
cache.setHosts([serverId]);
const projectEntry: ProjectListResult["projects"][number] = {
projectId: "P",
projectDisplayName: "Project P",
projectRootPath: "/repo",
projectKind: "git",
};
const project = normalizeProjectDescriptor(projectEntry);
const workspaceEntries: WorkspaceFetchResult["entries"] = ["A", "B"].map((id) => ({
id,
projectId: "P",
projectDisplayName: "Project P",
projectRootPath: "/repo",
workspaceDirectory: `/repo/${id}`,
projectKind: "git",
workspaceKind: "local_checkout",
name: id,
status: "done",
statusEnteredAt: null,
activityAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
}));
const workspaces = workspaceEntries.map(normalizeWorkspaceDescriptor);
const cursor = { generation: "g", afterSeq: 7 };
cache.replaceDirectoryBaseline(serverId, {
agents: new Map(),
projects: new Map([[project.projectId, project]]),
workspaces: new Map(
(damaged ? [] : workspaces).map((workspace) => [workspace.id, workspace]),
),
checkpoint: { projects: cursor, workspaces: cursor, agents: cursor },
});
await cache.flush();
if (damaged) {
await storage.apply({
deletes: [],
upserts: [
{
serverId,
kind: "checkpoint",
id: "singleton",
payload: JSON.stringify({ projects: cursor, workspaces: cursor, agents: cursor }),
},
],
});
}
// The host keeps A/B unchanged. A current cursor legitimately receives no rows.
class UnchangedDirectoryClient extends FakeDirectoryClient {
readonly workspaceSyncRequests: unknown[] = [];
override async fetchWorkspaces(
options?: Parameters<DaemonClient["fetchWorkspaces"]>[0],
): Promise<WorkspaceFetchResult> {
this.fetchWorkspacesCalls += 1;
this.lastWorkspaceOptions = options;
this.workspaceSyncRequests.push(options?.sync);
const changes = options?.sync?.generation === "g" && options.sync.afterSeq === 7;
return {
requestId: "workspaces",
entries: changes ? [] : [workspaceEntries[options?.page?.cursor ? 1 : 0]],
emptyProjects: [],
pageInfo: {
hasMore: !changes && !options?.page?.cursor,
nextCursor: !changes && !options?.page?.cursor ? "page-2" : null,
prevCursor: null,
},
sync: {
generation: "g",
headSeq: 7,
mode: changes ? "changes" : "snapshot",
removals: [],
},
};
}
}
const client = new UnchangedDirectoryClient();
const releaseAgents = client.holdAgentFetch();
const releaseProjects = client.holdProjectFetch();
const projectResult: ProjectListResult = {
requestId: "projects",
projects: [projectEntry],
sync: { generation: "g", headSeq: 7, mode: "snapshot", removals: [] },
};
client.projectResult = projectResult;
const callbacks = {
onAgentStoppedRunning: () => {},
markAgentLoading: () => {},
markAgentReady: () => {},
markAgentError: () => {},
};
const directory = new DirectorySync(serverId, callbacks, cache);
const publishServerInfo = () => {
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true, projectList: true },
});
};
const initializeSession = (ready = true) => {
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1);
if (ready) publishServerInfo();
};
const connection = {
client: client as unknown as DaemonClient,
status: "online" as const,
source: { clientGeneration: 1, connectionEpoch: 1 },
};
initializeSession(timing !== "before-metadata");
directory.connectionChanged(connection);
const visibleIds = () =>
[...useSessionStore.getState().sessions[serverId].workspaces.keys()].sort();
let restarted: DirectorySync | undefined;
const restartedCache = reopen();
try {
if (cacheFirst) await directory.restoreCachedDirectory();
directory.setAgentRouteDemand(["agent"]);
if (timing === "before-metadata") {
await directory.restoreCachedDirectory();
publishServerInfo();
}
await expect.poll(() => client.listProjectsCalls).toBe(1);
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
directory.setDemand({}, true);
await directory.restoreCachedDirectory();
expect(visibleIds()).toEqual(damaged ? [] : ["A", "B"]);
releaseProjects(projectResult);
releaseAgents({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: {
generation: "g",
headSeq: 7,
mode: cacheFirst ? "changes" : "snapshot",
removals: [],
},
});
await directory.refreshDemand();
expect(client.lastProjectOptions).toEqual({ sync: cacheFirst ? cursor : {} });
expect(client.workspaceSyncRequests).toEqual(cacheFirst ? [cursor] : [{}, {}]);
expect.soft(visibleIds(), "after overlapping refresh").toEqual(["A", "B"]);
await cache.flush();
expect([...(await cache.readDirectory(serverId)).workspaces.keys()].sort()).toEqual([
"A",
"B",
]);
await directory.refreshAll();
await cache.flush();
directory.dispose();
useSessionStore.getState().clearSession(serverId);
restartedCache.setHosts([serverId]);
const persisted = await restartedCache.readDirectory(serverId);
expect(persisted.checkpoint?.workspaces).toEqual(cursor);
expect
.soft([...persisted.workspaces.keys()].sort(), "persisted baseline")
.toEqual(["A", "B"]);
initializeSession();
restarted = new DirectorySync(serverId, callbacks, restartedCache);
restarted.connectionChanged(connection);
await restarted.refreshAll();
expect.soft(visibleIds(), "after restart and refresh").toEqual(["A", "B"]);
const beforeUpdate = visibleIds();
client.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: workspaceEntries[0], generation: "g", seq: 8 },
});
expect(visibleIds()).toEqual([...new Set([...beforeUpdate, "A"])].sort());
client.emit({
type: "workspace_update",
payload: { kind: "upsert", workspace: workspaceEntries[1], generation: "g", seq: 9 },
});
expect(visibleIds()).toEqual(["A", "B"]);
} finally {
directory.dispose();
restarted?.dispose();
await cache.flush();
await restartedCache.flush();
database.close();
}
},
);