mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
fix(app): require explicit workspace recovery
This commit is contained in:
@@ -167,6 +167,10 @@ import {
|
||||
createWorkspaceProvisioningService,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import {
|
||||
createWorkspaceRecoveryService,
|
||||
type WorkspaceRecoveryService,
|
||||
} from "./session/workspace-recovery/workspace-recovery-service.js";
|
||||
import {
|
||||
createAgentUpdatesService,
|
||||
matchesAgentUpdatesFilter,
|
||||
@@ -563,6 +567,7 @@ export class Session {
|
||||
private readonly workspaceAutoName: WorkspaceAutoName;
|
||||
private readonly gitMutation: GitMutationService;
|
||||
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
||||
private readonly workspaceRecovery: WorkspaceRecoveryService;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
@@ -697,6 +702,15 @@ export class Session {
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
getProject: (projectId) => this.projectRegistry.get(projectId),
|
||||
isDirectory: (path) => this.filesystem.isDirectory(path),
|
||||
recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace),
|
||||
unarchiveWorkspace: async (workspace) => {
|
||||
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||
},
|
||||
});
|
||||
this.checkoutSession = new CheckoutSession({
|
||||
host: {
|
||||
emit: (msg) => this.emit(msg),
|
||||
@@ -1389,6 +1403,7 @@ export class Session {
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
this.dispatchWorkspaceRecoveryMessage(msg) ??
|
||||
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
||||
this.dispatchWorkspaceFileMessage(msg) ??
|
||||
this.dispatchProviderMessage(msg) ??
|
||||
@@ -1685,6 +1700,17 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "workspace.recovery.inspect.request":
|
||||
return this.handleWorkspaceRecoveryInspectRequest(msg);
|
||||
case "workspace.recovery.restore.request":
|
||||
return this.handleWorkspaceRecoveryRestoreRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchProviderMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "list_provider_models_request":
|
||||
@@ -2400,6 +2426,54 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleWorkspaceRecoveryInspectRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.recovery.inspect.request" }>,
|
||||
): Promise<void> {
|
||||
const state = await this.workspaceRecovery.inspect(request.workspaceId);
|
||||
this.emit({
|
||||
type: "workspace.recovery.inspect.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
state,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async handleWorkspaceRecoveryRestoreRequest(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.workspaceRecovery.restore(request.workspaceId);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([request.workspaceId], {
|
||||
skipReconcile: true,
|
||||
});
|
||||
this.emit({
|
||||
type: "workspace.recovery.restore.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = getErrorMessageOr(error, "Failed to recover workspace");
|
||||
this.sessionLogger.warn(
|
||||
{ err: error, workspaceId: request.workspaceId, requestId: request.requestId },
|
||||
"session: workspace.recovery.restore.request rejected",
|
||||
);
|
||||
this.emit({
|
||||
type: "workspace.recovery.restore.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspaceId: request.workspaceId,
|
||||
accepted: false,
|
||||
error: message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle text message to agent (with optional image attachments)
|
||||
*/
|
||||
@@ -2763,7 +2837,6 @@ export class Session {
|
||||
|
||||
try {
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||
await this.unarchiveOwningWorkspaceForAgent(agentId);
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
@@ -3873,35 +3946,14 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record?.workspaceId) {
|
||||
return;
|
||||
private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
|
||||
});
|
||||
}
|
||||
const workspace = await this.workspaceRegistry.get(record.workspaceId);
|
||||
if (!workspace?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
|
||||
if (!directoryExists) {
|
||||
if (workspace.kind !== "worktree" || !workspace.branch) {
|
||||
return;
|
||||
}
|
||||
// Recreate the worktree directory from its kept branch BEFORE clearing
|
||||
// archivedAt — the reconciler re-archives workspaces whose directory is
|
||||
// missing, so the record must point at a real directory first.
|
||||
await this.recreateOwningWorktreeForRestore(workspace, workspace.branch);
|
||||
}
|
||||
|
||||
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
||||
}
|
||||
|
||||
private async recreateOwningWorktreeForRestore(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
branch: string,
|
||||
): Promise<void> {
|
||||
const project = await this.projectRegistry.get(workspace.projectId);
|
||||
if (!project) {
|
||||
throw new WorktreeRequestError({
|
||||
|
||||
@@ -32,13 +32,13 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { createWorktree, UnknownBranchError } from "../utils/worktree.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import {
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
} from "../utils/worktree-metadata.js";
|
||||
import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js";
|
||||
import { WorktreeRequestError } from "./worktree-errors.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
@@ -128,10 +128,7 @@ interface SessionTestAccess {
|
||||
agentUpdates: AgentUpdatesService;
|
||||
workspaceUpdatesSubscription: unknown;
|
||||
interruptAgentIfRunning(agentId: string): unknown;
|
||||
recreateOwningWorktreeForRestore(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
branch: string,
|
||||
): Promise<void>;
|
||||
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
|
||||
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
||||
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||
changed: boolean;
|
||||
@@ -4422,7 +4419,7 @@ test("open_project_request recreates a missing project record when unarchiving i
|
||||
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
|
||||
});
|
||||
|
||||
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
|
||||
test("refresh_agent_request leaves workspace archival independent when its directory exists", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4514,13 +4511,13 @@ test("refresh_agent_request unarchives the owning workspace when its directory e
|
||||
requestId: "req-refresh-unarchive",
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
||||
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(unarchivedWorkspaceIds).toEqual([]);
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => {
|
||||
test("refresh_agent_request leaves workspace archival independent when its directory is missing", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4609,7 +4606,7 @@ test("refresh_agent_request leaves the owning workspace archived when its direct
|
||||
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("refresh_agent_request recreates a deleted worktree directory and unarchives the same workspace", async () => {
|
||||
test("refresh_agent_request does not recreate or unarchive a deleted worktree", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4675,13 +4672,6 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
|
||||
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
||||
session.agentStorage.upsert = async () => {};
|
||||
|
||||
const recreateCalls: string[] = [];
|
||||
session.recreateOwningWorktreeForRestore = async (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<void> => {
|
||||
recreateCalls.push(workspace.workspaceId);
|
||||
};
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: agentId,
|
||||
cwd,
|
||||
@@ -4709,14 +4699,13 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
|
||||
requestId: "req-refresh-recreate-worktree",
|
||||
});
|
||||
|
||||
expect(recreateCalls).toEqual([workspaceId]);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
||||
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
|
||||
expect(unarchivedWorkspaceIds).toEqual([]);
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refresh_agent_request leaves the worktree archived and surfaces a typed error when recreation fails", async () => {
|
||||
test("refresh_agent_request does not inspect an archived worktree branch", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4782,10 +4771,6 @@ test("refresh_agent_request leaves the worktree archived and surfaces a typed er
|
||||
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
||||
session.agentStorage.upsert = async () => {};
|
||||
|
||||
session.recreateOwningWorktreeForRestore = async (): Promise<void> => {
|
||||
throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd }));
|
||||
};
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: agentId,
|
||||
cwd,
|
||||
@@ -4807,9 +4792,7 @@ test("refresh_agent_request leaves the worktree archived and surfaces a typed er
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
const rpcError = findByType(emitted, "rpc_error");
|
||||
expect(rpcError).toBeDefined();
|
||||
expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch");
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
});
|
||||
|
||||
function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
|
||||
@@ -4828,7 +4811,7 @@ function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => {
|
||||
test("refresh_agent_request leaves a real deleted worktree for explicit workspace recovery", async () => {
|
||||
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||
const branch = "feature/keep";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -4937,22 +4920,15 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
expect(existsSync(worktreePath)).toBe(true);
|
||||
const headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(headBranch).toBe(branch);
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
||||
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and leaves the workspace archived when the project root is missing", async () => {
|
||||
test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
|
||||
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||
const branch = "feature/keep";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -5005,9 +4981,9 @@ test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and l
|
||||
session.filesystem.isDirectory = async (target: string) =>
|
||||
existsSync(target) && statSync(target).isDirectory();
|
||||
|
||||
await expect(
|
||||
session.recreateOwningWorktreeForRestore(workspaceRecord, branch),
|
||||
).rejects.toBeInstanceOf(WorktreeRequestError);
|
||||
await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
|
||||
WorktreeRequestError,
|
||||
);
|
||||
// Guard fires before createWorktree, so archivedAt is untouched.
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
} from "../../workspace-registry.js";
|
||||
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
|
||||
|
||||
const NOW = "2026-07-11T10:12:30.752Z";
|
||||
|
||||
function createProject(): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: "/repo",
|
||||
rootPath: "/repo",
|
||||
kind: "git",
|
||||
displayName: "repo",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
function createWorkspace(
|
||||
overrides: Partial<PersistedWorkspaceRecord> = {},
|
||||
): PersistedWorkspaceRecord {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
projectId: "/repo",
|
||||
cwd: "/worktrees/trigger-1525443412986298439",
|
||||
kind: "worktree",
|
||||
displayName: "diagnose-repro-tdd",
|
||||
title: "Codex TDD reproduction",
|
||||
branch: "diagnose-repro-tdd",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: NOW,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function createHarness(input?: {
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
directories?: string[];
|
||||
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
}) {
|
||||
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
|
||||
const project = input?.project === undefined ? createProject() : input.project;
|
||||
const directories = new Set(input?.directories ?? ["/repo"]);
|
||||
const unarchived: string[] = [];
|
||||
const recreated: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspace?.workspaceId === workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
|
||||
isDirectory: async (path) => directories.has(path),
|
||||
recreateWorktree: async (record) => {
|
||||
recreated.push(record.workspaceId);
|
||||
await input?.recreate?.(record);
|
||||
},
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
return { service, recreated, unarchived };
|
||||
}
|
||||
|
||||
describe("workspace recovery", () => {
|
||||
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
|
||||
const { service, recreated, unarchived } = createHarness();
|
||||
|
||||
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||
kind: "recoverable",
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
workspaceName: "Codex TDD reproduction",
|
||||
action: "restore",
|
||||
branch: "diagnose-repro-tdd",
|
||||
});
|
||||
expect(recreated).toEqual([]);
|
||||
expect(unarchived).toEqual([]);
|
||||
});
|
||||
|
||||
test("describes an archived workspace whose directory remains as unarchivable", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
const { service } = createHarness({
|
||||
workspace,
|
||||
directories: ["/repo", workspace.cwd],
|
||||
});
|
||||
|
||||
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
|
||||
kind: "recoverable",
|
||||
action: "unarchive",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not offer recovery for a missing non-worktree directory", async () => {
|
||||
const workspace = createWorkspace({ kind: "directory", branch: null });
|
||||
const { service } = createHarness({ workspace });
|
||||
|
||||
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
|
||||
kind: "unavailable",
|
||||
workspaceId: workspace.workspaceId,
|
||||
reason: "workspace_directory_missing",
|
||||
message: "The archived workspace directory no longer exists and cannot be recreated.",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
|
||||
let attempts = 0;
|
||||
const { service, recreated, unarchived } = createHarness({
|
||||
recreate: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("git branch diagnose-repro-tdd is unavailable");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
|
||||
"git branch diagnose-repro-tdd is unavailable",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
|
||||
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
|
||||
workspaceId: "wks_15a1b5630ebaab33",
|
||||
action: "restore",
|
||||
});
|
||||
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
|
||||
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
resolveWorkspaceDisplayName,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
} from "../../workspace-registry.js";
|
||||
|
||||
export type WorkspaceRecoveryAction = "unarchive" | "restore";
|
||||
|
||||
export type WorkspaceRecoveryState =
|
||||
| {
|
||||
kind: "recoverable";
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
action: WorkspaceRecoveryAction;
|
||||
branch: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "unavailable";
|
||||
workspaceId: string;
|
||||
reason:
|
||||
| "workspace_not_found"
|
||||
| "workspace_not_archived"
|
||||
| "project_not_found"
|
||||
| "project_directory_missing"
|
||||
| "workspace_directory_missing"
|
||||
| "worktree_branch_missing";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface WorkspaceRecoveryService {
|
||||
inspect(workspaceId: string): Promise<WorkspaceRecoveryState>;
|
||||
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
|
||||
}
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
|
||||
}): WorkspaceRecoveryService {
|
||||
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
|
||||
const workspace = await deps.getWorkspace(workspaceId);
|
||||
if (!workspace) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "workspace_not_found",
|
||||
message: "This workspace is no longer known to the host.",
|
||||
};
|
||||
}
|
||||
if (!workspace.archivedAt) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "workspace_not_archived",
|
||||
message: "This workspace is not archived, but it is unavailable from the host.",
|
||||
};
|
||||
}
|
||||
|
||||
const project = await deps.getProject(workspace.projectId);
|
||||
if (!project) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "project_not_found",
|
||||
message: "The project for this archived workspace no longer exists.",
|
||||
};
|
||||
}
|
||||
|
||||
if (await deps.isDirectory(workspace.cwd)) {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
action: "unarchive",
|
||||
branch: workspace.branch,
|
||||
};
|
||||
}
|
||||
|
||||
if (workspace.kind !== "worktree") {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "workspace_directory_missing",
|
||||
message: "The archived workspace directory no longer exists and cannot be recreated.",
|
||||
};
|
||||
}
|
||||
if (!workspace.branch) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "worktree_branch_missing",
|
||||
message: "The archived worktree has no branch recorded, so it cannot be restored.",
|
||||
};
|
||||
}
|
||||
if (!(await deps.isDirectory(project.rootPath))) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
workspaceId,
|
||||
reason: "project_directory_missing",
|
||||
message: "The project directory needed to restore this worktree no longer exists.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "recoverable",
|
||||
workspaceId,
|
||||
workspaceName: resolveWorkspaceDisplayName(workspace),
|
||||
action: "restore",
|
||||
branch: workspace.branch,
|
||||
};
|
||||
}
|
||||
|
||||
async function restore(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
|
||||
const state = await inspect(workspaceId);
|
||||
if (state.kind === "unavailable") {
|
||||
throw new Error(state.message);
|
||||
}
|
||||
|
||||
const workspace = await deps.getWorkspace(workspaceId);
|
||||
if (!workspace?.archivedAt) {
|
||||
throw new Error("The archived workspace changed before it could be recovered.");
|
||||
}
|
||||
if (state.action === "restore") {
|
||||
await deps.recreateWorktree(workspace);
|
||||
}
|
||||
await deps.unarchiveWorkspace(workspace);
|
||||
return { workspaceId, action: state.action };
|
||||
}
|
||||
|
||||
return { inspect, restore };
|
||||
}
|
||||
@@ -1228,8 +1228,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
projectRemove: true,
|
||||
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
projectAdd: true,
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
worktreeRestore: true,
|
||||
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
|
||||
workspaceRecovery: true,
|
||||
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
|
||||
Reference in New Issue
Block a user