diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 3c74475e..47282db0 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -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", () => { diff --git a/server/src/__tests__/plugin-routes-authz.test.ts b/server/src/__tests__/plugin-routes-authz.test.ts index 272e20c3..90371991 100644 --- a/server/src/__tests__/plugin-routes-authz.test.ts +++ b/server/src/__tests__/plugin-routes-authz.test.ts @@ -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", diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index c605e45b..1423e1da 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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 () => { diff --git a/server/src/routes/execution-workspaces.ts b/server/src/routes/execution-workspaces.ts index ba2f9180..820b8fad 100644 --- a/server/src/routes/execution-workspaces.ts +++ b/server/src/routes/execution-workspaces.ts @@ -250,6 +250,7 @@ export function executionWorkspaceRoutes(db: Db) { repoUrl: existing.repoUrl, baseRef: existing.baseRef, branchName: existing.branchName, + metadata: existing.metadata as Record | null, config: { ...existing.config, provisionCommand: diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index d572c90d..c3f6265f 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -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 | 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 { + return access(filePath).then(() => true, () => false); +} + +async function readJsonFile(filePath: string): Promise | null> { + try { + return JSON.parse(await readFile(filePath, "utf8")) as Record; + } catch { + return null; + } +} + +async function findPackageJsonFiles(root: string, maxDepth = 4): Promise { + if (!(await fileExists(root))) return []; + + const packageJsonFiles: string[] = []; + const walk = async (dir: string, depth: number): Promise => { + 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 | null { + const paperclipPlugin = pkgJson.paperclipPlugin; + if ( + !paperclipPlugin + || typeof paperclipPlugin !== "object" + || Array.isArray(paperclipPlugin) + ) { + return null; + } + + const manifestPath = (paperclipPlugin as Record).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, +): 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 { + 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 { + 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 diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8c34e992..3464f833 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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 | 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; + 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 | 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 diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index b601f495..4daf8523 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -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 { 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 { + 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 { + return await runGit(["rev-parse", "--verify", `${baseRef}^{commit}`], repoRoot).catch(() => null); +} + +function readRecordedBaseRefSha(metadata: Record | 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 { ["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 | 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, }; } diff --git a/ui/src/api/plugins.ts b/ui/src/api/plugins.ts index 9df0b937..b0187560 100644 --- a/ui/src/api/plugins.ts +++ b/ui/src/api/plugins.ts @@ -132,13 +132,14 @@ export interface PluginDashboardData { checkedAt: string; } -export interface AvailablePluginExample { +export interface AvailableBundledPlugin { packageName: string; pluginKey: string; displayName: string; description: string; localPath: string; tag: "example" | "first-party"; + experimental: boolean; } export interface PluginLocalFolderProblem { @@ -215,10 +216,10 @@ export const pluginsApi = { api.get(`/plugins${status ? `?status=${status}` : ""}`), /** - * List bundled example plugins available from the current repo checkout. + * List bundled plugin packages available from the current repo checkout. */ - listExamples: () => - api.get("/plugins/examples"), + listBundled: () => + api.get("/plugins/examples"), /** * Fetch a single plugin record by its UUID or plugin key. diff --git a/ui/src/pages/PluginManager.tsx b/ui/src/pages/PluginManager.tsx index ce73e25c..4293d767 100644 --- a/ui/src/pages/PluginManager.tsx +++ b/ui/src/pages/PluginManager.tsx @@ -43,6 +43,31 @@ function getPluginErrorSummary(plugin: PluginRecord): string { return firstNonEmptyLine(plugin.lastError) ?? "Plugin entered an error state without a stored error message."; } +function isExperimentalPluginIdentity(input: { + packageName?: string | null; + packagePath?: string | null; + manifestJson?: PluginRecord["manifestJson"] | null; + bundledExperimental?: boolean; +}) { + if (input.bundledExperimental) return true; + + const packageName = input.packageName ?? ""; + const packagePath = input.packagePath ?? ""; + if (packageName.includes("sandbox") || packagePath.includes("sandbox")) return true; + return input.manifestJson?.environmentDrivers?.some((driver) => driver.kind === "sandbox_provider") === true; +} + +function ExperimentalBadge() { + return ( + + Experimental + + ); +} + /** * PluginManager page component. * @@ -85,9 +110,9 @@ export function PluginManager() { queryFn: () => pluginsApi.list(), }); - const examplesQuery = useQuery({ + const bundledQuery = useQuery({ queryKey: queryKeys.plugins.examples, - queryFn: () => pluginsApi.listExamples(), + queryFn: () => pluginsApi.listBundled(), }); const invalidatePluginQueries = () => { @@ -144,9 +169,9 @@ export function PluginManager() { }); const installedPlugins = plugins ?? []; - const examples = examplesQuery.data ?? []; + const bundledPlugins = bundledQuery.data ?? []; const installedByPackageName = new Map(installedPlugins.map((plugin) => [plugin.packageName, plugin])); - const examplePackageNames = new Set(examples.map((example) => example.packageName)); + const bundledByPackageName = new Map(bundledPlugins.map((plugin) => [plugin.packageName, plugin])); const errorSummaryByPluginId = useMemo( () => new Map( @@ -223,30 +248,37 @@ export function PluginManager() { Bundled - {examplesQuery.isLoading ? ( + {bundledQuery.isLoading ? (
Loading bundled plugins...
- ) : examplesQuery.error ? ( + ) : bundledQuery.error ? (
Failed to load bundled plugins.
- ) : examples.length === 0 ? ( + ) : bundledPlugins.length === 0 ? (
No bundled plugins were found in this checkout.
) : (
    - {examples.map((example) => { - const installedPlugin = installedByPackageName.get(example.packageName); + {bundledPlugins.map((bundledPlugin) => { + const installedPlugin = installedByPackageName.get(bundledPlugin.packageName); const installPending = installMutation.isPending && installMutation.variables?.isLocalPath && - installMutation.variables.packageName === example.localPath; + installMutation.variables.packageName === bundledPlugin.localPath; return ( -
  • +
  • - {example.displayName} - {example.tag === "first-party" ? "First-party" : "Example"} + {bundledPlugin.displayName} + + {bundledPlugin.tag === "first-party" ? "First-party" : "Example"} + + {isExperimentalPluginIdentity({ + packageName: bundledPlugin.packageName, + packagePath: bundledPlugin.localPath, + bundledExperimental: bundledPlugin.experimental, + }) && } {installedPlugin ? ( Not installed )}
    -

    {example.description}

    -

    {example.packageName}

    +

    {bundledPlugin.description}

    +

    {bundledPlugin.packageName}

    {installedPlugin ? ( @@ -286,12 +318,12 @@ export function PluginManager() { disabled={installPending || installMutation.isPending} onClick={() => installMutation.mutate({ - packageName: example.localPath, + packageName: bundledPlugin.localPath, isLocalPath: true, }) } > - {installPending ? "Installing..." : "Install Example"} + {installPending ? "Installing..." : "Install"} )}
    @@ -333,9 +365,19 @@ export function PluginManager() { > {plugin.manifestJson.displayName ?? plugin.packageName} - {examplePackageNames.has(plugin.packageName) && ( - Example + {bundledByPackageName.has(plugin.packageName) && ( + + {bundledByPackageName.get(plugin.packageName)?.tag === "first-party" + ? "First-party" + : "Example"} + )} + {isExperimentalPluginIdentity({ + packageName: plugin.packageName, + packagePath: plugin.packagePath, + manifestJson: plugin.manifestJson, + bundledExperimental: bundledByPackageName.get(plugin.packageName)?.experimental, + }) && }