[codex] Show bundled plugins in plugin manager (#6734)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - The plugin system is how Paperclip exposes optional capabilities and integrations without bloating the control plane. > - Operators need the Instance Settings plugin manager to show both installed external plugins and bundled built-in plugins. > - Bundled plugins were available in the server/UI surface but were not represented consistently in the plugin manager list. > - Workspace runtime reuse also needed to stay pinned to the current branch/base so the plugin manager can be validated from the intended checkout. > - This pull request shows bundled plugins in the manager, marks experimental bundled plugins clearly, and tightens runtime/worktree reuse guards. > - The benefit is that operators can discover bundled plugins from the same management screen as installed plugins without stale workspace sessions hiding the latest branch state. ## What Changed - Lists bundled monorepo plugin packages through the plugin routes API, including plugin status and install metadata needed by the UI. - Updates the plugin manager UI/API client to render bundled plugins and display experimental badges based on installed plugin records. - Adds server authorization coverage around plugin routes so board and agent access stay company-scoped. - Guards execution workspace/runtime reuse against stale base refs and defaults new worktrees to the fetched target base. - Expands workspace runtime tests for service reuse, stale workspace prevention, and controlled runtime stops. - Addressed Greptile feedback by respecting `origin/HEAD`, using async cached bundled-plugin discovery, and avoiding duplicated UI experimental plugin lists. ## Verification - `pnpm exec vitest run server/src/__tests__/plugin-routes-authz.test.ts server/src/__tests__/workspace-runtime.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/plugin-sdk build && pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server typecheck` - `gh pr checks 6734 --repo paperclipai/paperclip` reports all checks passing on `10e1ba9e0f505637cd913713fb28c2c99ae92011`. - Greptile Review reports 5/5 on `10e1ba9e0f505637cd913713fb28c2c99ae92011`. - Confirmed the branch is rebased onto `public-gh/master` and the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - UI screenshots were not captured in this PR-creation pass because the available local board runtime is authenticated; the visible UI path is covered by the plugin manager code changes and server/API tests above. ## Risks - Medium risk: this touches shared plugin listing behavior and workspace runtime reuse, so regressions could affect plugin manager visibility or service reuse across execution workspaces. - No database migrations. - No lockfile or GitHub workflow changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI GPT-5 Codex, coding-agent workflow with shell/tool use in a local Paperclip worktree. Context window not surfaced by the runtime; reasoning mode not externally reported. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -170,6 +170,8 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
|
||||
provisionCommand: "bash ./scripts/provision.sh",
|
||||
},
|
||||
shouldReuseExisting: false,
|
||||
baseRef: null,
|
||||
baseRefSha: null,
|
||||
})).toEqual({
|
||||
source: "task_session",
|
||||
createdByRuntime: true,
|
||||
@@ -200,6 +202,8 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
|
||||
provisionCommand: "bash ./scripts/new-provision.sh",
|
||||
},
|
||||
shouldReuseExisting: true,
|
||||
baseRef: null,
|
||||
baseRefSha: null,
|
||||
})).toEqual({
|
||||
config: {
|
||||
environmentId: "env-old",
|
||||
@@ -209,6 +213,25 @@ describe("mergeExecutionWorkspaceMetadataForPersistence", () => {
|
||||
createdByRuntime: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("records the resolved base ref SHA for newly realized workspaces", () => {
|
||||
expect(mergeExecutionWorkspaceMetadataForPersistence({
|
||||
existingMetadata: null,
|
||||
source: "task_session",
|
||||
createdByRuntime: true,
|
||||
configSnapshot: null,
|
||||
shouldReuseExisting: false,
|
||||
baseRef: "origin/main",
|
||||
baseRefSha: "abc1234567890",
|
||||
})).toEqual({
|
||||
source: "task_session",
|
||||
createdByRuntime: true,
|
||||
baseRefSnapshot: {
|
||||
baseRef: "origin/main",
|
||||
resolvedSha: "abc1234567890",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRealizedExecutionWorkspaceFromPersisted", () => {
|
||||
|
||||
@@ -139,6 +139,27 @@ describe.sequential("plugin install and upgrade authz", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("lists bundled monorepo plugin packages", async () => {
|
||||
const { app } = await createApp(boardActor());
|
||||
|
||||
const res = await request(app).get("/api/plugins/examples");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const packageNames = res.body.map((plugin: { packageName: string }) => plugin.packageName);
|
||||
const byPackageName = new Map(
|
||||
res.body.map((plugin: { packageName: string; experimental: boolean }) => [plugin.packageName, plugin]),
|
||||
);
|
||||
expect(packageNames).toContain("@paperclipai/plugin-workspace-diff");
|
||||
expect(packageNames).toContain("@paperclipai/plugin-llm-wiki");
|
||||
expect(packageNames).toContain("@paperclipai/plugin-modal");
|
||||
expect(packageNames).toContain("@paperclipai/plugin-authoring-smoke-example");
|
||||
expect(packageNames).not.toContain("@paperclipai/plugin-sdk");
|
||||
expect(byPackageName.get("@paperclipai/plugin-workspace-diff")?.experimental).toBe(true);
|
||||
expect(byPackageName.get("@paperclipai/plugin-llm-wiki")?.experimental).toBe(true);
|
||||
expect(byPackageName.get("@paperclipai/plugin-modal")?.experimental).toBe(true);
|
||||
expect(byPackageName.get("@paperclipai/plugin-authoring-smoke-example")?.experimental).toBe(false);
|
||||
}, 20_000);
|
||||
|
||||
it("rejects plugin installation for non-admin board users", async () => {
|
||||
const { app, loader } = await createApp({
|
||||
type: "board",
|
||||
|
||||
@@ -62,6 +62,10 @@ async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function readGit(cwd: string, args: string[]) {
|
||||
return (await execFileAsync("git", args, { cwd })).stdout.trim();
|
||||
}
|
||||
|
||||
async function runPnpm(cwd: string, args: string[]) {
|
||||
await execFileAsync("pnpm", args, { cwd });
|
||||
}
|
||||
@@ -304,6 +308,57 @@ describe("ensureServerWorkspaceLinksCurrent", () => {
|
||||
});
|
||||
|
||||
describe("realizeExecutionWorkspace", () => {
|
||||
it("defaults new git worktrees to freshly fetched origin/master", async () => {
|
||||
const sourceRepo = await createTempRepo("master");
|
||||
const remoteDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-remote-"));
|
||||
const remotePath = path.join(remoteDir, "paperclip.git");
|
||||
await execFileAsync("git", ["clone", "--bare", sourceRepo, remotePath]);
|
||||
|
||||
const cloneRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-clone-"));
|
||||
const repoRoot = path.join(cloneRoot, "paperclip");
|
||||
await execFileAsync("git", ["clone", remotePath, repoRoot]);
|
||||
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
|
||||
await fs.writeFile(path.join(sourceRepo, "auth-fix.txt"), "cookie fix\n", "utf8");
|
||||
await runGit(sourceRepo, ["add", "auth-fix.txt"]);
|
||||
await runGit(sourceRepo, ["commit", "-m", "Add auth fix"]);
|
||||
await runGit(sourceRepo, ["push", remotePath, "master"]);
|
||||
const expectedRemoteHead = await readGit(sourceRepo, ["rev-parse", "master"]);
|
||||
expect(await readGit(repoRoot, ["rev-parse", "origin/master"])).not.toBe(expectedRemoteHead);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(workspace.baseRefSha).toBe(expectedRemoteHead);
|
||||
expect(await readGit(repoRoot, ["rev-parse", "origin/master"])).toBe(expectedRemoteHead);
|
||||
expect(await readGit(workspace.cwd, ["rev-parse", "HEAD"])).toBe(expectedRemoteHead);
|
||||
});
|
||||
|
||||
it("creates and reuses a git worktree for an issue-scoped branch", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
@@ -372,6 +427,75 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("warns when reusing a git worktree whose base ref has advanced", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
const initial = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "main",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
expect(initial.baseRefSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
|
||||
await fs.writeFile(path.join(repoRoot, "server-auth-fix.txt"), "cookie fix\n", "utf8");
|
||||
await runGit(repoRoot, ["add", "server-auth-fix.txt"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add auth runtime fix"]);
|
||||
|
||||
const reused = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "main",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-447",
|
||||
title: "Add Worktree Support",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(reused.created).toBe(false);
|
||||
expect(reused.cwd).toBe(initial.cwd);
|
||||
expect(reused.warnings).toEqual([
|
||||
expect.stringContaining("is behind main by 1 commit"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects reusing an empty directory that only looks like a worktree because it sits inside the repo", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-447-add-worktree-support";
|
||||
@@ -1773,7 +1897,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
// No baseRef configured — should auto-detect "master"
|
||||
// No baseRef configured — should default to origin/master.
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
@@ -1791,25 +1915,23 @@ describe("realizeExecutionWorkspace", () => {
|
||||
|
||||
expect(workspace.strategy).toBe("git_worktree");
|
||||
expect(workspace.created).toBe(true);
|
||||
// The worktree should have been created successfully (baseRef resolved to "master")
|
||||
// The worktree should have been created successfully from the canonical remote base.
|
||||
const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created);
|
||||
expect(worktreeOp).toBeDefined();
|
||||
expect(worktreeOp!.metadata!.baseRef).toBe("master");
|
||||
expect(worktreeOp!.metadata!.baseRef).toBe("origin/master");
|
||||
}, 10_000);
|
||||
|
||||
it("auto-detects the default branch via symbolic-ref when origin/HEAD is set", async () => {
|
||||
// Create a repo with "master" as default branch
|
||||
const repoRoot = await createTempRepo("master");
|
||||
const repoRoot = await createTempRepo("main");
|
||||
|
||||
// Set up a bare remote and push
|
||||
const bareRemote = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-bare-symref-"));
|
||||
await runGit(bareRemote, ["init", "--bare"]);
|
||||
await runGit(repoRoot, ["remote", "add", "origin", bareRemote]);
|
||||
await runGit(repoRoot, ["push", "-u", "origin", "master"]);
|
||||
await runGit(repoRoot, ["push", "-u", "origin", "main", "master"]);
|
||||
await runGit(repoRoot, ["fetch", "origin"]);
|
||||
// Explicitly set refs/remotes/origin/HEAD to exercise the symbolic-ref path
|
||||
// (git remote set-head -a requires the remote to advertise HEAD, so we set it manually)
|
||||
await runGit(repoRoot, ["remote", "set-head", "origin", "master"]);
|
||||
await runGit(repoRoot, ["remote", "set-head", "origin", "main"]);
|
||||
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
@@ -1825,7 +1947,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
// No baseRef configured — should auto-detect "master" via symbolic-ref
|
||||
// No baseRef configured — origin/HEAD should win over fallback branches.
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
@@ -1845,7 +1967,7 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(workspace.created).toBe(true);
|
||||
const worktreeOp = operations.find(op => op.phase === "worktree_prepare" && op.metadata?.created);
|
||||
expect(worktreeOp).toBeDefined();
|
||||
expect(worktreeOp!.metadata!.baseRef).toBe("master");
|
||||
expect(worktreeOp!.metadata!.baseRef).toBe("origin/main");
|
||||
}, 10_000);
|
||||
|
||||
it("removes a created git worktree and branch during cleanup", async () => {
|
||||
|
||||
@@ -250,6 +250,7 @@ export function executionWorkspaceRoutes(db: Db) {
|
||||
repoUrl: existing.repoUrl,
|
||||
baseRef: existing.baseRef,
|
||||
branchName: existing.branchName,
|
||||
metadata: existing.metadata as Record<string, unknown> | null,
|
||||
config: {
|
||||
...existing.config,
|
||||
provisionCommand:
|
||||
|
||||
+160
-51
@@ -18,7 +18,7 @@
|
||||
* @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { access, readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -114,13 +114,14 @@ interface PluginInstallRequest {
|
||||
isLocalPath?: boolean;
|
||||
}
|
||||
|
||||
interface AvailablePluginExample {
|
||||
interface AvailableBundledPlugin {
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: "example" | "first-party";
|
||||
experimental: boolean;
|
||||
}
|
||||
|
||||
/** Response body for GET /api/plugins/:pluginId/health */
|
||||
@@ -150,58 +151,166 @@ const PLUGIN_SCOPED_API_RESPONSE_HEADER_ALLOWLIST = new Set([
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||
const EXPERIMENTAL_BUNDLED_PLUGIN_PACKAGE_NAMES = new Set([
|
||||
"@paperclipai/plugin-llm-wiki",
|
||||
"@paperclipai/plugin-modal",
|
||||
"@paperclipai/plugin-workspace-diff",
|
||||
]);
|
||||
let bundledPluginsCache: Promise<AvailableBundledPlugin[]> | null = null;
|
||||
|
||||
const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
|
||||
{
|
||||
packageName: "@paperclipai/plugin-workspace-diff",
|
||||
pluginKey: "paperclip.workspace-diff",
|
||||
displayName: "Workspace Changes",
|
||||
description: "First-party workspace Changes tab backed by plugin-local Git diff computation.",
|
||||
localPath: "packages/plugins/plugin-workspace-diff",
|
||||
tag: "first-party",
|
||||
},
|
||||
{
|
||||
packageName: "@paperclipai/plugin-hello-world-example",
|
||||
pluginKey: "paperclip.hello-world-example",
|
||||
displayName: "Hello World Widget (Example)",
|
||||
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
|
||||
localPath: "packages/plugins/examples/plugin-hello-world-example",
|
||||
tag: "example",
|
||||
},
|
||||
{
|
||||
packageName: "@paperclipai/plugin-file-browser-example",
|
||||
pluginKey: "paperclip-file-browser-example",
|
||||
displayName: "File Browser (Example)",
|
||||
description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.",
|
||||
localPath: "packages/plugins/examples/plugin-file-browser-example",
|
||||
tag: "example",
|
||||
},
|
||||
{
|
||||
packageName: "@paperclipai/plugin-kitchen-sink-example",
|
||||
pluginKey: "paperclip-kitchen-sink-example",
|
||||
displayName: "Kitchen Sink (Example)",
|
||||
description: "Reference plugin that demonstrates the current Paperclip plugin API surface, bridge flows, UI extension surfaces, jobs, webhooks, tools, streams, and trusted local workspace/process demos.",
|
||||
localPath: "packages/plugins/examples/plugin-kitchen-sink-example",
|
||||
tag: "example",
|
||||
},
|
||||
{
|
||||
packageName: "@paperclipai/plugin-orchestration-smoke-example",
|
||||
pluginKey: "paperclipai.plugin-orchestration-smoke-example",
|
||||
displayName: "Orchestration Smoke (Example)",
|
||||
description: "Acceptance fixture for scoped plugin routes, restricted database namespaces, issue orchestration, documents, wakeups, summaries, and UI status surfaces.",
|
||||
localPath: "packages/plugins/examples/plugin-orchestration-smoke-example",
|
||||
tag: "example",
|
||||
},
|
||||
];
|
||||
function titleCasePluginName(packageName: string): string {
|
||||
const localName = packageName.split("/").pop() ?? packageName;
|
||||
return localName
|
||||
.replace(/^paperclip-plugin-/, "")
|
||||
.replace(/^plugin-/, "")
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function listBundledPluginExamples(): AvailablePluginExample[] {
|
||||
return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => {
|
||||
const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath);
|
||||
if (!existsSync(absoluteLocalPath)) return [];
|
||||
return [{ ...plugin, localPath: absoluteLocalPath }];
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
return access(filePath).then(() => true, () => false);
|
||||
}
|
||||
|
||||
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function findPackageJsonFiles(root: string, maxDepth = 4): Promise<string[]> {
|
||||
if (!(await fileExists(root))) return [];
|
||||
|
||||
const packageJsonFiles: string[] = [];
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isFile() && entry.name === "package.json") {
|
||||
packageJsonFiles.push(entryPath);
|
||||
} else if (entry.isDirectory()) {
|
||||
await walk(entryPath, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(root, 0);
|
||||
return packageJsonFiles;
|
||||
}
|
||||
|
||||
function manifestSourcePath(packageRoot: string, pkgJson: Record<string, unknown>): string | null {
|
||||
const paperclipPlugin = pkgJson.paperclipPlugin;
|
||||
if (
|
||||
!paperclipPlugin
|
||||
|| typeof paperclipPlugin !== "object"
|
||||
|| Array.isArray(paperclipPlugin)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifestPath = (paperclipPlugin as Record<string, unknown>).manifest;
|
||||
if (typeof manifestPath !== "string") return null;
|
||||
|
||||
const sourcePath = manifestPath
|
||||
.replace(/^\.\/dist\//, "./src/")
|
||||
.replace(/\.js$/, ".ts");
|
||||
return path.resolve(packageRoot, sourcePath);
|
||||
}
|
||||
|
||||
function firstStringLiteral(source: string, key: string): string | null {
|
||||
const match = source.match(
|
||||
new RegExp(`${key}:\\s*(?:"([^"]*)"|'([^']*)'|\`([^\`]*)\`)`, "s"),
|
||||
);
|
||||
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
||||
}
|
||||
|
||||
async function bundledPluginMetadata(
|
||||
packageRoot: string,
|
||||
pkgJson: Record<string, unknown>,
|
||||
): Promise<{ pluginKey?: string; displayName?: string; description?: string }> {
|
||||
const sourcePath = manifestSourcePath(packageRoot, pkgJson);
|
||||
if (!sourcePath || !(await fileExists(sourcePath))) return {};
|
||||
|
||||
try {
|
||||
const source = await readFile(sourcePath, "utf8");
|
||||
const pluginId = source
|
||||
.match(/(?:export\s+)?const\s+PLUGIN_ID\s*=\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)/)
|
||||
?.slice(1)
|
||||
.find(Boolean)
|
||||
?? firstStringLiteral(source, "id")
|
||||
?? null;
|
||||
return {
|
||||
pluginKey: pluginId ?? undefined,
|
||||
displayName: firstStringLiteral(source, "displayName") ?? undefined,
|
||||
description: firstStringLiteral(source, "description") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function isExperimentalBundledPlugin(packageRoot: string, packageName: string): boolean {
|
||||
return (
|
||||
EXPERIMENTAL_BUNDLED_PLUGIN_PACKAGE_NAMES.has(packageName)
|
||||
|| packageRoot.includes(`${path.sep}sandbox-providers${path.sep}`)
|
||||
|| packageName.includes("sandbox")
|
||||
);
|
||||
}
|
||||
|
||||
async function discoverBundledPlugins(): Promise<AvailableBundledPlugin[]> {
|
||||
const pluginRoot = path.resolve(REPO_ROOT, "packages/plugins");
|
||||
const bundledPlugins: AvailableBundledPlugin[] = [];
|
||||
for (const packageJsonPath of await findPackageJsonFiles(pluginRoot)) {
|
||||
const packageRoot = path.dirname(packageJsonPath);
|
||||
const pkgJson = await readJsonFile(packageJsonPath);
|
||||
const paperclipPlugin = pkgJson?.paperclipPlugin;
|
||||
if (
|
||||
!pkgJson
|
||||
|| !paperclipPlugin
|
||||
|| typeof paperclipPlugin !== "object"
|
||||
|| Array.isArray(paperclipPlugin)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const packageName = pkgJson.name;
|
||||
if (typeof packageName !== "string" || packageName.length === 0) continue;
|
||||
|
||||
const metadata = await bundledPluginMetadata(packageRoot, pkgJson);
|
||||
const tag = packageRoot.includes(`${path.sep}examples${path.sep}`) ? "example" : "first-party";
|
||||
bundledPlugins.push({
|
||||
packageName,
|
||||
pluginKey: metadata.pluginKey ?? packageName,
|
||||
displayName: metadata.displayName ?? titleCasePluginName(packageName),
|
||||
description: metadata.description
|
||||
?? `Bundled Paperclip plugin from ${path.relative(REPO_ROOT, packageRoot)}.`,
|
||||
localPath: packageRoot,
|
||||
tag,
|
||||
experimental: isExperimentalBundledPlugin(packageRoot, packageName),
|
||||
});
|
||||
}
|
||||
|
||||
return bundledPlugins.sort((left, right) => {
|
||||
if (left.tag !== right.tag) return left.tag === "first-party" ? -1 : 1;
|
||||
return left.displayName.localeCompare(right.displayName);
|
||||
});
|
||||
}
|
||||
|
||||
async function listBundledPlugins(): Promise<AvailableBundledPlugin[]> {
|
||||
bundledPluginsCache ??= discoverBundledPlugins().catch((error: unknown) => {
|
||||
bundledPluginsCache = null;
|
||||
throw error;
|
||||
});
|
||||
return bundledPluginsCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a plugin by either database ID or plugin key.
|
||||
*
|
||||
@@ -677,12 +786,12 @@ export function pluginRoutes(
|
||||
/**
|
||||
* GET /api/plugins/examples
|
||||
*
|
||||
* Return first-party example plugins bundled in this repo, if present.
|
||||
* Return plugin packages bundled in this repo, if present.
|
||||
* These can be installed through the normal local-path install flow.
|
||||
*/
|
||||
router.get("/plugins/examples", async (req, res) => {
|
||||
assertBoardOrgAccess(req);
|
||||
res.json(listBundledPluginExamples());
|
||||
res.json(await listBundledPlugins());
|
||||
});
|
||||
|
||||
// IMPORTANT: Static routes must come before parameterized routes
|
||||
|
||||
@@ -87,6 +87,7 @@ import { logActivity, publishPluginDomainEvent, type LogActivityInput } from "./
|
||||
import {
|
||||
buildWorkspaceReadyComment,
|
||||
cleanupExecutionWorkspaceArtifacts,
|
||||
ensurePersistedExecutionWorkspaceAvailable,
|
||||
ensureRuntimeServicesForRun,
|
||||
persistAdapterManagedRuntimeServices,
|
||||
realizeExecutionWorkspace,
|
||||
@@ -594,6 +595,8 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: {
|
||||
createdByRuntime: boolean;
|
||||
configSnapshot: Record<string, unknown> | null;
|
||||
shouldReuseExisting: boolean;
|
||||
baseRef: string | null | undefined;
|
||||
baseRefSha: string | null | undefined;
|
||||
}) {
|
||||
const base = {
|
||||
...(input.existingMetadata ?? {}),
|
||||
@@ -601,6 +604,17 @@ export function mergeExecutionWorkspaceMetadataForPersistence(input: {
|
||||
createdByRuntime: input.createdByRuntime,
|
||||
} as Record<string, unknown>;
|
||||
|
||||
const existingSnapshot = parseObject(base.baseRefSnapshot);
|
||||
if (
|
||||
typeof existingSnapshot.resolvedSha !== "string"
|
||||
&& input.baseRefSha
|
||||
) {
|
||||
base.baseRefSnapshot = {
|
||||
baseRef: input.baseRef ?? null,
|
||||
resolvedSha: input.baseRefSha,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.shouldReuseExisting || !input.configSnapshot) {
|
||||
return base;
|
||||
}
|
||||
@@ -624,6 +638,8 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
|
||||
}
|
||||
|
||||
const strategy = input.workspace.strategyType === "git_worktree" ? "git_worktree" : "project_primary";
|
||||
const baseRefSnapshot = parseObject(input.workspace.metadata?.baseRefSnapshot);
|
||||
const baseRefSha = typeof baseRefSnapshot.resolvedSha === "string" ? baseRefSnapshot.resolvedSha : null;
|
||||
return {
|
||||
baseCwd: input.base.baseCwd,
|
||||
source: input.workspace.mode === "shared_workspace" ? "project_primary" : "task_session",
|
||||
@@ -637,6 +653,7 @@ export function buildRealizedExecutionWorkspaceFromPersisted(input: {
|
||||
worktreePath: strategy === "git_worktree" ? (readNonEmptyString(input.workspace.providerRef) ?? cwd) : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
baseRefSha,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7229,7 +7246,34 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
repoRef: resolvedWorkspace.repoRef,
|
||||
} satisfies ExecutionWorkspaceInput;
|
||||
const reusedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||
? buildRealizedExecutionWorkspaceFromPersisted({
|
||||
? await ensurePersistedExecutionWorkspaceAvailable({
|
||||
base: executionWorkspaceBase,
|
||||
workspace: {
|
||||
mode: existingExecutionWorkspace.mode,
|
||||
strategyType: existingExecutionWorkspace.strategyType,
|
||||
cwd: existingExecutionWorkspace.cwd,
|
||||
providerRef: existingExecutionWorkspace.providerRef,
|
||||
projectId: existingExecutionWorkspace.projectId,
|
||||
projectWorkspaceId: existingExecutionWorkspace.projectWorkspaceId,
|
||||
repoUrl: existingExecutionWorkspace.repoUrl,
|
||||
baseRef: existingExecutionWorkspace.baseRef,
|
||||
branchName: existingExecutionWorkspace.branchName,
|
||||
metadata: existingExecutionWorkspace.metadata as Record<string, unknown> | null,
|
||||
config: {
|
||||
provisionCommand:
|
||||
existingExecutionWorkspace.config?.provisionCommand
|
||||
?? projectExecutionWorkspacePolicy?.workspaceStrategy?.provisionCommand
|
||||
?? null,
|
||||
},
|
||||
},
|
||||
issue: issueRef,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
companyId: agent.companyId,
|
||||
},
|
||||
recorder: workspaceOperationRecorder,
|
||||
}) ?? buildRealizedExecutionWorkspaceFromPersisted({
|
||||
base: executionWorkspaceBase,
|
||||
workspace: existingExecutionWorkspace,
|
||||
})
|
||||
@@ -7254,6 +7298,8 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
createdByRuntime: executionWorkspace.created,
|
||||
configSnapshot,
|
||||
shouldReuseExisting,
|
||||
baseRef: executionWorkspace.repoRef,
|
||||
baseRefSha: executionWorkspace.baseRefSha ?? null,
|
||||
});
|
||||
try {
|
||||
persistedExecutionWorkspace = shouldReuseExisting && existingExecutionWorkspace
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface RealizedExecutionWorkspace extends ExecutionWorkspaceInput {
|
||||
worktreePath: string | null;
|
||||
warnings: string[];
|
||||
created: boolean;
|
||||
baseRefSha?: string | null;
|
||||
}
|
||||
|
||||
export interface RuntimeServiceRef {
|
||||
@@ -524,11 +525,110 @@ async function runGit(args: string[], cwd: string): Promise<string> {
|
||||
return proc.stdout.trim();
|
||||
}
|
||||
|
||||
function formatShortSha(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 12) : "unknown";
|
||||
}
|
||||
|
||||
function gitErrorIncludes(error: unknown, needle: string) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.toLowerCase().includes(needle.toLowerCase());
|
||||
}
|
||||
|
||||
function parseRemoteTrackingRef(ref: string): { remote: string; branch: string } | null {
|
||||
const trimmed = ref.trim();
|
||||
const refsRemotesPrefix = "refs/remotes/";
|
||||
const normalized = trimmed.startsWith(refsRemotesPrefix)
|
||||
? trimmed.slice(refsRemotesPrefix.length)
|
||||
: trimmed;
|
||||
const slashIndex = normalized.indexOf("/");
|
||||
if (slashIndex <= 0 || slashIndex === normalized.length - 1) return null;
|
||||
const remote = normalized.slice(0, slashIndex);
|
||||
const branch = normalized.slice(slashIndex + 1);
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(remote)) return null;
|
||||
return { remote, branch };
|
||||
}
|
||||
|
||||
async function refreshRemoteTrackingBaseRef(repoRoot: string, baseRef: string): Promise<string[]> {
|
||||
const remoteTracking = parseRemoteTrackingRef(baseRef);
|
||||
if (!remoteTracking) return [];
|
||||
|
||||
const remoteExists = await runGit(["remote", "get-url", remoteTracking.remote], repoRoot)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!remoteExists) return [];
|
||||
|
||||
try {
|
||||
await runGit([
|
||||
"fetch",
|
||||
"--prune",
|
||||
remoteTracking.remote,
|
||||
`+refs/heads/${remoteTracking.branch}:refs/remotes/${remoteTracking.remote}/${remoteTracking.branch}`,
|
||||
], repoRoot);
|
||||
return [];
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return [`Could not refresh base ref ${baseRef} before preparing the execution workspace: ${message}`];
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBaseRefSha(repoRoot: string, baseRef: string): Promise<string | null> {
|
||||
return await runGit(["rev-parse", "--verify", `${baseRef}^{commit}`], repoRoot).catch(() => null);
|
||||
}
|
||||
|
||||
function readRecordedBaseRefSha(metadata: Record<string, unknown> | null | undefined): string | null {
|
||||
const snapshot = parseObject(metadata?.baseRefSnapshot);
|
||||
const resolvedSha = snapshot.resolvedSha;
|
||||
return typeof resolvedSha === "string" && resolvedSha.trim().length > 0 ? resolvedSha.trim() : null;
|
||||
}
|
||||
|
||||
export async function inspectExecutionWorkspaceBaseDrift(input: {
|
||||
repoRoot: string;
|
||||
worktreePath: string;
|
||||
branchName: string | null;
|
||||
baseRef: string | null;
|
||||
recordedBaseRefSha?: string | null;
|
||||
skipRefresh?: boolean;
|
||||
}): Promise<{
|
||||
warnings: string[];
|
||||
currentBaseRefSha: string | null;
|
||||
branchBaseRefSha: string | null;
|
||||
}> {
|
||||
const baseRef = input.baseRef?.trim();
|
||||
if (!baseRef) {
|
||||
return { warnings: [], currentBaseRefSha: null, branchBaseRefSha: null };
|
||||
}
|
||||
|
||||
const warnings = input.skipRefresh ? [] : await refreshRemoteTrackingBaseRef(input.repoRoot, baseRef);
|
||||
const currentBaseRefSha = await resolveBaseRefSha(input.repoRoot, baseRef);
|
||||
if (!currentBaseRefSha) {
|
||||
warnings.push(`Could not resolve base ref ${baseRef} while checking execution workspace freshness.`);
|
||||
return { warnings, currentBaseRefSha: null, branchBaseRefSha: null };
|
||||
}
|
||||
|
||||
const branchBaseRefSha = await runGit(["merge-base", "HEAD", baseRef], input.worktreePath).catch(() => null);
|
||||
if (!branchBaseRefSha) {
|
||||
warnings.push(`Could not compare execution workspace ${input.branchName ?? "branch"} against base ref ${baseRef}.`);
|
||||
return { warnings, currentBaseRefSha, branchBaseRefSha: null };
|
||||
}
|
||||
|
||||
if (branchBaseRefSha !== currentBaseRefSha) {
|
||||
const behindCountRaw = await runGit(["rev-list", "--count", `HEAD..${baseRef}`], input.worktreePath).catch(() => "");
|
||||
const behindCount = Number.parseInt(behindCountRaw, 10);
|
||||
const behindText = Number.isFinite(behindCount) && behindCount > 0
|
||||
? `${behindCount} commit${behindCount === 1 ? "" : "s"}`
|
||||
: "newer commits";
|
||||
const recordedText = input.recordedBaseRefSha
|
||||
? `recorded base ${formatShortSha(input.recordedBaseRefSha)}`
|
||||
: `merge-base ${formatShortSha(branchBaseRefSha)}`;
|
||||
warnings.push(
|
||||
`Execution workspace branch ${input.branchName ? `"${input.branchName}"` : "HEAD"} is behind ${baseRef} by ${behindText}: ${recordedText}, current base ${formatShortSha(currentBaseRefSha)}. Refresh or rebase the workspace before relying on recent base-branch fixes.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { warnings, currentBaseRefSha, branchBaseRefSha };
|
||||
}
|
||||
|
||||
|
||||
type GitWorktreeListEntry = {
|
||||
worktree: string;
|
||||
branch: string | null;
|
||||
@@ -597,16 +697,19 @@ async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
||||
repoRoot,
|
||||
);
|
||||
const branch = remoteHead?.startsWith("origin/") ? remoteHead.slice("origin/".length) : remoteHead;
|
||||
if (branch) return branch;
|
||||
if (remoteHead) {
|
||||
await refreshRemoteTrackingBaseRef(repoRoot, remoteHead);
|
||||
if (await resolveBaseRefSha(repoRoot, remoteHead)) return remoteHead;
|
||||
}
|
||||
} catch {
|
||||
// Not set — fall through to heuristic
|
||||
}
|
||||
|
||||
// Fallback: check for common default branch names on the remote
|
||||
for (const candidate of ["main", "master"]) {
|
||||
for (const candidate of ["origin/master", "origin/main", "main", "master"]) {
|
||||
try {
|
||||
await runGit(["rev-parse", "--verify", `refs/remotes/origin/${candidate}`], repoRoot);
|
||||
await refreshRemoteTrackingBaseRef(repoRoot, candidate);
|
||||
await runGit(["rev-parse", "--verify", `${candidate}^{commit}`], repoRoot);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Not found — try next
|
||||
@@ -1003,6 +1106,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
baseRefSha: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1026,10 +1130,20 @@ export async function realizeExecutionWorkspace(input: {
|
||||
const baseRef = configuredBaseRef
|
||||
?? await detectDefaultBranch(repoRoot)
|
||||
?? "HEAD";
|
||||
const baseRefreshWarnings = await refreshRemoteTrackingBaseRef(repoRoot, baseRef);
|
||||
const currentBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
||||
|
||||
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||
|
||||
async function reuseExistingWorktree(reusablePath: string) {
|
||||
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
||||
repoRoot,
|
||||
worktreePath: reusablePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
recordedBaseRefSha: null,
|
||||
skipRefresh: true,
|
||||
});
|
||||
if (input.recorder) {
|
||||
await input.recorder.recordOperation({
|
||||
phase: "worktree_prepare",
|
||||
@@ -1039,6 +1153,8 @@ export async function realizeExecutionWorkspace(input: {
|
||||
worktreePath: reusablePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
currentBaseRefSha: baseDrift.currentBaseRefSha,
|
||||
branchBaseRefSha: baseDrift.branchBaseRefSha,
|
||||
created: false,
|
||||
reused: true,
|
||||
},
|
||||
@@ -1066,8 +1182,9 @@ export async function realizeExecutionWorkspace(input: {
|
||||
cwd: reusablePath,
|
||||
branchName,
|
||||
worktreePath: reusablePath,
|
||||
warnings: [],
|
||||
warnings: [...baseRefreshWarnings, ...baseDrift.warnings],
|
||||
created: false,
|
||||
baseRefSha: baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1109,6 +1226,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
baseRefSha: currentBaseRefSha,
|
||||
created: true,
|
||||
},
|
||||
successMessage: `Created git worktree at ${worktreePath}\n`,
|
||||
@@ -1128,6 +1246,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
baseRefSha: currentBaseRefSha,
|
||||
created: false,
|
||||
reusedExistingBranch: true,
|
||||
},
|
||||
@@ -1163,8 +1282,9 @@ export async function realizeExecutionWorkspace(input: {
|
||||
cwd: worktreePath,
|
||||
branchName,
|
||||
worktreePath,
|
||||
warnings: [],
|
||||
warnings: baseRefreshWarnings,
|
||||
created: true,
|
||||
baseRefSha: currentBaseRefSha,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1180,6 +1300,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
repoUrl: string | null | undefined;
|
||||
baseRef: string | null | undefined;
|
||||
branchName: string | null | undefined;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
config?: {
|
||||
provisionCommand?: string | null;
|
||||
} | null;
|
||||
@@ -1205,15 +1326,26 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
worktreePath: strategy === "git_worktree" ? (input.workspace.providerRef ?? cwd) : null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
baseRefSha: readRecordedBaseRefSha(input.workspace.metadata),
|
||||
};
|
||||
const provisionCommand = asString(input.workspace.config?.provisionCommand, "").trim();
|
||||
|
||||
if (strategy !== "git_worktree") {
|
||||
return realized;
|
||||
}
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
|
||||
if (await directoryExists(cwd)) {
|
||||
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
||||
repoRoot,
|
||||
worktreePath: realized.worktreePath ?? cwd,
|
||||
branchName: realized.branchName,
|
||||
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
||||
recordedBaseRefSha,
|
||||
});
|
||||
realized.warnings = baseDrift.warnings;
|
||||
realized.baseRefSha = recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
||||
if (provisionCommand) {
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
@@ -1232,7 +1364,6 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
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) {
|
||||
@@ -1241,6 +1372,9 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
|
||||
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
||||
await runGit(["worktree", "prune"], repoRoot).catch(() => {});
|
||||
const restoreBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;
|
||||
const restoreRefreshWarnings = restoreBaseRef ? await refreshRemoteTrackingBaseRef(repoRoot, restoreBaseRef) : [];
|
||||
const restoreCurrentBaseRefSha = restoreBaseRef ? await resolveBaseRefSha(repoRoot, restoreBaseRef) : null;
|
||||
|
||||
let created = false;
|
||||
try {
|
||||
@@ -1253,6 +1387,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
||||
currentBaseRefSha: restoreCurrentBaseRefSha,
|
||||
created: false,
|
||||
restored: true,
|
||||
},
|
||||
@@ -1268,6 +1403,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
throw error;
|
||||
}
|
||||
const baseRef = input.workspace.baseRef ?? await detectDefaultBranch(repoRoot) ?? "HEAD";
|
||||
const recreatedBaseRefSha = await resolveBaseRefSha(repoRoot, baseRef);
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", "-b", branchName, worktreePath, baseRef],
|
||||
@@ -1277,6 +1413,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
baseRefSha: recreatedBaseRefSha,
|
||||
created: true,
|
||||
restored: true,
|
||||
},
|
||||
@@ -1286,6 +1423,15 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
created = true;
|
||||
}
|
||||
|
||||
const baseDrift = await inspectExecutionWorkspaceBaseDrift({
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef: input.workspace.baseRef ?? input.base.repoRef ?? null,
|
||||
recordedBaseRefSha,
|
||||
skipRefresh: true,
|
||||
});
|
||||
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
@@ -1305,7 +1451,12 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
||||
...realized,
|
||||
cwd: worktreePath,
|
||||
worktreePath,
|
||||
warnings: [...restoreRefreshWarnings, ...baseDrift.warnings],
|
||||
created,
|
||||
baseRefSha:
|
||||
recordedBaseRefSha
|
||||
?? (created ? restoreCurrentBaseRefSha : baseDrift.branchBaseRefSha)
|
||||
?? baseDrift.currentBaseRefSha,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user