mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
fix(workspaces): preserve runtime lifecycle boundaries
This commit is contained in:
@@ -650,6 +650,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
message={item.text}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaceId={context.workspaceId}
|
||||
serverId={resolvedServerId}
|
||||
client={client}
|
||||
spacing={layoutItem.assistantSpacing}
|
||||
@@ -658,7 +659,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
</AssistantFileLinkResolverProvider>
|
||||
);
|
||||
},
|
||||
[agentId, client, handleInlinePathPress, resolvedServerId, toast, workspaceRoot],
|
||||
[
|
||||
agentId,
|
||||
client,
|
||||
context.workspaceId,
|
||||
handleInlinePathPress,
|
||||
resolvedServerId,
|
||||
toast,
|
||||
workspaceRoot,
|
||||
],
|
||||
);
|
||||
|
||||
const renderThoughtItem = useCallback(
|
||||
|
||||
@@ -94,6 +94,25 @@ describe("assistant image acquisition cache", () => {
|
||||
expect(laterMessage).not.toBe(first);
|
||||
});
|
||||
|
||||
it("scopes same-cwd file acquisitions to the selected workspace", () => {
|
||||
const first = createAssistantImageFileAcquisitionKey({
|
||||
serverId: "server",
|
||||
workspaceId: "workspace-1",
|
||||
occurrenceKey: "message:image",
|
||||
cwd: "/workspace",
|
||||
path: "screenshot.png",
|
||||
});
|
||||
const second = createAssistantImageFileAcquisitionKey({
|
||||
serverId: "server",
|
||||
workspaceId: "workspace-2",
|
||||
occurrenceKey: "message:image",
|
||||
cwd: "/workspace",
|
||||
path: "screenshot.png",
|
||||
});
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("scopes persisted file previews to the rendered message occurrence", () => {
|
||||
const first = createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: "server-1",
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createAssistantImageOccurrenceKey(input: {
|
||||
|
||||
export function createAssistantImageFilePreviewAttachmentId(input: {
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
occurrenceKey: string;
|
||||
mimeType: string;
|
||||
path: string;
|
||||
@@ -32,17 +33,18 @@ export function createAssistantImageFilePreviewAttachmentId(input: {
|
||||
size: input.size,
|
||||
modifiedAt: input.modifiedAt,
|
||||
contentLength: input.contentLength,
|
||||
contentKey: `${input.serverId ?? "unknown-server"}:${input.occurrenceKey}`,
|
||||
contentKey: `${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function createAssistantImageFileAcquisitionKey(input: {
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
occurrenceKey: string;
|
||||
cwd: string;
|
||||
path: string;
|
||||
}): string {
|
||||
return `file:${input.serverId ?? "unknown-server"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
|
||||
return `file:${input.serverId ?? "unknown-server"}:${input.workspaceId ?? "legacy-workspace"}:${input.occurrenceKey}:${input.cwd}:${input.path}`;
|
||||
}
|
||||
|
||||
export function createAssistantImageAcquisitionCache<T>(input: {
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
} from "./file-acquisition";
|
||||
|
||||
class MemoryFileAcquisitionPort implements AssistantImageFileAcquisitionPort {
|
||||
readonly reads: Array<{ cwd: string; path: string }> = [];
|
||||
readonly reads: Array<{ cwd: string; path: string; workspaceId?: string }> = [];
|
||||
|
||||
async readFile(cwd: string, path: string) {
|
||||
this.reads.push({ cwd, path });
|
||||
async readFile(cwd: string, path: string, workspaceId?: string) {
|
||||
this.reads.push({ cwd, path, ...(workspaceId ? { workspaceId } : {}) });
|
||||
return {
|
||||
kind: "image" as const,
|
||||
path,
|
||||
@@ -38,6 +38,7 @@ describe("assistant image file acquisition", () => {
|
||||
const common = {
|
||||
resolution: { kind: "file_rpc" as const, cwd: "/workspace", path: "reconnect.png" },
|
||||
serverId: "server",
|
||||
workspaceId: "workspace-selected",
|
||||
occurrenceKey: "agent:message:reconnect-image",
|
||||
unavailableMessage: "Image unavailable",
|
||||
};
|
||||
@@ -48,6 +49,23 @@ describe("assistant image file acquisition", () => {
|
||||
expect(disconnected?.key).toBe(connected?.key);
|
||||
await expect(disconnected?.locate()).rejects.toThrow("Image unavailable");
|
||||
await expect(connected?.locate()).resolves.toMatchObject({ mimeType: "image/png" });
|
||||
expect(connectedPort.reads).toEqual([{ cwd: "/workspace", path: "reconnect.png" }]);
|
||||
expect(connectedPort.reads).toEqual([
|
||||
{ cwd: "/workspace", path: "reconnect.png", workspaceId: "workspace-selected" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps legacy file acquisition cwd-compatible when workspaceId is absent", async () => {
|
||||
const port = new MemoryFileAcquisitionPort();
|
||||
const acquisition = createAssistantImageFileAcquisition({
|
||||
port,
|
||||
resolution: { kind: "file_rpc", cwd: "/legacy", path: "preview.png" },
|
||||
serverId: "server",
|
||||
occurrenceKey: "agent:message:legacy-image",
|
||||
unavailableMessage: "Image unavailable",
|
||||
});
|
||||
|
||||
await acquisition?.locate();
|
||||
|
||||
expect(port.reads).toEqual([{ cwd: "/legacy", path: "preview.png" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "./acquisition-cache";
|
||||
|
||||
export interface AssistantImageFileAcquisitionPort {
|
||||
readFile(cwd: string, path: string): Promise<FileReadResult>;
|
||||
readFile(cwd: string, path: string, workspaceId?: string): Promise<FileReadResult>;
|
||||
persist(input: {
|
||||
id: string;
|
||||
bytes: Uint8Array;
|
||||
@@ -26,6 +26,7 @@ export function createAssistantImageFileAcquisition(input: {
|
||||
port: AssistantImageFileAcquisitionPort | null;
|
||||
resolution: AssistantImageSourceResolution | null;
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
occurrenceKey: string;
|
||||
unavailableMessage: string;
|
||||
}): AssistantImageAcquisition | null {
|
||||
@@ -36,6 +37,7 @@ export function createAssistantImageFileAcquisition(input: {
|
||||
return {
|
||||
key: createAssistantImageFileAcquisitionKey({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
occurrenceKey: input.occurrenceKey,
|
||||
cwd: resolution.cwd,
|
||||
path: resolution.path,
|
||||
@@ -44,13 +46,14 @@ export function createAssistantImageFileAcquisition(input: {
|
||||
if (!port) {
|
||||
throw new Error(input.unavailableMessage);
|
||||
}
|
||||
const file = await port.readFile(resolution.cwd, resolution.path);
|
||||
const file = await port.readFile(resolution.cwd, resolution.path, input.workspaceId);
|
||||
if (file.kind !== "image") {
|
||||
throw new Error(input.unavailableMessage);
|
||||
}
|
||||
return await port.persist({
|
||||
id: createAssistantImageFilePreviewAttachmentId({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
occurrenceKey: input.occurrenceKey,
|
||||
mimeType: file.mime,
|
||||
path: file.path || resolution.path,
|
||||
|
||||
@@ -82,6 +82,7 @@ interface UseAssistantImageInput {
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
serverId?: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
type PreviewUrlState =
|
||||
@@ -377,6 +378,7 @@ export function useAssistantImage({
|
||||
client,
|
||||
workspaceRoot,
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: UseAssistantImageInput): AssistantImageResult {
|
||||
const { t } = useTranslation();
|
||||
const resolution = useMemo(
|
||||
@@ -387,7 +389,8 @@ export function useAssistantImage({
|
||||
const fileAcquisition = useMemo(() => {
|
||||
const port: AssistantImageFileAcquisitionPort | null = client
|
||||
? {
|
||||
readFile: async (cwd, path) => await client.readFile(cwd, path),
|
||||
readFile: async (cwd, path, selectedWorkspaceId) =>
|
||||
await client.readFile(cwd, path, undefined, selectedWorkspaceId),
|
||||
persist: persistAttachmentFromBytes,
|
||||
}
|
||||
: null;
|
||||
@@ -395,10 +398,11 @@ export function useAssistantImage({
|
||||
port,
|
||||
resolution,
|
||||
serverId,
|
||||
workspaceId,
|
||||
occurrenceKey,
|
||||
unavailableMessage: t("message.attachments.imagePreviewUnavailable"),
|
||||
});
|
||||
}, [client, occurrenceKey, resolution, serverId, t]);
|
||||
}, [client, occurrenceKey, resolution, serverId, t, workspaceId]);
|
||||
const dataImageAcquisition = useMemo(
|
||||
() => createDataImageAcquisition({ source, dataImage }),
|
||||
[dataImage, source],
|
||||
|
||||
@@ -187,13 +187,13 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
||||
return (
|
||||
<>
|
||||
<CompactExplorerOpenGestureSurface
|
||||
enabled={enabled && Boolean(model?.workspaceRoot)}
|
||||
enabled={enabled && Boolean(model?.workspaceRoot) && Boolean(workspaceGit)}
|
||||
onOpenExplorer={handleOpenExplorer}
|
||||
>
|
||||
{children}
|
||||
</CompactExplorerOpenGestureSurface>
|
||||
<WorkspaceGitBoundary workspaceGit={workspaceGit}>
|
||||
{enabled && model ? (
|
||||
{enabled && model && workspaceGit ? (
|
||||
<CompactExplorerSidebar
|
||||
serverId={model.serverId}
|
||||
workspaceId={model.workspaceId}
|
||||
|
||||
@@ -736,6 +736,7 @@ interface AssistantMessageProps {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
workspaceRoot?: string;
|
||||
workspaceId?: string;
|
||||
serverId?: string;
|
||||
client?: DaemonClient | null;
|
||||
spacing?: "default" | "compactTop" | "compactBottom" | "compactBoth";
|
||||
@@ -799,6 +800,7 @@ function AssistantMarkdownImage({
|
||||
hasLeadingContent,
|
||||
client,
|
||||
workspaceRoot,
|
||||
workspaceId,
|
||||
serverId,
|
||||
}: {
|
||||
source: string;
|
||||
@@ -807,6 +809,7 @@ function AssistantMarkdownImage({
|
||||
hasLeadingContent: boolean;
|
||||
client?: DaemonClient | null;
|
||||
workspaceRoot?: string;
|
||||
workspaceId?: string;
|
||||
serverId?: string;
|
||||
}) {
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
@@ -821,6 +824,7 @@ function AssistantMarkdownImage({
|
||||
occurrenceKey,
|
||||
client,
|
||||
workspaceRoot,
|
||||
workspaceId,
|
||||
serverId,
|
||||
});
|
||||
const binding = image.status === "failed" ? null : image.binding;
|
||||
@@ -1448,6 +1452,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
workspaceRoot,
|
||||
workspaceId,
|
||||
serverId,
|
||||
client,
|
||||
spacing = "default",
|
||||
@@ -1885,12 +1890,22 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
hasLeadingContent={hasLeadingContent}
|
||||
client={client}
|
||||
workspaceRoot={workspaceRoot}
|
||||
workspaceId={workspaceId}
|
||||
serverId={serverId}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
|
||||
}, [
|
||||
client,
|
||||
fileLinkActions,
|
||||
markdownParser,
|
||||
occurrenceKey,
|
||||
phase,
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
]);
|
||||
|
||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||
const keyedBlocks = useMemo(
|
||||
|
||||
@@ -3689,8 +3689,10 @@ function WorkspaceScreenContent({
|
||||
[isFocusModeEnabled, isMobile],
|
||||
);
|
||||
const showExplorerSidebar = useMemo(
|
||||
() => shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }),
|
||||
[isRouteFocused, isFocusModeEnabled, isMobile],
|
||||
() =>
|
||||
Boolean(workspaceGit) &&
|
||||
shouldShowWorkspaceExplorerSidebar({ isRouteFocused, isFocusModeEnabled, isMobile }),
|
||||
[isRouteFocused, isFocusModeEnabled, isMobile, workspaceGit],
|
||||
);
|
||||
const createTerminalDisabled = useMemo(
|
||||
() => createTerminalMutation.isPending || pendingTerminalCreateInput !== null,
|
||||
|
||||
@@ -28,7 +28,10 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
archiveAgentForClose: (agentId: string) => Promise<unknown>;
|
||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
archiveWorkspaceRecord: (
|
||||
workspaceId: string,
|
||||
options?: { releaseBacking?: boolean },
|
||||
) => Promise<void>;
|
||||
destroyWorkspace: (workspaceId: string) => Promise<void>;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
emitAgentRemove: (agentId: string) => Promise<void>;
|
||||
@@ -227,6 +230,7 @@ export class CreateAgentLifecycleDispatch {
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId },
|
||||
requestId: randomUUID(),
|
||||
releaseBacking: true,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1142,12 +1142,14 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
const archiveWorkspaceRecordExternal = async (
|
||||
workspaceId: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
context?: WorkspaceArchiveContext & { releaseBacking?: boolean },
|
||||
) => {
|
||||
const workspace = await workspaceRegistry.get(workspaceId);
|
||||
if (workspace?.runtime) {
|
||||
if (!workspaceRuntime) throw new Error(`Workspace runtime is not available: ${workspaceId}`);
|
||||
await workspaceRuntime.archive(workspaceId);
|
||||
await workspaceRuntime.archive(workspaceId, {
|
||||
releaseBacking: context?.releaseBacking,
|
||||
});
|
||||
if (context?.autoArchivedChangeRequestUrl) {
|
||||
await workspaceRegistry.update(workspaceId, (archived) => ({
|
||||
...archived,
|
||||
|
||||
@@ -98,6 +98,7 @@ async function createAgentInBranchOffWorktree(options?: {
|
||||
autoArchive?: boolean;
|
||||
branchName?: string;
|
||||
repoDir?: string;
|
||||
startIdle?: boolean;
|
||||
}): Promise<{ repoDir: string; agentId: string; worktreePath: string }> {
|
||||
const repoDir = options?.repoDir ?? createGitRepo();
|
||||
const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`;
|
||||
@@ -112,7 +113,7 @@ async function createAgentInBranchOffWorktree(options?: {
|
||||
base: "main",
|
||||
},
|
||||
...(options?.autoArchive !== undefined ? { autoArchive: options.autoArchive } : {}),
|
||||
initialPrompt: "Say done.",
|
||||
...(options?.startIdle ? {} : { initialPrompt: "Say done." }),
|
||||
});
|
||||
return { repoDir, agentId: created.id, worktreePath: created.cwd };
|
||||
}
|
||||
@@ -156,10 +157,10 @@ test("create_agent_request creates a worktree and auto-archives both after the f
|
||||
await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false);
|
||||
// Archived tabs can continue asking for history. These reads must not recreate
|
||||
// the removed workspace observation or compromise the next agent lifecycle.
|
||||
const staleTimelineReads = await Promise.allSettled(
|
||||
await Promise.allSettled(
|
||||
Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })),
|
||||
);
|
||||
expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true);
|
||||
expect(existsSync(created.cwd)).toBe(false);
|
||||
const subsequent = await ctx.client.createAgent({
|
||||
config: { ...getFullAccessConfig("codex"), cwd: repoDir },
|
||||
initialPrompt: "Say done.",
|
||||
@@ -335,19 +336,19 @@ test("create_agent_request with worktree but no autoArchive leaves agent and wor
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
|
||||
});
|
||||
|
||||
test("archiving a created worktree removes the directory on last reference", async () => {
|
||||
test("archiving a created worktree preserves it for restore", async () => {
|
||||
const created = await createAgentInBranchOffWorktree();
|
||||
|
||||
await ctx.client.waitForFinish(created.agentId, 10000);
|
||||
await ctx.client.archivePaseoWorktree({ worktreePath: created.worktreePath });
|
||||
|
||||
await expectAgentAbsentFromActiveList(created.agentId);
|
||||
await expectWorktreeListEmpty(created.repoDir);
|
||||
expect(existsSync(created.worktreePath)).toBe(false);
|
||||
await expectWorktreePresentInList(created.repoDir, created.worktreePath);
|
||||
expect(existsSync(created.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("auto-archiving a created worktree keeps the directory when a sibling workspace references it", async () => {
|
||||
const created = await createAgentInBranchOffWorktree({ autoArchive: true });
|
||||
const created = await createAgentInBranchOffWorktree({ autoArchive: true, startIdle: true });
|
||||
|
||||
// Create a sibling workspace that shares the same backing directory.
|
||||
const sibling = await ctx.client.createWorkspace({
|
||||
@@ -358,6 +359,7 @@ test("auto-archiving a created worktree keeps the directory when a sibling works
|
||||
throw new Error(sibling.error ?? "Failed to create sibling workspace");
|
||||
}
|
||||
|
||||
await ctx.client.sendMessage(created.agentId, "Say done.");
|
||||
await ctx.client.waitForFinish(created.agentId, 10000);
|
||||
|
||||
await expectAgentAbsentFromActiveList(created.agentId);
|
||||
|
||||
@@ -953,8 +953,10 @@ export class Session {
|
||||
isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider),
|
||||
buildProjectPlacementForWorkspaceId: (workspaceId) =>
|
||||
this.buildProjectPlacementForWorkspaceId(workspaceId),
|
||||
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
|
||||
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
|
||||
emitWorkspaceUpdateForWorkspaceId: (workspaceId) => {
|
||||
this.workspacesAwaitingInitialAgent.delete(workspaceId);
|
||||
return this.emitWorkspaceUpdateForWorkspaceId(workspaceId);
|
||||
},
|
||||
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
|
||||
this.directorySync.sequenceAgentUpdate(
|
||||
payload,
|
||||
@@ -976,7 +978,8 @@ export class Session {
|
||||
archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId),
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
archiveWorkspaceRecord: (workspaceId, archiveOptions) =>
|
||||
this.archiveWorkspaceRecord(workspaceId, archiveOptions),
|
||||
destroyWorkspace: async (workspaceId) => {
|
||||
if (!this.workspaceRuntime) {
|
||||
throw new Error(`Workspace runtime is not available: ${workspaceId}`);
|
||||
@@ -1228,26 +1231,22 @@ export class Session {
|
||||
workspace: WorkspaceDescriptorPayload,
|
||||
optimisticStatus?: WorkspaceDescriptorPayload["status"],
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (this.workspaceUpdatesSubscription) {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(
|
||||
[workspace.id],
|
||||
optimisticStatus ? { optimisticStatus } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12.
|
||||
// Older clients create before subscribing and require the causal update beside the response.
|
||||
this.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (optimisticStatus) this.workspacesAwaitingInitialAgent.delete(workspace.id);
|
||||
if (this.workspaceUpdatesSubscription) {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds(
|
||||
[workspace.id],
|
||||
optimisticStatus ? { optimisticStatus } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// COMPAT(workspaceCreateCausalUpdate): added in v0.1.106, remove after 2027-01-12.
|
||||
// Older clients create before subscribing and require the causal update beside the response.
|
||||
this.emit({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: optimisticStatus ? { ...workspace, status: optimisticStatus } : workspace,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
markWorkspaceArchivingForExternalMutation(
|
||||
@@ -3218,6 +3217,7 @@ export class Session {
|
||||
|
||||
let createdWorktreeForCleanup: CreatePaseoWorktreeWorkflowResult | null = null;
|
||||
let createdAgentId: string | null = null;
|
||||
let initialAgentWorkspaceId: string | null = null;
|
||||
try {
|
||||
const requestedCwd = resolve(config.cwd);
|
||||
const needsRequestedDirectory =
|
||||
@@ -3248,6 +3248,7 @@ export class Session {
|
||||
createdWorktree,
|
||||
workspacePromptTitle,
|
||||
});
|
||||
initialAgentWorkspaceId = resolvedIntent.intent.workspaceId;
|
||||
await this.requireCreateAgentWorkspaceReady(resolvedIntent);
|
||||
|
||||
const { snapshot, liveSnapshot } = await createAgentCommand(
|
||||
@@ -3317,6 +3318,10 @@ export class Session {
|
||||
createdWorktree: createdWorktreeForCleanup,
|
||||
createdAgentId,
|
||||
});
|
||||
if (initialAgentWorkspaceId) {
|
||||
this.workspacesAwaitingInitialAgent.delete(initialAgentWorkspaceId);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(initialAgentWorkspaceId);
|
||||
}
|
||||
const wireError = toWorktreeWireError(error);
|
||||
this.sessionLogger.error({ err: error }, "Failed to create agent");
|
||||
if (requestId) {
|
||||
@@ -4064,7 +4069,8 @@ export class Session {
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
archiveWorkspaceRecord: (workspaceId, options) =>
|
||||
this.archiveWorkspaceRecord(workspaceId, options),
|
||||
emit: (message) => this.emit(message),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
@@ -4901,15 +4907,20 @@ export class Session {
|
||||
}));
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const archiveTimestamp = archivedAt ?? new Date().toISOString();
|
||||
private async archiveWorkspaceRecord(
|
||||
workspaceId: string,
|
||||
options?: { archivedAt?: string; releaseBacking?: boolean },
|
||||
): Promise<void> {
|
||||
const archiveTimestamp = options?.archivedAt ?? new Date().toISOString();
|
||||
const currentWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||
const existingWorkspace = currentWorkspace?.runtime
|
||||
? await (async () => {
|
||||
if (!this.workspaceRuntime) {
|
||||
throw new Error(`Workspace runtime is not available: ${workspaceId}`);
|
||||
}
|
||||
await this.workspaceRuntime.archive(workspaceId);
|
||||
await this.workspaceRuntime.archive(workspaceId, {
|
||||
releaseBacking: options?.releaseBacking,
|
||||
});
|
||||
return currentWorkspace;
|
||||
})()
|
||||
: await archivePersistedWorkspaceRecord({
|
||||
@@ -5015,7 +5026,7 @@ export class Session {
|
||||
: null;
|
||||
const nextWorkspace = this.applyOptimisticWorkspaceStatus(
|
||||
filteredWorkspace,
|
||||
options?.optimisticStatus,
|
||||
this.resolveWorkspaceUpdateOptimisticStatus(workspaceId, options),
|
||||
);
|
||||
const lastEmitted = subscription.lastEmittedByWorkspaceId.get(workspaceId);
|
||||
if (
|
||||
@@ -5068,6 +5079,14 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveWorkspaceUpdateOptimisticStatus(
|
||||
workspaceId: string,
|
||||
options: WorkspaceUpdateOptions | undefined,
|
||||
): WorkspaceDescriptorPayload["status"] | undefined {
|
||||
if (options?.optimisticStatus) return options.optimisticStatus;
|
||||
return this.workspacesAwaitingInitialAgent.has(workspaceId) ? "running" : undefined;
|
||||
}
|
||||
|
||||
private applyOptimisticWorkspaceStatus(
|
||||
workspace: WorkspaceDescriptorPayload | null,
|
||||
optimisticStatus: WorkspaceDescriptorPayload["status"] | undefined,
|
||||
@@ -6329,7 +6348,8 @@ export class Session {
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
archiveWorkspaceRecord: (workspaceId, options) =>
|
||||
this.archiveWorkspaceRecord(workspaceId, options),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
|
||||
@@ -9472,11 +9472,38 @@ test("workspace create emits through a matching workspace subscription", async (
|
||||
firstAgentContext: { prompt: "Implement the requested change" },
|
||||
});
|
||||
|
||||
const createdWorkspace = Array.from(workspaces.values())[0];
|
||||
expect(createdWorkspace).toBeDefined();
|
||||
await mutationListener?.({
|
||||
kind: "upsert",
|
||||
workspaceId: createdWorkspace.workspaceId,
|
||||
workspace: createdWorkspace,
|
||||
});
|
||||
|
||||
const statuses = filterByType(emitted, "workspace_update").flatMap((message) =>
|
||||
message.payload.kind === "upsert" ? [message.payload.workspace.status] : [],
|
||||
);
|
||||
expect(statuses).toContain("running");
|
||||
expect(statuses).not.toContain("done");
|
||||
|
||||
emitted.length = 0;
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-match-agent-failure",
|
||||
workspaceId: createdWorkspace.workspaceId,
|
||||
config: { provider: "unknown-provider", cwd: createdWorkspace.cwd },
|
||||
initialPrompt: "This agent cannot be created.",
|
||||
attachments: [],
|
||||
});
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_create_failed",
|
||||
requestId: "req-create-match-agent-failure",
|
||||
});
|
||||
const failureStatuses = filterByType(emitted, "workspace_update").flatMap((message) =>
|
||||
message.payload.kind === "upsert" ? [message.payload.workspace.status] : [],
|
||||
);
|
||||
expect(failureStatuses).toContain("done");
|
||||
expect(failureStatuses).not.toContain("running");
|
||||
});
|
||||
|
||||
test("workspace create stays out of a non-matching workspace subscription", async () => {
|
||||
|
||||
@@ -196,6 +196,75 @@ function assertArchiveResult(
|
||||
}
|
||||
|
||||
describe("archiveByScope", () => {
|
||||
test("an external runtime never exposes its host source checkout as removable backing", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const sourceWorktree = await createPaseoOwnedWorktree(
|
||||
repoDir,
|
||||
paseoHome,
|
||||
"external-source-decoy",
|
||||
);
|
||||
const workspaceId = "ws-external-source-decoy";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{
|
||||
workspaceId,
|
||||
cwd: "/runtime/workspace",
|
||||
hostVisiblePath: sourceWorktree.worktreePath,
|
||||
kind: "worktree",
|
||||
runtimeId: "external-sandbox",
|
||||
worktreeRoot: sourceWorktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: repoDir,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-external-source-decoy",
|
||||
releaseBacking: true,
|
||||
});
|
||||
|
||||
expect(result.removedDirectory).toBe(false);
|
||||
expect(existsSync(sourceWorktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("host-invisible runtimes sharing a public cwd release independently", async () => {
|
||||
const { tempDir } = createGitRepo();
|
||||
const workspaceId = "ws-host-invisible-a";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
activeWorkspaces: [
|
||||
{ workspaceId, cwd: "/workspace", kind: "git", runtimeId: "remote-a" },
|
||||
{
|
||||
workspaceId: "ws-host-invisible-b",
|
||||
cwd: "/workspace",
|
||||
kind: "git",
|
||||
runtimeId: "remote-b",
|
||||
},
|
||||
],
|
||||
});
|
||||
const archiveWorkspaceRecord = deps.archiveWorkspaceRecord;
|
||||
const releases: boolean[] = [];
|
||||
deps.archiveWorkspaceRecord = async (targetWorkspaceId, options) => {
|
||||
releases.push(options?.releaseBacking === true);
|
||||
await archiveWorkspaceRecord(targetWorkspaceId, options);
|
||||
};
|
||||
|
||||
await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-host-invisible-release",
|
||||
releaseBacking: true,
|
||||
});
|
||||
|
||||
expect(releases).toEqual([true]);
|
||||
expect(deps.activeWorkspaces.map((workspace) => workspace.workspaceId)).toContain(
|
||||
"ws-host-invisible-b",
|
||||
);
|
||||
});
|
||||
|
||||
test("workspace scope archives the record and removes the directory on last reference", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
@@ -694,7 +763,15 @@ describe("archiveByScope", () => {
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [
|
||||
{ workspaceId: targetWorkspaceId, cwd: worktree.worktreePath, kind: "worktree" },
|
||||
{
|
||||
workspaceId: targetWorkspaceId,
|
||||
cwd: worktree.worktreePath,
|
||||
kind: "worktree",
|
||||
runtimeId: "worktree",
|
||||
worktreeRoot: worktree.worktreePath,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: repoDir,
|
||||
},
|
||||
],
|
||||
});
|
||||
deps.agentManager = {
|
||||
@@ -724,17 +801,18 @@ describe("archiveByScope", () => {
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
|
||||
requestId: "req-snapshot-scope",
|
||||
releaseBacking: true,
|
||||
});
|
||||
|
||||
assertArchiveResult(result, {
|
||||
archivedWorkspaceIds: [targetWorkspaceId],
|
||||
removedDirectory: true,
|
||||
removedDirectory: false,
|
||||
});
|
||||
expect(result.archivedAgentIds).toContain(liveAgentId);
|
||||
expect(result.archivedAgentIds).toContain(targetStoredAgentId);
|
||||
expect(result.archivedAgentIds).not.toContain(otherStoredAgentId);
|
||||
expect(deps.archivedSnapshotIds).toEqual([targetStoredAgentId]);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("archives the durable snapshot when an observed live agent closes before teardown", async () => {
|
||||
|
||||
@@ -44,7 +44,10 @@ export interface ArchiveDependencies {
|
||||
// reject ambiguous same-cwd matches when archiving by path without an explicit
|
||||
// workspaceId.
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
archiveWorkspaceRecord: (
|
||||
workspaceId: string,
|
||||
options?: { releaseBacking?: boolean },
|
||||
) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
@@ -72,6 +75,7 @@ export interface ArchiveResult {
|
||||
export interface ArchiveByScopeRequest {
|
||||
scope: ArchiveScope;
|
||||
requestId: string;
|
||||
releaseBacking?: boolean;
|
||||
}
|
||||
|
||||
export async function requireActiveWorkspaceForArchive(
|
||||
@@ -96,7 +100,8 @@ interface BackingDirectory {
|
||||
|
||||
interface ArchiveTarget {
|
||||
backing: BackingDirectory | null;
|
||||
preserveBackingDirectory: boolean;
|
||||
runtimeManaged: boolean;
|
||||
runtimeReferencePath: string | null;
|
||||
teardownTargets: Array<{ workspaceId: string | null; cwd: string }>;
|
||||
setupWorkspaceIds: string[];
|
||||
workspaceIds: string[];
|
||||
@@ -154,10 +159,21 @@ async function archiveByScopeWithPriority(
|
||||
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds);
|
||||
}
|
||||
|
||||
const releaseBacking = request.releaseBacking ?? !target.runtimeManaged;
|
||||
const releaseRuntimeBacking =
|
||||
releaseBacking &&
|
||||
(target.runtimeReferencePath === null ||
|
||||
(await isDirectoryUnreferenced(
|
||||
await dependencies.listActiveWorkspaces(),
|
||||
target.runtimeReferencePath,
|
||||
new Set(targetWorkspaceIds),
|
||||
dependencies,
|
||||
)));
|
||||
const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords(
|
||||
dependencies,
|
||||
targetWorkspaceIds,
|
||||
request.requestId,
|
||||
releaseRuntimeBacking,
|
||||
);
|
||||
|
||||
if (target.backing?.mainRepoRoot) {
|
||||
@@ -174,7 +190,7 @@ async function archiveByScopeWithPriority(
|
||||
}
|
||||
}
|
||||
|
||||
if (target.backing !== null) {
|
||||
if (releaseBacking && target.backing !== null) {
|
||||
removedDirectory = await maybeRemoveDirectory(
|
||||
dependencies,
|
||||
request,
|
||||
@@ -214,21 +230,18 @@ async function resolveArchiveTarget(
|
||||
);
|
||||
return {
|
||||
backing: null,
|
||||
preserveBackingDirectory: false,
|
||||
runtimeManaged: false,
|
||||
runtimeReferencePath: null,
|
||||
teardownTargets: [],
|
||||
setupWorkspaceIds: [],
|
||||
workspaceIds: [],
|
||||
};
|
||||
}
|
||||
const isArchived = "archivedAt" in record && Boolean(record.archivedAt);
|
||||
const isRuntimeSelected =
|
||||
("runtime" in record && Boolean(record.runtime)) ||
|
||||
("runtimeId" in record && Boolean(record.runtimeId));
|
||||
return {
|
||||
backing: isRuntimeSelected
|
||||
? null
|
||||
: await resolveWorkspaceBackingDirectory(record, dependencies),
|
||||
preserveBackingDirectory: isRuntimeSelected,
|
||||
backing: await resolveWorkspaceBackingDirectory(record, dependencies),
|
||||
runtimeManaged: hasSelectedRuntime(record),
|
||||
runtimeReferencePath: hasSelectedRuntime(record) ? (record.hostVisiblePath ?? null) : null,
|
||||
teardownTargets: isArchived ? [] : [{ workspaceId, cwd: record.cwd }],
|
||||
setupWorkspaceIds: [workspaceId],
|
||||
workspaceIds: isArchived ? [] : [workspaceId],
|
||||
@@ -253,9 +266,11 @@ async function resolveArchiveTarget(
|
||||
...backing,
|
||||
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
|
||||
},
|
||||
preserveBackingDirectory: targetWorkspaces.some(
|
||||
(workspace) => workspace.runtimeId !== null && workspace.runtimeId !== undefined,
|
||||
),
|
||||
runtimeManaged: targetWorkspaces.some(hasSelectedRuntime),
|
||||
runtimeReferencePath:
|
||||
targetWorkspaces.find(
|
||||
(workspace) => hasSelectedRuntime(workspace) && workspace.hostVisiblePath,
|
||||
)?.hostVisiblePath ?? null,
|
||||
teardownTargets:
|
||||
targetWorkspaces.length > 0
|
||||
? targetWorkspaces.map((workspace) => ({
|
||||
@@ -293,6 +308,14 @@ async function resolveWorkspaceBackingDirectory(
|
||||
workspace: ActiveWorkspaceRef,
|
||||
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||
): Promise<BackingDirectory> {
|
||||
if (hasSelectedRuntime(workspace)) {
|
||||
return {
|
||||
path: resolve(workspace.hostVisiblePath ?? workspace.cwd),
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: workspace.mainRepoRoot ?? null,
|
||||
paseoWorktreesRoot: null,
|
||||
};
|
||||
}
|
||||
if (workspace.isPaseoOwnedWorktree && workspace.worktreeRoot && workspace.mainRepoRoot) {
|
||||
return {
|
||||
path: resolve(workspace.worktreeRoot),
|
||||
@@ -340,6 +363,7 @@ async function archiveTargetRecords(
|
||||
dependencies: ArchiveDependencies,
|
||||
targetWorkspaceIds: string[],
|
||||
requestId: string,
|
||||
releaseBacking: boolean,
|
||||
): Promise<{ archivedAgents: Set<string>; archivedWorkspaceIds: string[] }> {
|
||||
const archivedAgents = new Set<string>();
|
||||
const archivedWorkspaceIds: string[] = [];
|
||||
@@ -347,7 +371,7 @@ async function archiveTargetRecords(
|
||||
const results = await Promise.allSettled(
|
||||
targetWorkspaceIds.map(async (workspaceId) => {
|
||||
const agents = await archiveWorkspaceContents(dependencies, workspaceId);
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId, { releaseBacking });
|
||||
return { workspaceId, agents };
|
||||
}),
|
||||
);
|
||||
@@ -376,7 +400,7 @@ async function maybeRemoveDirectory(
|
||||
archivedWorkspaceIds: string[],
|
||||
): Promise<boolean> {
|
||||
const backing = target.backing;
|
||||
if (target.preserveBackingDirectory || !backing?.isPaseoOwnedWorktree) {
|
||||
if (!backing?.isPaseoOwnedWorktree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -442,6 +466,13 @@ async function maybeRemoveDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
function hasSelectedRuntime(workspace: ActiveWorkspaceRef): boolean {
|
||||
return (
|
||||
workspace.runtimeId != null ||
|
||||
("runtime" in workspace && Boolean((workspace as { runtime?: unknown }).runtime))
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueFilesystemPaths(paths: string[]): string[] {
|
||||
const unique: string[] = [];
|
||||
for (const candidate of paths) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolve } from "node:path";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { createWorkspaceGitDirectory } from "./workspace-git-directory.js";
|
||||
import type { WorkspaceGitWorkspace } from "./workspace-git-service.js";
|
||||
@@ -69,13 +70,14 @@ test("legacy Git addresses remain available for host-visible runtime placements"
|
||||
});
|
||||
|
||||
await expect(directory.resolve({ kind: "legacy", cwd })).resolves.toBe(legacy);
|
||||
expect(bindLegacy).toHaveBeenCalledWith(cwd);
|
||||
expect(bindLegacy).toHaveBeenCalledWith(resolve(cwd));
|
||||
});
|
||||
|
||||
test("selected Git addresses normalize both fields before registry lookup", async () => {
|
||||
const runtimeCwd = process.platform === "win32" ? "/workspace" : String.raw`C:\workspace`;
|
||||
const record = {
|
||||
workspaceId: "workspace-a",
|
||||
cwd: "/shared",
|
||||
cwd: runtimeCwd,
|
||||
runtime: { runtimeId: "command" },
|
||||
};
|
||||
const get = vi.fn(async () => record);
|
||||
@@ -90,9 +92,9 @@ test("selected Git addresses normalize both fields before registry lookup", asyn
|
||||
directory.resolve({
|
||||
kind: "selected",
|
||||
workspaceId: " workspace-a ",
|
||||
cwd: " /shared ",
|
||||
cwd: ` ${runtimeCwd} `,
|
||||
}),
|
||||
).resolves.toBe(selected);
|
||||
expect(get).toHaveBeenCalledWith("workspace-a");
|
||||
expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: "/shared" });
|
||||
expect(bindWorkspace).toHaveBeenCalledWith({ workspaceId: "workspace-a", cwd: runtimeCwd });
|
||||
});
|
||||
|
||||
@@ -39,14 +39,14 @@ export function createWorkspaceGitDirectory(options: {
|
||||
function bindRecord(
|
||||
record: Pick<PersistedWorkspaceRecord, "workspaceId" | "cwd" | "runtime">,
|
||||
): WorkspaceGitWorkspace {
|
||||
const cwd = resolve(record.cwd);
|
||||
const selected = record.runtime?.runtimeId !== undefined;
|
||||
const cwd = selected ? record.cwd.trim() : resolve(record.cwd);
|
||||
const existing = boundRecords.get(record.workspaceId);
|
||||
if (existing) {
|
||||
if (existing.cwd !== cwd)
|
||||
throw new Error(`Workspace Git binding changed cwd: ${record.workspaceId}`);
|
||||
return existing.workspaceGit;
|
||||
}
|
||||
const selected = record.runtime?.runtimeId !== undefined;
|
||||
const workspaceGit = selected
|
||||
? workspaceGitService.bindWorkspace({
|
||||
workspaceId: record.workspaceId,
|
||||
@@ -61,21 +61,22 @@ export function createWorkspaceGitDirectory(options: {
|
||||
bindRecord,
|
||||
getBound(workspaceId, cwd) {
|
||||
const bound = boundRecords.get(workspaceId);
|
||||
if (!bound || bound.cwd !== resolve(cwd)) {
|
||||
const addressedCwd = bound?.selected ? cwd.trim() : resolve(cwd);
|
||||
if (!bound || bound.cwd !== addressedCwd) {
|
||||
throw new Error(`Workspace Git is not bound: ${workspaceId}`);
|
||||
}
|
||||
return bound.workspaceGit;
|
||||
},
|
||||
getObservationBinding(workspaceId, cwd) {
|
||||
const normalizedCwd = resolve(cwd);
|
||||
const bound = boundRecords.get(workspaceId);
|
||||
if (!bound || bound.cwd !== normalizedCwd) {
|
||||
const addressedCwd = bound?.selected ? cwd.trim() : resolve(cwd);
|
||||
if (!bound || bound.cwd !== addressedCwd) {
|
||||
throw new Error(`Workspace Git is not bound: ${workspaceId}`);
|
||||
}
|
||||
return {
|
||||
address: bound.selected
|
||||
? { kind: "selected", workspaceId, cwd: normalizedCwd }
|
||||
: { kind: "legacy", cwd: normalizedCwd },
|
||||
? { kind: "selected", workspaceId, cwd: addressedCwd }
|
||||
: { kind: "legacy", cwd: addressedCwd },
|
||||
workspaceGit: bound.workspaceGit,
|
||||
};
|
||||
},
|
||||
@@ -89,10 +90,9 @@ export function createWorkspaceGitDirectory(options: {
|
||||
if (selectedCwd.length === 0) {
|
||||
throw new Error("cwd is required for selected workspace Git");
|
||||
}
|
||||
const cwd = resolve(selectedCwd);
|
||||
const record = await workspaceRegistry.get(workspaceId);
|
||||
if (!record) throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
if (resolve(record.cwd) !== cwd) {
|
||||
if (record.cwd.trim() !== selectedCwd) {
|
||||
throw new Error(`Workspace cwd does not match ${workspaceId}`);
|
||||
}
|
||||
return bindRecord(record);
|
||||
|
||||
@@ -116,6 +116,8 @@ export interface WorkspaceRuntimeDriver {
|
||||
listener: () => void,
|
||||
): Promise<{ unsubscribe(): Promise<void> }>;
|
||||
pause(workspaceId: WorkspaceId): Promise<void>;
|
||||
/** Release restorable backing owned by this driver after the runtime is paused. */
|
||||
releaseBacking?(workspaceId: WorkspaceId): Promise<void>;
|
||||
resume(workspaceId: WorkspaceId): Promise<WorkspaceDriverReady>;
|
||||
destroy(workspaceId: WorkspaceId): Promise<void>;
|
||||
reconcile?(workspaceIds: readonly WorkspaceId[]): Promise<void>;
|
||||
|
||||
@@ -145,7 +145,7 @@ export interface WorkspaceRuntimeService {
|
||||
requireHostVisiblePath(workspaceId: string): Promise<string>;
|
||||
pause(workspaceId: string): Promise<void>;
|
||||
resume(workspaceId: string): Promise<void>;
|
||||
archive(workspaceId: string): Promise<void>;
|
||||
archive(workspaceId: string, options?: { releaseBacking?: boolean }): Promise<void>;
|
||||
restore(workspaceId: string): Promise<void>;
|
||||
destroy(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ export function createService(
|
||||
unavailableFiles.delete(workspaceId);
|
||||
});
|
||||
},
|
||||
async archive(workspaceId) {
|
||||
async archive(workspaceId, archiveOptions) {
|
||||
await sequence(workspaceId, async () => {
|
||||
if (!records.archiveWorkspaceRecord) {
|
||||
throw new Error("Workspace runtime record store cannot archive workspaces");
|
||||
@@ -324,6 +324,9 @@ export function createService(
|
||||
const inspection = await driver.inspect(workspaceId);
|
||||
if (inspection.status === "ready") await runArchiveHooks(workspaceId, driver);
|
||||
await pauseWithDriver(workspaceId, driver);
|
||||
if (archiveOptions?.releaseBacking) {
|
||||
await driver.releaseBacking?.(workspaceId);
|
||||
}
|
||||
await records.archiveWorkspaceRecord(workspaceId);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { mkdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
@@ -21,6 +21,8 @@ import { createRuntimeStateStore } from "./runtime-state.js";
|
||||
import { writePaseoWorktreeFirstAgentBranchAutoNameMetadata } from "../../../utils/worktree-metadata.js";
|
||||
import { createExternalProcessEnv } from "../../paseo-env.js";
|
||||
import { createStringCommandShellEnv } from "../../../utils/string-command-shell.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
|
||||
interface WorktreeRuntimeState {
|
||||
workspaceId: string;
|
||||
@@ -29,6 +31,8 @@ interface WorktreeRuntimeState {
|
||||
worktreeRoot: string;
|
||||
lifecycle: "ready" | "paused";
|
||||
lifecycleEnvironment: Readonly<Record<string, string>>;
|
||||
branchName?: string;
|
||||
relativeCwd?: string;
|
||||
ownsWorktree?: boolean;
|
||||
}
|
||||
|
||||
@@ -162,6 +166,8 @@ export function createWorktreeRuntime(options: {
|
||||
PASEO_WORKTREE_PATH: worktree.worktreePath,
|
||||
PASEO_BRANCH_NAME: worktree.branchName,
|
||||
},
|
||||
branchName: worktree.branchName,
|
||||
...(input.placement.relativeCwd ? { relativeCwd: input.placement.relativeCwd } : {}),
|
||||
ownsWorktree: true,
|
||||
};
|
||||
await states.write(state);
|
||||
@@ -193,12 +199,41 @@ export function createWorktreeRuntime(options: {
|
||||
const state = await states.read(workspaceId);
|
||||
if (state?.lifecycle === "paused") return;
|
||||
if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`);
|
||||
await states.write({ ...state, lifecycle: "paused" });
|
||||
const branchName = state.ownsWorktree === false ? undefined : await currentBranch(state);
|
||||
await states.write({
|
||||
...state,
|
||||
lifecycle: "paused",
|
||||
...(branchName
|
||||
? {
|
||||
branchName,
|
||||
lifecycleEnvironment: {
|
||||
...state.lifecycleEnvironment,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
async releaseBacking(workspaceId) {
|
||||
const state = await states.read(workspaceId);
|
||||
if (!state) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`);
|
||||
if (state.lifecycle !== "paused") {
|
||||
throw new Error(`Workspace runtime worktree is not paused: ${workspaceId}`);
|
||||
}
|
||||
if (state.ownsWorktree === false) return;
|
||||
await deletePaseoWorktree({
|
||||
cwd: state.sourceRoot,
|
||||
worktreePath: state.worktreeRoot,
|
||||
teardownCwds: [],
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesRoot,
|
||||
});
|
||||
},
|
||||
async resume(workspaceId) {
|
||||
const current = await states.read(workspaceId);
|
||||
if (!current) throw new Error(`Workspace runtime worktree is missing: ${workspaceId}`);
|
||||
const state = { ...current, lifecycle: "ready" as const };
|
||||
const rematerialized = await rematerializeOwnedWorktree(current, options);
|
||||
const state = { ...rematerialized, lifecycle: "ready" as const };
|
||||
await states.write(state);
|
||||
return { state: publicState(state), placement: hostPlacement(state.root) };
|
||||
},
|
||||
@@ -241,10 +276,109 @@ function isWorktreeRuntimeState(
|
||||
(state.lifecycle === "ready" || state.lifecycle === "paused") &&
|
||||
!!state.lifecycleEnvironment &&
|
||||
typeof state.lifecycleEnvironment === "object" &&
|
||||
(state.branchName === undefined || typeof state.branchName === "string") &&
|
||||
(state.relativeCwd === undefined || typeof state.relativeCwd === "string") &&
|
||||
(state.ownsWorktree === undefined || typeof state.ownsWorktree === "boolean")
|
||||
);
|
||||
}
|
||||
|
||||
async function currentBranch(state: WorktreeRuntimeState): Promise<string | undefined> {
|
||||
const { stdout } = await runGitCommand(["branch", "--show-current"], {
|
||||
cwd: state.worktreeRoot,
|
||||
});
|
||||
const branchName = stdout.trim();
|
||||
if (!branchName) {
|
||||
throw new Error(`Workspace runtime worktree has no current branch: ${state.workspaceId}`);
|
||||
}
|
||||
return branchName;
|
||||
}
|
||||
|
||||
async function rematerializeOwnedWorktree(
|
||||
state: WorktreeRuntimeState,
|
||||
options: { paseoHome: string; worktreesRoot?: string },
|
||||
): Promise<WorktreeRuntimeState> {
|
||||
const existing = await validateExistingWorktree(state);
|
||||
if (existing) return existing;
|
||||
if (state.ownsWorktree === false) {
|
||||
throw new Error(`Workspace runtime worktree is missing: ${state.workspaceId}`);
|
||||
}
|
||||
const branchName = state.branchName ?? state.lifecycleEnvironment.PASEO_BRANCH_NAME;
|
||||
if (!branchName) {
|
||||
throw new Error(`Workspace runtime worktree has no restorable branch: ${state.workspaceId}`);
|
||||
}
|
||||
await mkdir(path.dirname(state.worktreeRoot), { recursive: true });
|
||||
await runGitCommand(["worktree", "add", state.worktreeRoot, branchName], {
|
||||
cwd: state.sourceRoot,
|
||||
timeout: 120_000,
|
||||
});
|
||||
try {
|
||||
const relativeCwd = state.relativeCwd ?? path.relative(state.worktreeRoot, state.root);
|
||||
const root = await resolveRuntimeCwd(state.worktreeRoot, relativeCwd || undefined).catch(
|
||||
(error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw new Error("Selected project directory is missing from the worktree", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
root,
|
||||
branchName,
|
||||
...(relativeCwd ? { relativeCwd } : {}),
|
||||
lifecycleEnvironment: {
|
||||
...state.lifecycleEnvironment,
|
||||
PASEO_WORKTREE_PATH: state.worktreeRoot,
|
||||
PASEO_BRANCH_NAME: branchName,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await deletePaseoWorktree({
|
||||
cwd: state.sourceRoot,
|
||||
worktreePath: state.worktreeRoot,
|
||||
teardownCwds: [],
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesBaseRoot: options.worktreesRoot,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateExistingWorktree(
|
||||
state: WorktreeRuntimeState,
|
||||
): Promise<WorktreeRuntimeState | null> {
|
||||
try {
|
||||
if (!(await stat(state.worktreeRoot)).isDirectory()) return null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
const { exitCode, stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
cwd: state.worktreeRoot,
|
||||
acceptExitCodes: [0, 128],
|
||||
});
|
||||
if (exitCode !== 0 || !createRealpathAwarePathMatcher(state.worktreeRoot)(stdout.trim())) {
|
||||
throw new Error(`Workspace runtime worktree path is occupied: ${state.worktreeRoot}`);
|
||||
}
|
||||
const branchName = await currentBranch(state);
|
||||
const expectedBranchName = state.branchName ?? state.lifecycleEnvironment.PASEO_BRANCH_NAME;
|
||||
if (!expectedBranchName || branchName !== expectedBranchName) {
|
||||
throw new Error(
|
||||
`Workspace runtime worktree branch changed: expected ${expectedBranchName ?? "unknown"}, received ${branchName}`,
|
||||
);
|
||||
}
|
||||
const relativeCwd = state.relativeCwd ?? path.relative(state.worktreeRoot, state.root);
|
||||
try {
|
||||
const root = await resolveRuntimeCwd(state.worktreeRoot, relativeCwd || undefined);
|
||||
if ((await stat(root)).isDirectory()) return { ...state, root };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
throw new Error(`Selected project directory is missing from the worktree: ${state.root}`);
|
||||
}
|
||||
|
||||
function hostPlacement(root: string) {
|
||||
return { cwd: root, hostVisiblePath: root };
|
||||
}
|
||||
|
||||
@@ -442,16 +442,20 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
fixture.service.archive(fixture.workspaceId),
|
||||
fixture.service.archive(fixture.workspaceId),
|
||||
fixture.service.archive(fixture.workspaceId, { releaseBacking: true }),
|
||||
fixture.service.archive(fixture.workspaceId, { releaseBacking: true }),
|
||||
]);
|
||||
expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(true);
|
||||
if (runtimeId === "worktree") {
|
||||
expect(existsSync(runtimeRoot)).toBe(false);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fixture.service.restore(fixture.workspaceId),
|
||||
fixture.service.restore(fixture.workspaceId),
|
||||
]);
|
||||
expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false);
|
||||
expect(existsSync(runtimeRoot)).toBe(true);
|
||||
await writeFile(path.join(runtimeRoot, "dirty-untracked.txt"), "observed\n");
|
||||
await expect(restoredChange).resolves.toEqual(["dirty-untracked.txt"]);
|
||||
await subscription.unsubscribe();
|
||||
@@ -471,9 +475,11 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
|
||||
reconstructed.restore(fixture.workspaceId),
|
||||
]);
|
||||
expect(fixture.archivedWorkspaceIds.has(fixture.workspaceId)).toBe(false);
|
||||
expect(await readFile(path.join(runtimeRoot, "dirty-untracked.txt"), "utf8")).toBe(
|
||||
"observed\n",
|
||||
);
|
||||
if (runtimeId !== "worktree") {
|
||||
expect(await readFile(path.join(runtimeRoot, "dirty-untracked.txt"), "utf8")).toBe(
|
||||
"observed\n",
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
reconstructed.destroy(fixture.workspaceId),
|
||||
@@ -626,6 +632,127 @@ posixDescribe.each(runtimeContractIds)("%s runtime public contract", (runtimeId)
|
||||
});
|
||||
});
|
||||
|
||||
posixDescribe("worktree exact restore", () => {
|
||||
test("rejects a preserved worktree whose branch changed", async () => {
|
||||
const fixture = await createFixture("worktree", { lifecycleRecords: true });
|
||||
await fixture.service.create(fixture.createInput);
|
||||
const runtimeRoot = fixture.runtimeRoot();
|
||||
await fixture.service.archive(fixture.workspaceId);
|
||||
execFileSync("git", ["switch", "-c", "unexpected-branch"], {
|
||||
cwd: runtimeRoot,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(
|
||||
"Workspace runtime worktree branch changed: expected runtime-branch, received unexpected-branch",
|
||||
);
|
||||
|
||||
execFileSync("git", ["switch", "runtime-branch"], { cwd: runtimeRoot, stdio: "pipe" });
|
||||
await fixture.service.restore(fixture.workspaceId);
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
|
||||
test("rejects a selected subdirectory replaced by an escaping symlink", async () => {
|
||||
const fixture = await createFixture("worktree", { lifecycleRecords: true });
|
||||
await mkdir(path.join(fixture.repo, "nested"));
|
||||
await writeFile(path.join(fixture.repo, "nested", "file.txt"), "nested\n");
|
||||
execFileSync("git", ["add", "."], { cwd: fixture.repo, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], {
|
||||
cwd: fixture.repo,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await fixture.service.create({
|
||||
...fixture.createInput,
|
||||
placement: {
|
||||
...(fixture.createInput.placement as Extract<
|
||||
CreateWorkspaceInput["placement"],
|
||||
{ kind: "branch" }
|
||||
>),
|
||||
relativeCwd: "nested",
|
||||
},
|
||||
});
|
||||
const runtimeRoot = fixture.runtimeRoot();
|
||||
await fixture.service.archive(fixture.workspaceId);
|
||||
await rm(path.join(runtimeRoot, "nested"), { recursive: true });
|
||||
await symlink(fixture.root, path.join(runtimeRoot, "nested"));
|
||||
|
||||
await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow();
|
||||
expect(await realpath(path.join(runtimeRoot, "nested"))).toBe(await realpath(fixture.root));
|
||||
});
|
||||
|
||||
test("fails closed when the saved path is occupied", async () => {
|
||||
const fixture = await createFixture("worktree", { lifecycleRecords: true });
|
||||
await fixture.service.create(fixture.createInput);
|
||||
const runtimeRoot = fixture.runtimeRoot();
|
||||
await fixture.service.archive(fixture.workspaceId, { releaseBacking: true });
|
||||
await mkdir(runtimeRoot, { recursive: true });
|
||||
|
||||
await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(
|
||||
`Workspace runtime worktree path is occupied: ${runtimeRoot}`,
|
||||
);
|
||||
expect(listLinkedWorktrees(fixture.repo)).not.toContain(runtimeRoot);
|
||||
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
await fixture.service.restore(fixture.workspaceId);
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
|
||||
test("fails closed when the saved branch is checked out elsewhere", async () => {
|
||||
const fixture = await createFixture("worktree", { lifecycleRecords: true });
|
||||
await fixture.service.create(fixture.createInput);
|
||||
const runtimeRoot = fixture.runtimeRoot();
|
||||
await fixture.service.archive(fixture.workspaceId, { releaseBacking: true });
|
||||
const otherRoot = path.join(fixture.root, "branch-owner");
|
||||
execFileSync("git", ["worktree", "add", otherRoot, "runtime-branch"], {
|
||||
cwd: fixture.repo,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow();
|
||||
expect(existsSync(runtimeRoot)).toBe(false);
|
||||
|
||||
execFileSync("git", ["worktree", "remove", "--force", otherRoot], {
|
||||
cwd: fixture.repo,
|
||||
stdio: "pipe",
|
||||
});
|
||||
await fixture.service.restore(fixture.workspaceId);
|
||||
await fixture.service.destroy(fixture.workspaceId);
|
||||
});
|
||||
|
||||
test("removes a partial restore when the selected subdirectory is missing", async () => {
|
||||
const fixture = await createFixture("worktree", { lifecycleRecords: true });
|
||||
await mkdir(path.join(fixture.repo, "nested"));
|
||||
await writeFile(path.join(fixture.repo, "nested", "file.txt"), "nested\n");
|
||||
execFileSync("git", ["add", "."], { cwd: fixture.repo, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested"], {
|
||||
cwd: fixture.repo,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const createInput: CreateWorkspaceInput = {
|
||||
...fixture.createInput,
|
||||
placement: {
|
||||
...(fixture.createInput.placement as Extract<
|
||||
CreateWorkspaceInput["placement"],
|
||||
{ kind: "branch" }
|
||||
>),
|
||||
relativeCwd: "nested",
|
||||
},
|
||||
};
|
||||
await fixture.service.create(createInput);
|
||||
const runtimeRoot = fixture.runtimeRoot();
|
||||
await fixture.service.archive(fixture.workspaceId, { releaseBacking: true });
|
||||
execFileSync("git", ["branch", "-f", "runtime-branch", "main~1"], {
|
||||
cwd: fixture.repo,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
await expect(fixture.service.restore(fixture.workspaceId)).rejects.toThrow(
|
||||
"Selected project directory is missing from the worktree",
|
||||
);
|
||||
expect(existsSync(runtimeRoot)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
posixDescribe("reconstruction placement recovery", () => {
|
||||
test("refreshes persisted compatibility cwd from driver inspect", async () => {
|
||||
const root = await temporaryRoot("reconcile-compatibility-cwd");
|
||||
|
||||
Reference in New Issue
Block a user