From a1cd50c2ae45c11e618d34f55e7bb410d6cde9aa Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 20:25:50 +0200 Subject: [PATCH] Reimport archived sessions into the current workspace (#2123) * fix(sessions): reimport archived sessions in their workspace Archived agent records were treated as active imports, hiding their provider sessions permanently. Workspace-originated imports also discarded their workspace identity and created a duplicate workspace. * fix(sessions): validate archived session restores Restore archived imports under their existing Paseo agent identity, reject stale workspace ownership, and gate workspace targeting when the host cannot honor it. * fix(sessions): roll back failed archived imports If provider resume or history hydration fails, close any partial runtime, re-archive the provider session, and restore the original stored agent record. * fix(sessions): validate restored import ownership * fix(sessions): serialize concurrent restores * fix(sessions): harden archived import recovery * fix(sessions): validate archived import placement * refactor(sessions): centralize provider session imports Keep workspace placement rollback and stored-agent activation behind their existing owners so Session remains the wire boundary. --- .../import-session-sheet-view-model.test.ts | 30 + .../import-session-sheet-view-model.ts | 8 + .../src/components/import-session-sheet.tsx | 17 +- .../screens/workspace/workspace-screen.tsx | 1 + packages/client/src/daemon-client.ts | 2 + packages/protocol/src/messages.test.ts | 15 + packages/protocol/src/messages.ts | 3 + .../server/src/server/agent/agent-loading.ts | 2 +- .../src/server/agent/agent-manager.test.ts | 12 +- .../server/src/server/agent/agent-manager.ts | 7 +- .../server/src/server/agent/agent-prompt.ts | 7 +- .../src/server/agent/import-sessions.test.ts | 536 ++++++++++++++---- .../src/server/agent/import-sessions.ts | 207 +++++-- packages/server/src/server/session.ts | 14 +- .../src/server/session.workspaces.test.ts | 82 +++ .../workspace-provisioning-service.test.ts | 139 ++++- .../workspace-provisioning-service.ts | 83 ++- .../server/src/server/websocket-server.ts | 2 + 18 files changed, 1013 insertions(+), 154 deletions(-) diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts index 6b2226309..784ad9d39 100644 --- a/packages/app/src/components/import-session-sheet-view-model.test.ts +++ b/packages/app/src/components/import-session-sheet-view-model.test.ts @@ -9,6 +9,7 @@ import { getPromptPreview, getSessionTitle, resolveProvidersToFetch, + requiresImportSessionsHostUpgrade, type SessionsQueryResult, sumFilteredAlreadyImportedCount, } from "@/components/import-session-sheet-view-model"; @@ -70,6 +71,35 @@ describe("resolveProvidersToFetch", () => { }); }); +describe("requiresImportSessionsHostUpgrade", () => { + it("allows home imports on hosts without workspace targeting", () => { + expect( + requiresImportSessionsHostUpgrade({ + supportsSnapshot: true, + workspaceId: null, + supportsWorkspaceTarget: false, + }), + ).toBe(false); + }); + + it("requires host support for imports opened from a workspace", () => { + expect( + requiresImportSessionsHostUpgrade({ + supportsSnapshot: true, + workspaceId: "ws-current", + supportsWorkspaceTarget: false, + }), + ).toBe(true); + expect( + requiresImportSessionsHostUpgrade({ + supportsSnapshot: true, + workspaceId: "ws-current", + supportsWorkspaceTarget: true, + }), + ).toBe(false); + }); +}); + describe("buildProviderLabelMap", () => { it("returns an empty map when snapshot entries are missing", () => { expect(buildProviderLabelMap(undefined).size).toBe(0); diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts index 9650c8071..ec6cd78f9 100644 --- a/packages/app/src/components/import-session-sheet-view-model.ts +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -5,6 +5,14 @@ import { i18n } from "@/i18n/i18next"; export const PER_PROVIDER_LIMIT = 15; export const ALL_FILTER_VALUE = "__all__"; +export function requiresImportSessionsHostUpgrade(input: { + supportsSnapshot: boolean; + workspaceId?: string | null; + supportsWorkspaceTarget: boolean; +}): boolean { + return !input.supportsSnapshot || (Boolean(input.workspaceId) && !input.supportsWorkspaceTarget); +} + export interface SessionsQueryResult { data: | { diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index ce38cec43..fedb19961 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -15,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com import { getProviderIcon } from "@/components/provider-icons"; import { formatTimeAgo } from "@/utils/time"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { useHostFeature } from "@/runtime/host-features"; import { i18n } from "@/i18n/i18next"; import { aggregateSessionEntries, @@ -26,6 +27,7 @@ import { getSessionTitle, PER_PROVIDER_LIMIT, resolveProvidersToFetch, + requiresImportSessionsHostUpgrade, sumFilteredAlreadyImportedCount, } from "@/components/import-session-sheet-view-model"; @@ -44,6 +46,7 @@ interface ImportSessionSheetProps { client: RecentProviderSessionsClient | null; serverId: string | null; cwd?: string | null; + workspaceId?: string | null; onClose: () => void; onImportedAgent?: (agentId: string) => void; onImported?: (agent: ImportedAgent) => void; @@ -260,6 +263,7 @@ export function ImportSessionSheet({ client, serverId, cwd, + workspaceId, onClose, onImportedAgent, onImported, @@ -272,10 +276,16 @@ export function ImportSessionSheet({ cwd, enabled: visible, }); + const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget"); + const requiresHostUpgrade = requiresImportSessionsHostUpgrade({ + supportsSnapshot, + workspaceId, + supportsWorkspaceTarget, + }); const providersToFetch = useMemo( - () => resolveProvidersToFetch(supportsSnapshot, snapshotEntries), - [supportsSnapshot, snapshotEntries], + () => (requiresHostUpgrade ? null : resolveProvidersToFetch(supportsSnapshot, snapshotEntries)), + [requiresHostUpgrade, supportsSnapshot, snapshotEntries], ); const providerLabelById = useMemo( @@ -408,6 +418,7 @@ export function ImportSessionSheet({ providerId: entry.providerId, providerHandleId: entry.providerHandleId, cwd: entry.cwd, + ...(workspaceId ? { workspaceId } : {}), }); return agent; }, @@ -450,7 +461,7 @@ export function ImportSessionSheet({ [isRefreshing, handleRefresh, t], ); - const isSnapshotUnsupported = !supportsSnapshot; + const isSnapshotUnsupported = requiresHostUpgrade; const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined; const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0; const isQueryingProviders = queries.length > 0; diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 1712f2259..eac11a20b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3709,6 +3709,7 @@ function WorkspaceScreenContent({ client={client} serverId={normalizedServerId} cwd={workspaceDirectory} + workspaceId={normalizedWorkspaceId} onClose={closeImportSheet} onImportedAgent={handleImportedAgent} /> diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1c5d897c7..031fb12ed 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -159,6 +159,7 @@ const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000; interface ImportAgentInputBase { cwd?: string; + workspaceId?: string; labels?: Record; } @@ -2524,6 +2525,7 @@ export class DaemonClient { ? { providerId: input.providerId, providerHandleId: input.providerHandleId } : { provider: input.provider, sessionId: input.sessionId }), ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), ...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}), }); diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 9cd91e40d..dec27d458 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -293,6 +293,21 @@ describe("agent detach RPC", () => { } expect(parsed.features?.agentDetach).toBe(true); }); + + test("parses the workspace-targeted session import feature gate", () => { + const parsed = parseServerInfoStatusPayload({ + status: "server_info", + serverId: "srv-test", + features: { + importSessionWorkspaceTarget: true, + }, + }); + + if (!parsed) { + throw new Error("Expected server info payload to parse"); + } + expect(parsed.features?.importSessionWorkspaceTarget).toBe(true); + }); }); describe("agent setting action responses", () => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f3858f269..a5ad3d22d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1267,6 +1267,7 @@ export const ImportAgentRequestMessageSchema = z.object({ sessionId: z.string().optional(), providerHandleId: z.string().optional(), cwd: z.string().optional(), + workspaceId: z.string().optional(), labels: z.record(z.string(), z.string()).optional(), requestId: z.string(), }); @@ -2560,6 +2561,8 @@ export const ServerInfoStatusPayloadSchema = z commitsList: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), + // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. + importSessionWorkspaceTarget: z.boolean().optional(), }) .optional(), }) diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 578502195..dfe06fc43 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -13,7 +13,7 @@ import { const pendingAgentInitializations = new Map>(); -type AgentLoaderManager = Pick< +export type AgentLoaderManager = Pick< AgentManager, | "createAgent" | "getAgent" diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 98814047e..48e32643c 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5970,12 +5970,18 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi title: "Native unarchive target", }, undefined, - { workspaceId: undefined }, + { + workspaceId: undefined, + labels: { [PARENT_AGENT_ID_LABEL]: "archived-parent", retained: "yes" }, + }, ); await manager.archiveAgent(agent.id); client.readArchivedAtDuringUnarchive = async () => (await storage.get(agent.id))?.archivedAt; - const unarchived = await manager.unarchiveSnapshot(agent.id); + const unarchived = await manager.unarchiveSnapshot(agent.id, { + workspaceId: "ws-restored", + labels: { [PARENT_AGENT_ID_LABEL]: null, source: "reimport" }, + }); const stored = await storage.get(agent.id); expect(unarchived).toBe(true); @@ -5983,6 +5989,8 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi expect(client.unarchivedHandles).toEqual(client.archivedHandles); expect(client.archivedAtDuringUnarchive).toEqual(expect.any(String)); expect(stored?.archivedAt).toBeNull(); + expect(stored?.workspaceId).toBe("ws-restored"); + expect(stored?.labels).toEqual({ retained: "yes", source: "reimport" }); }); test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 20d1da830..73f51346e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1669,7 +1669,10 @@ export class AgentManager { return nextRecord; } - async unarchiveSnapshot(agentId: string): Promise { + async unarchiveSnapshot( + agentId: string, + updates?: { workspaceId?: string; labels?: AgentLabelPatch }, + ): Promise { const registry = this.requireRegistry(); const record = await registry.get(agentId); if (!record || !record.archivedAt) { @@ -1680,6 +1683,8 @@ export class AgentManager { await registry.upsert({ ...record, + ...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}), + ...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}), archivedAt: null, updatedAt: new Date().toISOString(), }); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 0178ee578..325fa0dcb 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -5,6 +5,8 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js"; import type { AgentStorage } from "./agent-storage.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +export type AgentUnarchiveController = Pick; + export type AgentRunController = Pick< AgentManager, "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent" @@ -91,10 +93,11 @@ export async function startAgentRun( */ export async function unarchiveAgentState( _agentStorage: AgentStorage, - agentManager: AgentManager, + agentManager: AgentUnarchiveController, agentId: string, + updates?: { workspaceId?: string; labels?: Record }, ): Promise { - const unarchived = await agentManager.unarchiveSnapshot(agentId); + const unarchived = await agentManager.unarchiveSnapshot(agentId, updates); if (!unarchived) return false; agentManager.notifyAgentState(agentId); return true; diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 7c6093bd1..266ed29ab 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, expect, test, vi } from "vitest"; -import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { @@ -7,10 +7,15 @@ import type { ManagedAgent, ManagedImportableProviderSession, } from "./agent-manager.js"; -import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; +import { AgentStorage, type StoredAgentRecord } from "./agent-storage.js"; import type { FetchRecentProviderSessionsRequestMessage } from "@getpaseo/protocol/messages"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import { createPersistedWorkspaceRecord } from "../workspace-registry.js"; +import type { WorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; +import { createTestLogger } from "../../test-utils/test-logger.js"; import { + type ImportSessionAgentManager, ImportSessionsRequestError, importProviderSession, listImportableProviderSessions, @@ -18,6 +23,7 @@ import { } from "./import-sessions.js"; const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; +const importTestDirectories: string[] = []; const TEST_CAPABILITIES = { supportsStreaming: true, @@ -32,6 +38,12 @@ beforeEach(() => { vi.clearAllMocks(); }); +afterEach(() => { + for (const directory of importTestDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + function makeImportableSession(args: { provider?: string; sessionId: string; @@ -98,6 +110,28 @@ function makeManagedAgent(args: { } satisfies ManagedAgent; } +function createImportWorkspace( + workspaceId: string, +): Pick { + return { + async runInImportWorkspace(input, operation) { + const workspace = createPersistedWorkspaceRecord({ + workspaceId, + projectId: `project-${workspaceId}`, + cwd: input.cwd, + kind: "directory", + displayName: "imported", + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + }); + return { + value: await operation(workspace), + createdWorkspace: null, + }; + }, + }; +} + function makeRequest( overrides: Partial = {}, ): FetchRecentProviderSessionsRequestMessage { @@ -239,6 +273,87 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import }); }); +test("listImportableProviderSessions includes a provider session after its Paseo agent is archived", async () => { + const cwd = "/tmp/project"; + const archivedSession = makeImportableSession({ + provider: "claude", + sessionId: "archived-session", + cwd, + title: "Archived import", + lastActivityAt: "2026-04-30T12:00:00.000Z", + firstPrompt: "import me again", + }); + + const result = await listImportableProviderSessions({ + request: makeRequest({ cwd, providers: ["claude"] }), + agentManager: { + listAgents: () => [], + listImportableSessions: async () => [archivedSession], + }, + agentStorage: { + list: async () => [ + { + provider: "claude", + archivedAt: "2026-04-30T12:01:00.000Z", + persistence: { + provider: "claude", + sessionId: "archived-session", + }, + } as StoredAgentRecord, + ], + }, + providerSnapshotManager: { getProviderLabel: () => "Claude" }, + }); + + expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["archived-session"]); + expect(result.filteredAlreadyImportedCount).toBe(0); +}); + +test("listImportableProviderSessions includes an archived provider session still loaded in memory", async () => { + const cwd = "/tmp/project"; + const agentId = "00000000-0000-4000-8000-000000000633"; + const archivedSession = makeImportableSession({ + provider: "claude", + sessionId: "archived-live-session", + cwd, + title: "Archived live import", + lastActivityAt: "2026-04-30T12:00:00.000Z", + firstPrompt: "import the loaded session again", + }); + + const result = await listImportableProviderSessions({ + request: makeRequest({ cwd, providers: ["claude"] }), + agentManager: { + listAgents: () => [ + makeManagedAgent({ + id: agentId, + provider: "claude", + cwd, + sessionId: "archived-live-session", + }), + ], + listImportableSessions: async () => [archivedSession], + }, + agentStorage: { + list: async () => [ + { + id: agentId, + provider: "claude", + archivedAt: "2026-04-30T12:01:00.000Z", + persistence: { + provider: "claude", + sessionId: "archived-live-session", + }, + } as StoredAgentRecord, + ], + }, + providerSnapshotManager: { getProviderLabel: () => "Claude" }, + }); + + expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["archived-live-session"]); + expect(result.filteredAlreadyImportedCount).toBe(0); +}); + test("listImportableProviderSessions filters out metadata generation sessions", async () => { const cwd = "/tmp/project"; const sessions = [ @@ -357,108 +472,335 @@ test("normalizeImportAgentRequest accepts new and legacy import handle shapes", }); }); -test("importProviderSession imports a selected provider session without listing", async () => { - const cwd = "/tmp/imported-agent"; - const timeline: AgentTimelineItem[] = [ - { type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, +function makeStoredProviderSession(input: { + id: string; + cwd: string; + sessionId: string; + nativeHandle?: string; + workspaceId?: string; + labels?: Record; + archivedAt?: string | null; +}): StoredAgentRecord { + return { + id: input.id, + provider: "codex", + cwd: input.cwd, + workspaceId: input.workspaceId ?? "ws-archived", + createdAt: "2026-04-30T10:00:00.000Z", + updatedAt: "2026-04-30T11:00:00.000Z", + lastActivityAt: "2026-04-30T10:30:00.000Z", + lastUserMessageAt: null, + labels: input.labels ?? {}, + config: { provider: "codex", cwd: input.cwd }, + persistence: { + provider: "codex", + sessionId: input.sessionId, + nativeHandle: input.nativeHandle ?? input.sessionId, + metadata: { provider: "codex", cwd: input.cwd }, + }, + archivedAt: input.archivedAt === undefined ? "2026-04-30T12:00:00.000Z" : input.archivedAt, + }; +} + +class ProviderImportHarness { + readonly storage: AgentStorage; + readonly manager: ImportSessionAgentManager; + readonly snapshot: ManagedAgent; + readonly freshImports: unknown[] = []; + readonly closedAgentIds: string[] = []; + timeline: AgentTimelineItem[] = []; + activeAgent: ManagedAgent | null = null; + resumeError: Error | null = null; + resumeAttempts = 0; + private unarchiveWait: Promise | null = null; + private releaseUnarchive: (() => void) | null = null; + + private constructor(input: { storage: AgentStorage; snapshot: ManagedAgent }) { + this.storage = input.storage; + this.snapshot = input.snapshot; + this.manager = { + importProviderSession: async (request: unknown) => { + this.freshImports.push(request); + this.activeAgent = this.snapshot; + return this.snapshot; + }, + unarchiveSnapshot: async ( + agentId: string, + updates?: { workspaceId?: string; labels?: Record }, + ) => { + if (this.unarchiveWait) { + await this.unarchiveWait; + } + const record = await this.storage.get(agentId); + if (!record?.archivedAt) { + return false; + } + const labels = { ...record.labels }; + for (const [key, value] of Object.entries(updates?.labels ?? {})) { + if (value === null) { + delete labels[key]; + } else { + labels[key] = value; + } + } + await this.storage.upsert({ + ...record, + workspaceId: updates?.workspaceId ?? record.workspaceId, + labels, + archivedAt: null, + }); + return true; + }, + notifyAgentState: () => {}, + getAgent: () => this.activeAgent, + getRegisteredProviderIds: () => ["codex"], + createAgent: async () => { + throw new Error("Stored provider imports must resume their persisted session"); + }, + resumeAgentFromPersistence: async ( + _handle: unknown, + _overrides: unknown, + _agentId?: string, + _options?: unknown, + ) => { + this.resumeAttempts += 1; + if (this.resumeError) { + this.activeAgent = this.snapshot; + throw this.resumeError; + } + this.activeAgent = this.snapshot; + return this.snapshot; + }, + hydrateTimelineFromProvider: async () => {}, + getTimeline: () => this.timeline, + closeAgent: async (agentId: string) => { + this.closedAgentIds.push(agentId); + this.activeAgent = null; + }, + archiveSnapshot: async (agentId: string, archivedAt: string) => { + const record = await this.storage.get(agentId); + if (!record) { + throw new Error("Agent not found: " + agentId); + } + const archived = { ...record, archivedAt }; + await this.storage.upsert(archived); + return archived; + }, + } satisfies ImportSessionAgentManager; + } + + static async create( + input: { + id?: string; + cwd?: string; + sessionId?: string; + nativeHandle?: string; + } = {}, + ): Promise { + const directory = mkdtempSync(path.join(tmpdir(), "provider-import-")); + importTestDirectories.push(directory); + const storage = new AgentStorage(path.join(directory, "agents"), createTestLogger()); + await storage.initialize(); + const cwd = input.cwd ?? "/tmp/imported-agent"; + const sessionId = input.sessionId ?? "thread-imported"; + const snapshot = makeManagedAgent({ + id: input.id, + provider: "codex", + cwd, + sessionId, + nativeHandle: input.nativeHandle, + }); + return new ProviderImportHarness({ storage, snapshot }); + } + + async seed(record: StoredAgentRecord): Promise { + await this.storage.upsert(record); + } + + blockUnarchive(): () => void { + this.unarchiveWait = new Promise((resolve) => { + this.releaseUnarchive = resolve; + }); + return () => { + this.releaseUnarchive?.(); + this.unarchiveWait = null; + this.releaseUnarchive = null; + }; + } + + import(input: { providerHandleId: string; cwd?: string; labels?: Record }) { + return importProviderSession({ + request: { + requestId: "import-thread", + provider: "codex", + providerHandleId: input.providerHandleId, + cwd: input.cwd, + labels: input.labels, + }, + workspaceProvisioning: createImportWorkspace("ws-restored"), + agentManager: this.manager, + agentStorage: this.storage, + logger: createTestLogger(), + }); + } +} + +test("importProviderSession uses the provider import path with the requested labels", async () => { + const harness = await ProviderImportHarness.create(); + harness.timeline = [ + { type: "user_message", text: "Trace recent provider sessions" }, { type: "assistant_message", text: "I will inspect the provider listing." }, ]; - const snapshot = makeManagedAgent({ - id: "00000000-0000-4000-8000-000000000633", - provider: "custom-codex", - cwd, - sessionId: "thread-imported", - nativeHandle: "provider-thread-imported", - title: null, - }); - const agentManager = { - importProviderSession: vi.fn().mockResolvedValue(snapshot), - getTimeline: vi.fn().mockReturnValue(timeline), - unarchiveSnapshot: vi.fn().mockResolvedValue(false), - } as unknown as AgentManager; - const agentStorage = { - list: vi.fn().mockResolvedValue([]), - get: vi.fn().mockResolvedValue(null), - } as unknown as AgentStorage; - const result = await importProviderSession({ - request: { - requestId: "import-thread", - provider: "custom-codex", - providerHandleId: "provider-thread-imported", - cwd, - }, - workspaceId: "ws-imported", - agentManager, - agentStorage, - logger: { warn: vi.fn(), error: vi.fn() } as never, - }); - - expect(agentManager.importProviderSession).toHaveBeenCalledWith({ - provider: "custom-codex", - providerHandleId: "provider-thread-imported", - cwd, - workspaceId: "ws-imported", - labels: undefined, - }); - expect(result).toEqual({ snapshot, timelineSize: 2 }); -}); - -test("importProviderSession passes labels through the manager import operation", async () => { - const cwd = "/tmp/imported-agent"; - const snapshot = makeManagedAgent({ - provider: "codex", - cwd, - sessionId: "thread-imported", - nativeHandle: "thread-imported", - }); - const agentManager = { - importProviderSession: vi.fn().mockResolvedValue(snapshot), - getTimeline: vi.fn().mockReturnValue([]), - unarchiveSnapshot: vi.fn().mockResolvedValue(false), - } as unknown as AgentManager; - const agentStorage = { - list: vi.fn().mockResolvedValue([]), - get: vi.fn().mockResolvedValue(null), - } as unknown as AgentStorage; - - await importProviderSession({ - request: { - requestId: "import-thread", - provider: "codex", - providerHandleId: "thread-imported", - cwd, - labels: { source: "import" }, - }, - workspaceId: "ws-imported", - agentManager, - agentStorage, - logger: { warn: vi.fn(), error: vi.fn() } as never, - }); - - expect(agentManager.importProviderSession).toHaveBeenCalledWith({ - provider: "codex", + const result = await harness.import({ providerHandleId: "thread-imported", - cwd, - workspaceId: "ws-imported", + cwd: "/tmp/imported-agent", labels: { source: "import" }, }); + + expect(harness.freshImports).toEqual([ + { + provider: "codex", + providerHandleId: "thread-imported", + cwd: "/tmp/imported-agent", + workspaceId: "ws-restored", + labels: { source: "import" }, + }, + ]); + expect(result).toEqual({ + snapshot: harness.snapshot, + timelineSize: 2, + createdWorkspace: null, + }); +}); + +test("importProviderSession rejects a provider session with an active stored owner", async () => { + const harness = await ProviderImportHarness.create({ sessionId: "thread-active" }); + await harness.seed( + makeStoredProviderSession({ + id: harness.snapshot.id, + cwd: harness.snapshot.cwd, + sessionId: "thread-active", + archivedAt: null, + }), + ); + + await expect( + harness.import({ providerHandleId: "thread-active", cwd: harness.snapshot.cwd }), + ).rejects.toThrow("Provider session is already imported: thread-active"); + expect(harness.freshImports).toEqual([]); +}); + +test("importProviderSession restores an archived session as the same standalone agent", async () => { + const harness = await ProviderImportHarness.create({ sessionId: "thread-archived" }); + harness.timeline = [{ type: "user_message", text: "restored" }]; + const archived = makeStoredProviderSession({ + id: harness.snapshot.id, + cwd: harness.snapshot.cwd, + sessionId: "thread-archived", + labels: { existing: "label", [PARENT_AGENT_ID_LABEL]: "archived-parent" }, + }); + await harness.seed(archived); + + const result = await harness.import({ + providerHandleId: "thread-archived", + cwd: harness.snapshot.cwd, + labels: { source: "reimport" }, + }); + + expect(result).toEqual({ + snapshot: harness.snapshot, + timelineSize: 1, + createdWorkspace: null, + }); + expect(await harness.storage.get(harness.snapshot.id)).toMatchObject({ + id: harness.snapshot.id, + workspaceId: "ws-restored", + labels: { existing: "label", source: "reimport" }, + archivedAt: null, + }); + expect((await harness.storage.get(harness.snapshot.id))?.labels).not.toHaveProperty( + PARENT_AGENT_ID_LABEL, + ); + expect(harness.resumeAttempts).toBe(1); + expect(harness.freshImports).toEqual([]); +}); + +test("importProviderSession rejects an archived session from a different cwd before restoring", async () => { + const harness = await ProviderImportHarness.create({ sessionId: "thread-other-cwd" }); + const archived = makeStoredProviderSession({ + id: harness.snapshot.id, + cwd: "/tmp/other-agent", + sessionId: "thread-other-cwd", + }); + await harness.seed(archived); + + await expect( + harness.import({ providerHandleId: "thread-other-cwd", cwd: "/tmp/target-agent" }), + ).rejects.toThrow("Provider session cwd does not match import cwd: thread-other-cwd"); + expect(await harness.storage.get(harness.snapshot.id)).toEqual(archived); + expect(harness.resumeAttempts).toBe(0); +}); + +test("importProviderSession restores storage and closes a partial runtime when loading fails", async () => { + const harness = await ProviderImportHarness.create({ sessionId: "thread-stale" }); + const archived = makeStoredProviderSession({ + id: harness.snapshot.id, + cwd: harness.snapshot.cwd, + sessionId: "thread-stale", + }); + await harness.seed(archived); + harness.resumeError = new Error("provider session is unavailable"); + + await expect( + harness.import({ providerHandleId: "thread-stale", cwd: harness.snapshot.cwd }), + ).rejects.toThrow("provider session is unavailable"); + + expect(await harness.storage.get(harness.snapshot.id)).toEqual(archived); + expect(harness.activeAgent).toBeNull(); + expect(harness.closedAgentIds).toEqual([harness.snapshot.id]); +}); + +test("importProviderSession serializes legacy and native aliases for one archived session", async () => { + const harness = await ProviderImportHarness.create({ + sessionId: "legacy-thread", + nativeHandle: "native-thread", + }); + await harness.seed( + makeStoredProviderSession({ + id: harness.snapshot.id, + cwd: harness.snapshot.cwd, + sessionId: "legacy-thread", + nativeHandle: "native-thread", + }), + ); + const releaseUnarchive = harness.blockUnarchive(); + + const winningRestore = harness.import({ + providerHandleId: "native-thread", + cwd: harness.snapshot.cwd, + }); + const duplicateRestore = harness.import({ + providerHandleId: "legacy-thread", + cwd: harness.snapshot.cwd, + }); + releaseUnarchive(); + + await expect(winningRestore).resolves.toMatchObject({ + snapshot: { id: harness.snapshot.id }, + timelineSize: 0, + }); + await expect(duplicateRestore).rejects.toThrow( + "Provider session is already imported: legacy-thread", + ); + expect(harness.resumeAttempts).toBe(1); + expect(harness.closedAgentIds).toEqual([]); }); test("importProviderSession requires cwd from the selected provider row", async () => { - const agentManager = {} as unknown as AgentManager; + const harness = await ProviderImportHarness.create(); - await expect( - importProviderSession({ - request: { - requestId: "import-thread", - provider: "opencode", - providerHandleId: "thread-imported", - }, - workspaceId: "ws-imported", - agentManager, - agentStorage: { list: vi.fn() } as unknown as AgentStorage, - logger: { warn: vi.fn(), error: vi.fn() } as never, - }), - ).rejects.toThrow("Import requires cwd from the selected provider session"); + await expect(harness.import({ providerHandleId: "thread-imported" })).rejects.toThrow( + "Import requires cwd from the selected provider session", + ); }); diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index f2c5b563e..7ec047e92 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -8,24 +8,44 @@ import type { } from "./agent-manager.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { AgentPersistenceHandle, AgentProvider } from "./agent-sdk-types.js"; +import { ensureAgentLoaded, type AgentLoaderManager } from "./agent-loading.js"; import { unarchiveAgentState } from "./agent-prompt.js"; import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js"; +import type { WorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js"; +import type { PersistedWorkspaceRecord } from "../workspace-registry.js"; import type { FetchRecentProviderSessionsRequestMessage, ImportAgentRequestMessageSchema, RecentProviderSessionDescriptorPayload, } from "@getpaseo/protocol/messages"; +import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createRealpathAwarePathMatcher } from "../../utils/path.js"; type ImportAgentRequestMessage = z.infer; const METADATA_GENERATION_PROMPT_PREFIX = "Generate metadata for a coding agent based on the user prompt."; +export type ImportSessionAgentManager = AgentLoaderManager & + Pick< + AgentManager, + | "archiveSnapshot" + | "closeAgent" + | "getTimeline" + | "importProviderSession" + | "notifyAgentState" + | "unarchiveSnapshot" + >; + +const providerSessionImportMutations = new WeakMap< + ImportSessionAgentManager, + Map> +>(); export interface NormalizedImportAgentRequest { provider: AgentProvider; providerHandleId: string; cwd?: string; + workspaceId?: string; labels?: Record; requestId: string; } @@ -54,8 +74,8 @@ export interface ListImportableProviderSessionsResult { export interface ImportProviderSessionInput { request: NormalizedImportAgentRequest; - workspaceId: string; - agentManager: AgentManager; + workspaceProvisioning: Pick; + agentManager: ImportSessionAgentManager; agentStorage: AgentStorage; logger: Logger; } @@ -63,6 +83,12 @@ export interface ImportProviderSessionInput { export interface ImportProviderSessionResult { snapshot: ManagedAgent; timelineSize: number; + createdWorkspace: PersistedWorkspaceRecord | null; +} + +interface ImportedProviderSession { + snapshot: ManagedAgent; + timelineSize: number; } // COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape @@ -82,6 +108,7 @@ export function normalizeImportAgentRequest( provider: provider as AgentProvider, providerHandleId, cwd: msg.cwd, + workspaceId: msg.workspaceId, labels: msg.labels, requestId: msg.requestId, }; @@ -138,18 +165,72 @@ export async function listImportableProviderSessions( export async function importProviderSession( input: ImportProviderSessionInput, ): Promise { - const { provider, providerHandleId, cwd, labels } = input.request; + const cwd = input.request.cwd; if (!cwd) { throw new Error("Import requires cwd from the selected provider session"); } + const key = await resolveProviderSessionImportMutationKey(input); + return serializeProviderSessionImport(input.agentManager, key, async () => { + const placement = await input.workspaceProvisioning.runInImportWorkspace( + { cwd, requestedWorkspaceId: input.request.workspaceId }, + (workspace) => importProviderSessionNow(input, cwd, workspace.workspaceId), + ); + return { ...placement.value, createdWorkspace: placement.createdWorkspace }; + }); +} + +async function importProviderSessionNow( + input: ImportProviderSessionInput, + cwd: string, + workspaceId: string, +): Promise { + const { provider, providerHandleId, labels } = input.request; + + const matchingRecords = (await input.agentStorage.list()).filter((record) => + recordMatchesProviderHandle(record, { provider, providerHandleId }), + ); + const activeRecord = matchingRecords.find((record) => !record.archivedAt); + if (activeRecord) { + throw new Error(`Provider session is already imported: ${providerHandleId}`); + } + const archivedRecord = matchingRecords.find((record) => record.archivedAt); + if (archivedRecord?.persistence && archivedRecord.archivedAt) { + if (!createRealpathAwarePathMatcher(cwd)(archivedRecord.cwd)) { + throw new Error(`Provider session cwd does not match import cwd: ${providerHandleId}`); + } + const requestedParentAgentId = getParentAgentIdFromLabels(input.request.labels); + const labelPatch: Record = { ...input.request.labels }; + if ( + Object.hasOwn(archivedRecord.labels, PARENT_AGENT_ID_LABEL) || + Object.hasOwn(input.request.labels ?? {}, PARENT_AGENT_ID_LABEL) + ) { + labelPatch[PARENT_AGENT_ID_LABEL] = requestedParentAgentId; + } + await unarchiveAgentState(input.agentStorage, input.agentManager, archivedRecord.id, { + workspaceId, + labels: Object.keys(labelPatch).length > 0 ? labelPatch : undefined, + }); + try { + const snapshot = await ensureAgentLoaded(archivedRecord.id, { + agentManager: input.agentManager, + agentStorage: input.agentStorage, + logger: input.logger, + }); + return { + snapshot, + timelineSize: input.agentManager.getTimeline(snapshot.id).length, + }; + } catch (error) { + await rollbackArchivedImport(input, archivedRecord, archivedRecord.archivedAt); + throw error; + } + } - const handle = buildImportPersistenceHandle({ provider, providerHandleId, cwd }); - await unarchiveAgentByHandle(input.agentStorage, input.agentManager, handle); const snapshot = await input.agentManager.importProviderSession({ provider, providerHandleId, cwd, - workspaceId: input.workspaceId, + workspaceId, labels, }); await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); @@ -160,22 +241,80 @@ export async function importProviderSession( }; } -async function unarchiveAgentByHandle( - agentStorage: AgentStorage, - agentManager: AgentManager, - handle: AgentPersistenceHandle, -): Promise { - const records = await agentStorage.list(); - const matched = records.find( - (record) => - record.persistence?.provider === handle.provider && - (record.persistence.sessionId === handle.sessionId || - record.persistence.nativeHandle === handle.nativeHandle), - ); - if (!matched) { - return; +async function serializeProviderSessionImport( + agentManager: ImportSessionAgentManager, + key: string, + operation: () => Promise, +): Promise { + let mutations = providerSessionImportMutations.get(agentManager); + if (!mutations) { + mutations = new Map(); + providerSessionImportMutations.set(agentManager, mutations); } - await unarchiveAgentState(agentStorage, agentManager, matched.id); + + const previous = mutations.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(operation); + mutations.set(key, next); + try { + return await next; + } finally { + if (mutations.get(key) === next) { + mutations.delete(key); + } + } +} + +async function resolveProviderSessionImportMutationKey( + input: ImportProviderSessionInput, +): Promise { + const identity = { + provider: input.request.provider, + providerHandleId: input.request.providerHandleId, + }; + const matchingRecord = (await input.agentStorage.list()).find((record) => + recordMatchesProviderHandle(record, identity), + ); + return matchingRecord + ? `agent\0${matchingRecord.id}` + : `handle\0${toProviderSessionHandleKey(identity.provider, identity.providerHandleId)}`; +} + +async function rollbackArchivedImport( + input: ImportProviderSessionInput, + archivedRecord: StoredAgentRecord, + archivedAt: string, +): Promise { + try { + if (input.agentManager.getAgent(archivedRecord.id)) { + await input.agentManager.closeAgent(archivedRecord.id); + } + await input.agentManager.archiveSnapshot(archivedRecord.id, archivedAt); + } catch (error) { + input.logger.error( + { err: error, agentId: archivedRecord.id }, + "Failed to re-archive provider session after import failure", + ); + } + + try { + await input.agentStorage.upsert(archivedRecord); + } catch (error) { + input.logger.error( + { err: error, agentId: archivedRecord.id }, + "Failed to restore archived agent record after import failure", + ); + } +} + +function recordMatchesProviderHandle( + record: StoredAgentRecord, + identity: { provider: string; providerHandleId: string }, +): boolean { + return ( + record.persistence?.provider === identity.provider && + (record.persistence.sessionId === identity.providerHandleId || + record.persistence.nativeHandle === identity.providerHandleId) + ); } function parseRecentProviderSessionsSince(since: string | undefined): number | null { @@ -189,33 +328,25 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n return timestamp; } -function buildImportPersistenceHandle(input: { - provider: string; - providerHandleId: string; - cwd: string; -}): AgentPersistenceHandle { - return { - provider: input.provider, - sessionId: input.providerHandleId, - nativeHandle: input.providerHandleId, - metadata: { - provider: input.provider, - cwd: input.cwd, - }, - }; -} - async function collectImportedProviderSessionHandles( agentManager: Pick, agentStorage: Pick, ): Promise> { const handles = new Set(); + const records = await agentStorage.list(); + const storedRecordsById = new Map(records.map((record) => [record.id, record])); for (const agent of agentManager.listAgents()) { + if (storedRecordsById.get(agent.id)?.archivedAt) { + continue; + } collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence); } - for (const record of await agentStorage.list()) { + for (const record of records) { + if (record.archivedAt) { + continue; + } collectProviderSessionHandleKeys(handles, record.provider, record.persistence); } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b848a3300..5f0b8282d 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -714,6 +714,7 @@ export class Session { workspaceRegistry: this.workspaceRegistry, projectRegistry: this.projectRegistry, workspaceGitService: this.workspaceGitService, + logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), @@ -2817,19 +2818,16 @@ export class Session { if (!normalized.cwd) { throw new Error("Import requires cwd from the selected provider session"); } - // An imported agent mints its own workspace; ownership is its workspaceId, - // never an existing same-cwd workspace resolved by path. - const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory( - normalized.cwd, - ); - const { snapshot, timelineSize } = await importProviderSession({ + const { snapshot, timelineSize, createdWorkspace } = await importProviderSession({ request: normalized, - workspaceId: workspace.workspaceId, + workspaceProvisioning: this.workspaceProvisioning, agentManager: this.agentManager, agentStorage: this.agentStorage, logger: this.sessionLogger, }); - await this.registerWorkspaceForImportedAgent(workspace); + if (createdWorkspace) { + await this.registerWorkspaceForImportedAgent(createdWorkspace); + } const agentPayload = await this.buildAgentPayload(snapshot); this.emit({ type: "status", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index a11bfb585..f615b09dd 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -3692,6 +3692,88 @@ test("import_agent_request registers a workspace for a never-seen cwd", async () ).toBe(true); }); +test("import_agent_request imports into the workspace that opened the import sheet", async () => { + const session = createSessionForWorkspaceTests(); + const workspaceId = "ws-repo-running"; + let importedWorkspaceId: string | undefined; + let workspaceCreated = false; + + session.projectRegistry.get = async () => + createPersistedProjectRecord({ + projectId: "proj-repo-running", + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.workspaceRegistry.upsert = async () => { + workspaceCreated = true; + }; + + session.agentManager.importProviderSession = async (input: unknown) => { + importedWorkspaceId = (input as { workspaceId: string }).workspaceId; + return makeManagedAgent({ + id: "imported-agent", + cwd: REPO_CWD, + workspaceId: importedWorkspaceId, + lifecycle: "idle", + updatedAt: "2026-05-21T00:00:00.000Z", + }); + }; + session.agentManager.getTimeline = () => []; + session.agentStorage.list = async () => []; + session.agentStorage.get = async () => null; + session.agentUpdates.forwardLiveAgent = async () => undefined; + + await session.handleMessage({ + type: "import_agent_request", + requestId: "req-import-current-workspace", + providerId: "codex", + providerHandleId: "session-xyz", + cwd: REPO_CWD, + workspaceId, + }); + + expect(importedWorkspaceId).toBe(workspaceId); + expect(workspaceCreated).toBe(false); +}); + +test("import_agent_request maps an import failure to agent_create_failed", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); + session.projectRegistry.get = async () => + createPersistedProjectRecord({ + projectId: "proj-repo-running", + rootPath: REPO_CWD, + kind: "non_git", + displayName: "repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }); + session.agentStorage.list = async () => []; + session.agentManager.importProviderSession = async () => { + throw new Error("provider session is unavailable"); + }; + + await session.handleMessage({ + type: "import_agent_request", + requestId: "req-failed-import", + providerId: "codex", + providerHandleId: "stale-session", + cwd: REPO_CWD, + workspaceId: "ws-repo-running", + }); + + expect(findByType(emitted, "status")?.payload).toMatchObject({ + status: "agent_create_failed", + requestId: "req-failed-import", + error: "provider session is unavailable", + }); +}); + test("open_project_response returns immediately even when the GitHub fetch is slow", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests(); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 039bf73b9..91349f238 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -9,6 +9,7 @@ import { createNoopWorkspaceGitService } from "../../test-utils/workspace-git-se import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry, + type PersistedProjectRecord, } from "../../workspace-registry.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { @@ -22,6 +23,7 @@ import { const logger = createTestLogger(); const ARCHIVED_AT = "2026-01-01T00:00:00.000Z"; +const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; let tmpDir: string; let gitRoots: Set; @@ -72,6 +74,7 @@ beforeEach(async () => { workspaceRegistry, projectRegistry, workspaceGitService: gitService(), + logger, }); }); @@ -214,3 +217,137 @@ test("findOrCreateProjectForDirectory reuses the active project for the same roo expect(second.projectId).toBe(first.projectId); expect(await projectRegistry.list()).toHaveLength(1); }); + +test("runInImportWorkspace uses an active requested workspace without creating another", async () => { + const cwd = path.join(tmpDir, "requested"); + mkdirSync(cwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + + const result = await provisioning.runInImportWorkspace( + { cwd, requestedWorkspaceId: workspace.workspaceId }, + async (target) => target.workspaceId, + ); + + expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null }); + expect(await workspaceRegistry.list()).toEqual([workspace]); +}); + +test.each(["missing", "archived"] as const)( + "runInImportWorkspace rejects a %s requested workspace before importing", + async (state) => { + const cwd = path.join(tmpDir, "unavailable-workspace"); + mkdirSync(cwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + if (state === "archived") { + await workspaceRegistry.archive(workspace.workspaceId, ARCHIVED_AT); + } else { + await workspaceRegistry.remove(workspace.workspaceId); + } + let imported = false; + + await expect( + provisioning.runInImportWorkspace( + { cwd, requestedWorkspaceId: workspace.workspaceId }, + async () => { + imported = true; + }, + ), + ).rejects.toThrow(`Workspace not found: ${workspace.workspaceId}`); + expect(imported).toBe(false); + }, +); + +test.each(["missing", "archived"] as const)( + "runInImportWorkspace rejects a requested workspace whose project is %s before importing", + async (state) => { + const cwd = path.join(tmpDir, "unavailable-project"); + mkdirSync(cwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + if (state === "archived") { + await projectRegistry.archive(workspace.projectId, ARCHIVED_AT); + } else { + await projectRegistry.remove(workspace.projectId); + } + let imported = false; + + await expect( + provisioning.runInImportWorkspace( + { cwd, requestedWorkspaceId: workspace.workspaceId }, + async () => { + imported = true; + }, + ), + ).rejects.toThrow(`Project not found: ${workspace.projectId}`); + expect(imported).toBe(false); + }, +); + +test("runInImportWorkspace accepts a filesystem-equivalent requested cwd", async () => { + const cwd = path.join(tmpDir, "real-directory"); + const alias = path.join(tmpDir, "directory-alias"); + mkdirSync(cwd); + symlinkSync(cwd, alias, directorySymlinkType); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + + const result = await provisioning.runInImportWorkspace( + { cwd: alias, requestedWorkspaceId: workspace.workspaceId }, + async (target) => target.workspaceId, + ); + + expect(result.value).toBe(workspace.workspaceId); +}); + +test("runInImportWorkspace rejects a requested workspace with a different cwd", async () => { + const cwd = path.join(tmpDir, "workspace-directory"); + const otherCwd = path.join(tmpDir, "other-directory"); + mkdirSync(cwd); + mkdirSync(otherCwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + let imported = false; + + await expect( + provisioning.runInImportWorkspace( + { cwd: otherCwd, requestedWorkspaceId: workspace.workspaceId }, + async () => { + imported = true; + }, + ), + ).rejects.toThrow(`Import cwd does not match workspace: ${workspace.workspaceId}`); + expect(imported).toBe(false); +}); + +test("runInImportWorkspace creates one fresh workspace for an untargeted import", async () => { + const cwd = path.join(tmpDir, "fresh-import"); + mkdirSync(cwd); + + const result = await provisioning.runInImportWorkspace( + { cwd }, + async (workspace) => workspace.workspaceId, + ); + + expect(result.value).toBe(result.createdWorkspace?.workspaceId); + expect(await workspaceRegistry.list()).toEqual([result.createdWorkspace]); +}); + +test.each(["missing", "archived"] as const)( + "runInImportWorkspace restores the exact %s project state when an untargeted import fails", + async (state) => { + const cwd = path.join(tmpDir, `failed-import-${state}`); + mkdirSync(cwd); + let previousProject: PersistedProjectRecord | null = null; + if (state === "archived") { + const project = await provisioning.findOrCreateProjectForDirectory(cwd); + await projectRegistry.archive(project.projectId, ARCHIVED_AT); + previousProject = await projectRegistry.get(project.projectId); + } + + await expect( + provisioning.runInImportWorkspace({ cwd }, async () => { + throw new Error("provider session is unavailable"); + }), + ).rejects.toThrow("provider session is unavailable"); + + expect(await workspaceRegistry.list()).toEqual([]); + expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []); + }, +); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 219da22ea..15d910aaf 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -1,4 +1,5 @@ import { resolve } from "node:path"; +import type { Logger } from "pino"; import { checkoutLiteFromGitSnapshot, classifyDirectoryForProjectMembership, @@ -14,6 +15,7 @@ import { } from "../../workspace-registry.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; +import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; /** * Resolves which workspace and project records a directory belongs to, creating, @@ -34,7 +36,21 @@ export interface ResolveOrCreateWorkspaceIdInput { initialTitle: string | null; } +export interface ImportWorkspaceInput { + cwd: string; + requestedWorkspaceId?: string; +} + +export interface ImportWorkspaceResult { + value: T; + createdWorkspace: PersistedWorkspaceRecord | null; +} + export interface WorkspaceProvisioningService { + runInImportWorkspace( + input: ImportWorkspaceInput, + operation: (workspace: PersistedWorkspaceRecord) => Promise, + ): Promise>; findOrCreateWorkspaceForDirectory(cwd: string): Promise; resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise; createWorkspaceForDirectory( @@ -51,8 +67,72 @@ export function createWorkspaceProvisioningService(deps: { workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; workspaceGitService: Pick; + logger: Logger; }): WorkspaceProvisioningService { - const { workspaceRegistry, projectRegistry, workspaceGitService } = deps; + const { workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; + + async function runInImportWorkspace( + input: ImportWorkspaceInput, + operation: (workspace: PersistedWorkspaceRecord) => Promise, + ): Promise> { + if (input.requestedWorkspaceId) { + const workspace = await workspaceRegistry.get(input.requestedWorkspaceId); + if (!workspace || workspace.archivedAt) { + throw new Error(`Workspace not found: ${input.requestedWorkspaceId}`); + } + const project = await projectRegistry.get(workspace.projectId); + if (!project || project.archivedAt) { + throw new Error(`Project not found: ${workspace.projectId}`); + } + if (!createRealpathAwarePathMatcher(workspace.cwd)(input.cwd)) { + throw new Error(`Import cwd does not match workspace: ${workspace.workspaceId}`); + } + return { + value: await operation(workspace), + createdWorkspace: null, + }; + } + + const projectsBeforeImport = await projectRegistry.list(); + const workspace = await createWorkspaceForDirectory(input.cwd); + const previousProject = + projectsBeforeImport.find((project) => project.projectId === workspace.projectId) ?? null; + + try { + return { + value: await operation(workspace), + createdWorkspace: workspace, + }; + } catch (error) { + await rollbackFailedImportWorkspace(workspace, previousProject); + throw error; + } + } + + async function rollbackFailedImportWorkspace( + workspace: PersistedWorkspaceRecord, + previousProject: PersistedProjectRecord | null, + ): Promise { + try { + await workspaceRegistry.remove(workspace.workspaceId); + const projectHasActiveWorkspace = (await workspaceRegistry.list()).some( + (candidate) => candidate.projectId === workspace.projectId && !candidate.archivedAt, + ); + if (projectHasActiveWorkspace) { + return; + } + if (previousProject?.archivedAt) { + await projectRegistry.upsert(previousProject); + } else if (!previousProject) { + await projectRegistry.remove(workspace.projectId); + } + } catch (error) { + logger.error( + { err: error, workspaceId: workspace.workspaceId, projectId: workspace.projectId }, + "Failed to restore workspace state after provider import failure", + ); + } + } async function resolveWorkspaceDirectory( cwd: string, @@ -275,6 +355,7 @@ export function createWorkspaceProvisioningService(deps: { } return { + runInImportWorkspace, findOrCreateWorkspaceForDirectory, resolveOrCreateWorkspaceIdForCreateAgent, createWorkspaceForDirectory, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 239de93b2..d0a59cedc 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1266,6 +1266,8 @@ export class VoiceAssistantWebSocketServer { commitsList: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, + // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. + importSessionWorkspaceTarget: true, }, }; }