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,
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);

View File

@@ -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:
| {

View File

@@ -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;

View File

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

View File

@@ -159,6 +159,7 @@ const PROJECT_GITHUB_CLONE_TIMEOUT_MS = 5 * 60 * 1000;
interface ImportAgentInputBase {
cwd?: string;
workspaceId?: string;
labels?: Record<string, string>;
}
@@ -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 } : {}),
});

View File

@@ -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", () => {

View File

@@ -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(),
})

View File

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

View File

@@ -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 () => {

View File

@@ -1669,7 +1669,10 @@ export class AgentManager {
return nextRecord;
}
async unarchiveSnapshot(agentId: string): Promise<boolean> {
async unarchiveSnapshot(
agentId: string,
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
): Promise<boolean> {
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(),
});

View File

@@ -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<AgentManager, "notifyAgentState" | "unarchiveSnapshot">;
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<string, string | null> },
): Promise<boolean> {
const unarchived = await agentManager.unarchiveSnapshot(agentId);
const unarchived = await agentManager.unarchiveSnapshot(agentId, updates);
if (!unarchived) return false;
agentManager.notifyAgentState(agentId);
return true;

View File

@@ -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<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(
overrides: Partial<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 () => {
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<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." },
];
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",
);
});

View File

@@ -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<typeof ImportAgentRequestMessageSchema>;
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<string, Promise<unknown>>
>();
export interface NormalizedImportAgentRequest {
provider: AgentProvider;
providerHandleId: string;
cwd?: string;
workspaceId?: string;
labels?: Record<string, string>;
requestId: string;
}
@@ -54,8 +74,8 @@ export interface ListImportableProviderSessionsResult {
export interface ImportProviderSessionInput {
request: NormalizedImportAgentRequest;
workspaceId: string;
agentManager: AgentManager;
workspaceProvisioning: Pick<WorkspaceProvisioningService, "runInImportWorkspace">;
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<ImportProviderSessionResult> {
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<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({
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<void> {
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<T>(
agentManager: ImportSessionAgentManager,
key: string,
operation: () => Promise<T>,
): Promise<T> {
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<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 {
@@ -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<AgentManager, "listAgents">,
agentStorage: Pick<AgentStorage, "list">,
): Promise<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()) {
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);
}

View File

@@ -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",

View File

@@ -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();

View File

@@ -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<string>;
@@ -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] : []);
},
);

View File

@@ -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<T> {
value: T;
createdWorkspace: PersistedWorkspaceRecord | null;
}
export interface WorkspaceProvisioningService {
runInImportWorkspace<T>(
input: ImportWorkspaceInput,
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
): Promise<ImportWorkspaceResult<T>>;
findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord>;
resolveOrCreateWorkspaceIdForCreateAgent(input: ResolveOrCreateWorkspaceIdInput): Promise<string>;
createWorkspaceForDirectory(
@@ -51,8 +67,72 @@ export function createWorkspaceProvisioningService(deps: {
workspaceRegistry: WorkspaceRegistry;
projectRegistry: ProjectRegistry;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
logger: Logger;
}): 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(
cwd: string,
@@ -275,6 +355,7 @@ export function createWorkspaceProvisioningService(deps: {
}
return {
runInImportWorkspace,
findOrCreateWorkspaceForDirectory,
resolveOrCreateWorkspaceIdForCreateAgent,
createWorkspaceForDirectory,

View File

@@ -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,
},
};
}