mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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] : []);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user