mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
Keep an archived worktree restorable after a failed re-import (#5238)
* fix(server): keep a workspace's placement when its directory is gone Resolving a workspace read the checkout at its cwd and reconciled the recorded placement against it. A git read answers "not a checkout" for a plain directory and for a directory that is not there, so an archived worktree whose directory had been removed came back as kind "directory" with branch, worktreeRoot and mainRepoRoot null. That rewrite happens before the import operation runs, and the failure path only rolls back a workspace the import created, so a failed re-import left the record erased. Workspace recovery can only recreate a missing directory while the record still says "worktree" with a branch, so the archived worktree stopped being restorable from Paseo and reported workspace_directory_missing instead. Placement is now reconciled only against a directory that is there. The absence is resolved once, where the workspace directory is read, and reconcileWorkspacePlacement refuses to rewrite from it, so the active refresh path is covered by the same rule. The workspace reconciliation pass already skipped missing directories for this reason. Closes #5111 * fix(server): treat a directory lost mid-read as an absence A worktree removed or unmounted while the git read is in flight makes that read report "not a checkout", which erased the placement the same way a directory that was already gone did. The read now counts as evidence only while the directory is there on both sides of it. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -737,6 +737,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const workspaceAutoName = new WorkspaceAutoName({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describeHookWorkspace } from "./plugins/lifecycle/index.js";
|
||||
import express from "express";
|
||||
import { createServer as createHTTPServer, type IncomingMessage, type ServerResponse } from "http";
|
||||
import { constants, existsSync, unlinkSync } from "fs";
|
||||
import { open, rm } from "fs/promises";
|
||||
import { open, rm, stat } from "fs/promises";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -891,6 +891,7 @@ export async function createPaseoDaemon(
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async (target) => (await stat(target).catch(() => null))?.isDirectory() ?? false,
|
||||
logger,
|
||||
});
|
||||
const agentProviderRuntime = await createAgentProviderRuntime({
|
||||
|
||||
@@ -1117,6 +1117,7 @@ function createDeps(options?: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
|
||||
@@ -212,6 +212,7 @@ async function createRegistryBackedScheduleWorkspaceDeps(rootDir: string): Promi
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
});
|
||||
return {
|
||||
workspaceRegistry,
|
||||
|
||||
@@ -902,6 +902,7 @@ export class Session {
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
isDirectory: (path) => this.filesystem.isDirectory(path),
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
|
||||
+154
-1
@@ -1,9 +1,10 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { getCheckoutStatus } from "../../../utils/checkout-git.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import {
|
||||
createNoGitWorkspaceRuntimeSnapshot,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
createPersistedWorkspaceRecord,
|
||||
type WorkspaceRegistry,
|
||||
} from "../../workspace-registry.js";
|
||||
import { checkoutLiteFromGitSnapshot } from "../../workspace-registry-model.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js";
|
||||
import {
|
||||
createWorkspaceProvisioningService,
|
||||
@@ -31,6 +33,15 @@ const logger = createTestLogger();
|
||||
const ARCHIVED_AT = "2026-01-01T00:00:00.000Z";
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
// The real filesystem, so "this directory is gone" is observed rather than modelled.
|
||||
async function isDirectory(target: string): Promise<boolean> {
|
||||
try {
|
||||
return statSync(target).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
let gitRoots: Set<string>;
|
||||
let gitBranches: Map<string, string | null>;
|
||||
@@ -106,6 +117,7 @@ beforeEach(async () => {
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
isDirectory,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
@@ -161,6 +173,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an
|
||||
|
||||
test("re-opening refreshes mutable checkout metadata without renaming the workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
const first = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...first, title: "Pinned work" });
|
||||
gitRoots.add(repo);
|
||||
@@ -187,6 +200,7 @@ test("persists manual worktree ownership separately from its workspace kind", as
|
||||
const manualWorktreeProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
@@ -224,6 +238,7 @@ test("re-opening an archived workspace by its exact path unarchives it and keeps
|
||||
|
||||
test("reopening archived exact-root records restores the fresh Git project", async () => {
|
||||
const cwd = path.join(tmpDir, "repo");
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
@@ -249,6 +264,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
const archivedProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async () => ({
|
||||
@@ -279,6 +295,7 @@ test("reopening archived exact-root records restores the fresh Git project", asy
|
||||
|
||||
test("uses one workspace snapshot when reopening an archived workspace", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT);
|
||||
@@ -300,6 +317,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
workspaceRegistry: snapshotRegistry,
|
||||
projectRegistry,
|
||||
workspaceGitService: gitService(),
|
||||
isDirectory,
|
||||
});
|
||||
|
||||
const reopened = await snapshotProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
@@ -310,6 +328,7 @@ test("uses one workspace snapshot when reopening an archived workspace", async (
|
||||
|
||||
test("reopening an archived workspace refreshes placement without renaming it", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" });
|
||||
@@ -430,6 +449,7 @@ test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged
|
||||
|
||||
test("does not unarchive either record when checkout refresh fails", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
await projectRegistry.archive(created.projectId, ARCHIVED_AT);
|
||||
@@ -767,3 +787,136 @@ test.each(["missing", "archived"] as const)(
|
||||
expect(await projectRegistry.list()).toEqual(previousProject ? [previousProject] : []);
|
||||
},
|
||||
);
|
||||
|
||||
test("a failed import keeps an archived worktree's placement when its directory is gone", async () => {
|
||||
// The git port here is the production read: getCheckoutStatus against the real
|
||||
// filesystem, so "the worktree directory is gone" is observed, not modelled.
|
||||
const mainRepoRoot = path.join(tmpDir, "main-repo");
|
||||
mkdirSync(mainRepoRoot, { recursive: true });
|
||||
const worktreeCwd = path.join(tmpDir, "worktrees", "feature-example");
|
||||
const project = await projectRegistry.getOrCreateActiveByRoot({
|
||||
rootPath: mainRepoRoot,
|
||||
kind: "git",
|
||||
displayName: "main-repo",
|
||||
timestamp: ARCHIVED_AT,
|
||||
});
|
||||
const archived = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-archived-worktree",
|
||||
projectId: project.projectId,
|
||||
cwd: worktreeCwd,
|
||||
kind: "worktree",
|
||||
displayName: "feature/example",
|
||||
branch: "feature/example",
|
||||
worktreeRoot: worktreeCwd,
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot,
|
||||
createdAt: ARCHIVED_AT,
|
||||
updatedAt: ARCHIVED_AT,
|
||||
archivedAt: ARCHIVED_AT,
|
||||
});
|
||||
await workspaceRegistry.upsert(archived);
|
||||
const realCheckoutProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
logger,
|
||||
isDirectory,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
const status = await getCheckoutStatus(cwd, { logger });
|
||||
return checkoutLiteFromGitSnapshot(
|
||||
cwd,
|
||||
status.isGit
|
||||
? {
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
repoRoot: status.repoRoot,
|
||||
isPaseoOwnedWorktree: status.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
}
|
||||
: {
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
repoRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
realCheckoutProvisioning.runInImportWorkspace({ cwd: worktreeCwd }, async () => {
|
||||
throw new Error("provider resume failed");
|
||||
}),
|
||||
).rejects.toThrow("provider resume failed");
|
||||
|
||||
expect(await workspaceRegistry.get(archived.workspaceId)).toMatchObject({
|
||||
kind: "worktree",
|
||||
branch: "feature/example",
|
||||
worktreeRoot: worktreeCwd,
|
||||
mainRepoRoot,
|
||||
isPaseoOwnedWorktree: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("re-opening an active worktree workspace keeps its placement while the directory is away", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/away");
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/away" });
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
gitRoots.delete(repo);
|
||||
|
||||
const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/away",
|
||||
});
|
||||
});
|
||||
|
||||
test("a directory that goes away while the git read is in flight keeps its placement", async () => {
|
||||
const repo = path.join(tmpDir, "repo");
|
||||
mkdirSync(repo, { recursive: true });
|
||||
gitRoots.add(repo);
|
||||
gitBranches.set(repo, "feature/vanishing");
|
||||
const created = await provisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
expect(created).toMatchObject({ kind: "local_checkout", branch: "feature/vanishing" });
|
||||
const vanishingProvisioning = createWorkspaceProvisioningService({
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
isDirectory,
|
||||
logger,
|
||||
workspaceGitService: createNoopWorkspaceGitService({
|
||||
peekSnapshot: () => null,
|
||||
getCheckout: async (cwd: string) => {
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
return {
|
||||
cwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const reopened = await vanishingProvisioning.findOrCreateWorkspaceForDirectory(repo);
|
||||
|
||||
expect(reopened).toMatchObject({
|
||||
workspaceId: created.workspaceId,
|
||||
kind: "local_checkout",
|
||||
branch: "feature/vanishing",
|
||||
});
|
||||
});
|
||||
|
||||
+26
-8
@@ -1,6 +1,7 @@
|
||||
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
|
||||
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
|
||||
import { basename, resolve } from "node:path";
|
||||
import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
generateWorkspaceId,
|
||||
@@ -94,11 +95,27 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
|
||||
isDirectory: (path: string) => Promise<boolean>;
|
||||
logger: Logger;
|
||||
lifecycle?: PluginLifecycle;
|
||||
}): WorkspaceProvisioningService {
|
||||
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
|
||||
|
||||
/**
|
||||
* Placement facts at a workspace directory, or null when there is nothing
|
||||
* there to read. A git read answers "not a checkout" for a plain directory
|
||||
* and for a directory that is gone, and only the first is evidence that a
|
||||
* worktree stopped being one. That answer therefore counts only while the
|
||||
* directory is there on both sides of the read, so a worktree removed or
|
||||
* unmounted while the read is in flight stays an absence.
|
||||
*/
|
||||
async function observeWorkspaceCheckout(cwd: string): Promise<ProjectCheckoutLitePayload | null> {
|
||||
if (!(await deps.isDirectory(cwd))) return null;
|
||||
const checkout = await workspaceGitService.getCheckout(cwd);
|
||||
if (!checkout.isGit && !(await deps.isDirectory(cwd))) return null;
|
||||
return checkout;
|
||||
}
|
||||
|
||||
async function runInImportWorkspace<T>(
|
||||
input: ImportWorkspaceInput,
|
||||
operation: (workspace: PersistedWorkspaceRecord) => Promise<T>,
|
||||
@@ -366,12 +383,12 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
const timestamp = new Date().toISOString();
|
||||
const checkout =
|
||||
workspace.archivedAt || project.archivedAt
|
||||
? await workspaceGitService.getCheckout(workspace.cwd)
|
||||
? await observeWorkspaceCheckout(workspace.cwd)
|
||||
: null;
|
||||
const autoArchivedChangeRequestUrl =
|
||||
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
|
||||
let next: PersistedWorkspaceRecord | null = null;
|
||||
if (workspace.archivedAt && checkout) {
|
||||
if (workspace.archivedAt) {
|
||||
const placementUpdate = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout,
|
||||
@@ -384,10 +401,11 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
if (checkout && (project.archivedAt || workspace.archivedAt)) {
|
||||
const projectCheckout = areEquivalentPaths(project.rootPath, workspace.cwd)
|
||||
? checkout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
if (project.archivedAt || workspace.archivedAt) {
|
||||
const projectCheckout =
|
||||
checkout && areEquivalentPaths(project.rootPath, workspace.cwd)
|
||||
? checkout
|
||||
: await workspaceGitService.getCheckout(project.rootPath);
|
||||
const kind = projectCheckout.isGit ? "git" : "non_git";
|
||||
const projectKey = deriveProjectKey({
|
||||
rootPath: project.rootPath,
|
||||
@@ -414,10 +432,10 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
async function refreshWorkspaceRecord(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const checkout = await workspaceGitService.getCheckout(workspace.cwd);
|
||||
const checkout = await observeWorkspaceCheckout(workspace.cwd);
|
||||
const project = await projectRegistry.get(workspace.projectId);
|
||||
if (project && !project.archivedAt) {
|
||||
await refreshProjectKind(project, workspace.cwd, checkout);
|
||||
await refreshProjectKind(project, workspace.cwd, checkout ?? undefined);
|
||||
}
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
|
||||
@@ -87,6 +87,31 @@ describe("workspace placement", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the recorded placement when nothing was read at the workspace directory", () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
projectId: "project-one",
|
||||
cwd: "/repo-feature",
|
||||
kind: "worktree",
|
||||
displayName: "feature/placement",
|
||||
branch: "feature/placement",
|
||||
worktreeRoot: "/repo-feature",
|
||||
baseBranch: "main",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace,
|
||||
checkout: null,
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(update).toBeNull();
|
||||
});
|
||||
|
||||
test("updates live placement while preserving its durable name and base branch", () => {
|
||||
const workspace = createPersistedWorkspaceRecord({
|
||||
workspaceId: "workspace-one",
|
||||
|
||||
@@ -109,12 +109,17 @@ export function initialWorkspacePlacement(
|
||||
/**
|
||||
* Applies live placement facts without rewriting the workspace's durable name
|
||||
* or its creation-time base branch.
|
||||
*
|
||||
* A null checkout is an absence, not an observation: nothing was read at the
|
||||
* workspace directory, so the recorded placement stands. Only a directory that
|
||||
* is there can show that a worktree stopped being one.
|
||||
*/
|
||||
export function reconcileWorkspacePlacement(input: {
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
checkout: ProjectCheckoutLitePayload;
|
||||
checkout: ProjectCheckoutLitePayload | null;
|
||||
updatedAt: string;
|
||||
}): WorkspacePlacementUpdate | null {
|
||||
if (!input.checkout) return null;
|
||||
const observed = initialWorkspacePlacement({
|
||||
source: "checkout",
|
||||
cwd: input.workspace.cwd,
|
||||
|
||||
@@ -366,6 +366,7 @@ function createPaseoWorktreeForTest(options: {
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
isDirectory: async () => true,
|
||||
logger: createLogger(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user