Make workspace IDs opaque, independent of the filesystem path (#1503)

* Make workspace IDs opaque, independent of the filesystem path

Workspace IDs were the resolved checkout/worktree path, so code could
treat an ID as a path: prefix matching, deriving directories from it,
falling back to a path when a lookup missed. IDs are now opaque -
compared only by exact equality and never used as a path. Anything
path-shaped resolves the workspace record first and reads its cwd.

New workspaces get a generated `wks_` ID; existing path-shaped IDs are
read from disk and never regenerated, so there is no migration and no
change to the wire or persisted schemas. Groundwork for running
multiple workspaces in a single directory.

* Keep attachment scope key stable and align refetch test

The attachment scope key keeps `workspace=` instead of `wsid=` so existing
persisted drafts are not orphaned; the rename was cosmetic. The SDK refetch
test no longer asserts a filter, matching refetch fetching one page and
selecting by id client-side.

* Fix live agent updates in directories without a registered workspace

The opaque-ID work removed buildProjectPlacementForCwd's directory
fallback, so an agent running in a folder with no registered workspace
(e.g. a fresh non-git dir) produced a null placement. forwardAgentUpdate
then threw "Workspace not found", the error was swallowed by its catch,
and no agent_update was emitted — live model/thinking switches and status
updates silently stopped. Caught by the live-preferences e2e suite.

The fallback builds a directory-scoped project placement keyed by the
path. That key is a project grouping key (non-git projects group by
path), not a workspace id, so it stays within the opaque-id rule.

* Always run server worktree archive, even without a resolved workspace

Archiving bailed out entirely when the workspace was not found in the
client store, so a race or stale state could make "archive" do nothing
server-side with only a console.warn. The server archive is keyed by
worktreePath, which is always available, so it now runs regardless; only
the optimistic client-side updates (keyed by workspace id) are gated on
the workspace being resolved.

* Expect a directory-scoped placement for unregistered agent dirs

This unit test asserted the no-placement behavior reverted in the live
agent-update fix, which had broken live model/thinking switching. Update
it to expect the directory-scoped placement now emitted for an agent in a
directory with no registered workspace.

* Fix opaque workspace routing in app E2E

* Preserve workspace IDs during partial bootstrap

* Fix archive flows for opaque workspace IDs

* Stop treating opaque workspace IDs as filesystem paths

Workspace IDs are opaque (wks_<hex>), but several call sites still passed
the id where a directory was expected, which broke those flows for opaque
IDs. The branch switcher sent the id as a cwd to branch/stash/checkout git
operations, and server archive/reconcile cleanup keyed git-watch and
subscription teardown by id, leaking that state.

Git and filesystem operations now take the workspace directory; the opaque
id is used only for identity and cache keys. Archive/reconcile cleanup
routes through a single teardownArchivedWorkspace helper that keeps the key
split explicit: runtime store by id, git watch and subscription by cwd.
Path-derived grouping keys are renamed to directory keys, and the helpers
are split into workspace-identity and workspace-directory so the id-vs-path
boundary is obvious.
This commit is contained in:
Mohamed Boudra
2026-06-14 22:03:27 +08:00
committed by GitHub
parent f352072dac
commit 21deb08673
105 changed files with 1530 additions and 1045 deletions
@@ -849,7 +849,6 @@ export class AgentManager {
agentId?: string,
options?: {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
@@ -867,7 +866,6 @@ export class AgentManager {
const session = await client.createSession(launchConfig, launchContext, createOptions);
return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options?.labels,
workspaceId: options?.workspaceId,
initialTitle: options?.initialTitle,
});
}
@@ -2321,7 +2319,6 @@ export class AgentManager {
config: AgentSessionConfig,
agentId: string,
options?: {
workspaceId?: string;
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
@@ -2369,7 +2366,6 @@ export class AgentManager {
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
await this.refreshRuntimeInfo(managed, { emit: !options?.publishWhenReady });
await this.persistSnapshot(managed, {
workspaceId: options?.workspaceId,
title: initialPersistedTitle,
});
if (!options?.publishWhenReady) {
@@ -2378,7 +2374,7 @@ export class AgentManager {
await this.refreshSessionState(managed, { emit: !options?.publishWhenReady });
managed.lifecycle = "idle";
await this.persistSnapshot(managed, { workspaceId: options?.workspaceId });
await this.persistSnapshot(managed);
this.emitState(managed, { persist: false });
this.subscribeToSession(managed);
return { ...managed };
@@ -2639,7 +2635,7 @@ export class AgentManager {
private async persistSnapshot(
agent: ManagedAgent,
options?: { workspaceId?: string; title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
if (!this.registry) {
return;
@@ -2648,10 +2644,6 @@ export class AgentManager {
if (agent.internal) {
return;
}
if (options?.workspaceId !== undefined) {
await this.registry.applySnapshot(agent, options.workspaceId, options);
return;
}
await this.registry.applySnapshot(agent, options);
}
@@ -191,23 +191,19 @@ export class AgentStorage {
async applySnapshot(
agent: ManagedAgent,
workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions;
await this.load();
await this.waitForPendingWrite(agent.id);
const existing = (await this.get(agent.id)) ?? null;
const hasTitleOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride
? nextOptions?.internal
: (agent.internal ?? existing?.internal),
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
});
// Preserve soft-delete/archive status across snapshot flushes.
@@ -26,6 +26,7 @@ interface CreateAgentLifecycleDispatchDependencies {
workspaceGitService: WorkspaceGitService;
createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn;
archiveAgentForClose: (agentId: string) => Promise<unknown>;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emit: (message: SessionOutboundMessage) => void;
emitAgentRemove: (agentId: string) => void;
@@ -207,6 +208,7 @@ export class CreateAgentLifecycleDispatch {
workspaceGitService: this.dependencies.workspaceGitService,
agentManager: this.dependencies.agentManager,
agentStorage: this.dependencies.agentStorage,
resolveWorkspaceIdForCwd: this.dependencies.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving,
@@ -38,7 +38,6 @@ test("session create forwards clientMessageId to the initial prompt run options"
explicitTitle: "Explicit title",
firstAgentContext: { attachments: [] },
buildSessionConfig: async (config) => ({ sessionConfig: config }),
resolveWorkspace: async () => ({ workspaceId: "workspace-1" }),
});
expect(streamAgent).toHaveBeenCalledWith("agent-1", "hello from create", {
@@ -32,10 +32,6 @@ import {
emitLiveTimelineItemIfAgentKnown,
} from "../timeline-append.js";
export interface CreateAgentWorkspace {
workspaceId: string;
}
export interface CreateAgentSessionWorktreeResult {
sessionConfig: AgentSessionConfig;
setupContinuation?: AgentWorktreeSetupContinuation;
@@ -79,7 +75,6 @@ export interface CreateAgentFromSessionInput {
legacyWorktreeName?: string,
firstAgentContext?: FirstAgentContext,
) => Promise<CreateAgentSessionWorktreeResult>;
resolveWorkspace: (input: { cwd: string; workspaceId?: string }) => Promise<CreateAgentWorkspace>;
}
export interface CreateAgentFromMcpInput {
@@ -134,7 +129,6 @@ interface ResolvedCreateAgent {
interface AgentCreateOptions {
labels?: Record<string, string>;
workspaceId?: string;
initialPrompt?: string;
env?: Record<string, string>;
initialTitle?: string | null;
@@ -196,10 +190,6 @@ async function resolveSessionCreateAgent(
input.worktreeName,
input.firstAgentContext,
);
const workspace = await input.resolveWorkspace({
cwd: sessionConfig.cwd,
workspaceId: input.workspaceId,
});
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
const clientMessageId = normalizeClientMessageId(input.clientMessageId);
@@ -215,7 +205,6 @@ async function resolveSessionCreateAgent(
config: sessionConfig,
createOptions: {
labels: input.labels,
workspaceId: workspace.workspaceId,
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,
@@ -96,15 +96,16 @@ export async function archiveAgentCommand(
agentId: string,
): Promise<ArchiveAgentResult> {
const liveAgent = dependencies.agentManager.getAgent(agentId);
let record: StoredAgentRecord | null;
if (liveAgent) {
await cancelAgentRunCommand(dependencies, agentId);
await dependencies.agentManager.clearAgentAttention(agentId).catch(() => undefined);
await dependencies.agentManager.archiveAgent(agentId);
record = await dependencies.agentStorage.get(agentId);
} else {
await archiveStoredAgent(dependencies, agentId);
record = await archiveStoredAgent(dependencies, agentId);
}
const record = await dependencies.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found in storage after archive: ${agentId}`);
}
@@ -174,16 +175,16 @@ export async function setAgentModeCommand(
async function archiveStoredAgent(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager" | "agentStorage">,
agentId: string,
): Promise<void> {
): Promise<StoredAgentRecord> {
const existing = await dependencies.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
if (existing.archivedAt) {
return;
return existing;
}
const archivedAt = new Date().toISOString();
await dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
return dependencies.agentManager.archiveSnapshot(agentId, archivedAt);
}
@@ -1258,7 +1258,7 @@ describe("create_agent MCP tool", () => {
baseRefName: "main",
},
workspace: {
workspaceId: "/tmp/worktrees/pr-123",
workspaceId: "ws-pr-123",
projectId: REPO_CWD,
cwd: "/tmp/worktrees/pr-123",
kind: "worktree" as const,
@@ -1395,7 +1395,7 @@ describe("create_agent MCP tool", () => {
});
expect(setupContinuations).toEqual([undefined]);
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toContain("tool-worktree");
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally {
await rm(tempDir, { recursive: true, force: true });
}
@@ -1444,6 +1444,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-tool-worktree"),
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving,
@@ -1473,16 +1474,14 @@ describe("create_agent MCP tool", () => {
force: true,
reason: "mcp:archive-worktree",
});
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith("ws-archive-tool-worktree");
expect(markWorkspaceArchiving).toHaveBeenCalledWith(
[created.structuredContent.worktreePath],
["ws-archive-tool-worktree"],
expect.any(String),
);
expect(clearWorkspaceArchiving).toHaveBeenCalledWith([
created.structuredContent.worktreePath,
]);
expect(clearWorkspaceArchiving).toHaveBeenCalledWith(["ws-archive-tool-worktree"]);
expect(Array.from(emitWorkspaceUpdatesForWorkspaceIds.mock.calls[0]?.[0] ?? [])).toEqual([
created.structuredContent.worktreePath,
"ws-archive-tool-worktree",
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
@@ -1528,6 +1527,7 @@ describe("create_agent MCP tool", () => {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>,
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-mcp"),
archiveWorkspaceRecord: vi.fn(async () => undefined),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => undefined),
markWorkspaceArchiving: vi.fn(),
@@ -93,6 +93,7 @@ export interface AgentMcpServerOptions {
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
resolveWorkspaceIdForCwd?: ArchivePaseoWorktreeDependencies["resolveWorkspaceIdForCwd"];
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"];
@@ -2406,6 +2407,9 @@ function archiveWorktreeDependencies(
if (!options.archiveWorkspaceRecord) {
throw new Error("Workspace registry archiver is required to archive worktrees");
}
if (!options.resolveWorkspaceIdForCwd) {
throw new Error("Workspace resolver is required to archive worktrees");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to archive worktrees");
}
@@ -2422,6 +2426,7 @@ function archiveWorktreeDependencies(
workspaceGitService: options.workspaceGitService,
agentManager: context.agentManager,
agentStorage: context.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
@@ -93,6 +93,7 @@ function createHarness(overrides?: {
agentManager: {} as AutoArchiveArchiveOptions["agentManager"],
agentStorage: {} as AutoArchiveArchiveOptions["agentStorage"],
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
archiveWorkspaceRecord: vi.fn(),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
@@ -22,6 +22,7 @@ export interface AutoArchiveArchiveOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
@@ -102,6 +103,7 @@ export async function archiveIfSafe(input: {
workspaceGitService: options.workspaceGitService,
agentManager: options.agentManager,
agentStorage: options.agentStorage,
resolveWorkspaceIdForCwd: options.resolveWorkspaceIdForCwd,
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: options.markWorkspaceArchiving,
+6
View File
@@ -109,6 +109,7 @@ import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore } from "./daemon-config-store.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import { resolveRegisteredWorkspaceIdForCwd } from "./workspace-directory.js";
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js";
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
@@ -665,6 +666,9 @@ export async function createPaseoDaemon(
projectRegistry,
});
};
const resolveWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveRegisteredWorkspaceIdForCwd(cwd, await workspaceRegistry.list());
};
const markWorkspaceArchivingExternal = (workspaceIds: Iterable<string>, archivingAt: string) => {
const workspaceIdList = Array.from(workspaceIds);
for (const session of wsServer?.listActiveSessions() ?? []) {
@@ -699,6 +703,7 @@ export async function createPaseoDaemon(
agentStorage,
terminalManager,
logger,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
@@ -721,6 +726,7 @@ export async function createPaseoDaemon(
providerSnapshotManager,
github,
workspaceGitService,
resolveWorkspaceIdForCwd: resolveWorkspaceIdForCwdExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
@@ -3,7 +3,6 @@ import type { Logger } from "pino";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import type { GitHubService } from "../services/github-service.js";
import {
deletePaseoWorktree,
@@ -19,6 +18,7 @@ export interface ArchivePaseoWorktreeDependencies {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
resolveWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
@@ -57,7 +57,6 @@ export async function archivePaseoWorktree(
const archivedAgents = new Set<string>();
const affectedWorkspaceCwds = new Set<string>([targetPath]);
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
const liveAgents = dependencies.agentManager
.listAgents()
@@ -65,7 +64,6 @@ export async function archivePaseoWorktree(
for (const agent of liveAgents) {
archivedAgents.add(agent.id);
affectedWorkspaceCwds.add(agent.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
}
let storedRecords: StoredAgentRecord[] = [];
@@ -84,10 +82,12 @@ export async function archivePaseoWorktree(
for (const record of matchingStoredRecords) {
archivedAgents.add(record.id);
affectedWorkspaceCwds.add(record.cwd);
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
}
const affectedWorkspaceIdList = Array.from(affectedWorkspaceIds);
const affectedWorkspaceIdList = await resolveAffectedWorkspaceIds(
dependencies,
affectedWorkspaceCwds,
);
dependencies.markWorkspaceArchiving(affectedWorkspaceIdList, new Date().toISOString());
try {
@@ -176,6 +176,28 @@ export async function archivePaseoWorktree(
return Array.from(archivedAgents);
}
async function resolveAffectedWorkspaceIds(
dependencies: Pick<
ArchivePaseoWorktreeDependencies,
"resolveWorkspaceIdForCwd" | "sessionLogger"
>,
cwds: Iterable<string>,
): Promise<string[]> {
const workspaceIds = new Set<string>();
for (const cwd of cwds) {
const workspaceId = await dependencies.resolveWorkspaceIdForCwd(cwd);
if (!workspaceId) {
dependencies.sessionLogger?.warn(
{ cwd },
"Skipping workspace archive update for unregistered directory",
);
continue;
}
workspaceIds.add(workspaceId);
}
return Array.from(workspaceIds);
}
export async function killTerminalsUnderPath(
dependencies: KillTerminalsUnderPathDependencies,
rootPath: string,
@@ -34,7 +34,7 @@ test("creates a worktree and registers it in the source workspace project withou
displayName: "acme/repo",
});
const sourceWorkspace = createPersistedWorkspaceRecordForTest({
workspaceId: repoDir,
workspaceId: "ws-main-checkout",
projectId: sourceProject.projectId,
cwd: repoDir,
kind: "local_checkout",
@@ -57,6 +57,7 @@ test("creates a worktree and registers it in the source workspace project withou
expect(result.created).toBe(true);
expect(result.workspace.cwd).toBe(result.worktree.worktreePath);
expect(result.workspace.kind).toBe("worktree");
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
expect(result.workspace.displayName).toBe("feature-one");
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -76,7 +77,7 @@ test("registers a new worktree in the existing root project after the main check
displayName: "acme/repo",
});
const existingWorktree = createPersistedWorkspaceRecordForTest({
workspaceId: path.join(tempDir, "existing-worktree"),
workspaceId: "ws-existing-worktree",
projectId: sourceProject.projectId,
cwd: path.join(tempDir, "existing-worktree"),
kind: "worktree",
@@ -1,4 +1,5 @@
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { resolve } from "node:path";
import {
type PersistedWorkspaceRecord,
type ProjectRegistry,
@@ -6,7 +7,7 @@ import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js";
import { deriveProjectGroupingName, generateWorkspaceId } from "./workspace-registry-model.js";
import {
createWorktreeCore,
type CreateWorktreeCoreDeps,
@@ -195,11 +196,14 @@ async function upsertWorkspaceForWorktree(options: {
projectId?: string;
repoRoot: string;
worktree: WorktreeConfig;
deps: Pick<CreatePaseoWorktreeDeps, "projectRegistry" | "workspaceRegistry">;
deps: Pick<
CreatePaseoWorktreeDeps,
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
>;
}): Promise<PersistedWorkspaceRecord> {
const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath);
const normalizedInputCwd = normalizeWorkspaceId(options.inputCwd);
const normalizedRepoRoot = normalizeWorkspaceId(options.repoRoot);
const normalizedCwd = resolve(options.worktree.worktreePath);
const normalizedInputCwd = resolve(options.inputCwd);
const normalizedRepoRoot = resolve(options.repoRoot);
const existingWorkspace = await findWorkspaceByDirectory(
normalizedCwd,
options.deps.workspaceRegistry,
@@ -211,7 +215,7 @@ async function upsertWorkspaceForWorktree(options: {
existingWorkspace,
deps: options.deps,
});
const workspaceId = normalizedCwd;
const workspaceId = existingWorkspace?.workspaceId ?? generateWorkspaceId();
const now = new Date().toISOString();
await options.deps.projectRegistry.upsert(
+91 -64
View File
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { stat } from "node:fs/promises";
import { basename, resolve, sep } from "path";
import { resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
@@ -154,10 +154,10 @@ import {
} from "./agent/import-sessions.js";
import {
checkoutLiteFromGitSnapshot,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveProjectGroupingName,
classifyDirectoryForProjectMembership,
deriveProjectGroupingName,
deriveWorkspaceDisplayName,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
@@ -962,6 +962,7 @@ export class Session {
createPaseoWorktreeWorkflow: (input, workflowOptions) =>
this.createPaseoWorktreeWorkflow(input, workflowOptions),
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitAgentRemove: (agentId) => {
@@ -1607,6 +1608,9 @@ export class Session {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd, options);
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(normalizedCwd, workspaces);
if (!workspaceId) {
return null;
}
return workspaces.find((workspace) => workspace.workspaceId === workspaceId) ?? null;
}
@@ -1623,14 +1627,14 @@ export class Session {
cwd: string,
options?: { refreshGit?: boolean },
): Promise<string> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
if (options?.refreshGit === false) {
const snapshot = this.workspaceGitService.peekSnapshot(normalizedCwd);
return normalizePersistedWorkspaceId(snapshot?.git.repoRoot ?? normalizedCwd);
return resolve(snapshot?.git.repoRoot ?? normalizedCwd);
}
const checkout = await this.workspaceGitService.getCheckout(normalizedCwd);
return normalizePersistedWorkspaceId(checkout.worktreeRoot ?? normalizedCwd);
return resolve(checkout.worktreeRoot ?? normalizedCwd);
}
private async buildProjectPlacementForWorkspace(
@@ -1661,12 +1665,16 @@ export class Session {
return null;
}
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
// An agent can run in a directory that has no registered workspace yet
// (e.g. a fresh non-git folder). Synthesize a directory-scoped placement so
// the agent still emits updates. projectKey/cwd are paths here — a project
// grouping key, not a workspace id — so this stays opaque-id-safe.
const resolvedCwd = resolve(cwd);
return {
projectKey: normalizedCwd,
projectName: deriveProjectGroupingName(normalizedCwd),
projectKey: resolvedCwd,
projectName: deriveProjectGroupingName(resolvedCwd),
checkout: {
cwd: normalizedCwd,
cwd: resolvedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
@@ -3169,8 +3177,6 @@ export class Session {
firstAgentContext,
buildSessionConfig: (sessionConfig, gitOptions, legacyWorktreeName, ctx) =>
this.buildAgentSessionConfig(sessionConfig, gitOptions, legacyWorktreeName, ctx),
resolveWorkspace: ({ cwd, workspaceId }) =>
this.resolveCreateAgentWorkspace(cwd, workspaceId),
},
);
createdAgentId = snapshot.id;
@@ -3228,20 +3234,6 @@ export class Session {
}
}
private async resolveCreateAgentWorkspace(
cwd: string,
workspaceId?: string,
): Promise<{ workspaceId: string }> {
const resolvedWorkspace = workspaceId
? await this.workspaceRegistry.get(workspaceId)
: ((await this.findWorkspaceByDirectory(cwd)) ??
(await this.findOrCreateWorkspaceForDirectory(cwd)));
if (!resolvedWorkspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return { workspaceId: resolvedWorkspace.workspaceId };
}
private async handleResumeAgentRequest(
msg: Extract<SessionInboundMessage, { type: "resume_agent_request" }>,
): Promise<void> {
@@ -4845,7 +4837,7 @@ export class Session {
}
private async removeWorkspaceGitWatchTarget(cwd: string): Promise<void> {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) {
this.closeWorkspaceGitWatchTarget(target);
@@ -4854,7 +4846,7 @@ export class Session {
}
private removeWorkspaceGitSubscription(cwd: string): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const target = this.workspaceGitWatchTargets.get(normalizedCwd);
if (target) {
const unsubscribeFetch = this.workspaceGitFetchSubscriptions.get(normalizedCwd);
@@ -4877,11 +4869,20 @@ export class Session {
]);
}
private resolveWorkspaceGitWatchTarget(workspaceId: string): WorkspaceGitWatchTarget | null {
for (const target of this.workspaceGitWatchTargets.values()) {
if (target.workspaceId === workspaceId) {
return target;
}
}
return null;
}
private shouldSkipWorkspaceGitWatchUpdate(
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): boolean {
const target = this.workspaceGitWatchTargets.get(workspaceId);
const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (!target) {
return false;
}
@@ -4897,7 +4898,7 @@ export class Session {
workspaceId: string,
workspace: WorkspaceDescriptorPayload | null,
): void {
const target = this.workspaceGitWatchTargets.get(workspaceId);
const target = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (!target) {
return;
}
@@ -4906,7 +4907,7 @@ export class Session {
}
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
const target = this.workspaceGitWatchTargets.get(resolve(cwd));
if (!target) {
return;
}
@@ -4934,7 +4935,7 @@ export class Session {
cwd: string,
options: { isGit: boolean; workspaceId: string },
): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd);
return;
@@ -5792,6 +5793,7 @@ export class Session {
workspaceGitService: this.workspaceGitService,
agentManager: this.agentManager,
agentStorage: this.agentStorage,
resolveWorkspaceIdForCwd: (cwd) => this.resolveWorkspaceIdForCwd(cwd),
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
emit: (message) => this.emit(message),
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
@@ -6179,7 +6181,7 @@ export class Session {
),
);
for (let i = 0; i < pairs.length; i += 1) {
placementsByCwd.set(normalizePersistedWorkspaceId(pairs[i].workspace.cwd), placements[i]);
placementsByCwd.set(resolve(pairs[i].workspace.cwd), placements[i]);
}
return placementsByCwd;
@@ -6248,17 +6250,14 @@ export class Session {
scope === "active" ? await this.buildActiveProjectPlacementsByWorkspaceCwd() : null;
if (activePlacementsByCwd) {
agents = agents.filter(
(agent) =>
!agent.archivedAt && activePlacementsByCwd.has(normalizePersistedWorkspaceId(agent.cwd)),
(agent) => !agent.archivedAt && activePlacementsByCwd.has(resolve(agent.cwd)),
);
}
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload | null>>();
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload | null> => {
if (activePlacementsByCwd) {
return Promise.resolve(
activePlacementsByCwd.get(normalizePersistedWorkspaceId(cwd)) ?? null,
);
return Promise.resolve(activePlacementsByCwd.get(resolve(cwd)) ?? null);
}
const existing = placementByCwd.get(cwd);
if (existing) {
@@ -6509,10 +6508,15 @@ export class Session {
private resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string {
): string | null {
return this.workspaceDirectory.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private async resolveWorkspaceIdForCwd(cwd: string): Promise<string | null> {
const workspaces = await this.workspaceRegistry.list();
return this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
private matchesWorkspaceFilter(input: {
workspace: WorkspaceDescriptorPayload;
filter: FetchWorkspacesRequestFilter | undefined;
@@ -6600,7 +6604,7 @@ export class Session {
}
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const inputCwd = normalizePersistedWorkspaceId(cwd);
const inputCwd = resolve(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
refreshGit: false,
@@ -6608,22 +6612,26 @@ export class Session {
if (existingWorkspace) {
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
const timestamp = new Date().toISOString();
const displayName = basename(inputCwd) || inputCwd;
const projectRecord = createPersistedProjectRecord({
projectId: inputCwd,
rootPath: inputCwd,
kind: "non_git",
displayName,
createdAt: timestamp,
updatedAt: timestamp,
const checkout = checkoutLiteFromGitSnapshot(inputCwd, {
isGit: false,
currentBranch: null,
remoteUrl: null,
repoRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
});
const membership = classifyDirectoryForProjectMembership({ cwd: inputCwd, checkout });
const projectRecord = await this.resolveProjectRecordForPlacement({
membership,
timestamp,
});
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: inputCwd,
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd: inputCwd,
kind: "directory",
displayName,
kind: membership.workspaceKind,
displayName: membership.workspaceDisplayName,
createdAt: timestamp,
updatedAt: timestamp,
});
@@ -6652,7 +6660,7 @@ export class Session {
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: membership.workspaceId,
workspaceId: generateWorkspaceId(),
projectId: projectRecord.projectId,
cwd,
kind: membership.workspaceKind,
@@ -6681,7 +6689,6 @@ export class Session {
const displayName = membership.workspaceDisplayName;
if (
input.workspace.workspaceId === membership.workspaceId &&
input.workspace.projectId === projectId &&
input.workspace.kind === kind &&
input.workspace.displayName === displayName
@@ -6693,7 +6700,7 @@ export class Session {
const nextWorkspace = {
...input.workspace,
workspaceId: membership.workspaceId,
workspaceId: input.workspace.workspaceId,
projectId,
cwd: input.cwd,
kind,
@@ -6797,7 +6804,10 @@ export class Session {
projectRegistry: this.projectRegistry,
});
if (!existingWorkspace) {
this.removeWorkspaceGitSubscription(workspaceId);
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (watchTarget) {
this.removeWorkspaceGitSubscription(watchTarget.cwd);
}
return;
}
@@ -6817,9 +6827,22 @@ export class Session {
);
}
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
await this.teardownArchivedWorkspace({
workspaceId: existingWorkspace.workspaceId,
cwd: existingWorkspace.cwd,
});
}
// Git watch and subscription state is keyed by directory; the script runtime
// store is keyed by the opaque workspace id. Each cleanup uses its own key so an
// opaque id is never resolved as a filesystem path.
private async teardownArchivedWorkspace(input: {
workspaceId: string;
cwd: string;
}): Promise<void> {
await this.removeWorkspaceGitWatchTarget(input.cwd);
this.scriptRuntimeStore?.removeForWorkspace(input.workspaceId);
this.removeWorkspaceGitSubscription(input.cwd);
}
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
@@ -6854,9 +6877,10 @@ export class Session {
result.changesApplied.map(async (change) => {
switch (change.kind) {
case "workspace_archived":
await this.removeWorkspaceGitWatchTarget(change.directory);
this.scriptRuntimeStore?.removeForWorkspace(change.directory);
this.removeWorkspaceGitSubscription(change.workspaceId);
await this.teardownArchivedWorkspace({
workspaceId: change.workspaceId,
cwd: change.directory,
});
changedWorkspaceIds.add(change.workspaceId);
break;
case "workspace_updated":
@@ -6912,7 +6936,7 @@ export class Session {
) {
continue;
}
const watchTarget = this.workspaceGitWatchTargets.get(workspaceId);
const watchTarget = this.resolveWorkspaceGitWatchTarget(workspaceId);
if (watchTarget && this.onBranchChanged) {
const newBranchName = nextWorkspace?.name ?? null;
if (newBranchName !== watchTarget.lastBranchName) {
@@ -6961,6 +6985,9 @@ export class Session {
): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
if (!workspaceId) {
return;
}
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], options);
}
@@ -7583,10 +7610,10 @@ export class Session {
throw new Error(`Workspace not found: ${requestedWorkspaceId}`);
}
const workspaceCwd = normalizePersistedWorkspaceId(workspace.cwd);
const workspaceCwd = resolve(workspace.cwd);
const clearableAgentIds = agents
.filter((agent) => !agent.archivedAt)
.filter((agent) => normalizePersistedWorkspaceId(agent.cwd) === workspaceCwd)
.filter((agent) => resolve(agent.cwd) === workspaceCwd)
.filter((agent) => agent.requiresAttention === true)
.filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0)
.filter((agent) => agent.attentionReason !== "permission")
@@ -495,6 +495,66 @@ describe("workspace git watch targets", () => {
await session.cleanup();
});
test("archiving a workspace clears its script runtime entries by opaque workspace id", async () => {
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: "ws-10",
scriptName: "app",
type: "service",
lifecycle: "running",
terminalId: "term-app",
exitCode: null,
});
const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests({
scriptRuntimeStore: runtimeStore,
});
seedGitWorkspace({
projects,
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: "/tmp/repo",
name: "main",
});
await asInternals<{ archiveWorkspaceRecord: (workspaceId: string) => Promise<void> }>(
session,
).archiveWorkspaceRecord("ws-10");
expect(runtimeStore.listForWorkspace("ws-10")).toEqual([]);
await session.cleanup();
});
test("archiving a workspace releases its git watch subscription for the directory", async () => {
const { session, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests();
const sessionAny = asInternals<
SessionInternals & { archiveWorkspaceRecord: (workspaceId: string) => Promise<void> }
>(session);
seedGitWorkspace({
projects,
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: REPO_CWD,
name: "main",
});
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
expect(subscriptions).toHaveLength(1);
await sessionAny.archiveWorkspaceRecord("ws-10");
// Re-observing the directory establishes a fresh subscription only if archive
// tore down the prior one, which is keyed by cwd — not the opaque workspace id.
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
expect(subscriptions).toHaveLength(2);
await session.cleanup();
});
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
const { session, emitted, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests();
@@ -204,7 +204,7 @@ const PARENT_CHILD = path.join(PARENT, "child");
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
workspaceId: rootPath,
workspaceId: `ws-${path.basename(rootPath) || "root"}`,
projectId: rootPath,
cwd: rootPath,
kind: "local_checkout",
@@ -217,7 +217,7 @@ function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
function dirWorkspace(cwd: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId: `ws-${path.basename(cwd) || "root"}`,
projectId: cwd,
cwd,
kind: "directory",
@@ -252,6 +252,17 @@ function dirProject(rootPath: string, archivedAt: string | null = null) {
});
}
function workspaceByCwd(
workspaces: Map<string, PersistedWorkspaceRecord>,
cwd: string,
): PersistedWorkspaceRecord | null {
return Array.from(workspaces.values()).find((workspace) => workspace.cwd === cwd) ?? null;
}
function hasWorkspaceCwd(workspaces: Map<string, PersistedWorkspaceRecord>, cwd: string): boolean {
return workspaceByCwd(workspaces, cwd) !== null;
}
// ─────────────────────────────────────────────────────────────────────────────
// S1. Open a fresh git repo: creates a workspace at the canonical root.
// ─────────────────────────────────────────────────────────────────────────────
@@ -262,7 +273,7 @@ test("S1: open fresh git repo creates workspace at canonical root", async () =>
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(FOO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has(FOO)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -276,7 +287,7 @@ test("S2: open fresh non-git directory creates a directory workspace at exact pa
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(BAR);
expect(resp?.workspace?.workspaceKind).toBe("directory");
expect(h.workspaces.has(BAR)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -291,9 +302,9 @@ test("S3: re-open active workspace by exact path returns the same record", async
});
await openProject(h.session, FOO);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe(FOO);
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
expect(h.workspaces.size).toBe(1);
expect(h.workspaces.get(FOO)?.archivedAt).toBeNull();
expect(workspaceByCwd(h.workspaces, FOO)?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -308,7 +319,7 @@ test("S4: open subdir of active git workspace returns the repo-root workspace",
});
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe(FOO);
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, FOO)?.workspaceId);
expect(h.workspaces.size).toBe(1);
});
@@ -324,8 +335,8 @@ test("S5: open subdir of active non-git directory creates a SEPARATE workspace",
await openProject(h.session, BAR_BAZ);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe(BAR_BAZ);
expect(h.workspaces.has(BAR)).toBe(true);
expect(h.workspaces.has(BAR_BAZ)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, BAR)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, BAR_BAZ)).toBe(true);
expect(h.workspaces.size).toBe(2);
});
@@ -341,7 +352,7 @@ test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", asy
gitRoots: [TOOLBOX],
});
await openProject(h.session, TOOLBOX);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
});
@@ -357,8 +368,8 @@ test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the in
await openProject(h.session, FOO_SUB);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe(FOO_SUB);
expect(h.workspaces.has(FOO)).toBe(true);
expect(h.workspaces.has(FOO_SUB)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, FOO)).toBe(true);
expect(hasWorkspaceCwd(h.workspaces, FOO_SUB)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -373,8 +384,8 @@ test("S8: open child of archived non-git ancestor creates fresh workspace; ances
projects: [dirProject(USERS_DEVELOPER, archivedAt)],
});
await openProject(h.session, USERS_PROJECT);
expect(h.workspaces.get(USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has(USERS_PROJECT)).toBe(true);
expect(workspaceByCwd(h.workspaces, USERS_DEVELOPER)?.archivedAt).toBe(archivedAt);
expect(hasWorkspaceCwd(h.workspaces, USERS_PROJECT)).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -390,7 +401,7 @@ test("S9: opening child of archived git workspace does NOT auto-unarchive the pa
gitRoots: [TOOLBOX],
});
await openProject(h.session, TOOLBOX_FLOMO);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBe(archivedAt);
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
@@ -414,8 +425,8 @@ test("S10: opening a git repo nested inside an archived non-git directory create
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe(SOME_GIT_REPO);
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has(SOME_GIT_REPO)).toBe(true);
expect(h.workspaces.get(PROJECTS)?.archivedAt).toBe(archivedAt);
expect(hasWorkspaceCwd(h.workspaces, SOME_GIT_REPO)).toBe(true);
expect(workspaceByCwd(h.workspaces, PROJECTS)?.archivedAt).toBe(archivedAt);
expect(h.projects.get(PROJECTS)?.archivedAt).toBe(archivedAt);
});
@@ -434,11 +445,11 @@ test("S11: re-opening an archived project by exact path unarchives project + wor
await openProject(h.session, TOOLBOX);
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.id).toBe(TOOLBOX);
expect(resp?.workspace?.id).toBe(workspaceByCwd(h.workspaces, TOOLBOX)?.workspaceId);
expect(resp?.workspace?.projectId).toBe(TOOLBOX);
expect(h.workspaces.size).toBe(1);
expect(h.projects.size).toBe(1);
expect(h.workspaces.get(TOOLBOX)?.archivedAt).toBeNull();
expect(workspaceByCwd(h.workspaces, TOOLBOX)?.archivedAt).toBeNull();
expect(h.projects.get(TOOLBOX)?.archivedAt).toBeNull();
});
@@ -490,8 +490,29 @@ function createSessionForWorkspaceTests(
notifyAgentState: () => {},
}),
agentStorage: asAgentStorage({
list: async () => [],
get: async () => null,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
}),
projectRegistry: {
@@ -506,8 +527,29 @@ function createSessionForWorkspaceTests(
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -853,10 +895,10 @@ test("agent_update placement does not refresh git snapshots", async () => {
});
});
test("agent_update emits fallback placement when no workspace is registered", async () => {
test("agent_update emits a directory-scoped placement when no workspace is registered", async () => {
const emitted: SessionOutboundMessage[] = [];
const getSnapshot = vi.fn(async () => {
throw new Error("getSnapshot should not be called for fallback agent_update placement");
throw new Error("getSnapshot should not be called for unregistered agent_update placement");
});
const session = asTestSession(
createSessionForWorkspaceTests({
@@ -886,17 +928,15 @@ test("agent_update emits fallback placement when no workspace is registered", as
);
expect(getSnapshot).not.toHaveBeenCalled();
expect(emitted.find((message) => message.type === "agent_update")?.payload).toMatchObject({
kind: "upsert",
project: {
projectKey: UNREGISTERED_CWD,
projectName: "unregistered",
checkout: {
cwd: UNREGISTERED_CWD,
isGit: false,
},
},
});
// An agent in a directory with no registered workspace must still emit an
// upsert (live model/thinking switches depend on this). It gets a directory-
// scoped placement keyed by the path — a project key, not a workspace id.
const update = emitted.find((message) => message.type === "agent_update");
if (update?.type !== "agent_update" || update.payload.kind !== "upsert") {
throw new Error("expected an agent_update upsert");
}
expect(update.payload.agent.id).toBe("agent-1");
expect(update.payload.project?.checkout.isGit).toBe(false);
});
test("archive emits an authoritative agent_update upsert for subscribed clients", async () => {
@@ -1158,7 +1198,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a
const emitted: SessionOutboundMessage[] = [];
const workspaces = [
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-a",
workspaceId: "ws-repo-a",
projectId: "/tmp/repo-a",
cwd: "/tmp/repo-a",
kind: "directory",
@@ -1167,7 +1207,7 @@ test("workspace clear attention can clear multiple workspaces in one request", a
updatedAt: "2026-03-30T15:00:00.000Z",
}),
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo-b",
workspaceId: "ws-repo-b",
projectId: "/tmp/repo-b",
cwd: "/tmp/repo-b",
kind: "directory",
@@ -2570,8 +2610,29 @@ test("workspace update stream keeps persisted workspace visible after agents sto
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
list: async () => [
createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
}),
],
get: async (workspaceId: string) =>
workspaceId === "ws-repo-running"
? createPersistedWorkspaceRecord({
workspaceId: "ws-repo-running",
projectId: "proj-repo-running",
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
})
: null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -2619,7 +2680,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
REPO_CWD,
"ws-repo-running",
{
id: "ws-repo-running",
projectId: "proj-repo-running",
@@ -2638,7 +2699,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
session.buildWorkspaceDescriptorMap = async () =>
new Map([
[
REPO_CWD,
"ws-repo-running",
{
id: "ws-repo-running",
projectId: "proj-repo-running",
@@ -2736,8 +2797,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
const workspaces = new Map();
const projects = new Map();
session.paseoHome = paseoHome;
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -2775,7 +2836,8 @@ test("create paseo worktree request returns a registered workspace descriptor",
name: "worktree-123",
status: "done",
});
expect(response?.payload.workspace?.id).toContain(path.join("worktree-123"));
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true);
});
@@ -2875,8 +2937,8 @@ test("open_project_request registers a workspace before any agent exists", async
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -2904,10 +2966,13 @@ test("open_project_request registers a workspace before any agent exists", async
requestId: "req-open",
});
expect(workspaces.get(REPO_CWD)).toBeTruthy();
const registeredWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === REPO_CWD,
);
expect(registeredWorkspace).toBeTruthy();
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(REPO_CWD);
expect(response?.payload.workspace?.id).toBe(registeredWorkspace?.workspaceId);
});
test("import_agent_request registers a workspace for a never-seen cwd", async () => {
@@ -2927,8 +2992,8 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -2971,15 +3036,22 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.buildWorkspaceDescriptorMap = async () =>
new Map([
session.buildWorkspaceDescriptorMap = async () => {
const workspace = Array.from(workspaces.values()).find(
(candidate) => candidate.cwd === importedCwd,
);
if (!workspace) {
return new Map();
}
return new Map([
[
importedCwd,
workspace.workspaceId,
{
id: importedCwd,
projectId: importedCwd,
id: workspace.workspaceId,
projectId: workspace.projectId,
projectDisplayName: "imported-project",
projectRootPath: importedCwd,
workspaceDirectory: importedCwd,
projectKind: "non_git",
workspaceKind: "directory",
name: "imported-project",
@@ -2988,6 +3060,7 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
},
],
]);
};
await session.handleMessage({
type: "import_agent_request",
@@ -2997,12 +3070,17 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
cwd: importedCwd,
});
expect(workspaces.get(importedCwd)).toBeTruthy();
const importedWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === importedCwd,
);
expect(importedWorkspace).toBeTruthy();
const workspaceUpdates = filterByType(emitted, "workspace_update");
expect(workspaceUpdates.length).toBeGreaterThan(0);
expect(
workspaceUpdates.some(
(update) => update.payload.kind === "upsert" && update.payload.workspace.id === importedCwd,
(update) =>
update.payload.kind === "upsert" &&
update.payload.workspace.workspaceDirectory === importedCwd,
),
).toBe(true);
});
@@ -3023,8 +3101,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3062,7 +3140,8 @@ test("open_project_response returns immediately even when the GitHub fetch is sl
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(cwd);
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toBe(cwd);
expect(response?.payload.workspace?.gitRuntime).toBeUndefined();
expect(response?.payload.workspace?.githubRuntime).toBeUndefined();
@@ -3089,8 +3168,8 @@ test("open_project_request emits a workspace_update with githubRuntime once the
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3143,7 +3222,7 @@ test("open_project_request emits a workspace_update with githubRuntime once the
.map((update) => update.payload)
.filter(
(payload): payload is WorkspaceUpsertPayload =>
payload.kind === "upsert" && payload.workspace.id === cwd,
payload.kind === "upsert" && payload.workspace.workspaceDirectory === cwd,
)
.find((payload) => payload.workspace.githubRuntime?.pullRequest);
expect(upsertedWithGitHub?.workspace.githubRuntime?.pullRequest).toEqual(
@@ -3155,6 +3234,7 @@ interface WorkspaceUpsertPayload {
kind: "upsert";
workspace: {
id: string;
workspaceDirectory?: string;
githubRuntime?: {
pullRequest?: { url?: string } | null;
} | null;
@@ -3202,8 +3282,8 @@ test("open_project_request does not match a new child directory to an existing p
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3220,9 +3300,11 @@ test("open_project_request does not match a new child directory to an existing p
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(worktree);
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
expect(workspaces.get(worktree)).toBeTruthy();
expect(Array.from(workspaces.values()).some((workspace) => workspace.cwd === worktree)).toBe(
true,
);
});
test("open_project_request does not unarchive an archived parent workspace for a new child directory", async () => {
@@ -3269,8 +3351,8 @@ test("open_project_request does not unarchive an archived parent workspace for a
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3287,7 +3369,7 @@ test("open_project_request does not unarchive an archived parent workspace for a
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(worktree);
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toBe(worktree);
expect(workspaces.get(home)?.archivedAt).toBe(archivedAt);
expect(projects.get(home)?.archivedAt).toBe(archivedAt);
@@ -3308,6 +3390,7 @@ test("open_project_request reclassifies an archived directory workspace when git
);
const remoteProjectId = "remote:github.com/getpaseo/paseo";
const archivedAt = "2026-04-24T09:48:36.168Z";
const workspaceId = "ws-desktop-daemon-settings";
projects.set(
cwd,
@@ -3322,9 +3405,9 @@ test("open_project_request reclassifies an archived directory workspace when git
}),
);
workspaces.set(
cwd,
workspaceId,
createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
@@ -3344,8 +3427,8 @@ test("open_project_request reclassifies an archived directory workspace when git
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3386,9 +3469,9 @@ test("open_project_request reclassifies an archived directory workspace when git
expect(response?.payload.workspace?.projectId).toBe(remoteProjectId);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(projects.get(remoteProjectId)?.kind).toBe("git");
expect(workspaces.get(cwd)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(cwd)?.kind).toBe("worktree");
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings");
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
});
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
@@ -3427,10 +3510,12 @@ test("open_project_request reclassifies an active directory workspace when git m
updatedAt: "2026-04-24T09:40:00.000Z",
}),
);
const workspaceId = "ws-desktop-daemon-settings-active";
const repoWorkspaceId = "ws-paseo-main";
workspaces.set(
cwd,
workspaceId,
createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
@@ -3440,9 +3525,9 @@ test("open_project_request reclassifies an active directory workspace when git m
}),
);
workspaces.set(
repoRoot,
repoWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: repoRoot,
workspaceId: repoWorkspaceId,
projectId: repoRoot,
cwd: repoRoot,
kind: "local_checkout",
@@ -3461,8 +3546,8 @@ test("open_project_request reclassifies an active directory workspace when git m
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3502,9 +3587,9 @@ test("open_project_request reclassifies an active directory workspace when git m
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot);
expect(workspaces.get(cwd)?.kind).toBe("worktree");
expect(workspaces.get(cwd)?.displayName).toBe("feature/desktop-daemon-settings");
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
});
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
@@ -3554,8 +3639,8 @@ test("open_project_request groups a plain git worktree under an existing repo pr
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3595,8 +3680,11 @@ test("open_project_request groups a plain git worktree under an existing repo pr
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(cwd)?.projectId).toBe(repoRoot);
expect(workspaces.get(cwd)?.kind).toBe("worktree");
const worktreeWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === cwd,
);
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
expect(worktreeWorkspace?.kind).toBe("worktree");
});
test("open_project_request unarchives an existing archived workspace and project", async () => {
@@ -3606,6 +3694,7 @@ test("open_project_request unarchives an existing archived workspace and project
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const cwd = REPO_CWD;
const workspaceId = "ws-repo-archived";
projects.set(
cwd,
createPersistedProjectRecord({
@@ -3619,9 +3708,9 @@ test("open_project_request unarchives an existing archived workspace and project
}),
);
workspaces.set(
cwd,
workspaceId,
createPersistedWorkspaceRecord({
workspaceId: cwd,
workspaceId,
projectId: cwd,
cwd,
kind: "directory",
@@ -3641,8 +3730,8 @@ test("open_project_request unarchives an existing archived workspace and project
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3657,11 +3746,11 @@ test("open_project_request unarchives an existing archived workspace and project
requestId: "req-open-unarchive",
});
expect(workspaces.get(cwd)?.archivedAt).toBeNull();
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(cwd);
expect(response?.payload.workspace?.id).toBe(workspaceId);
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
@@ -3681,8 +3770,8 @@ test.skip("open_project_request collapses a git subdirectory onto the repo root
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
) => {
@@ -3842,8 +3931,8 @@ test.skip("opening a new worktree reconciles older local workspaces into the rem
if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -3948,8 +4037,8 @@ test.skip("fetch_workspaces_request reconciles remote URL changes for existing w
if (!existing) return;
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt });
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -4044,8 +4133,8 @@ test.skip("reconcile archives stale subdirectory workspace records when collapsi
) => {
projects.set(record.projectId, record);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
session.workspaceRegistry.upsert = async (
record: ReturnType<typeof createPersistedWorkspaceRecord>,
@@ -5040,7 +5129,7 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
const home = homedir();
const childCwd = path.join(home, "projects/new-app");
const homeWorkspace = createPersistedWorkspaceRecord({
workspaceId: home,
workspaceId: "ws-home",
projectId: "proj-home",
cwd: home,
kind: "directory",
@@ -5049,6 +5138,6 @@ test("resolveRegisteredWorkspaceIdForCwd does not match home directory as a pref
updatedAt: "2026-03-01T12:00:00.000Z",
});
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBe(childCwd);
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe(home);
expect(session.resolveRegisteredWorkspaceIdForCwd(childCwd, [homeWorkspace])).toBeNull();
expect(session.resolveRegisteredWorkspaceIdForCwd(home, [homeWorkspace])).toBe("ws-home");
});
@@ -1,5 +1,5 @@
import { homedir } from "node:os";
import { sep } from "node:path";
import { resolve, sep } from "node:path";
import type pino from "pino";
import type {
AgentSnapshotPayload,
@@ -15,7 +15,6 @@ import {
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
import { SortablePager } from "./pagination/sortable-pager.js";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const FETCH_WORKSPACES_SORT_KEYS = [
"status_priority",
@@ -52,6 +51,33 @@ type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload["pageInfo"
export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter;
export function resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
const resolvedCwd = resolve(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === resolvedCwd);
if (exact) {
return exact.workspaceId;
}
const userHome = homedir();
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!resolvedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? null;
}
export interface WorkspaceDirectoryDeps {
logger: pino.Logger;
projectRegistry: {
@@ -176,9 +202,7 @@ export class WorkspaceDirectory {
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds ? new Set(options.workspaceIds) : null;
const workspaceIdsByDirectory = new Map(
activeRecords.map(
(workspace) => [normalizeWorkspaceId(workspace.cwd), workspace.workspaceId] as const,
),
activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const),
);
const includedWorkspaces = activeRecords.filter(
@@ -228,7 +252,7 @@ export class WorkspaceDirectory {
});
}
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(workspaceAgent.cwd));
const workspaceId = workspaceIdsByDirectory.get(resolve(workspaceAgent.cwd));
if (workspaceId === undefined) {
continue;
}
@@ -252,7 +276,7 @@ export class WorkspaceDirectory {
(agent) =>
!agent.archivedAt &&
this.deps.isProviderVisibleToClient(agent.provider) &&
workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)) === workspaceId,
workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId,
);
const result = this.resolveStatusEnteredAt({
workspaceId,
@@ -366,28 +390,11 @@ export class WorkspaceDirectory {
return candidates.at(-1) ?? null;
}
resolveRegisteredWorkspaceIdForCwd(cwd: string, workspaces: PersistedWorkspaceRecord[]): string {
const normalizedCwd = normalizeWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
if (exact) {
return exact.workspaceId;
}
const userHome = homedir();
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
bestMatch = workspace;
}
}
return bestMatch?.workspaceId ?? normalizedCwd;
resolveRegisteredWorkspaceIdForCwd(
cwd: string,
workspaces: PersistedWorkspaceRecord[],
): string | null {
return resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
}
async listDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
@@ -415,12 +422,6 @@ export class WorkspaceDirectory {
}
}
if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
if (!workspace.id.startsWith(filter.idPrefix.trim())) {
return false;
}
}
if (filter.query && filter.query.trim().length > 0) {
const query = filter.query.trim().toLocaleLowerCase();
const haystacks = [workspace.name, workspace.projectId, workspace.id];
@@ -1,6 +1,6 @@
import { watch, type FSWatcher } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { join, resolve } from "node:path";
import { basename, join, resolve } from "node:path";
import { LRUCache } from "lru-cache";
import pLimit from "p-limit";
import type pino from "pino";
@@ -38,7 +38,7 @@ import {
buildWorkspaceGitMetadataFromSnapshot,
type WorkspaceGitMetadata,
} from "./workspace-git-metadata.js";
import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js";
import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
@@ -401,7 +401,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
params: { cwd: string },
listener: WorkspaceGitListener,
): WorkspaceGitSubscription {
const cwd = normalizeWorkspaceId(params.cwd);
const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
if (target.listeners.size === 1) {
@@ -432,7 +432,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string,
options?: WorkspaceGitSnapshotOptions,
): Promise<WorkspaceGitRuntimeSnapshot> {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
const target = this.ensureWorkspaceTarget(cwd);
if (!request.force && target.latestSnapshot) {
@@ -443,7 +443,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
async getCheckout(cwd: string): Promise<ProjectCheckoutLitePayload> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
try {
const status = await this.deps.getCheckoutStatus(normalizedCwd, {
paseoHome: this.paseoHome,
@@ -481,7 +481,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
peekSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot | null {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
return this.workspaceTargets.get(cwd)?.latestSnapshot ?? null;
}
@@ -490,7 +490,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options: CheckoutDiffCompare,
readOptions?: WorkspaceGitReadOptions,
): Promise<CheckoutDiffResult> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const normalizedOptions = this.normalizeCheckoutDiffOptions(options);
const key = this.buildCheckoutDiffCacheKey(normalizedCwd, normalizedOptions);
return this.readAuxiliaryCache(this.checkoutDiffCache, key, readOptions, () =>
@@ -530,7 +530,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
ref: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchValidationResult> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const normalizedRef = ref.trim();
const key = JSON.stringify(["branch-validation", normalizedCwd, normalizedRef]);
return this.readAuxiliaryCache(this.branchValidationCache, key, options, () =>
@@ -539,7 +539,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
hasLocalBranch(cwd: string, branch: string, options?: WorkspaceGitReadOptions): Promise<boolean> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const normalizedBranch = branch.trim();
const ref = `refs/heads/${normalizedBranch}`;
const key = JSON.stringify(["local-branch", normalizedCwd, ref]);
@@ -558,7 +558,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitBranchSuggestionsOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitBranchSuggestion[]> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const query = options?.query ?? "";
const limit = options?.limit;
const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]);
@@ -572,7 +572,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitStashListOptions,
readOptions?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitStashEntry[]> {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const paseoOnly = options?.paseoOnly !== false;
const key = JSON.stringify(["stashes", normalizedCwd, paseoOnly]);
return this.readAuxiliaryCache(this.stashListCache, key, readOptions, async () => {
@@ -606,15 +606,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
return snapshot.git.isPaseoOwnedWorktree
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd))
: (snapshot.git.repoRoot ?? normalizeWorkspaceId(cwd));
? (snapshot.git.mainRepoRoot ?? snapshot.git.repoRoot ?? resolve(cwd))
: (snapshot.git.repoRoot ?? resolve(cwd));
}
async resolveDefaultBranch(
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<string> {
const cwd = normalizeWorkspaceId(cwdOrRepoRoot);
const cwd = resolve(cwdOrRepoRoot);
const key = JSON.stringify(["default-branch", cwd]);
return this.readAuxiliaryCache(this.defaultBranchCache, key, options, async () => {
const defaultBranch = await this.deps.resolveRepositoryDefaultBranch(cwd);
@@ -630,10 +630,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
options?: WorkspaceGitReadOptions & { directoryName?: string },
): Promise<WorkspaceGitMetadata> {
const snapshot = await this.getSnapshot(cwd, options);
const directoryName =
options?.directoryName ?? normalizeWorkspaceId(cwd).split(/[\\/]/).findLast(Boolean) ?? cwd;
const directoryName = options?.directoryName ?? basename(cwd) ?? cwd;
return buildWorkspaceGitMetadataFromSnapshot({
cwd: normalizeWorkspaceId(cwd),
cwd: resolve(cwd),
directoryName,
isGit: snapshot.git.isGit,
repoRoot: snapshot.git.repoRoot,
@@ -652,7 +651,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
async refresh(cwd: string, _options?: { priority?: "normal" | "high" }): Promise<void> {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd);
await this.refreshWorkspaceTarget(target, {
force: false,
@@ -667,7 +666,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
cwd: string,
onChange: () => void,
): Promise<{ repoRoot: string | null; unsubscribe: () => void }> {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const target = await this.ensureWorkingTreeWatchTarget(cwd);
target.listeners.add(onChange);
@@ -680,7 +679,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
scheduleRefreshForCwd(cwd: string): void {
cwd = normalizeWorkspaceId(cwd);
cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd);
if (target) {
this.scheduleWorkspaceRefresh(target);
@@ -688,7 +687,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
onWorkspaceStateMayHaveChanged(cwd: string): void {
const normalizedCwd = normalizeWorkspaceId(cwd);
const normalizedCwd = resolve(cwd);
const target = this.workspaceTargets.get(normalizedCwd);
if (!target || target.closed) {
return;
@@ -1120,7 +1119,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
): void {
const target =
typeof targetOrCwd === "string"
? this.workspaceTargets.get(normalizeWorkspaceId(targetOrCwd))
? this.workspaceTargets.get(resolve(targetOrCwd))
: targetOrCwd;
if (!target || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
return;
@@ -1,4 +1,5 @@
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import type pino from "pino";
import type {
ProjectRegistry,
@@ -7,7 +8,6 @@ import type {
PersistedWorkspaceRecord,
} from "./workspace-registry.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
@@ -220,7 +220,7 @@ export class WorkspaceReconciliationService {
if (project.kind !== "git") {
continue;
}
const rootKey = normalizeWorkspaceId(project.rootPath);
const rootKey = resolve(project.rootPath);
const group = projectsByRoot.get(rootKey) ?? [];
group.push(project);
projectsByRoot.set(rootKey, group);
@@ -107,7 +107,8 @@ describe("bootstrapWorkspaceRegistries", () => {
const workspaces = await workspaceRegistry.list();
expect(workspaces).toHaveLength(1);
expect(workspaces[0]?.workspaceId).toBe(NON_GIT_PROJECT);
expect(workspaces[0]?.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT);
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
@@ -122,7 +123,7 @@ describe("bootstrapWorkspaceRegistries", () => {
await projectRegistry.initialize();
await workspaceRegistry.initialize();
await projectRegistry.upsert({
projectId: "/tmp/existing",
projectId: "proj-existing",
rootPath: "/tmp/existing",
kind: "non_git",
displayName: "existing",
@@ -131,8 +132,8 @@ describe("bootstrapWorkspaceRegistries", () => {
archivedAt: null,
});
await workspaceRegistry.upsert({
workspaceId: "/tmp/existing",
projectId: "/tmp/existing",
workspaceId: "ws-existing",
projectId: "proj-existing",
cwd: "/tmp/existing",
kind: "directory",
displayName: "existing",
@@ -171,6 +172,57 @@ describe("bootstrapWorkspaceRegistries", () => {
expect(await projectRegistry.list()).toHaveLength(1);
expect(await workspaceRegistry.list()).toHaveLength(1);
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing");
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("ws-existing");
});
test("preserves existing workspace IDs when only the projects file is missing", async () => {
await workspaceRegistry.initialize();
await workspaceRegistry.upsert({
workspaceId: "ws-existing",
projectId: NON_GIT_PROJECT,
cwd: NON_GIT_PROJECT,
kind: "directory",
displayName: "non-git-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await agentStorage.initialize();
await agentStorage.upsert({
id: "agent-1",
provider: "codex",
cwd: NON_GIT_PROJECT,
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceGitService,
logger,
});
const workspaces = await workspaceRegistry.list();
expect(workspaces).toHaveLength(1);
expect(workspaces[0]?.workspaceId).toBe("ws-existing");
expect(workspaces[0]?.cwd).toBe(NON_GIT_PROJECT);
const projects = await projectRegistry.list();
expect(projects).toHaveLength(1);
expect(projects[0]?.projectId).toBe(NON_GIT_PROJECT);
});
});
@@ -6,7 +6,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
classifyDirectoryForProjectMembership,
normalizeWorkspaceId,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import {
@@ -63,9 +63,15 @@ export async function bootstrapWorkspaceRegistries(options: {
return;
}
const existingWorkspaceIdsByCwd = new Map(
(await options.workspaceRegistry.list()).map((workspace) => [
path.resolve(workspace.cwd),
workspace.workspaceId,
]),
);
const records = await options.agentStorage.list();
const activeRecords = records.filter((record) => !record.archivedAt);
const recordsByWorkspaceId = new Map<
const recordsByDirectoryKey = new Map<
string,
{
membership: ReturnType<typeof classifyDirectoryForProjectMembership>;
@@ -74,19 +80,19 @@ export async function bootstrapWorkspaceRegistries(options: {
>();
const placements = await Promise.all(
activeRecords.map(async (record) => {
const normalizedCwd = normalizeWorkspaceId(record.cwd);
const normalizedCwd = path.resolve(record.cwd);
const checkout = await options.workspaceGitService.getCheckout(normalizedCwd);
const membership = classifyDirectoryForProjectMembership({
cwd: normalizedCwd,
checkout,
});
return { record, membership, workspaceId: membership.workspaceId };
return { record, membership, directoryKey: membership.workspaceDirectoryKey };
}),
);
for (const { record, membership, workspaceId } of placements) {
const existing = recordsByWorkspaceId.get(workspaceId) ?? { membership, records: [] };
for (const { record, membership, directoryKey } of placements) {
const existing = recordsByDirectoryKey.get(directoryKey) ?? { membership, records: [] };
existing.records.push(record);
recordsByWorkspaceId.set(workspaceId, existing);
recordsByDirectoryKey.set(directoryKey, existing);
}
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>();
@@ -98,7 +104,7 @@ export async function bootstrapWorkspaceRegistries(options: {
updatedAt: string;
}[] = [];
for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) {
for (const entry of recordsByDirectoryKey.values()) {
const { membership, records: workspaceRecords } = entry;
const workspaceCwd = membership.checkout.cwd;
let workspaceCreatedAt: string | null = null;
@@ -119,7 +125,13 @@ export async function bootstrapWorkspaceRegistries(options: {
existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt);
projectRanges.set(membership.projectKey, existingProjectRange);
workspaceUpsertInputs.push({ workspaceId, membership, workspaceCwd, createdAt, updatedAt });
workspaceUpsertInputs.push({
workspaceId: existingWorkspaceIdsByCwd.get(workspaceCwd) ?? generateWorkspaceId(),
membership,
workspaceCwd,
createdAt,
updatedAt,
});
}
await Promise.all(
@@ -161,7 +173,7 @@ export async function bootstrapWorkspaceRegistries(options: {
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
workspacesFile: path.join(options.paseoHome, "projects", "workspaces.json"),
materializedProjects: projectRanges.size,
materializedWorkspaces: recordsByWorkspaceId.size,
materializedWorkspaces: recordsByDirectoryKey.size,
},
"Workspace registries bootstrapped from existing agent storage",
);
@@ -1,23 +1,24 @@
import { describe, expect, test, vi } from "vitest";
import { basename, isAbsolute, resolve } from "node:path";
import {
classifyDirectoryForProjectMembership,
deriveProjectGroupingName,
deriveProjectRootPath,
deriveWorkspaceDirectoryKey,
deriveWorkspaceKind,
deriveWorkspaceId,
detectStaleWorkspaces,
normalizeWorkspaceId,
generateWorkspaceId,
} from "./workspace-registry-model.js";
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
function createWorkspaceRecord(workspaceId: string) {
function createWorkspaceRecord(cwd: string, workspaceId: string) {
return createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd: workspaceId,
cwd,
kind: "directory",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
displayName: basename(cwd) || cwd,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
});
@@ -59,19 +60,22 @@ describe("detectStaleWorkspaces", () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/existing"),
createWorkspaceRecord("/tmp/missing"),
createWorkspaceRecord("/tmp/existing", "ws-existing"),
createWorkspaceRecord("/tmp/missing", "ws-missing"),
],
checkDirectoryExists,
});
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]);
expect(Array.from(staleWorkspaceIds)).toEqual(["ws-missing"]);
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
});
test("keeps workspaces whose directories exist even when all agents are archived", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
activeWorkspaces: [
createWorkspaceRecord("/tmp/repo", "ws-repo"),
createWorkspaceRecord("/tmp/other", "ws-other"),
],
checkDirectoryExists: async () => true,
});
@@ -81,8 +85,8 @@ describe("detectStaleWorkspaces", () => {
test("keeps workspaces with no agents when directory exists", async () => {
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces: [
createWorkspaceRecord("/tmp/active"),
createWorkspaceRecord("/tmp/no-agents"),
createWorkspaceRecord("/tmp/active", "ws-active"),
createWorkspaceRecord("/tmp/no-agents", "ws-no-agents"),
],
checkDirectoryExists: async () => true,
});
@@ -91,10 +95,10 @@ describe("detectStaleWorkspaces", () => {
});
});
describe("deriveWorkspaceId", () => {
describe("deriveWorkspaceDirectoryKey", () => {
test("uses git worktree root when available", () => {
expect(
deriveWorkspaceId("/tmp/repo/packages/app", {
deriveWorkspaceDirectoryKey("/tmp/repo/packages/app", {
cwd: "/tmp/repo/packages/app",
isGit: true,
currentBranch: "main",
@@ -110,7 +114,7 @@ describe("deriveWorkspaceId", () => {
const cwd = String.raw`E:\project\node-ai`;
expect(
deriveWorkspaceId(cwd, {
deriveWorkspaceDirectoryKey(cwd, {
cwd,
isGit: true,
currentBranch: "main",
@@ -119,14 +123,14 @@ describe("deriveWorkspaceId", () => {
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe(normalizeWorkspaceId(cwd));
).toBe(resolve(cwd));
});
test("falls back to normalized cwd for non-git directories", () => {
const cwd = "/tmp/repo/../repo/scratch";
expect(
deriveWorkspaceId(cwd, {
deriveWorkspaceDirectoryKey(cwd, {
cwd,
isGit: false,
currentBranch: null,
@@ -135,7 +139,31 @@ describe("deriveWorkspaceId", () => {
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
).toBe(normalizeWorkspaceId("/tmp/repo/scratch"));
).toBe(resolve("/tmp/repo/scratch"));
});
});
describe("opaque workspace id versus directory key", () => {
test("generates opaque workspace ids that are not filesystem paths", () => {
const workspaceId = generateWorkspaceId();
expect(workspaceId).toMatch(/^wks_[0-9a-f]+$/);
expect(isAbsolute(workspaceId)).toBe(false);
});
test("derives a path-shaped directory key that is never an opaque workspace id", () => {
const directoryKey = deriveWorkspaceDirectoryKey("/tmp/repo/scratch", {
cwd: "/tmp/repo/scratch",
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
});
expect(directoryKey).toBe(resolve("/tmp/repo/scratch"));
expect(directoryKey.startsWith("wks_")).toBe(false);
});
});
@@ -155,8 +183,9 @@ describe("git worktree grouping", () => {
});
expect(membership).toMatchObject({
cwd: normalizeWorkspaceId("/tmp/repo-feature"),
workspaceId: "/tmp/repo-feature",
// Path-derived directory key, distinct from the opaque workspace id (generated separately).
cwd: resolve("/tmp/repo-feature"),
workspaceDirectoryKey: "/tmp/repo-feature",
workspaceKind: "worktree",
workspaceDisplayName: "feature/plain",
projectKey: "remote:github.com/acme/repo",
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import type {
@@ -13,7 +14,7 @@ export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"
export interface DirectoryProjectMembership {
cwd: string;
checkout: ProjectCheckoutLitePayload;
workspaceId: string;
workspaceDirectoryKey: string;
workspaceKind: PersistedWorkspaceKind;
workspaceDisplayName: string;
projectKey: string;
@@ -27,17 +28,18 @@ export interface DetectStaleWorkspacesInput {
checkDirectoryExists: (cwd: string) => Promise<boolean>;
}
export function normalizeWorkspaceId(cwd: string): string {
const trimmed = cwd.trim();
if (!trimmed) {
return cwd;
}
return resolve(trimmed);
export function generateWorkspaceId(): string {
return `wks_${randomBytes(8).toString("hex")}`;
}
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
// Path-derived grouping key for a workspace directory. This is NOT the opaque
// workspace identity (see generateWorkspaceId); never persist or compare it as one.
export function deriveWorkspaceDirectoryKey(
cwd: string,
checkout: ProjectCheckoutLitePayload,
): string {
const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null;
return worktreeRoot ?? normalizeWorkspaceId(cwd);
return worktreeRoot ?? resolve(cwd);
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
@@ -240,7 +242,7 @@ export function classifyDirectoryForProjectMembership(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
}): DirectoryProjectMembership {
const normalizedCwd = normalizeWorkspaceId(input.cwd);
const normalizedCwd = resolve(input.cwd);
const checkout: ProjectCheckoutLitePayload = {
...input.checkout,
cwd: normalizedCwd,
@@ -255,7 +257,7 @@ export function classifyDirectoryForProjectMembership(input: {
return {
cwd: normalizedCwd,
checkout,
workspaceId: deriveWorkspaceId(normalizedCwd, checkout),
workspaceDirectoryKey: deriveWorkspaceDirectoryKey(normalizedCwd, checkout),
workspaceKind: deriveWorkspaceKind(checkout),
workspaceDisplayName: deriveWorkspaceDisplayName({
cwd: normalizedCwd,
@@ -479,6 +479,7 @@ function createAgentStorageStub(): Pick<AgentStorage, "list"> {
function createWorkspaceArchivingDeps() {
return {
resolveWorkspaceIdForCwd: vi.fn(async () => "ws-archive-test"),
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async () => {}),
markWorkspaceArchiving: vi.fn(),
clearWorkspaceArchiving: vi.fn(),
@@ -1317,7 +1318,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
branchName: "fix-attached-pr-context",
},
workspace: {
workspaceId: "/tmp/worktrees/fix-attached-pr-context",
workspaceId: "ws-fix-attached-pr-context",
projectId: "/tmp/repo",
cwd: "/tmp/worktrees/fix-attached-pr-context",
kind: "worktree" as const,
@@ -1722,7 +1723,6 @@ describe("archivePaseoWorktree", () => {
runSetup: false,
paseoHome,
});
const teardownStartTimes: Record<string, number> = {};
const teardownEndTimes: Record<string, number> = {};
const archiveAgentSpy = vi.fn(async (agentId: string) => {
@@ -1793,13 +1793,14 @@ describe("archivePaseoWorktree", () => {
runSetup: false,
paseoHome,
});
const affectedIds = [created.worktreePath];
const workspaceId = "ws-archive-marked-during-close";
const affectedIds = [workspaceId];
const liveAgent = createManagedAgentForArchive({
id: "agent-1",
cwd: created.worktreePath,
});
const workspaceRecord: PersistedWorkspaceRecord = {
workspaceId: created.worktreePath,
workspaceId,
projectId: repoDir,
cwd: created.worktreePath,
kind: "worktree",
@@ -1813,13 +1814,13 @@ describe("archivePaseoWorktree", () => {
const archivedWorkspaceIds = new Set<string>();
const archivingByWorkspaceId = new Map<string, string>();
const emitWorkspaceUpdatesForWorkspaceIds = vi.fn(async (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(workspaceId)) {
for (const emittedWorkspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(emittedWorkspaceId)) {
emitted.push({
type: "workspace_update",
payload: {
kind: "remove",
id: workspaceId,
id: emittedWorkspaceId,
},
});
continue;
@@ -1830,7 +1831,7 @@ describe("archivePaseoWorktree", () => {
kind: "upsert",
workspace: {
...createWorkspaceDescriptor({ workspace: workspaceRecord, repoDir }),
archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null,
archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null,
},
},
});
@@ -1861,21 +1862,24 @@ describe("archivePaseoWorktree", () => {
archiveSnapshot,
},
agentStorage: createAgentStorageStub(),
archiveWorkspaceRecord: vi.fn(async (workspaceId: string) => {
archivedWorkspaceIds.add(workspaceId);
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
archiveWorkspaceRecord: vi.fn(async (archivedWorkspaceId: string) => {
archivedWorkspaceIds.add(archivedWorkspaceId);
}),
emit: (message) => emitted.push(message),
emitWorkspaceUpdatesForWorkspaceIds,
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
events.push(`mark:${Array.from(workspaceIds).join(",")}`);
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.set(workspaceId, archivingAt);
for (const markedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.set(markedWorkspaceId, archivingAt);
}
},
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
events.push(`clear:${Array.from(workspaceIds).join(",")}`);
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.delete(workspaceId);
for (const clearedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.delete(clearedWorkspaceId);
}
},
isPathWithinRoot: createIsPathWithinRoot(),
@@ -1895,14 +1899,14 @@ describe("archivePaseoWorktree", () => {
message.type === "workspace_update",
);
expect(events.slice(0, 3)).toEqual([
`mark:${created.worktreePath}`,
`emit:${created.worktreePath}`,
`mark:${workspaceId}`,
`emit:${workspaceId}`,
"close:start",
]);
expect(workspaceUpdates[0]?.payload).toEqual({
kind: "upsert",
workspace: expect.objectContaining({
id: created.worktreePath,
id: workspaceId,
archivingAt: expect.any(String),
}),
});
@@ -1913,18 +1917,15 @@ describe("archivePaseoWorktree", () => {
expect(workspaceUpdates[1]?.payload).toEqual({
kind: "upsert",
workspace: expect.objectContaining({
id: created.worktreePath,
id: workspaceId,
archivingAt,
}),
});
expect(workspaceUpdates.at(-1)?.payload).toEqual({
kind: "remove",
id: created.worktreePath,
id: workspaceId,
});
expect(events.slice(-2)).toEqual([
`clear:${created.worktreePath}`,
`emit:${created.worktreePath}`,
]);
expect(events.slice(-2)).toEqual([`clear:${workspaceId}`, `emit:${workspaceId}`]);
expect(
emitted.find((message) => message.type === "paseo_worktree_archive_response"),
).toMatchObject({
@@ -1960,6 +1961,7 @@ describe("archivePaseoWorktree", () => {
runSetup: false,
paseoHome,
});
const workspaceId = "ws-archive-delete-fails";
const archivingByWorkspaceId = new Map<string, string>();
const archivedWorkspaceIds = new Set<string>();
const emittedUpdates: Array<
@@ -1973,8 +1975,8 @@ describe("archivePaseoWorktree", () => {
workspaceId: string;
}
> = [];
const archiveWorkspaceRecord = vi.fn(async (workspaceId: string) => {
archivedWorkspaceIds.add(workspaceId);
const archiveWorkspaceRecord = vi.fn(async (archivedWorkspaceId: string) => {
archivedWorkspaceIds.add(archivedWorkspaceId);
});
await expect(
@@ -1991,31 +1993,34 @@ describe("archivePaseoWorktree", () => {
}),
},
agentStorage: createAgentStorageStub(),
resolveWorkspaceIdForCwd: vi.fn(async (cwd: string) =>
cwd === created.worktreePath ? workspaceId : null,
),
archiveWorkspaceRecord,
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(async (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(workspaceId)) {
for (const emittedWorkspaceId of workspaceIds) {
if (archivedWorkspaceIds.has(emittedWorkspaceId)) {
emittedUpdates.push({
kind: "remove",
workspaceId,
workspaceId: emittedWorkspaceId,
});
continue;
}
emittedUpdates.push({
kind: "upsert",
workspaceId,
archivingAt: archivingByWorkspaceId.get(workspaceId) ?? null,
workspaceId: emittedWorkspaceId,
archivingAt: archivingByWorkspaceId.get(emittedWorkspaceId) ?? null,
});
}
}),
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => {
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.set(workspaceId, archivingAt);
for (const markedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.set(markedWorkspaceId, archivingAt);
}
},
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => {
for (const workspaceId of workspaceIds) {
archivingByWorkspaceId.delete(workspaceId);
for (const clearedWorkspaceId of workspaceIds) {
archivingByWorkspaceId.delete(clearedWorkspaceId);
}
},
isPathWithinRoot: createIsPathWithinRoot(),
@@ -2032,15 +2037,15 @@ describe("archivePaseoWorktree", () => {
expect(existsSync(created.worktreePath)).toBe(true);
expect(existsSync(path.join(repoDir, "teardown-start.log"))).toBe(true);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.worktreePath);
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(workspaceId);
expect(emittedUpdates[0]).toEqual({
kind: "upsert",
workspaceId: created.worktreePath,
workspaceId,
archivingAt: expect.any(String),
});
expect(emittedUpdates.at(-1)).toEqual({
kind: "remove",
workspaceId: created.worktreePath,
workspaceId,
});
});