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.
This commit is contained in:
Mohamed Boudra
2026-07-16 20:25:50 +02:00
committed by GitHub
parent 7d80fdfd12
commit a1cd50c2ae
18 changed files with 1013 additions and 154 deletions

View File

@@ -9,6 +9,7 @@ import {
getPromptPreview, getPromptPreview,
getSessionTitle, getSessionTitle,
resolveProvidersToFetch, resolveProvidersToFetch,
requiresImportSessionsHostUpgrade,
type SessionsQueryResult, type SessionsQueryResult,
sumFilteredAlreadyImportedCount, sumFilteredAlreadyImportedCount,
} from "@/components/import-session-sheet-view-model"; } 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", () => { describe("buildProviderLabelMap", () => {
it("returns an empty map when snapshot entries are missing", () => { it("returns an empty map when snapshot entries are missing", () => {
expect(buildProviderLabelMap(undefined).size).toBe(0); expect(buildProviderLabelMap(undefined).size).toBe(0);

View File

@@ -5,6 +5,14 @@ import { i18n } from "@/i18n/i18next";
export const PER_PROVIDER_LIMIT = 15; export const PER_PROVIDER_LIMIT = 15;
export const ALL_FILTER_VALUE = "__all__"; 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 { export interface SessionsQueryResult {
data: data:
| { | {

View File

@@ -15,6 +15,7 @@ import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/com
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostFeature } from "@/runtime/host-features";
import { i18n } from "@/i18n/i18next"; import { i18n } from "@/i18n/i18next";
import { import {
aggregateSessionEntries, aggregateSessionEntries,
@@ -26,6 +27,7 @@ import {
getSessionTitle, getSessionTitle,
PER_PROVIDER_LIMIT, PER_PROVIDER_LIMIT,
resolveProvidersToFetch, resolveProvidersToFetch,
requiresImportSessionsHostUpgrade,
sumFilteredAlreadyImportedCount, sumFilteredAlreadyImportedCount,
} from "@/components/import-session-sheet-view-model"; } from "@/components/import-session-sheet-view-model";
@@ -44,6 +46,7 @@ interface ImportSessionSheetProps {
client: RecentProviderSessionsClient | null; client: RecentProviderSessionsClient | null;
serverId: string | null; serverId: string | null;
cwd?: string | null; cwd?: string | null;
workspaceId?: string | null;
onClose: () => void; onClose: () => void;
onImportedAgent?: (agentId: string) => void; onImportedAgent?: (agentId: string) => void;
onImported?: (agent: ImportedAgent) => void; onImported?: (agent: ImportedAgent) => void;
@@ -260,6 +263,7 @@ export function ImportSessionSheet({
client, client,
serverId, serverId,
cwd, cwd,
workspaceId,
onClose, onClose,
onImportedAgent, onImportedAgent,
onImported, onImported,
@@ -272,10 +276,16 @@ export function ImportSessionSheet({
cwd, cwd,
enabled: visible, enabled: visible,
}); });
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
const requiresHostUpgrade = requiresImportSessionsHostUpgrade({
supportsSnapshot,
workspaceId,
supportsWorkspaceTarget,
});
const providersToFetch = useMemo( const providersToFetch = useMemo(
() => resolveProvidersToFetch(supportsSnapshot, snapshotEntries), () => (requiresHostUpgrade ? null : resolveProvidersToFetch(supportsSnapshot, snapshotEntries)),
[supportsSnapshot, snapshotEntries], [requiresHostUpgrade, supportsSnapshot, snapshotEntries],
); );
const providerLabelById = useMemo( const providerLabelById = useMemo(
@@ -408,6 +418,7 @@ export function ImportSessionSheet({
providerId: entry.providerId, providerId: entry.providerId,
providerHandleId: entry.providerHandleId, providerHandleId: entry.providerHandleId,
cwd: entry.cwd, cwd: entry.cwd,
...(workspaceId ? { workspaceId } : {}),
}); });
return agent; return agent;
}, },
@@ -450,7 +461,7 @@ export function ImportSessionSheet({
[isRefreshing, handleRefresh, t], [isRefreshing, handleRefresh, t],
); );
const isSnapshotUnsupported = !supportsSnapshot; const isSnapshotUnsupported = requiresHostUpgrade;
const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined; const isWaitingForSnapshot = supportsSnapshot && snapshotEntries === undefined;
const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0; const hasNoImportableProviders = providersToFetch !== null && providersToFetch.length === 0;
const isQueryingProviders = queries.length > 0; const isQueryingProviders = queries.length > 0;

View File

@@ -3709,6 +3709,7 @@ function WorkspaceScreenContent({
client={client} client={client}
serverId={normalizedServerId} serverId={normalizedServerId}
cwd={workspaceDirectory} cwd={workspaceDirectory}
workspaceId={normalizedWorkspaceId}
onClose={closeImportSheet} onClose={closeImportSheet}
onImportedAgent={handleImportedAgent} onImportedAgent={handleImportedAgent}
/> />

View File

@@ -159,6 +159,7 @@ const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000;
interface ImportAgentInputBase { interface ImportAgentInputBase {
cwd?: string; cwd?: string;
workspaceId?: string;
labels?: Record<string, string>; labels?: Record<string, string>;
} }
@@ -2524,6 +2525,7 @@ export class DaemonClient {
? { providerId: input.providerId, providerHandleId: input.providerHandleId } ? { providerId: input.providerId, providerHandleId: input.providerHandleId }
: { provider: input.provider, sessionId: input.sessionId }), : { provider: input.provider, sessionId: input.sessionId }),
...(input.cwd ? { cwd: input.cwd } : {}), ...(input.cwd ? { cwd: input.cwd } : {}),
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}), ...(input.labels && Object.keys(input.labels).length > 0 ? { labels: input.labels } : {}),
}); });

View File

@@ -293,6 +293,21 @@ describe("agent detach RPC", () => {
} }
expect(parsed.features?.agentDetach).toBe(true); 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", () => { describe("agent setting action responses", () => {

View File

@@ -1267,6 +1267,7 @@ export const ImportAgentRequestMessageSchema = z.object({
sessionId: z.string().optional(), sessionId: z.string().optional(),
providerHandleId: z.string().optional(), providerHandleId: z.string().optional(),
cwd: z.string().optional(), cwd: z.string().optional(),
workspaceId: z.string().optional(),
labels: z.record(z.string(), z.string()).optional(), labels: z.record(z.string(), z.string()).optional(),
requestId: z.string(), requestId: z.string(),
}); });
@@ -2560,6 +2561,8 @@ export const ServerInfoStatusPayloadSchema = z
commitsList: z.boolean().optional(), commitsList: z.boolean().optional(),
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: z.boolean().optional(), providerRemoval: z.boolean().optional(),
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: z.boolean().optional(),
}) })
.optional(), .optional(),
}) })

