mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
Restore persisted workspaces before runtime control
This commit is contained in:
1 parent
2083888738
commit
1dce216cb3
3 files changed
+391
-28
No files matched your search
@@ -21,6 +21,7 @@ import { eq } from "drizzle-orm";
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
ensureServerWorkspaceLinksCurrent,
|
||||
ensureRuntimeServicesForRun,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
@@ -1447,6 +1448,187 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("reattaches a missing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-451-restore-persisted-worktree";
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "restore.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf '%s\\n' \"$PAPERCLIP_WORKSPACE_BRANCH\" > .paperclip-restored-branch",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "scripts/restore.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add restore script"]);
|
||||
|
||||
await runGit(repoRoot, ["checkout", "-b", branchName]);
|
||||
await fs.writeFile(path.join(repoRoot, "feature.txt"), "persisted\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "feature.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add persisted feature"]);
|
||||
const expectedHead = (await execFileAsync("git", ["rev-parse", branchName], { cwd: repoRoot })).stdout.trim();
|
||||
await runGit(repoRoot, ["checkout", "main"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Restore persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.rm(initial.cwd, { recursive: true, force: true });
|
||||
|
||||
const restored = await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: initial.cwd,
|
||||
providerRef: initial.worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-451",
|
||||
title: "Restore persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(restored).not.toBeNull();
|
||||
expect(restored?.cwd).toBe(initial.cwd);
|
||||
await expect(fs.readFile(path.join(initial.cwd, "feature.txt"), "utf8")).resolves.toBe("persisted\n");
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-branch"), "utf8")).resolves.toBe(`${branchName}\n`);
|
||||
const actualHead = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: initial.cwd })).stdout.trim();
|
||||
expect(actualHead).toBe(expectedHead);
|
||||
});
|
||||
|
||||
it("reprovisions an existing persisted git worktree before manual control starts it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "restore.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"printf 'reprovisioned\\n' > .paperclip-restored-state",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "restore.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "scripts/restore.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add reprovision script"]);
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-452",
|
||||
title: "Reprovision persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await fs.rm(path.join(initial.cwd, ".paperclip-restored-state"), { force: true });
|
||||
|
||||
await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
workspace: {
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
cwd: initial.cwd,
|
||||
providerRef: initial.worktreePath,
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
baseRef: "HEAD",
|
||||
branchName: initial.branchName,
|
||||
config: {
|
||||
provisionCommand: "bash ./scripts/restore.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-452",
|
||||
title: "Reprovision persisted worktree",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(initial.cwd, ".paperclip-restored-state"), "utf8")).resolves.toBe("reprovisioned\n");
|
||||
});
|
||||
|
||||
it("auto-detects the default branch when baseRef is not configured", async () => {
|
||||
// Create a repo with "master" as default branch (not "main")
|
||||
const repoRoot = await createTempRepo("master");
|
||||
|
||||
@@ -16,6 +16,7 @@ import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace
|
||||
import {
|
||||
buildWorkspaceRuntimeDesiredStatePatch,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
listConfiguredRuntimeServiceEntries,
|
||||
runWorkspaceJobForControl,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
@@ -123,6 +124,20 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
const projectWorkspaceRuntime = readProjectWorkspaceRuntimeConfig(
|
||||
(projectWorkspace?.metadata as Record<string, unknown> | null) ?? null,
|
||||
)?.workspaceRuntime ?? null;
|
||||
const projectPolicy = existing.projectId
|
||||
? await db
|
||||
.select({
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
eq(projects.id, existing.projectId),
|
||||
eq(projects.companyId, existing.companyId),
|
||||
),
|
||||
)
|
||||
.then((rows) => parseProjectExecutionWorkspacePolicy(rows[0]?.executionWorkspacePolicy))
|
||||
: null;
|
||||
const effectiveRuntimeConfig = existing.config?.workspaceRuntime ?? projectWorkspaceRuntime ?? null;
|
||||
const target = req.body as { workspaceCommandId?: string | null; runtimeServiceId?: string | null; serviceIndex?: number | null };
|
||||
const configuredServices = effectiveRuntimeConfig
|
||||
@@ -197,10 +212,57 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
serviceIndex: selectedServiceIndex,
|
||||
},
|
||||
run: async () => {
|
||||
const ensureWorkspaceAvailable = async () =>
|
||||
await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: {
|
||||
baseCwd: projectWorkspace?.cwd ?? workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
},
|
||||
workspace: {
|
||||
mode: existing.mode,
|
||||
strategyType: existing.strategyType,
|
||||
cwd: existing.cwd,
|
||||
providerRef: existing.providerRef,
|
||||
projectId: existing.projectId,
|
||||
projectWorkspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
baseRef: existing.baseRef,
|
||||
branchName: existing.branchName,
|
||||
config: {
|
||||
...existing.config,
|
||||
provisionCommand:
|
||||
existing.config?.provisionCommand
|
||||
?? projectPolicy?.workspaceStrategy?.provisionCommand
|
||||
?? null,
|
||||
},
|
||||
},
|
||||
issue: existing.sourceIssueId
|
||||
? {
|
||||
id: existing.sourceIssueId,
|
||||
identifier: null,
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
agent: {
|
||||
id: actor.agentId ?? null,
|
||||
name: actor.actorType === "user" ? "Board" : "Agent",
|
||||
companyId: existing.companyId,
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
if (action === "run") {
|
||||
if (!workspaceCommand || workspaceCommand.kind !== "job") {
|
||||
throw new Error("Workspace job selection is required");
|
||||
}
|
||||
const availableWorkspace = await ensureWorkspaceAvailable();
|
||||
if (!availableWorkspace) {
|
||||
throw new Error("Execution workspace needs a local path before Paperclip can run workspace commands");
|
||||
}
|
||||
return await runWorkspaceJobForControl({
|
||||
actor: {
|
||||
id: actor.agentId ?? null,
|
||||
@@ -214,20 +276,7 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
cwd: workspaceCwd,
|
||||
branchName: existing.branchName,
|
||||
worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
workspace: availableWorkspace,
|
||||
command: workspaceCommand.rawConfig,
|
||||
adapterEnv: {},
|
||||
recorder,
|
||||
@@ -261,6 +310,10 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
}
|
||||
|
||||
if (action === "start" || action === "restart") {
|
||||
const availableWorkspace = await ensureWorkspaceAvailable();
|
||||
if (!availableWorkspace) {
|
||||
throw new Error("Execution workspace needs a local path before Paperclip can manage local runtime services");
|
||||
}
|
||||
const startedServices = await startRuntimeServicesForWorkspaceControl({
|
||||
db,
|
||||
actor: {
|
||||
@@ -275,20 +328,7 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
title: existing.name,
|
||||
}
|
||||
: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceCwd,
|
||||
source: existing.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: existing.projectId,
|
||||
workspaceId: existing.projectWorkspaceId,
|
||||
repoUrl: existing.repoUrl,
|
||||
repoRef: existing.baseRef,
|
||||
strategy: existing.strategyType === "git_worktree" ? "git_worktree" : "project_primary",
|
||||
cwd: workspaceCwd,
|
||||
branchName: existing.branchName,
|
||||
worktreePath: existing.strategyType === "git_worktree" ? workspaceCwd : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
workspace: availableWorkspace,
|
||||
executionWorkspaceId: existing.id,
|
||||
config: { workspaceRuntime: effectiveRuntimeConfig },
|
||||
adapterEnv: {},
|
||||
|
||||
@@ -1160,6 +1160,147 @@ export async function realizeExecutionWorkspace(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
base: ExecutionWorkspaceInput;
|
||||
workspace: {
|
||||
mode: string | null | undefined;
|
||||
strategyType: string | null | undefined;
|
||||
cwd: string | null | undefined;
|
||||
providerRef: string | null | undefined;
|
||||
projectId: string | null | undefined;
|
||||
projectWorkspaceId: string | null | undefined;
|
||||
repoUrl: string | null | undefined;
|
||||
baseRef: string | null | undefined;
|
||||
branchName: string | null | undefined;
|
||||
config?: {
|
||||
provisionCommand?: string | null;
|
||||
} | null;
|
||||
};
|
||||
issue: ExecutionWorkspaceIssueRef | null;
|
||||
agent: ExecutionWorkspaceAgentRef;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}): Promise<RealizedExecutionWorkspace | null> {
|
||||
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
|
||||
if (!cwd) return null;
|
||||
|
||||
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
|
||||
const realized: RealizedExecutionWorkspace = {
|
||||
baseCwd: input.base.baseCwd,
|
||||
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
projectId: input.workspace.projectId ?? input.base.projectId,
|
||||
workspaceId: input.workspace.projectWorkspaceId ?? input.base.workspaceId,
|
||||
repoUrl: input.workspace.repoUrl ?? input.base.repoUrl,
|
||||
repoRef: input.workspace.baseRef ?? input.base.repoRef,
|
||||
strategy,
|
||||
cwd,
|
||||
branchName: input.workspace.branchName ?? null,
|
||||
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
|
||||
|
||||
if (strategy !== "git_worktree") {
|
||||
return realized;
|
||||
}
|
||||
if (await directoryExists(cwd)) {
|
||||
if (provisionCommand) {
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
provisionCommand,
|
||||
},
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath: realized.worktreePath ?? cwd,
|
||||
branchName: realized.branchName ?? "",
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
}
|
||||
return realized;
|
||||
}
|
||||
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
const worktreePath = realized.worktreePath ?? cwd;
|
||||
const branchName = asString(input.workspace.branchName, "").trim();
|
||||
if (!branchName) {
|
||||
throw new Error(`Execution workspace "${cwd}" is missing and cannot be restored because no branch name is recorded.`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
|
||||
|
||||
let created = false;
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", worktreePath, branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
||||
created: false,
|
||||
restored: true,
|
||||
},
|
||||
successMessage: `Reattached missing git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!gitErrorIncludes(error, "invalid reference")
|
||||
&& !gitErrorIncludes(error, "not a commit")
|
||||
&& !gitErrorIncludes(error, "unknown revision")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: true,
|
||||
restored: true,
|
||||
},
|
||||
successMessage: `Recreated missing git worktree at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
created = true;
|
||||
}
|
||||
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
...(provisionCommand ? { provisionCommand } : {}),
|
||||
},
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
...realized,
|
||||
cwd: worktreePath,
|
||||
worktreePath,
|
||||
created,
|
||||
};
|
||||
}
|
||||
|
||||
export async function cleanupExecutionWorkspaceArtifacts(input: {
|
||||
workspace: {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user