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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user