View File

@@ -13,7 +13,7 @@ import {
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>(); const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
type AgentLoaderManager = Pick< export type AgentLoaderManager = Pick<
AgentManager, AgentManager,
| "createAgent" | "createAgent"
| "getAgent" | "getAgent"

View File

@@ -5970,12 +5970,18 @@ test("unarchiveSnapshot unarchives native provider storage before clearing archi
title: "Native unarchive target", title: "Native unarchive target",
}, },
undefined, undefined,
{ workspaceId: undefined }, {
workspaceId: undefined,
labels: { [PARENT_AGENT_ID_LABEL]: "archived-parent", retained: "yes" },
},
); );
await manager.archiveAgent(agent.id); await manager.archiveAgent(agent.id);
client.readArchivedAtDuringUnarchive = async () => (await storage.get(agent.id))?.archivedAt; 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); const stored = await storage.get(agent.id);
expect(unarchived).toBe(true); 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.unarchivedHandles).toEqual(client.archivedHandles);
expect(client.archivedAtDuringUnarchive).toEqual(expect.any(String)); expect(client.archivedAtDuringUnarchive).toEqual(expect.any(String));
expect(stored?.archivedAt).toBeNull(); 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 () => { test("unarchiveSnapshotByHandle unarchives native provider storage for the matched snapshot", async () => {

View File

@@ -1669,7 +1669,10 @@ export class AgentManager {
return nextRecord; return nextRecord;
} }
async unarchiveSnapshot(agentId: string): Promise<boolean> { async unarchiveSnapshot(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
const registry = this.requireRegistry(); const registry = this.requireRegistry();
const record = await registry.get(agentId); const record = await registry.get(agentId);
if (!record || !record.archivedAt) { if (!record || !record.archivedAt) {
@@ -1680,6 +1683,8 @@ export class AgentManager {
await registry.upsert({ await registry.upsert({
...record, ...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
archivedAt: null, archivedAt: null,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });

View File

@@ -5,6 +5,8 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js"; import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js"; import { ensureAgentLoaded } from "./agent-loading.js";
export type AgentUnarchiveController = Pick<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
export type AgentRunController = Pick< export type AgentRunController = Pick<
AgentManager, AgentManager,
"getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent" "getAgent" | "tryRunOutOfBand" | "hasInFlightRun" | "replaceAgentRun" | "streamAgent"
@@ -91,10 +93,11 @@ export async function startAgentRun(
*/ */
export async function unarchiveAgentState( export async function unarchiveAgentState(
_agentStorage: AgentStorage, _agentStorage: AgentStorage,
agentManager: AgentManager, agentManager: AgentUnarchiveController,
agentId: string, agentId: string,
updates?: { workspaceId?: string; labels?: Record<string, string | null> },
): Promise<boolean> { ): Promise<boolean> {
const unarchived = await agentManager.unarchiveSnapshot(agentId); const unarchived = await agentManager.unarchiveSnapshot(agentId, updates);
if (!unarchived) return false; if (!unarchived) return false;
agentManager.notifyAgentState(agentId); agentManager.notifyAgentState(agentId);
return true; return true;

View File

@@ -1,5 +1,5 @@
import { beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs"; import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import type { import type {
@@ -7,10 +7,15 @@ import type {
ManagedAgent, ManagedAgent,
ManagedImportableProviderSession, ManagedImportableProviderSession,
} from "./agent-manager.js"; } 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 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 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 { import {
type ImportSessionAgentManager,
ImportSessionsRequestError, ImportSessionsRequestError,
importProviderSession, importProviderSession,
listImportableProviderSessions, listImportableProviderSessions,
@@ -18,6 +23,7 @@ import {
} from "./import-sessions.js"; } from "./import-sessions.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir"; const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
const importTestDirectories: string[] = [];
const TEST_CAPABILITIES = { const TEST_CAPABILITIES = {
supportsStreaming: true, supportsStreaming: true,
@@ -32,6 +38,12 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => {
for (const directory of importTestDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeImportableSession(args: { function makeImportableSession(args: {
provider?: string; provider?: string;
sessionId: string; sessionId: string;
@@ -98,6 +110,28 @@ function makeManagedAgent(args: {
} satisfies ManagedAgent; } satisfies ManagedAgent;
} }
function createImportWorkspace(
workspaceId: string,
): Pick<WorkspaceProvisioningService, "runInImportWorkspace"> {
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( function makeRequest(
overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {}, overrides: Partial<FetchRecentProviderSessionsRequestMessage> = {},
): FetchRecentProviderSessionsRequestMessage { ): 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 () => { test("listImportableProviderSessions filters out metadata generation sessions", async () => {
const cwd = "/tmp/project"; const cwd = "/tmp/project";
const sessions = [ 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 () => { function makeStoredProviderSession(input: {
const cwd = "/tmp/imported-agent"; id: string;
const timeline: AgentTimelineItem[] = [ cwd: string;
{ type: "user_message", text: "Trace recent provider sessions\n\nkeep it tight" }, sessionId: string;
nativeHandle?: string;
workspaceId?: string;
labels?: Record<string, string>;
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<void> | 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<string, string | null> },
) => {
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<ProviderImportHarness> {
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<void> {
await this.storage.upsert(record);
}
blockUnarchive(): () => void {
this.unarchiveWait = new Promise<void>((resolve) => {
this.releaseUnarchive = resolve;
});
return () => {
this.releaseUnarchive?.();
this.unarchiveWait = null;
this.releaseUnarchive = null;
};
}
import(input: { providerHandleId: string; cwd?: string; labels?: Record<string, string> }) {
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." }, { 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({ const result = await harness.import({
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",
providerHandleId: "thread-imported", providerHandleId: "thread-imported",
cwd, cwd: "/tmp/imported-agent",
workspaceId: "ws-imported",
labels: { source: "import" }, 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 () => { test("importProviderSession requires cwd from the selected provider row", async () => {
const agentManager = {} as unknown as AgentManager; const harness = await ProviderImportHarness.create();
await expect( await expect(harness.import({ providerHandleId: "thread-imported" })).rejects.toThrow(
importProviderSession({ "Import requires cwd from the selected provider session",
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");
}); });

View File

@@ -8,24 +8,44 @@ import type {
} from "./agent-manager.js"; } from "./agent-manager.js";
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js"; import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
import type { AgentPersistenceHandle, AgentProvider } from "./agent-sdk-types.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 { unarchiveAgentState } from "./agent-prompt.js";
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.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 { import type {
FetchRecentProviderSessionsRequestMessage, FetchRecentProviderSessionsRequestMessage,
ImportAgentRequestMessageSchema, ImportAgentRequestMessageSchema,
RecentProviderSessionDescriptorPayload, RecentProviderSessionDescriptorPayload,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createRealpathAwarePathMatcher } from "../../utils/path.js"; import { createRealpathAwarePathMatcher } from "../../utils/path.js";
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>; type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
const METADATA_GENERATION_PROMPT_PREFIX = const METADATA_GENERATION_PROMPT_PREFIX =
"Generate metadata for a coding agent based on the user prompt."; "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<string, Promise<unknown>>
>();
export interface NormalizedImportAgentRequest { export interface NormalizedImportAgentRequest {
provider: AgentProvider; provider: AgentProvider;
providerHandleId: string; providerHandleId: string;
cwd?: string; cwd?: string;
workspaceId?: string;
labels?: Record<string, string>; labels?: Record<string, string>;
requestId: string; requestId: string;
} }
@@ -54,8 +74,8 @@ export interface ListImportableProviderSessionsResult {
export interface ImportProviderSessionInput { export interface ImportProviderSessionInput {
request: NormalizedImportAgentRequest; request: NormalizedImportAgentRequest;
workspaceId: string; workspaceProvisioning: Pick<WorkspaceProvisioningService, "runInImportWorkspace">;
agentManager: AgentManager; agentManager: ImportSessionAgentManager;
agentStorage: AgentStorage; agentStorage: AgentStorage;
logger: Logger; logger: Logger;
} }
@@ -63,6 +83,12 @@ export interface ImportProviderSessionInput {
export interface ImportProviderSessionResult { export interface ImportProviderSessionResult {
snapshot: ManagedAgent; snapshot: ManagedAgent;
timelineSize: number; timelineSize: number;
createdWorkspace: PersistedWorkspaceRecord | null;
}
interface ImportedProviderSession {
snapshot: ManagedAgent;
timelineSize: number;
} }
// COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape // COMPAT(import-agent-request-v1): accept legacy {provider, sessionId} shape
@@ -82,6 +108,7 @@ export function normalizeImportAgentRequest(
provider: provider as AgentProvider, provider: provider as AgentProvider,
providerHandleId, providerHandleId,
cwd: msg.cwd, cwd: msg.cwd,
workspaceId: msg.workspaceId,
labels: msg.labels, labels: msg.labels,
requestId: msg.requestId, requestId: msg.requestId,
}; };
@@ -138,18 +165,72 @@ export async function listImportableProviderSessions(
export async function importProviderSession( export async function importProviderSession(
input: ImportProviderSessionInput, input: ImportProviderSessionInput,
): Promise<ImportProviderSessionResult> { ): Promise<ImportProviderSessionResult> {
const { provider, providerHandleId, cwd, labels } = input.request; const cwd = input.request.cwd;
if (!cwd) { if (!cwd) {
throw new Error("Import requires cwd from the selected provider session"); 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<ImportedProviderSession> {
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<string, string | null> = { ...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({ const snapshot = await input.agentManager.importProviderSession({
provider, provider,
providerHandleId, providerHandleId,
cwd, cwd,
workspaceId: input.workspaceId, workspaceId,
labels, labels,
}); });
await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id); await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
@@ -160,22 +241,80 @@ export async function importProviderSession(
}; };
} }
async function unarchiveAgentByHandle( async function serializeProviderSessionImport<T>(
agentStorage: AgentStorage, agentManager: ImportSessionAgentManager,
agentManager: AgentManager, key: string,
handle: AgentPersistenceHandle, operation: () => Promise<T>,
): Promise<void> { ): Promise<T> {
const records = await agentStorage.list(); let mutations = providerSessionImportMutations.get(agentManager);
const matched = records.find( if (!mutations) {
(record) => mutations = new Map();
record.persistence?.provider === handle.provider && providerSessionImportMutations.set(agentManager, mutations);
(record.persistence.sessionId === handle.sessionId ||
record.persistence.nativeHandle === handle.nativeHandle),
);
if (!matched) {
return;
} }
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<string> {
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<void> {
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 { function parseRecentProviderSessionsSince(since: string | undefined): number | null {
@@ -189,33 +328,25 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
return timestamp; 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( async function collectImportedProviderSessionHandles(
agentManager: Pick<AgentManager, "listAgents">, agentManager: Pick<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">, agentStorage: Pick<AgentStorage, "list">,
): Promise<Set<string>> { ): Promise<Set<string>> {
const handles = new Set<string>(); const handles = new Set<string>();
const records = await agentStorage.list();
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
for (const agent of agentManager.listAgents()) { for (const agent of agentManager.listAgents()) {
if (storedRecordsById.get(agent.id)?.archivedAt) {
continue;
}
collectProviderSessionHandleKeys(handles, agent.provider, agent.persistence); 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); collectProviderSessionHandleKeys(handles, record.provider, record.persistence);
} }

View File

@@ -714,6 +714,7 @@ export class Session {
workspaceRegistry: this.workspaceRegistry, workspaceRegistry: this.workspaceRegistry,
projectRegistry: this.projectRegistry, projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
logger: this.sessionLogger,
}); });
this.workspaceRecovery = createWorkspaceRecoveryService({ this.workspaceRecovery = createWorkspaceRecoveryService({
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
@@ -2817,19 +2818,16 @@ export class Session {
if (!normalized.cwd) { if (!normalized.cwd) {
throw new Error("Import requires cwd from the selected provider session"); throw new Error("Import requires cwd from the selected provider session");
} }
// An imported agent mints its own workspace; ownership is its workspaceId, const { snapshot, timelineSize, createdWorkspace } = await importProviderSession({
// never an existing same-cwd workspace resolved by path.
const workspace = await this.workspaceProvisioning.createWorkspaceForDirectory(
normalized.cwd,
);
const { snapshot, timelineSize } = await importProviderSession({
request: normalized, request: normalized,
workspaceId: workspace.workspaceId, workspaceProvisioning: this.workspaceProvisioning,
agentManager: this.agentManager, agentManager: this.agentManager,
agentStorage: this.agentStorage, agentStorage: this.agentStorage,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
await this.registerWorkspaceForImportedAgent(workspace); if (createdWorkspace) {
await this.registerWorkspaceForImportedAgent(createdWorkspace);
}
const agentPayload = await this.buildAgentPayload(snapshot); const agentPayload = await this.buildAgentPayload(snapshot);
this.emit({ this.emit({
type: "status", type: "status",

View File

@@ -3692,6 +3692,88 @@ test("import_agent_request registers a workspace for a never-seen cwd", async ()
).toBe(true); ).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 () => { test("open_project_response returns immediately even when the GitHub fetch is slow", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests(); const session = createSessionForWorkspaceTests();

View File

@@ -1,6 +1,6 @@
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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"; import { afterEach, beforeEach, expect, test } from "vitest";
@@ -9,6 +9,7 @@ import { createNoopWorkspaceGitService } from "../../test-utils/workspace-git-se
import { import {
FileBackedProjectRegistry, FileBackedProjectRegistry,
FileBackedWorkspaceRegistry, FileBackedWorkspaceRegistry,
type PersistedProjectRecord,
} from "../../workspace-registry.js"; } from "../../workspace-registry.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
import { import {
@@ -22,6 +23,7 @@ import {
const logger = createTestLogger(); const logger = createTestLogger();
const ARCHIVED_AT = "2026-01-01T00:00:00.000Z"; const ARCHIVED_AT = "2026-01-01T00:00:00.000Z";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
let tmpDir: string; let tmpDir: string;
let gitRoots: Set<string>; let gitRoots: Set<string>;
@@ -72,6 +74,7 @@ beforeEach(async () => {
workspaceRegistry, workspaceRegistry,
projectRegistry, projectRegistry,
workspaceGitService: gitService(), workspaceGitService: gitService(),
logger,
}); });
}); });
@@ -214,3 +217,137 @@ test("findOrCreateProjectForDirectory reuses the active project for the same roo
expect(second.projectId).toBe(first.projectId); expect(second.projectId).toBe(first.projectId);
expect(await projectRegistry.list()).toHaveLength(1); 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] : []);
},
);

View File

@@ -1,4 +1,5 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { Logger } from "pino";
import { import {
checkoutLiteFromGitSnapshot, checkoutLiteFromGitSnapshot,
classifyDirectoryForProjectMembership, classifyDirectoryForProjectMembership,
@@ -14,6 +15,7 @@ import {
} from "../../workspace-registry.js"; } from "../../workspace-registry.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.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, * Resolves which workspace and project records a directory belongs to, creating,
@@ -34,7 +36,21 @@ export interface ResolveOrCreateWorkspaceIdInput {
initialTitle: string | null; initialTitle: string | null;
} }
export interface ImportWorkspaceInput {
cwd: string;
requestedWorkspaceId?: string;
}
export interface ImportWorkspaceResult<T> {
value: T;
createdWorkspace: PersistedWorkspaceRecord | null;
}
export interface WorkspaceProvisioningService { export interface WorkspaceProvisioningService {
runInImportWorkspace<T>(
input: ImportWorkspaceInput,
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
): Promise<ImportWorkspaceResult<T>>;
findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord>; findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord>;
resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<string>; resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<string>;
createWorkspaceForDirectory( createWorkspaceForDirectory(
@@ -51,8 +67,72 @@ export function createWorkspaceProvisioningService(deps: {
workspaceRegistry: WorkspaceRegistry; workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry; projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">; workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
logger: Logger;
}): WorkspaceProvisioningService { }): WorkspaceProvisioningService {
const { workspaceRegistry, projectRegistry, workspaceGitService } = deps; const { workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
async function runInImportWorkspace<T>(
input: ImportWorkspaceInput,
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
): Promise<ImportWorkspaceResult<T>> {
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<void> {
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( async function resolveWorkspaceDirectory(
cwd: string, cwd: string,
@@ -275,6 +355,7 @@ export function createWorkspaceProvisioningService(deps: {
} }
return { return {
runInImportWorkspace,
findOrCreateWorkspaceForDirectory, findOrCreateWorkspaceForDirectory,
resolveOrCreateWorkspaceIdForCreateAgent, resolveOrCreateWorkspaceIdForCreateAgent,
createWorkspaceForDirectory, createWorkspaceForDirectory,

View File

@@ -1266,6 +1266,8 @@ export class VoiceAssistantWebSocketServer {
commitsList: true, commitsList: true,
// COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105.
providerRemoval: true, providerRemoval: true,
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
importSessionWorkspaceTarget: true,
}, },
}; };
} }