mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
feat(app): archive a workspace from its sidebar row and undo it
Hovering a workspace row on desktop shows an archive button next to the row menu. Archiving shows a toast with Undo and a Cmd/Ctrl+Z shortcut so a mis-click costs one keystroke instead of a trip through History. The daemon serializes archive and undo per workspace across every connection. An undo that lands while an archive is still tearing down aborts it at the next checkpoint and restores only the agents that archive took down; deleting the worktree directory is the point of no return, after which undo recreates the worktree.
This commit is contained in:
@@ -2540,16 +2540,20 @@ export class AgentManager {
|
||||
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
|
||||
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
|
||||
|
||||
await registry.upsert({
|
||||
const nextRecord: StoredAgentRecord = {
|
||||
...record,
|
||||
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
||||
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
||||
archivedAt: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
await registry.upsert(nextRecord);
|
||||
|
||||
if (this.getAgent(agentId)) {
|
||||
this.notifyAgentState(agentId);
|
||||
} else if (!nextRecord.internal) {
|
||||
// Same publication archiveSnapshot makes, so a stored-only agent reappears for every client.
|
||||
this.dispatchStoredAgentState(nextRecord);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -219,6 +219,7 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.base_branch.set.request": "workspace.write",
|
||||
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.request": ["workspace.manage", "hub.execute"],
|
||||
"workspace.archive.undo.request": ["workspace.manage", "hub.execute"],
|
||||
"workspace.script.list.request": "workspace.read",
|
||||
"workspace.script.start.request": "workspace.write",
|
||||
"workspace.script.stop.request": "workspace.write",
|
||||
@@ -462,6 +463,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.base_branch.set.response": "workspace.write",
|
||||
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
|
||||
"workspace.recovery.restore.response": ["workspace.manage", "hub.execute"],
|
||||
"workspace.archive.undo.response": ["workspace.manage", "hub.execute"],
|
||||
"workspace.script.list.response": "workspace.read",
|
||||
"workspace.script.start.response": "workspace.write",
|
||||
"workspace.script.stop.response": "workspace.write",
|
||||
|
||||
@@ -278,6 +278,10 @@ import {
|
||||
handleWorkspaceSetupRunRequest as handleWorkspaceSetupRunRequestMessage,
|
||||
} from "./worktree-session.js";
|
||||
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
||||
import {
|
||||
WorkspaceArchiveOperations,
|
||||
type WorkspaceArchiveUndoInput,
|
||||
} from "./workspace-archive-operations.js";
|
||||
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
|
||||
import { SessionAuthorization, type DaemonPermission } from "./authorization/index.js";
|
||||
|
||||
@@ -286,6 +290,12 @@ function resolveWorkspaceSetupRuntime(
|
||||
): WorkspaceSetupRuntime {
|
||||
return runtime ?? new WorkspaceSetupRuntime();
|
||||
}
|
||||
|
||||
function resolveWorkspaceArchiveOperations(
|
||||
operations: WorkspaceArchiveOperations | undefined,
|
||||
): WorkspaceArchiveOperations {
|
||||
return operations ?? new WorkspaceArchiveOperations();
|
||||
}
|
||||
import { WorktreeRequestError, toWorktreeWireError } from "./worktree-errors.js";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import {
|
||||
@@ -566,6 +576,8 @@ export interface SessionOptions {
|
||||
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
|
||||
workspaceSetupObservation?: WorkspaceSetupObservation;
|
||||
workspaceSetupRuntime?: WorkspaceSetupRuntime;
|
||||
/** Shared by every session so an undo from any connection reaches the archive it cancels. */
|
||||
workspaceArchiveOperations?: WorkspaceArchiveOperations;
|
||||
onBranchChanged?: (
|
||||
workspaceId: string,
|
||||
oldBranch: string | null,
|
||||
@@ -837,6 +849,7 @@ export class Session {
|
||||
private readonly workspaceSetupObservation: WorkspaceSetupObservation;
|
||||
private readonly unsubscribeSetupObservation: () => void;
|
||||
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
||||
private readonly workspaceArchiveOperations: WorkspaceArchiveOperations;
|
||||
private readonly workspaceGitObserver: WorkspaceGitObserverService;
|
||||
private readonly workspaceDirectory: WorkspaceDirectory;
|
||||
private readonly voiceSessions: VoiceSessions;
|
||||
@@ -902,6 +915,7 @@ export class Session {
|
||||
scriptRuntimeStore,
|
||||
workspaceSetupObservation,
|
||||
workspaceSetupRuntime,
|
||||
workspaceArchiveOperations,
|
||||
onBranchChanged,
|
||||
getDaemonTcpPort,
|
||||
getDaemonTcpHost,
|
||||
@@ -1249,6 +1263,7 @@ export class Session {
|
||||
},
|
||||
);
|
||||
this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime);
|
||||
this.workspaceArchiveOperations = resolveWorkspaceArchiveOperations(workspaceArchiveOperations);
|
||||
this.getDaemonTcpPort = getDaemonTcpPort ?? null;
|
||||
this.getDaemonTcpHost = getDaemonTcpHost ?? null;
|
||||
this.serviceProxyPublicBaseUrl = serviceProxyPublicBaseUrl ?? null;
|
||||
@@ -3306,6 +3321,8 @@ export class Session {
|
||||
return this.handleWorkspaceRecoveryInspectRequest(msg);
|
||||
case "workspace.recovery.restore.request":
|
||||
return this.handleWorkspaceRecoveryRestoreRequest(msg);
|
||||
case "workspace.archive.undo.request":
|
||||
return this.handleWorkspaceArchiveUndoRequest(msg);
|
||||
case "workspace.clear_attention.request":
|
||||
return this.handleWorkspaceClearAttentionRequest(msg);
|
||||
case "workspace.mark_unread.request":
|
||||
@@ -6180,6 +6197,10 @@ export class Session {
|
||||
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
||||
await this.workspaceRecovery.restore(workspaceId);
|
||||
await this.publishRecoveredWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private async publishRecoveredWorkspace(workspaceId: string): Promise<void> {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
|
||||
@@ -7791,36 +7812,55 @@ export class Session {
|
||||
throw new Error(`Workspace not found: ${request.workspaceId}`);
|
||||
}
|
||||
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
requestId: request.requestId,
|
||||
},
|
||||
const outcome = await this.workspaceArchiveOperations.archive(
|
||||
existing.workspaceId,
|
||||
(signal) =>
|
||||
archiveByScope(
|
||||
{
|
||||
paseoHome: this.paseoHome,
|
||||
paseoWorktreesBaseRoot: this.worktreesRoot,
|
||||
github: this.github,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
this.emitWorkspaceUpdatesForWorkspaceIds(workspaceIds),
|
||||
markWorkspaceArchiving: (workspaceIds, archivingAt) =>
|
||||
this.markWorkspaceArchiving(workspaceIds, archivingAt),
|
||||
clearWorkspaceArchiving: (workspaceIds) => this.clearWorkspaceArchiving(workspaceIds),
|
||||
assertWorkspaceAutomationAllowed: (workspaceId) =>
|
||||
assertWorkspaceAutomationAllowedForWorkspace(this.workspaceRegistry, workspaceId),
|
||||
killTerminalsForWorkspace: (workspaceId) =>
|
||||
this.terminalController.killTerminalsForWorkspace(workspaceId),
|
||||
stopWorkspaceSetup: (workspaceId) => this.workspaceSetupRuntime.stop(workspaceId),
|
||||
sessionLogger: this.sessionLogger,
|
||||
},
|
||||
{
|
||||
scope: { kind: "workspace", workspaceId: existing.workspaceId },
|
||||
requestId: request.requestId,
|
||||
signal,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (outcome.kind === "canceled") {
|
||||
this.emit({
|
||||
type: "archive_workspace_response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
archivedAt: null,
|
||||
error: "Workspace archive was undone",
|
||||
canceled: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const archivedWorkspace = await this.workspaceRegistry.get(request.workspaceId);
|
||||
const archivedAt = archivedWorkspace?.archivedAt ?? new Date().toISOString();
|
||||
this.emit({
|
||||
@@ -7850,6 +7890,69 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceArchiveUndoRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.archive.undo.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.workspaceArchiveOperations.undo(request.workspaceId, (input) =>
|
||||
this.restoreArchivedWorkspaceAndAgents(request.workspaceId, input),
|
||||
);
|
||||
this.emit({
|
||||
type: "workspace.archive.undo.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, workspaceId: request.workspaceId, requestId: request.requestId },
|
||||
"session: workspace.archive.undo.request rejected",
|
||||
);
|
||||
this.emit({
|
||||
type: "workspace.archive.undo.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
accepted: false,
|
||||
error: getErrorMessageOr(error, "Failed to undo the workspace archive"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Runs on the archive lane after any in-flight archive has stopped, so the record reflects how
|
||||
// far it got: still active when it was canceled early, archived (directory kept or removed)
|
||||
// otherwise.
|
||||
private async restoreArchivedWorkspaceAndAgents(
|
||||
workspaceId: string,
|
||||
input: WorkspaceArchiveUndoInput,
|
||||
): Promise<void> {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
if (workspace.archivedAt) {
|
||||
await this.workspaceRecovery.restore(workspaceId);
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
input.agentIds.map((agentId) =>
|
||||
unarchiveAgentState(this.agentStorage, this.agentManager, agentId),
|
||||
),
|
||||
);
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === "rejected") {
|
||||
this.sessionLogger.warn(
|
||||
{ err: result.reason, workspaceId, agentId: input.agentIds[index] },
|
||||
"Failed to unarchive agent while undoing workspace archive",
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.publishRecoveredWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private async handleWorkspaceClearAttentionRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.clear_attention.request" }>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
import type { HubRelationshipManagement } from "./hub/relationship-controller.js";
|
||||
import { WorkspaceSetupObservation } from "./workspace-setup-observation.js";
|
||||
import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js";
|
||||
import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js";
|
||||
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
|
||||
import type { AgentProvider } from "./agent/agent-sdk-types.js";
|
||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
@@ -609,6 +610,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
|
||||
private readonly workspaceSetupObservation = new WorkspaceSetupObservation();
|
||||
private readonly workspaceSetupRuntime: WorkspaceSetupRuntime;
|
||||
// One lane per workspace across every connection, so an undo from any client can cancel an
|
||||
// archive another connection started.
|
||||
private readonly workspaceArchiveOperations = new WorkspaceArchiveOperations();
|
||||
private readonly providerSnapshotManager: ProviderSnapshotManager;
|
||||
private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null;
|
||||
private onBranchChanged!:
|
||||
@@ -1697,6 +1701,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
scriptRuntimeStore: this.scriptRuntimeStore ?? undefined,
|
||||
workspaceSetupObservation: this.workspaceSetupObservation,
|
||||
workspaceSetupRuntime: this.workspaceSetupRuntime,
|
||||
workspaceArchiveOperations: this.workspaceArchiveOperations,
|
||||
onBranchChanged: this.onBranchChanged ?? undefined,
|
||||
getDaemonTcpPort: this.getDaemonTcpPort ?? undefined,
|
||||
getDaemonTcpHost: this.getDaemonTcpHost ?? undefined,
|
||||
@@ -2120,6 +2125,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
worktreeRestore: true,
|
||||
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
||||
workspaceRecovery: true,
|
||||
// COMPAT(workspaceArchiveUndo): added in v0.15.3, remove gate after 2027-05-06.
|
||||
workspaceArchiveUndo: true,
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
workspaceFileEditing: true,
|
||||
// COMPAT(fileByteWrites): added in v0.13.0, remove gate after 2027-04-20.
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { WorkspaceArchiveOperations } from "./workspace-archive-operations.js";
|
||||
import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
function deferred<T = void>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function archived(newlyArchivedAgentIds: string[]): ArchiveResult {
|
||||
return {
|
||||
archivedAgentIds: newlyArchivedAgentIds,
|
||||
newlyArchivedAgentIds,
|
||||
archivedWorkspaceIds: ["ws"],
|
||||
removedDirectory: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An archive that parks until released, then honours its abort signal the way archiveByScope's
|
||||
* checkpoints do: an aborted run reports the agents it already took down.
|
||||
*/
|
||||
function parkedArchive(agentIds: string[]) {
|
||||
const release = deferred();
|
||||
const started = deferred();
|
||||
let signal: AbortSignal | null = null;
|
||||
return {
|
||||
release: () => release.resolve(),
|
||||
started: started.promise,
|
||||
get aborted() {
|
||||
return signal?.aborted ?? false;
|
||||
},
|
||||
run: async (runSignal: AbortSignal) => {
|
||||
signal = runSignal;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
if (runSignal.aborted) throw new WorkspaceArchiveCanceledError(agentIds, false);
|
||||
return archived(agentIds);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkspaceArchiveOperations", () => {
|
||||
test("an undo during a running archive aborts it and restores the agents it took down", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const archive = parkedArchive(["agent-a", "agent-b"]);
|
||||
const restored: string[][] = [];
|
||||
|
||||
const archiveDone = operations.archive("ws", archive.run);
|
||||
await archive.started;
|
||||
const undoDone = operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
});
|
||||
|
||||
expect(archive.aborted).toBe(true);
|
||||
expect(restored).toEqual([]);
|
||||
archive.release();
|
||||
|
||||
await expect(archiveDone).resolves.toEqual({ kind: "canceled" });
|
||||
await undoDone;
|
||||
expect(restored).toEqual([["agent-a", "agent-b"]]);
|
||||
});
|
||||
|
||||
test("an undo after the archive finished restores the agents that archive took down", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
|
||||
const restored: string[][] = [];
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
});
|
||||
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
});
|
||||
|
||||
test("restore runs only after the aborted archive has stopped", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const archive = parkedArchive([]);
|
||||
const order: string[] = [];
|
||||
|
||||
const archiveDone = operations
|
||||
.archive("ws", async (signal) => {
|
||||
try {
|
||||
return await archive.run(signal);
|
||||
} finally {
|
||||
order.push("archive stopped");
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await archive.started;
|
||||
const undoDone = operations.undo("ws", async () => {
|
||||
order.push("restore");
|
||||
});
|
||||
archive.release();
|
||||
await Promise.all([archiveDone, undoDone]);
|
||||
|
||||
expect(order).toEqual(["archive stopped", "restore"]);
|
||||
});
|
||||
|
||||
test("a second archive while one runs joins it instead of archiving twice", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const archive = parkedArchive(["agent-a"]);
|
||||
let runs = 0;
|
||||
const run = (signal: AbortSignal) => {
|
||||
runs += 1;
|
||||
return archive.run(signal);
|
||||
};
|
||||
|
||||
const first = operations.archive("ws", run);
|
||||
const second = operations.archive("ws", run);
|
||||
archive.release();
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([
|
||||
{ kind: "archived", result: archived(["agent-a"]) },
|
||||
{ kind: "archived", result: archived(["agent-a"]) },
|
||||
]);
|
||||
expect(runs).toBe(1);
|
||||
});
|
||||
|
||||
test("an archive requested during an undo runs after the undo, and wins", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
|
||||
const restoreGate = deferred();
|
||||
const order: string[] = [];
|
||||
const undoDone = operations.undo("ws", async () => {
|
||||
await restoreGate.promise;
|
||||
order.push("restored");
|
||||
});
|
||||
const rearchive = operations.archive("ws", async () => {
|
||||
order.push("archived again");
|
||||
return archived(["agent-a"]);
|
||||
});
|
||||
|
||||
restoreGate.resolve();
|
||||
await undoDone;
|
||||
await expect(rearchive).resolves.toMatchObject({ kind: "archived" });
|
||||
expect(order).toEqual(["restored", "archived again"]);
|
||||
});
|
||||
|
||||
test("archive, undo, archive in quick succession leaves the workspace archived", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const first = parkedArchive(["agent-a"]);
|
||||
const restored: string[][] = [];
|
||||
|
||||
const firstDone = operations.archive("ws", first.run);
|
||||
await first.started;
|
||||
const undoDone = operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
});
|
||||
// The re-archive must not join the archive the undo just aborted.
|
||||
const secondDone = operations.archive("ws", async () => archived(["agent-a"]));
|
||||
first.release();
|
||||
|
||||
await expect(firstDone).resolves.toEqual({ kind: "canceled" });
|
||||
await undoDone;
|
||||
await expect(secondDone).resolves.toMatchObject({ kind: "archived" });
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
});
|
||||
|
||||
test("a second undo while one runs joins it", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
const restoreGate = deferred();
|
||||
let restores = 0;
|
||||
const restore = async () => {
|
||||
restores += 1;
|
||||
await restoreGate.promise;
|
||||
};
|
||||
|
||||
const first = operations.undo("ws", restore);
|
||||
const second = operations.undo("ws", restore);
|
||||
restoreGate.resolve();
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(restores).toBe(1);
|
||||
});
|
||||
|
||||
test("a failed restore keeps the agents for a retry", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
|
||||
await expect(
|
||||
operations.undo("ws", async () => {
|
||||
throw new Error("worktree recreation failed");
|
||||
}),
|
||||
).rejects.toThrow("worktree recreation failed");
|
||||
|
||||
const restored: string[][] = [];
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
});
|
||||
expect(restored).toEqual([["agent-a"]]);
|
||||
});
|
||||
|
||||
test("an undo restores the archive's agents once; a repeat undo restores none", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
const restored: string[][] = [];
|
||||
const restore = async ({ agentIds }: { agentIds: string[] }) => {
|
||||
restored.push(agentIds);
|
||||
};
|
||||
|
||||
await operations.undo("ws", restore);
|
||||
await operations.undo("ws", restore);
|
||||
|
||||
expect(restored).toEqual([["agent-a"], []]);
|
||||
});
|
||||
|
||||
test("an undo after the retention window restores the workspace without agents", async () => {
|
||||
let now = 0;
|
||||
const operations = new WorkspaceArchiveOperations({ undoRetentionMs: 1000, now: () => now });
|
||||
await operations.archive("ws", async () => archived(["agent-a"]));
|
||||
now = 1000;
|
||||
|
||||
const restored: string[][] = [];
|
||||
await operations.undo("ws", async ({ agentIds }) => {
|
||||
restored.push(agentIds);
|
||||
});
|
||||
expect(restored).toEqual([[]]);
|
||||
});
|
||||
|
||||
test("workspaces have independent lanes", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
const archive = parkedArchive(["agent-a"]);
|
||||
|
||||
const parked = operations.archive("ws-a", archive.run);
|
||||
await archive.started;
|
||||
await expect(operations.archive("ws-b", async () => archived(["agent-b"]))).resolves.toEqual({
|
||||
kind: "archived",
|
||||
result: archived(["agent-b"]),
|
||||
});
|
||||
await operations.undo("ws-b", async () => {});
|
||||
|
||||
expect(archive.aborted).toBe(false);
|
||||
archive.release();
|
||||
await expect(parked).resolves.toMatchObject({ kind: "archived" });
|
||||
});
|
||||
|
||||
test("a failed archive frees the lane for the next request", async () => {
|
||||
const operations = new WorkspaceArchiveOperations();
|
||||
await expect(
|
||||
operations.archive("ws", async () => {
|
||||
throw new Error("registry unavailable");
|
||||
}),
|
||||
).rejects.toThrow("registry unavailable");
|
||||
|
||||
await expect(operations.archive("ws", async () => archived([]))).resolves.toMatchObject({
|
||||
kind: "archived",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { type ArchiveResult, WorkspaceArchiveCanceledError } from "./workspace-archive-service.js";
|
||||
|
||||
export type WorkspaceArchiveOutcome =
|
||||
| { kind: "archived"; result: ArchiveResult }
|
||||
| { kind: "canceled" };
|
||||
|
||||
export interface WorkspaceArchiveUndoInput {
|
||||
/** Agents the undone archive took down. Empty when the daemon no longer remembers it. */
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
type LaneEntry =
|
||||
| { kind: "archiving"; controller: AbortController; done: Promise<WorkspaceArchiveOutcome> }
|
||||
| { kind: "undoing"; done: Promise<void> };
|
||||
|
||||
interface UndoableArchive {
|
||||
agentIds: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const DEFAULT_UNDO_RETENTION_MS = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* One lane per workspace for archive and its undo, shared by every session on the daemon.
|
||||
*
|
||||
* - An archive while one runs joins it.
|
||||
* - An undo while an archive runs aborts it, waits for it to stop, then restores.
|
||||
* - An archive while an undo runs waits for the undo, then archives.
|
||||
* - An undo while an undo runs joins it.
|
||||
*
|
||||
* Each call claims the lane synchronously, so whichever request arrives last decides the
|
||||
* workspace's final state no matter how the earlier work interleaves.
|
||||
*
|
||||
* The agents an archive took down are kept in memory for the undo window. After a daemon
|
||||
* restart, undo still restores the workspace but its agents stay archived.
|
||||
*/
|
||||
export class WorkspaceArchiveOperations {
|
||||
private readonly lanes = new Map<string, LaneEntry>();
|
||||
private readonly undoable = new Map<string, UndoableArchive>();
|
||||
private readonly undoRetentionMs: number;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: { undoRetentionMs?: number; now?: () => number } = {}) {
|
||||
this.undoRetentionMs = options.undoRetentionMs ?? DEFAULT_UNDO_RETENTION_MS;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
archive(
|
||||
workspaceId: string,
|
||||
run: (signal: AbortSignal) => Promise<ArchiveResult>,
|
||||
): Promise<WorkspaceArchiveOutcome> {
|
||||
const current = this.lanes.get(workspaceId);
|
||||
if (current?.kind === "archiving") {
|
||||
return current.done;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const done = (async (): Promise<WorkspaceArchiveOutcome> => {
|
||||
await settled(current?.done);
|
||||
try {
|
||||
const result = await run(controller.signal);
|
||||
this.retainUndoable(workspaceId, result.newlyArchivedAgentIds);
|
||||
return { kind: "archived", result };
|
||||
} catch (error) {
|
||||
if (!(error instanceof WorkspaceArchiveCanceledError)) throw error;
|
||||
this.retainUndoable(workspaceId, error.newlyArchivedAgentIds);
|
||||
return { kind: "canceled" };
|
||||
}
|
||||
})();
|
||||
this.claim(workspaceId, { kind: "archiving", controller, done });
|
||||
return done;
|
||||
}
|
||||
|
||||
undo(
|
||||
workspaceId: string,
|
||||
restore: (input: WorkspaceArchiveUndoInput) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const current = this.lanes.get(workspaceId);
|
||||
if (current?.kind === "undoing") {
|
||||
return current.done;
|
||||
}
|
||||
current?.controller.abort();
|
||||
const done = (async () => {
|
||||
await settled(current?.done);
|
||||
const agentIds = this.takeUndoable(workspaceId);
|
||||
try {
|
||||
await restore({ agentIds });
|
||||
} catch (error) {
|
||||
// Keep the agents for a retry; the archive they came from is still the latest one.
|
||||
this.retainUndoable(workspaceId, agentIds);
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
this.claim(workspaceId, { kind: "undoing", done });
|
||||
return done;
|
||||
}
|
||||
|
||||
private claim(workspaceId: string, entry: LaneEntry): void {
|
||||
this.lanes.set(workspaceId, entry);
|
||||
const release = () => {
|
||||
if (this.lanes.get(workspaceId) === entry) this.lanes.delete(workspaceId);
|
||||
};
|
||||
void (entry.done as Promise<unknown>).then(release, release);
|
||||
}
|
||||
|
||||
private retainUndoable(workspaceId: string, agentIds: string[]): void {
|
||||
const now = this.now();
|
||||
for (const [id, entry] of this.undoable) {
|
||||
if (entry.expiresAt <= now) this.undoable.delete(id);
|
||||
}
|
||||
this.undoable.set(workspaceId, { agentIds, expiresAt: now + this.undoRetentionMs });
|
||||
}
|
||||
|
||||
private takeUndoable(workspaceId: string): string[] {
|
||||
const entry = this.undoable.get(workspaceId);
|
||||
this.undoable.delete(workspaceId);
|
||||
if (!entry || entry.expiresAt <= this.now()) return [];
|
||||
return entry.agentIds;
|
||||
}
|
||||
}
|
||||
|
||||
async function settled(promise: Promise<unknown> | undefined): Promise<void> {
|
||||
if (!promise) return;
|
||||
try {
|
||||
await promise;
|
||||
} catch {
|
||||
// The earlier request reports its own failure; this one only waits for the lane.
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ArchiveDependencies,
|
||||
type ArchiveResult,
|
||||
resolveWorkspaceIdAtPath,
|
||||
WorkspaceArchiveCanceledError,
|
||||
} from "./workspace-archive-service.js";
|
||||
import { WorkspaceAutomationBlockedError } from "./workspace-automation-gate.js";
|
||||
|
||||
@@ -1019,6 +1020,179 @@ describe("archiveByScope", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function gate(): {
|
||||
wait: Promise<void>;
|
||||
open: () => void;
|
||||
reached: Promise<void>;
|
||||
reach: () => void;
|
||||
} {
|
||||
let open!: () => void;
|
||||
let reach!: () => void;
|
||||
const wait = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
const reached = new Promise<void>((resolve) => {
|
||||
reach = resolve;
|
||||
});
|
||||
return { wait, open, reached, reach };
|
||||
}
|
||||
|
||||
async function canceledArchive(promise: Promise<unknown>): Promise<WorkspaceArchiveCanceledError> {
|
||||
const error = await promise.then(
|
||||
() => null,
|
||||
(reason: unknown) => reason,
|
||||
);
|
||||
if (!(error instanceof WorkspaceArchiveCanceledError)) {
|
||||
throw new Error(`Expected the archive to be canceled, got ${String(error)}`);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
describe("archiveByScope cancellation", () => {
|
||||
function storedAgents(
|
||||
workspaceId: string,
|
||||
agents: Array<{ id: string; archivedAt: string | null }>,
|
||||
) {
|
||||
return {
|
||||
listByWorkspace: async () =>
|
||||
agents.map((agent) => ({ ...agent, workspaceId })) as StoredAgentRecord[],
|
||||
} as Pick<AgentStorage, "listByWorkspace">;
|
||||
}
|
||||
|
||||
test("an abort while agents archive keeps the record and reports only the agents it took down", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-agents");
|
||||
const workspaceId = "ws-cancel-agents";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
deps.agentStorage = storedAgents(workspaceId, [
|
||||
{ id: "agent-active", archivedAt: null },
|
||||
{ id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
const agentArchive = gate();
|
||||
deps.agentManager.archiveSnapshot = vi.fn(async (agentId: string) => {
|
||||
agentArchive.reach();
|
||||
await agentArchive.wait;
|
||||
deps.archivedSnapshotIds.push(agentId);
|
||||
return {} as StoredAgentRecord;
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const archive = archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-cancel-agents",
|
||||
signal: controller.signal,
|
||||
});
|
||||
await agentArchive.reached;
|
||||
controller.abort();
|
||||
agentArchive.open();
|
||||
const error = await canceledArchive(archive);
|
||||
|
||||
expect({
|
||||
newlyArchivedAgentIds: error.newlyArchivedAgentIds,
|
||||
recordArchived: error.recordArchived,
|
||||
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
|
||||
directoryExists: existsSync(worktree.worktreePath),
|
||||
}).toEqual({
|
||||
newlyArchivedAgentIds: ["agent-active"],
|
||||
recordArchived: false,
|
||||
activeWorkspaceIds: [workspaceId],
|
||||
directoryExists: true,
|
||||
});
|
||||
// The archiving marker never outlives a canceled archive.
|
||||
expect(deps.clearWorkspaceArchiving).toHaveBeenCalledWith([workspaceId]);
|
||||
});
|
||||
|
||||
test("an abort after the record archived keeps the worktree directory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-record");
|
||||
const workspaceId = "ws-cancel-record";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
const recordArchive = gate();
|
||||
const archiveRecord = deps.archiveWorkspaceRecord;
|
||||
deps.archiveWorkspaceRecord = async (id: string) => {
|
||||
recordArchive.reach();
|
||||
await recordArchive.wait;
|
||||
await archiveRecord(id);
|
||||
};
|
||||
const controller = new AbortController();
|
||||
|
||||
const archive = archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-cancel-record",
|
||||
signal: controller.signal,
|
||||
});
|
||||
await recordArchive.reached;
|
||||
controller.abort();
|
||||
recordArchive.open();
|
||||
const error = await canceledArchive(archive);
|
||||
|
||||
expect({
|
||||
recordArchived: error.recordArchived,
|
||||
activeWorkspaceIds: (await deps.listActiveWorkspaces()).map((w) => w.workspaceId),
|
||||
directoryExists: existsSync(worktree.worktreePath),
|
||||
}).toEqual({ recordArchived: true, activeWorkspaceIds: [], directoryExists: true });
|
||||
});
|
||||
|
||||
test("an archive aborted before it starts touches nothing", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "cancel-before");
|
||||
const workspaceId = "ws-cancel-before";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
deps.agentStorage = storedAgents(workspaceId, [{ id: "agent-active", archivedAt: null }]);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
const error = await canceledArchive(
|
||||
archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-cancel-before",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error.newlyArchivedAgentIds).toEqual([]);
|
||||
expect(deps.archivedSnapshotIds).toEqual([]);
|
||||
expect(deps.markWorkspaceArchiving).not.toHaveBeenCalled();
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("a completed archive reports the agents it newly archived", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "newly-archived");
|
||||
const workspaceId = "ws-newly-archived";
|
||||
const deps = createArchiveDeps({
|
||||
paseoHome,
|
||||
activeWorkspaces: [{ workspaceId, cwd: worktree.worktreePath, kind: "worktree" }],
|
||||
});
|
||||
deps.agentStorage = storedAgents(workspaceId, [
|
||||
{ id: "agent-active", archivedAt: null },
|
||||
{ id: "agent-already-archived", archivedAt: "2026-01-01T00:00:00.000Z" },
|
||||
]);
|
||||
|
||||
const result = await archiveByScope(deps, {
|
||||
scope: { kind: "workspace", workspaceId },
|
||||
requestId: "req-newly-archived",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(result.archivedAgentIds.sort()).toEqual(["agent-active", "agent-already-archived"]);
|
||||
expect(result.newlyArchivedAgentIds).toEqual(["agent-active"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdAtPath", () => {
|
||||
test("prefers the worktree-kind record on an exact cwd tie", async () => {
|
||||
const targetPath = "/worktrees/repo/feature";
|
||||
|
||||
@@ -67,6 +67,9 @@ export type ArchiveScope =
|
||||
|
||||
export interface ArchiveResult {
|
||||
archivedAgentIds: string[];
|
||||
// Agents this request moved from active to archived. `archivedAgentIds` also lists agents that
|
||||
// were already archived; undo must restore only the ones this archive took down.
|
||||
newlyArchivedAgentIds: string[];
|
||||
archivedWorkspaceIds: string[];
|
||||
removedDirectory: boolean;
|
||||
// Set when the record archived but its worktree directory was left on
|
||||
@@ -78,6 +81,24 @@ export interface ArchiveResult {
|
||||
export interface ArchiveByScopeRequest {
|
||||
scope: ArchiveScope;
|
||||
requestId: string;
|
||||
// Aborting cancels the archive at its next checkpoint. Removing the worktree directory is the
|
||||
// point of no return: once it starts the archive completes.
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The archive stopped at a checkpoint because its signal aborted. Whatever ran before the
|
||||
* checkpoint stays done; `newlyArchivedAgentIds` and `recordArchived` say how far it got so the
|
||||
* canceller can put it back.
|
||||
*/
|
||||
export class WorkspaceArchiveCanceledError extends Error {
|
||||
constructor(
|
||||
public readonly newlyArchivedAgentIds: string[],
|
||||
public readonly recordArchived: boolean,
|
||||
) {
|
||||
super("Workspace archive was canceled");
|
||||
this.name = "WorkspaceArchiveCanceledError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireActiveWorkspaceForArchive(
|
||||
@@ -144,10 +165,22 @@ async function archiveByScopeWithPriority(
|
||||
dependencies: ArchiveDependencies,
|
||||
request: ArchiveByScopeRequest,
|
||||
): Promise<ArchiveResult> {
|
||||
const progress: ArchiveProgress = { newlyArchivedAgentIds: new Set(), recordArchived: false };
|
||||
const checkpoint = () => {
|
||||
if (request.signal?.aborted) {
|
||||
throw new WorkspaceArchiveCanceledError(
|
||||
Array.from(progress.newlyArchivedAgentIds),
|
||||
progress.recordArchived,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
checkpoint();
|
||||
const target = await resolveArchiveTarget(dependencies, request.scope);
|
||||
const targetWorkspaceIds = target.workspaceIds;
|
||||
|
||||
await stopWorkspaceSetups(dependencies, target.setupWorkspaceIds, request.requestId);
|
||||
checkpoint();
|
||||
|
||||
if (targetWorkspaceIds.length > 0) {
|
||||
dependencies.markWorkspaceArchiving(targetWorkspaceIds, new Date().toISOString());
|
||||
@@ -160,12 +193,14 @@ async function archiveByScopeWithPriority(
|
||||
if (targetWorkspaceIds.length > 0) {
|
||||
await dependencies.emitWorkspaceUpdatesForWorkspaceIds(targetWorkspaceIds);
|
||||
}
|
||||
checkpoint();
|
||||
|
||||
const { archivedAgents, archivedWorkspaceIds } = await archiveTargetRecords(
|
||||
dependencies,
|
||||
targetWorkspaceIds,
|
||||
request.requestId,
|
||||
{ requestId: request.requestId, checkpoint, progress },
|
||||
);
|
||||
checkpoint();
|
||||
|
||||
if (target.backing?.mainRepoRoot) {
|
||||
try {
|
||||
@@ -184,7 +219,7 @@ async function archiveByScopeWithPriority(
|
||||
if (target.backing !== null) {
|
||||
const removal = await maybeRemoveDirectory(
|
||||
dependencies,
|
||||
request,
|
||||
{ requestId: request.requestId, checkpoint },
|
||||
target,
|
||||
archivedWorkspaceIds,
|
||||
);
|
||||
@@ -194,6 +229,7 @@ async function archiveByScopeWithPriority(
|
||||
|
||||
return {
|
||||
archivedAgentIds: Array.from(archivedAgents),
|
||||
newlyArchivedAgentIds: Array.from(progress.newlyArchivedAgentIds),
|
||||
archivedWorkspaceIds,
|
||||
removedDirectory,
|
||||
...(directoryLeftBehind ? { directoryLeftBehind } : {}),
|
||||
@@ -206,6 +242,11 @@ async function archiveByScopeWithPriority(
|
||||
}
|
||||
}
|
||||
|
||||
interface ArchiveProgress {
|
||||
newlyArchivedAgentIds: Set<string>;
|
||||
recordArchived: boolean;
|
||||
}
|
||||
|
||||
async function resolveArchiveTarget(
|
||||
dependencies: ArchiveDependencies,
|
||||
scope: ArchiveScope,
|
||||
@@ -352,16 +393,23 @@ async function resolveBackingDirectory(
|
||||
async function archiveTargetRecords(
|
||||
dependencies: ArchiveDependencies,
|
||||
targetWorkspaceIds: string[],
|
||||
requestId: string,
|
||||
options: { requestId: string; checkpoint: () => void; progress: ArchiveProgress },
|
||||
): Promise<{ archivedAgents: Set<string>; archivedWorkspaceIds: string[] }> {
|
||||
const archivedAgents = new Set<string>();
|
||||
const archivedWorkspaceIds: string[] = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
targetWorkspaceIds.map(async (workspaceId) => {
|
||||
const agents = await archiveWorkspaceContents(dependencies, workspaceId);
|
||||
const contents = await archiveWorkspaceContentsWithProgress(dependencies, workspaceId);
|
||||
for (const agentId of contents.newlyArchivedAgentIds) {
|
||||
options.progress.newlyArchivedAgentIds.add(agentId);
|
||||
}
|
||||
// Agents and terminals are down, the record is not: an abort here leaves the workspace
|
||||
// listed, and the canceller restores the agents it reports.
|
||||
options.checkpoint();
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
return { workspaceId, agents };
|
||||
options.progress.recordArchived = true;
|
||||
return { workspaceId, agents: contents.agentIds };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -371,9 +419,14 @@ async function archiveTargetRecords(
|
||||
for (const agentId of result.value.agents) {
|
||||
archivedAgents.add(agentId);
|
||||
}
|
||||
} else if (result.reason instanceof WorkspaceArchiveCanceledError) {
|
||||
throw new WorkspaceArchiveCanceledError(
|
||||
Array.from(options.progress.newlyArchivedAgentIds),
|
||||
options.progress.recordArchived,
|
||||
);
|
||||
} else {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, requestId },
|
||||
{ err: result.reason, requestId: options.requestId },
|
||||
"archiveByScope workspace teardown failed; continuing",
|
||||
);
|
||||
}
|
||||
@@ -384,7 +437,7 @@ async function archiveTargetRecords(
|
||||
|
||||
async function maybeRemoveDirectory(
|
||||
dependencies: ArchiveDependencies,
|
||||
request: Pick<ArchiveByScopeRequest, "requestId">,
|
||||
request: { requestId: string; checkpoint: () => void },
|
||||
target: ArchiveTarget,
|
||||
archivedWorkspaceIds: string[],
|
||||
): Promise<{
|
||||
@@ -423,6 +476,7 @@ async function maybeRemoveDirectory(
|
||||
teardownTargets,
|
||||
);
|
||||
for (const teardownTarget of uniqueTeardownTargets(allowedTeardownTargets)) {
|
||||
request.checkpoint();
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: backing.path,
|
||||
teardownCwd: teardownTarget.cwd,
|
||||
@@ -452,6 +506,9 @@ async function maybeRemoveDirectory(
|
||||
return { removedDirectory: false };
|
||||
}
|
||||
|
||||
// Last chance to keep the directory. Teardown commands already ran, so a cancel here restores
|
||||
// a workspace whose teardown hooks fired; that beats deleting what the user asked to keep.
|
||||
request.checkpoint();
|
||||
try {
|
||||
await deletePaseoWorktree({
|
||||
cwd: backing.mainRepoRoot,
|
||||
@@ -518,6 +575,13 @@ export async function archiveWorkspaceContents(
|
||||
dependencies: ArchiveWorkspaceContentsDependencies,
|
||||
workspaceId: string,
|
||||
): Promise<Set<string>> {
|
||||
return (await archiveWorkspaceContentsWithProgress(dependencies, workspaceId)).agentIds;
|
||||
}
|
||||
|
||||
async function archiveWorkspaceContentsWithProgress(
|
||||
dependencies: ArchiveWorkspaceContentsDependencies,
|
||||
workspaceId: string,
|
||||
): Promise<{ agentIds: Set<string>; newlyArchivedAgentIds: string[] }> {
|
||||
const archivedAgents = new Set<string>();
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
@@ -542,29 +606,48 @@ export async function archiveWorkspaceContents(
|
||||
}
|
||||
|
||||
const archivedAt = new Date().toISOString();
|
||||
const agentIdsToArchive = new Set([
|
||||
...liveAgents.map((agent) => agent.id),
|
||||
...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id),
|
||||
]);
|
||||
const archiveResults = await Promise.allSettled([
|
||||
...[...agentIdsToArchive].map((agentId) =>
|
||||
dependencies.agentManager.getAgent(agentId)
|
||||
? dependencies.agentManager.archiveAgent(agentId)
|
||||
: dependencies.agentManager.archiveSnapshot(agentId, archivedAt),
|
||||
const agentIdsToArchive = [
|
||||
...new Set([
|
||||
...liveAgents.map((agent) => agent.id),
|
||||
...matchingStoredRecords.filter((record) => !record.archivedAt).map((record) => record.id),
|
||||
]),
|
||||
];
|
||||
// Internal agents are retired rather than archived: nothing on disk can bring them back.
|
||||
const restorableAgentIds = new Set(agentIdsToArchive);
|
||||
for (const agent of liveAgents) {
|
||||
if (agent.internal) restorableAgentIds.delete(agent.id);
|
||||
}
|
||||
const [agentResults, [terminalResult]] = await Promise.all([
|
||||
Promise.allSettled(
|
||||
agentIdsToArchive.map((agentId) =>
|
||||
dependencies.agentManager.getAgent(agentId)
|
||||
? dependencies.agentManager.archiveAgent(agentId)
|
||||
: dependencies.agentManager.archiveSnapshot(agentId, archivedAt),
|
||||
),
|
||||
),
|
||||
dependencies.killTerminalsForWorkspace(workspaceId),
|
||||
Promise.allSettled([dependencies.killTerminalsForWorkspace(workspaceId)]),
|
||||
]);
|
||||
|
||||
for (const result of archiveResults) {
|
||||
const newlyArchivedAgentIds: string[] = [];
|
||||
for (const [index, result] of agentResults.entries()) {
|
||||
const agentId = agentIdsToArchive[index]!;
|
||||
if (result.status === "rejected") {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, workspaceId },
|
||||
{ err: result.reason, workspaceId, agentId },
|
||||
"Workspace archive teardown step failed; continuing",
|
||||
);
|
||||
} else if (restorableAgentIds.has(agentId)) {
|
||||
newlyArchivedAgentIds.push(agentId);
|
||||
}
|
||||
}
|
||||
if (terminalResult?.status === "rejected") {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: terminalResult.reason, workspaceId },
|
||||
"Workspace archive teardown step failed; continuing",
|
||||
);
|
||||
}
|
||||
|
||||
return archivedAgents;
|
||||
return { agentIds: archivedAgents, newlyArchivedAgentIds };
|
||||
}
|
||||
|
||||
// True when, after archiving
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js";
|
||||
|
||||
// Undo of a workspace archive, end to end through a real daemon. The archive is parked inside a
|
||||
// worktree teardown command that waits on a file, so the undo lands at a known point.
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
for (const tempRoot of tempRoots.splice(0)) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
interface GatedRepo {
|
||||
repoDir: string;
|
||||
teardownStartedPath: string;
|
||||
releaseTeardown: () => void;
|
||||
}
|
||||
|
||||
function git(repoDir: string, args: string[]): void {
|
||||
execFileSync("git", args, { cwd: repoDir, stdio: "pipe" });
|
||||
}
|
||||
|
||||
/** A repo whose worktree teardown parks until released. */
|
||||
function createRepoWithGatedTeardown(): GatedRepo {
|
||||
const tempRoot = mkdtempSync(path.join(tmpdir(), "workspace-archive-undo-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const repoDir = path.join(tempRoot, "repo");
|
||||
const teardownStartedPath = path.join(tempRoot, "teardown-started");
|
||||
const releasePath = path.join(tempRoot, "release-teardown");
|
||||
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
||||
git(repoDir, ["config", "user.email", "test@getpaseo.local"]);
|
||||
git(repoDir, ["config", "user.name", "Paseo Test"]);
|
||||
const teardown = `node -e "const fs=require('fs');fs.writeFileSync('${teardownStartedPath}','started');const timer=setInterval(()=>{if(fs.existsSync('${releasePath}'))clearInterval(timer)},20)"`;
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({ worktree: { teardown: [teardown] } }),
|
||||
);
|
||||
git(repoDir, ["add", "paseo.json"]);
|
||||
git(repoDir, ["-c", "commit.gpgsign=false", "commit", "-m", "gated teardown"]);
|
||||
return {
|
||||
repoDir,
|
||||
teardownStartedPath,
|
||||
releaseTeardown: () => writeFileSync(releasePath, "release\n"),
|
||||
};
|
||||
}
|
||||
|
||||
async function createWorktreeWorkspaceWithAgent(repoDir: string, slug: string) {
|
||||
const result = await ctx.client.createWorkspace({
|
||||
source: { kind: "worktree", cwd: repoDir, worktreeSlug: slug, baseBranch: "main" },
|
||||
});
|
||||
const workspace = result.workspace;
|
||||
if (!workspace?.workspaceDirectory) {
|
||||
throw new Error(result.error ?? "Failed to create worktree workspace");
|
||||
}
|
||||
const agent = await ctx.client.createAgent({
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: workspace.workspaceDirectory,
|
||||
workspaceId: workspace.id,
|
||||
title: "Undo agent",
|
||||
});
|
||||
return { workspaceId: workspace.id, directory: workspace.workspaceDirectory, agentId: agent.id };
|
||||
}
|
||||
|
||||
async function isWorkspaceActive(workspaceId: string): Promise<boolean> {
|
||||
const workspaces = await ctx.client.fetchWorkspaces();
|
||||
return workspaces.entries.some((entry) => entry.id === workspaceId);
|
||||
}
|
||||
|
||||
async function isAgentArchived(agentId: string): Promise<boolean> {
|
||||
const agents = await ctx.client.fetchAgents({ filter: { includeArchived: true } });
|
||||
const entry = agents.entries.find((candidate) => candidate.agent.id === agentId);
|
||||
if (!entry) throw new Error(`Agent ${agentId} is missing`);
|
||||
return entry.agent.archivedAt !== null && entry.agent.archivedAt !== undefined;
|
||||
}
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"an undo while teardown runs cancels the archive before the worktree is deleted",
|
||||
async () => {
|
||||
const repo = createRepoWithGatedTeardown();
|
||||
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
|
||||
repo.repoDir,
|
||||
"undo-during-teardown",
|
||||
);
|
||||
|
||||
try {
|
||||
const archive = ctx.client.archiveWorkspace(workspaceId);
|
||||
await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true);
|
||||
const undo = ctx.client.undoWorkspaceArchive(workspaceId);
|
||||
repo.releaseTeardown();
|
||||
|
||||
const [archiveResponse] = await Promise.all([archive, undo]);
|
||||
|
||||
expect({
|
||||
canceled: archiveResponse.canceled,
|
||||
archivedAt: archiveResponse.archivedAt,
|
||||
workspaceActive: await isWorkspaceActive(workspaceId),
|
||||
directoryExists: existsSync(directory),
|
||||
agentArchived: await isAgentArchived(agentId),
|
||||
}).toEqual({
|
||||
canceled: true,
|
||||
archivedAt: null,
|
||||
workspaceActive: true,
|
||||
directoryExists: true,
|
||||
agentArchived: false,
|
||||
});
|
||||
} finally {
|
||||
repo.releaseTeardown();
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"an undo after the archive finished recreates the worktree and its agents",
|
||||
async () => {
|
||||
const repo = createRepoWithGatedTeardown();
|
||||
repo.releaseTeardown();
|
||||
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
|
||||
repo.repoDir,
|
||||
"undo-after-archive",
|
||||
);
|
||||
|
||||
const archived = await ctx.client.archiveWorkspace(workspaceId);
|
||||
expect(archived.error).toBeNull();
|
||||
expect(existsSync(directory)).toBe(false);
|
||||
expect(await isAgentArchived(agentId)).toBe(true);
|
||||
|
||||
await ctx.client.undoWorkspaceArchive(workspaceId);
|
||||
|
||||
expect({
|
||||
workspaceActive: await isWorkspaceActive(workspaceId),
|
||||
directoryExists: existsSync(directory),
|
||||
agentArchived: await isAgentArchived(agentId),
|
||||
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"archive, undo, archive leaves the workspace archived",
|
||||
async () => {
|
||||
const repo = createRepoWithGatedTeardown();
|
||||
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
|
||||
repo.repoDir,
|
||||
"archive-undo-archive",
|
||||
);
|
||||
|
||||
try {
|
||||
const first = ctx.client.archiveWorkspace(workspaceId);
|
||||
await expect.poll(() => existsSync(repo.teardownStartedPath), { timeout: 15000 }).toBe(true);
|
||||
const undo = ctx.client.undoWorkspaceArchive(workspaceId);
|
||||
const second = ctx.client.archiveWorkspace(workspaceId);
|
||||
repo.releaseTeardown();
|
||||
|
||||
const [firstResponse, , secondResponse] = await Promise.all([first, undo, second]);
|
||||
|
||||
expect({
|
||||
firstCanceled: firstResponse.canceled,
|
||||
secondError: secondResponse.error,
|
||||
secondCanceled: secondResponse.canceled ?? false,
|
||||
workspaceActive: await isWorkspaceActive(workspaceId),
|
||||
directoryExists: existsSync(directory),
|
||||
agentArchived: await isAgentArchived(agentId),
|
||||
}).toEqual({
|
||||
firstCanceled: true,
|
||||
secondError: null,
|
||||
secondCanceled: false,
|
||||
workspaceActive: false,
|
||||
directoryExists: false,
|
||||
agentArchived: true,
|
||||
});
|
||||
} finally {
|
||||
repo.releaseTeardown();
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("an undo on a workspace that was never archived leaves it as it was", async () => {
|
||||
const repo = createRepoWithGatedTeardown();
|
||||
repo.releaseTeardown();
|
||||
const { workspaceId, directory, agentId } = await createWorktreeWorkspaceWithAgent(
|
||||
repo.repoDir,
|
||||
"undo-without-archive",
|
||||
);
|
||||
|
||||
await ctx.client.undoWorkspaceArchive(workspaceId);
|
||||
|
||||
expect({
|
||||
workspaceActive: await isWorkspaceActive(workspaceId),
|
||||
directoryExists: existsSync(directory),
|
||||
agentArchived: await isAgentArchived(agentId),
|
||||
}).toEqual({ workspaceActive: true, directoryExists: true, agentArchived: false });
|
||||
}, 60000);
|
||||
Reference in New Issue
Block a user