From 314288ff8223cc7313e7e072b8722684eeffd406 Mon Sep 17 00:00:00 2001 From: Kevin Mok Date: Thu, 5 Mar 2026 18:59:42 -0600 Subject: [PATCH 001/118] Add gpt-5.4 fallback and xhigh effort options --- packages/adapters/codex-local/src/index.ts | 2 +- packages/adapters/opencode-local/src/index.ts | 3 ++- server/src/__tests__/adapter-models.test.ts | 8 ++++++++ ui/src/components/AgentConfigForm.tsx | 2 ++ ui/src/components/NewIssueDialog.tsx | 2 ++ 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index f09e50d9..da7a8d0b 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -24,7 +24,7 @@ Core fields: - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime - model (string, optional): Codex model id -- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=... +- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=... - promptTemplate (string, optional): run prompt template - search (boolean, optional): run codex with --search - dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 2688a0f2..5f5be605 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -4,6 +4,7 @@ export const DEFAULT_OPENCODE_LOCAL_MODEL = "openai/gpt-5.2-codex"; export const models = [ { id: DEFAULT_OPENCODE_LOCAL_MODEL, label: DEFAULT_OPENCODE_LOCAL_MODEL }, + { id: "openai/gpt-5.4", label: "openai/gpt-5.4" }, { id: "openai/gpt-5.2", label: "openai/gpt-5.2" }, { id: "openai/gpt-5.1-codex-max", label: "openai/gpt-5.1-codex-max" }, { id: "openai/gpt-5.1-codex-mini", label: "openai/gpt-5.1-codex-mini" }, @@ -27,7 +28,7 @@ Core fields: - cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible) - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt - model (string, optional): OpenCode model id in provider/model format (for example openai/gpt-5.2-codex) -- variant (string, optional): provider-specific reasoning/profile variant passed as --variant +- variant (string, optional): provider-specific reasoning/profile variant passed as --variant (for example minimal|low|medium|high|xhigh|max) - promptTemplate (string, optional): run prompt template - command (string, optional): defaults to "opencode" - extraArgs (string[], optional): additional CLI args diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index b03166b6..1bd878b7 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { models as codexFallbackModels } from "@paperclipai/adapter-codex-local"; import { models as cursorFallbackModels } from "@paperclipai/adapter-cursor-local"; +import { models as opencodeFallbackModels } from "@paperclipai/adapter-opencode-local"; import { listAdapterModels } from "../adapters/index.js"; import { resetCodexModelsCacheForTests } from "../adapters/codex-models.js"; import { resetCursorModelsCacheForTests, setCursorModelsRunnerForTests } from "../adapters/cursor-models.js"; @@ -72,6 +73,13 @@ describe("adapter model listing", () => { expect(models).toEqual(cursorFallbackModels); }); + it("returns opencode fallback models including gpt-5.4", async () => { + const models = await listAdapterModels("opencode_local"); + + expect(models).toEqual(opencodeFallbackModels); + expect(models.some((model) => model.id === "openai/gpt-5.4")).toBe(true); + }); + it("loads cursor models dynamically and caches them", async () => { const runner = vi.fn(() => ({ status: 0, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index a99fb25b..b5a23db4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -130,6 +130,7 @@ const codexThinkingEffortOptions = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, + { id: "xhigh", label: "X-High" }, ] as const; const opencodeVariantOptions = [ @@ -138,6 +139,7 @@ const opencodeVariantOptions = [ { id: "low", label: "Low" }, { id: "medium", label: "Medium" }, { id: "high", label: "High" }, + { id: "xhigh", label: "X-High" }, { id: "max", label: "Max" }, ] as const; diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 1b07385f..1840c727 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -82,6 +82,7 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, + { value: "xhigh", label: "X-High" }, ], opencode_local: [ { value: "", label: "Default" }, @@ -89,6 +90,7 @@ const ISSUE_THINKING_EFFORT_OPTIONS = { { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, + { value: "xhigh", label: "X-High" }, { value: "max", label: "Max" }, ], } as const; From 666ab5364893d1c9c1e0205c575adfcfd37f85e0 Mon Sep 17 00:00:00 2001 From: Kevin Mok Date: Thu, 5 Mar 2026 19:55:15 -0600 Subject: [PATCH 002/118] Remove redundant opencode model assertion --- server/src/__tests__/adapter-models.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/__tests__/adapter-models.test.ts b/server/src/__tests__/adapter-models.test.ts index 1bd878b7..81be014e 100644 --- a/server/src/__tests__/adapter-models.test.ts +++ b/server/src/__tests__/adapter-models.test.ts @@ -77,7 +77,6 @@ describe("adapter model listing", () => { const models = await listAdapterModels("opencode_local"); expect(models).toEqual(opencodeFallbackModels); - expect(models.some((model) => model.id === "openai/gpt-5.4")).toBe(true); }); it("loads cursor models dynamically and caches them", async () => { From 59b1d1551add33a16e9386e77062731d731c9f1f Mon Sep 17 00:00:00 2001 From: Genie Date: Sun, 15 Mar 2026 20:13:09 -0300 Subject: [PATCH 003/118] fix: Vite HMR WebSocket for reverse proxy + WS StrictMode guard When running behind a reverse proxy (e.g. Caddy), the live-events WebSocket would fail to connect because it constructed the URL from window.location without accounting for proxy routing. Also fixes React StrictMode double-invoke of WebSocket connections by deferring the connect call via a cleanup guard. - Replace deprecated apple-mobile-web-app-capable meta tag - Guard WS connect with mounted flag to prevent StrictMode double-open - Use protocol-relative WebSocket URL derivation for proxy compatibility --- ui/index.html | 2 +- ui/src/context/LiveUpdatesProvider.tsx | 96 ++++++++++++++++++++------ 2 files changed, 75 insertions(+), 23 deletions(-) diff --git a/ui/index.html b/ui/index.html index 1bb9152e..d982aa0a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -4,7 +4,7 @@ - + Paperclip diff --git a/ui/src/context/LiveUpdatesProvider.tsx b/ui/src/context/LiveUpdatesProvider.tsx index 5ad06a72..69fdc61f 100644 --- a/ui/src/context/LiveUpdatesProvider.tsx +++ b/ui/src/context/LiveUpdatesProvider.tsx @@ -511,24 +511,39 @@ function handleLiveEvent( } export function LiveUpdatesProvider({ children }: { children: ReactNode }) { - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany } = useCompany(); const queryClient = useQueryClient(); const { pushToast } = useToast(); const gateRef = useRef({ cooldownHits: new Map(), suppressUntil: 0 }); - const { data: session } = useQuery({ + const { data: session, status: sessionStatus } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, }); const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; + const socketAuthKey = session?.session?.id ?? currentUserId ?? "signed_out"; + const liveCompanyId = selectedCompany?.id === selectedCompanyId ? selectedCompanyId : null; + const canConnectSocket = sessionStatus === "success" && session !== null && liveCompanyId !== null; + const currentActorRef = useRef<{ userId: string | null; agentId: string | null }>({ + userId: currentUserId, + agentId: null, + }); useEffect(() => { - if (!selectedCompanyId) return; + currentActorRef.current = { + userId: currentUserId, + agentId: null, + }; + }, [currentUserId]); + + useEffect(() => { + if (!canConnectSocket || !liveCompanyId) return; let closed = false; let reconnectAttempt = 0; let reconnectTimer: number | null = null; let socket: WebSocket | null = null; + const noop = () => undefined; const clearReconnect = () => { if (reconnectTimer !== null) { @@ -537,6 +552,35 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { } }; + const closeSocketQuietly = (target: WebSocket | null, reason: string) => { + if (!target) return; + + if (target.readyState === WebSocket.CONNECTING) { + // Let the handshake complete and then close. Calling close() while the + // socket is still CONNECTING is what triggers the noisy browser error. + target.onopen = () => { + target.onopen = null; + target.onmessage = null; + target.onerror = null; + target.onclose = null; + target.close(1000, reason); + }; + target.onmessage = null; + target.onerror = noop; + target.onclose = null; + return; + } + + target.onopen = null; + target.onmessage = null; + target.onerror = null; + target.onclose = null; + + if (target.readyState === WebSocket.OPEN) { + target.close(1000, reason); + } + }; + const scheduleReconnect = () => { if (closed) return; reconnectAttempt += 1; @@ -550,55 +594,63 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) { const connect = () => { if (closed) return; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`; - socket = new WebSocket(url); + const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(liveCompanyId)}/events/ws`; + const nextSocket = new WebSocket(url); + socket = nextSocket; - socket.onopen = () => { + nextSocket.onopen = () => { + if (closed || socket !== nextSocket) { + closeSocketQuietly(nextSocket, "stale_connection"); + return; + } if (reconnectAttempt > 0) { gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS; } reconnectAttempt = 0; }; - socket.onmessage = (message) => { + nextSocket.onmessage = (message) => { const raw = typeof message.data === "string" ? message.data : ""; if (!raw) return; try { const parsed = JSON.parse(raw) as LiveEvent; - handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, { - userId: currentUserId, - agentId: null, + handleLiveEvent(queryClient, liveCompanyId, parsed, pushToast, gateRef.current, { + userId: currentActorRef.current.userId, + agentId: currentActorRef.current.agentId, }); } catch { // Ignore non-JSON payloads. } }; - socket.onerror = () => { - socket?.close(); + nextSocket.onerror = () => { + // Wait for onclose to drive the reconnect. Self-closing here is what + // produces the "closed before connection established" browser noise. }; - socket.onclose = () => { + nextSocket.onclose = () => { + if (socket !== nextSocket) return; + socket = null; if (closed) return; scheduleReconnect(); }; }; - connect(); + // Delay initial connect slightly so React StrictMode's double-invoke + // cleanup fires before the WebSocket is created, avoiding the + // "WebSocket closed before connection established" dev-mode error. + const connectTimer = window.setTimeout(connect, 0); return () => { closed = true; + window.clearTimeout(connectTimer); clearReconnect(); - if (socket) { - socket.onopen = null; - socket.onmessage = null; - socket.onerror = null; - socket.onclose = null; - socket.close(1000, "provider_unmount"); - } + const activeSocket = socket; + socket = null; + closeSocketQuietly(activeSocket, "provider_unmount"); }; - }, [queryClient, selectedCompanyId, pushToast, currentUserId]); + }, [queryClient, liveCompanyId, pushToast, canConnectSocket, socketAuthKey]); return <>{children}; } From d0e01d2863d99191e722eb09a48f806a8487defb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:06:43 -0700 Subject: [PATCH 004/118] fix(server): include x-forwarded-host in board mutation origin check Behind a reverse proxy with a custom port (e.g. Caddy on :3443), the browser sends an Origin header that includes the port, but the board mutation guard only read the Host header which often omits the port. This caused a 403 "Board mutation requires trusted browser origin" for self-hosted deployments behind reverse proxies. Read x-forwarded-host (first value, comma-split) with the same pattern already used in private-hostname-guard.ts and routes/access.ts. Fixes #1734 --- server/src/__tests__/board-mutation-guard.test.ts | 11 +++++++++++ server/src/middleware/board-mutation-guard.ts | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/board-mutation-guard.test.ts b/server/src/__tests__/board-mutation-guard.test.ts index 62e1e68e..03c1a8df 100644 --- a/server/src/__tests__/board-mutation-guard.test.ts +++ b/server/src/__tests__/board-mutation-guard.test.ts @@ -84,6 +84,17 @@ describe("boardMutationGuard", () => { expect(res.status).toBe(204); }); + it("allows board mutations when x-forwarded-host matches origin", async () => { + const app = createApp("board"); + const res = await request(app) + .post("/mutate") + .set("Host", "127.0.0.1") + .set("X-Forwarded-Host", "10.90.10.20:3443") + .set("Origin", "https://10.90.10.20:3443") + .send({ ok: true }); + expect(res.status).toBe(204); + }); + it("does not block authenticated agent mutations", async () => { const middleware = boardMutationGuard(); const req = { diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index de66a4ce..feff3b40 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -18,7 +18,8 @@ function parseOrigin(value: string | undefined) { function trustedOriginsForRequest(req: Request) { const origins = new Set(DEFAULT_DEV_ORIGINS.map((value) => value.toLowerCase())); - const host = req.header("host")?.trim(); + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const host = forwardedHost || req.header("host")?.trim(); if (host) { origins.add(`http://${host}`.toLowerCase()); origins.add(`https://${host}`.toLowerCase()); From 4bb42005ea41db13400adf8d1d9c4b0571917677 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 01:06:27 -0500 Subject: [PATCH 005/118] docs: fix SPEC accuracy for adapters and backend - align adapter list with current built-in adapters - update backend framework references to Express - remove outdated V1 not-supported template export claim - clarify work artifact boundaries with issue documents Co-Authored-By: Paperclip --- doc/SPEC.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/doc/SPEC.md b/doc/SPEC.md index 82315bce..671431bb 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an ### Execution Adapters -Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters: +Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include: -| Adapter | Mechanism | Example | -| -------------------- | ----------------------- | --------------------------------------------- | -| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | -| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | -| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | -| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval | -| `hermes_local` | Hermes agent process | Local Hermes agent | +| Adapter | Mechanism | Example | +| ---------------- | -------------------------- | -------------------------------------------------- | +| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | +| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | +| `claude_local` | Local Claude Code process | Claude Code heartbeat worker | +| `codex_local` | Local Codex process | Codex CLI heartbeat worker | +| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker | +| `pi_local` | Local Pi process | Pi CLI heartbeat worker | +| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker | +| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | +| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker | -The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). +The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). ### Adapter Interface @@ -376,7 +380,7 @@ Flow: | Layer | Technology | | -------- | ------------------------------------------------------------ | | Frontend | React + Vite | -| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) | +| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) | | Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) | | Auth | [Better Auth](https://www.better-auth.com/) | @@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization ### Work Artifacts -Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope. +Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain. ### Open Questions @@ -476,15 +480,14 @@ Each is a distinct page/route: - [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill - [ ] **Default CEO** — strategic planning, delegation, board communication - [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API -- [ ] **REST API** — full API for agent interaction (Hono) +- [ ] **REST API** — full API for agent interaction (Express) - [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views - [ ] **Agent auth** — connection string generation with URL + key + instructions - [ ] **One-command dev setup** — embedded PGlite, everything local -- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter) +- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters) ### Not V1 -- Template export/import - Knowledge base - a future plugin - Advanced governance models (hiring budgets, multi-member boards) - Revenue/expense tracking beyond token costs - a future plugin From e186449f942fb471196983c3b62066cfc306c048 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 06:48:58 -0500 Subject: [PATCH 006/118] docs: update adapter list and repo map accuracy - Add missing adapters (opencode_local, hermes_local, cursor, pi_local, openclaw_gateway) to agents-runtime.md - Document bootstrapPromptTemplate in prompt templates section - Update AGENTS.md repo map with packages/adapters, adapter-utils, plugins - Fix troubleshooting section to reference all local CLI adapters Co-Authored-By: Paperclip --- AGENTS.md | 3 +++ docs/agents-runtime.md | 22 ++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dad6684f..bdfa3e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ Before making changes, read in this order: - `ui/`: React + Vite board UI - `packages/db/`: Drizzle schema, migrations, DB clients - `packages/shared/`: shared types, constants, validators, API path constants +- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.) +- `packages/adapter-utils/`: shared adapter utilities +- `packages/plugins/`: plugin system packages - `doc/`: operational and product docs ## 4. Dev Setup (Auto DB) diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index bda72729..ba9bb122 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -1,7 +1,7 @@ # Agent Runtime Guide -Status: User-facing guide -Last updated: 2026-02-17 +Status: User-facing guide +Last updated: 2026-03-26 Audience: Operators setting up and running agents in Paperclip ## 1. What this system does @@ -32,14 +32,19 @@ If an agent is already running, new wakeups are merged (coalesced) instead of la ## 3.1 Adapter choice -Common choices: +Built-in adapters: - `claude_local`: runs your local `claude` CLI - `codex_local`: runs your local `codex` CLI +- `opencode_local`: runs your local `opencode` CLI +- `hermes_local`: runs your local `hermes` CLI +- `cursor`: runs Cursor in background mode +- `pi_local`: runs an embedded Pi agent locally +- `openclaw_gateway`: connects to an OpenClaw gateway endpoint - `process`: generic shell command adapter - `http`: calls an external HTTP endpoint -For `claude_local` and `codex_local`, Paperclip assumes the CLI is already installed and authenticated on the host machine. +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. ## 3.2 Runtime behavior @@ -66,6 +71,7 @@ For local adapters, set: You can set: - `promptTemplate`: used for every run (first run and resumed sessions) +- `bootstrapPromptTemplate`: used only on the first run of a new session (before the agent has any prior context) Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values. @@ -133,7 +139,7 @@ If the connection drops, the UI reconnects automatically. If runs fail repeatedly: -1. Check adapter command availability (`claude`/`codex` installed and logged in). +1. Check adapter command availability (e.g. `claude`/`codex`/`opencode`/`hermes` installed and logged in). 2. Verify `cwd` exists and is accessible. 3. Inspect run error + stderr excerpt, then full log. 4. Confirm timeout is not too low. @@ -166,9 +172,9 @@ Start with least privilege where possible, and avoid exposing secrets in broad r ## 10. Minimal setup checklist -1. Choose adapter (`claude_local` or `codex_local`). -2. Set `cwd` to the target workspace. -3. Add bootstrap + normal prompt templates. +1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). +2. Set `cwd` to the target workspace (for local adapters). +3. Optionally add prompt templates (`promptTemplate` and/or `bootstrapPromptTemplate`). 4. Configure heartbeat policy (timer and/or assignment wakeups). 5. Trigger a manual wakeup. 6. Confirm run succeeds and session/token usage is recorded. From 0a32e3838ada7075bb02ccb17117d49dc89b85be Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 07:22:24 -0500 Subject: [PATCH 007/118] fix: render mention autocomplete via portal to prevent overflow clipping The mention suggestion dropdown was getting clipped when typing at the end of a long description inside modals/dialogs because parent containers had overflow-y-auto. Render it via createPortal to document.body with fixed positioning and z-index 9999 so it always appears above all UI. Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 88 +++++++++++++++------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 342a74de..68469761 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -8,6 +8,7 @@ import { useState, type DragEvent, } from "react"; +import { createPortal } from "react-dom"; import { CodeMirrorEditor, MDXEditor, @@ -82,6 +83,9 @@ interface MentionState { query: string; top: number; left: number; + /** Viewport-relative coords for portal positioning */ + viewportTop: number; + viewportLeft: number; textNode: Text; atPos: number; endPos: number; @@ -155,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null { query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, + viewportTop: rect.bottom, + viewportLeft: rect.left, textNode: textNode as Text, atPos, endPos: offset, @@ -554,46 +560,48 @@ export const MarkdownEditor = forwardRef plugins={plugins} /> - {/* Mention dropdown */} - {mentionActive && filteredMentions.length > 0 && ( -
- {filteredMentions.map((option, i) => ( - - ))} -
- )} + {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} + {mentionActive && filteredMentions.length > 0 && + createPortal( +
+ {filteredMentions.map((option, i) => ( + + ))} +
, + document.body, + )} {isDragOver && canDropImage && (
Date: Thu, 26 Mar 2026 07:23:09 -0500 Subject: [PATCH 008/118] docs: update SPEC work artifacts and deprecate bootstrapPromptTemplate - SPEC: reflect that Paperclip now manages task-linked documents and attachments (issue documents, file attachments) instead of claiming it does not manage work artifacts - agents-runtime: remove bootstrapPromptTemplate from recommended config, add deprecation notice, update minimal setup checklist Co-Authored-By: Paperclip --- doc/SPEC.md | 4 ++-- docs/agents-runtime.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/SPEC.md b/doc/SPEC.md index 671431bb..6a7039ca 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -410,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization ### Work Artifacts -Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain. +Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline. ### Open Questions @@ -512,7 +512,7 @@ Things Paperclip explicitly does **not** do: - **Not a SaaS** — single-tenant, self-hosted - **Not opinionated about Agent implementation** — any language, any framework, any runtime - **Not automatically self-healing** — surfaces problems, doesn't silently fix them -- **Does not manage work artifacts** — no repo management, no deployment, no file systems +- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments) - **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed - **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core. diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index ba9bb122..f3672723 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -71,10 +71,11 @@ For local adapters, set: You can set: - `promptTemplate`: used for every run (first run and resumed sessions) -- `bootstrapPromptTemplate`: used only on the first run of a new session (before the agent has any prior context) Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values. +> **Note:** `bootstrapPromptTemplate` is deprecated and should not be used for new agents. Existing configs that use it will continue to work but should be migrated to the managed instructions bundle system. + ## 4. Session resume behavior Paperclip stores session IDs for resumable adapters. @@ -174,7 +175,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r 1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). 2. Set `cwd` to the target workspace (for local adapters). -3. Optionally add prompt templates (`promptTemplate` and/or `bootstrapPromptTemplate`). +3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle. 4. Configure heartbeat policy (timer and/or assignment wakeups). 5. Trigger a manual wakeup. 6. Confirm run succeeds and session/token usage is recorded. From c6364149b1676f647048a86802c2c76c187d059c Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 26 Mar 2026 08:11:22 -0700 Subject: [PATCH 009/118] Add delegation instructions to default CEO agent prompt (#1796) New CEO agents created during onboarding now include explicit delegation rules: triage tasks, route to CTO/CMO/UXDesigner, never do IC work, and follow up on delegated work. --- server/src/onboarding-assets/ceo/AGENTS.md | 32 +++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/server/src/onboarding-assets/ceo/AGENTS.md b/server/src/onboarding-assets/ceo/AGENTS.md index f971561b..c9aee7d4 100644 --- a/server/src/onboarding-assets/ceo/AGENTS.md +++ b/server/src/onboarding-assets/ceo/AGENTS.md @@ -1,9 +1,39 @@ -You are the CEO. +You are the CEO. Your job is to lead the company, not to do individual contributor work. You own strategy, prioritization, and cross-functional coordination. Your home directory is $AGENT_HOME. Everything personal to you -- life, memory, knowledge -- lives there. Other agents may have their own folders and you may update them when necessary. Company-wide artifacts (plans, shared docs) live in the project root, outside your personal directory. +## Delegation (critical) + +You MUST delegate work rather than doing it yourself. When a task is assigned to you: + +1. **Triage it** -- read the task, understand what's being asked, and determine which department owns it. +2. **Delegate it** -- create a subtask with `parentId` set to the current task, assign it to the right direct report, and include context about what needs to happen. Use these routing rules: + - **Code, bugs, features, infra, devtools, technical tasks** → CTO + - **Marketing, content, social media, growth, devrel** → CMO + - **UX, design, user research, design-system** → UXDesigner + - **Cross-functional or unclear** → break into separate subtasks for each department, or assign to the CTO if it's primarily technical with a design component + - If the right report doesn't exist yet, use the `paperclip-create-agent` skill to hire one before delegating. +3. **Do NOT write code, implement features, or fix bugs yourself.** Your reports exist for this. Even if a task seems small or quick, delegate it. +4. **Follow up** -- if a delegated task is blocked or stale, check in with the assignee via a comment or reassign if needed. + +## What you DO personally + +- Set priorities and make product decisions +- Resolve cross-team conflicts or ambiguity +- Communicate with the board (human users) +- Approve or reject proposals from your reports +- Hire new agents when the team needs capacity +- Unblock your direct reports when they escalate to you + +## Keeping work moving + +- Don't let tasks sit idle. If you delegate something, check that it's progressing. +- If a report is blocked, help unblock them -- escalate to the board if needed. +- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work. +- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why). + ## Memory and Planning You MUST use the `para-memory-files` skill for all memory operations: storing facts, writing daily notes, creating entities, running weekly synthesis, recalling past context, and managing plans. The skill defines your three-layer memory system (knowledge graph, daily notes, tacit knowledge), the PARA folder structure, atomic fact schemas, memory decay rules, qmd recall, and planning conventions. From 01b550d61a1e11921f534afd9cc72d97515648c7 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 01:06:27 -0500 Subject: [PATCH 010/118] docs: fix SPEC accuracy for adapters and backend - align adapter list with current built-in adapters - update backend framework references to Express - remove outdated V1 not-supported template export claim - clarify work artifact boundaries with issue documents Co-Authored-By: Paperclip --- doc/SPEC.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/doc/SPEC.md b/doc/SPEC.md index 82315bce..671431bb 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -186,17 +186,21 @@ The heartbeat is a protocol, not a runtime. Paperclip defines how to initiate an ### Execution Adapters -Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Initial adapters: +Agent configuration includes an **adapter** that defines how Paperclip invokes the agent. Built-in adapters include: -| Adapter | Mechanism | Example | -| -------------------- | ----------------------- | --------------------------------------------- | -| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | -| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | -| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | -| `gemini_local` | Gemini CLI process | Local Gemini CLI with sandbox and approval | -| `hermes_local` | Hermes agent process | Local Hermes agent | +| Adapter | Mechanism | Example | +| ---------------- | -------------------------- | -------------------------------------------------- | +| `process` | Execute a child process | `python run_agent.py --agent-id {id}` | +| `http` | Send an HTTP request | `POST https://openclaw.example.com/hook/{id}` | +| `claude_local` | Local Claude Code process | Claude Code heartbeat worker | +| `codex_local` | Local Codex process | Codex CLI heartbeat worker | +| `opencode_local` | Local OpenCode process | OpenCode heartbeat worker | +| `pi_local` | Local Pi process | Pi CLI heartbeat worker | +| `cursor` | Cursor API/CLI bridge | Cursor-integrated heartbeat worker | +| `openclaw_gateway` | OpenClaw gateway API | Managed OpenClaw agent via gateway | +| `hermes_local` | Local Hermes process | Hermes agent heartbeat worker | -The `process` and `http` adapters ship as defaults. Additional adapters have been added for specific agent runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). +The `process` and `http` adapters ship as generic defaults. Additional built-in adapters cover common local coding runtimes (see list above), and new adapter types can be registered via the plugin system (see Plugin / Extension Architecture). ### Adapter Interface @@ -376,7 +380,7 @@ Flow: | Layer | Technology | | -------- | ------------------------------------------------------------ | | Frontend | React + Vite | -| Backend | TypeScript + Hono (REST API, not tRPC — need non-TS clients) | +| Backend | TypeScript + Express (REST API, not tRPC — need non-TS clients) | | Database | PostgreSQL (see [doc/DATABASE.md](./doc/DATABASE.md) for details — PGlite embedded for dev, Docker or hosted Supabase for production) | | Auth | [Better Auth](https://www.better-auth.com/) | @@ -406,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization ### Work Artifacts -Paperclip does **not** manage work artifacts (code repos, file systems, deployments, documents). That's entirely the agent's domain. Paperclip tracks tasks and costs. Where and how work gets done is outside scope. +Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain. ### Open Questions @@ -476,15 +480,14 @@ Each is a distinct page/route: - [ ] **Default agent** — basic Claude Code/Codex loop with Paperclip skill - [ ] **Default CEO** — strategic planning, delegation, board communication - [ ] **Paperclip skill (SKILL.md)** — teaches agents to interact with the API -- [ ] **REST API** — full API for agent interaction (Hono) +- [ ] **REST API** — full API for agent interaction (Express) - [ ] **Web UI** — React/Vite: org chart, task board, dashboard, cost views - [ ] **Agent auth** — connection string generation with URL + key + instructions - [ ] **One-command dev setup** — embedded PGlite, everything local -- [ ] **Multiple Adapter types** (HTTP Adapter, OpenClaw Adapter) +- [ ] **Multiple Adapter types** (HTTP, OpenClaw gateway, and local coding adapters) ### Not V1 -- Template export/import - Knowledge base - a future plugin - Advanced governance models (hiring budgets, multi-member boards) - Revenue/expense tracking beyond token costs - a future plugin From 692105e202b75f44cd729862f9a9a4b5135f04dd Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 06:48:58 -0500 Subject: [PATCH 011/118] docs: update adapter list and repo map accuracy - Add missing adapters (opencode_local, hermes_local, cursor, pi_local, openclaw_gateway) to agents-runtime.md - Document bootstrapPromptTemplate in prompt templates section - Update AGENTS.md repo map with packages/adapters, adapter-utils, plugins - Fix troubleshooting section to reference all local CLI adapters Co-Authored-By: Paperclip --- AGENTS.md | 3 +++ docs/agents-runtime.md | 22 ++++++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dad6684f..bdfa3e5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,9 @@ Before making changes, read in this order: - `ui/`: React + Vite board UI - `packages/db/`: Drizzle schema, migrations, DB clients - `packages/shared/`: shared types, constants, validators, API path constants +- `packages/adapters/`: agent adapter implementations (Claude, Codex, Cursor, etc.) +- `packages/adapter-utils/`: shared adapter utilities +- `packages/plugins/`: plugin system packages - `doc/`: operational and product docs ## 4. Dev Setup (Auto DB) diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index bda72729..ba9bb122 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -1,7 +1,7 @@ # Agent Runtime Guide -Status: User-facing guide -Last updated: 2026-02-17 +Status: User-facing guide +Last updated: 2026-03-26 Audience: Operators setting up and running agents in Paperclip ## 1. What this system does @@ -32,14 +32,19 @@ If an agent is already running, new wakeups are merged (coalesced) instead of la ## 3.1 Adapter choice -Common choices: +Built-in adapters: - `claude_local`: runs your local `claude` CLI - `codex_local`: runs your local `codex` CLI +- `opencode_local`: runs your local `opencode` CLI +- `hermes_local`: runs your local `hermes` CLI +- `cursor`: runs Cursor in background mode +- `pi_local`: runs an embedded Pi agent locally +- `openclaw_gateway`: connects to an OpenClaw gateway endpoint - `process`: generic shell command adapter - `http`: calls an external HTTP endpoint -For `claude_local` and `codex_local`, Paperclip assumes the CLI is already installed and authenticated on the host machine. +For local CLI adapters (`claude_local`, `codex_local`, `opencode_local`, `hermes_local`), Paperclip assumes the CLI is already installed and authenticated on the host machine. ## 3.2 Runtime behavior @@ -66,6 +71,7 @@ For local adapters, set: You can set: - `promptTemplate`: used for every run (first run and resumed sessions) +- `bootstrapPromptTemplate`: used only on the first run of a new session (before the agent has any prior context) Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values. @@ -133,7 +139,7 @@ If the connection drops, the UI reconnects automatically. If runs fail repeatedly: -1. Check adapter command availability (`claude`/`codex` installed and logged in). +1. Check adapter command availability (e.g. `claude`/`codex`/`opencode`/`hermes` installed and logged in). 2. Verify `cwd` exists and is accessible. 3. Inspect run error + stderr excerpt, then full log. 4. Confirm timeout is not too low. @@ -166,9 +172,9 @@ Start with least privilege where possible, and avoid exposing secrets in broad r ## 10. Minimal setup checklist -1. Choose adapter (`claude_local` or `codex_local`). -2. Set `cwd` to the target workspace. -3. Add bootstrap + normal prompt templates. +1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). +2. Set `cwd` to the target workspace (for local adapters). +3. Optionally add prompt templates (`promptTemplate` and/or `bootstrapPromptTemplate`). 4. Configure heartbeat policy (timer and/or assignment wakeups). 5. Trigger a manual wakeup. 6. Confirm run succeeds and session/token usage is recorded. From ed73547fb6a096714011de0e30d05657d7b81560 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 07:23:09 -0500 Subject: [PATCH 012/118] docs: update SPEC work artifacts and deprecate bootstrapPromptTemplate - SPEC: reflect that Paperclip now manages task-linked documents and attachments (issue documents, file attachments) instead of claiming it does not manage work artifacts - agents-runtime: remove bootstrapPromptTemplate from recommended config, add deprecation notice, update minimal setup checklist Co-Authored-By: Paperclip --- doc/SPEC.md | 4 ++-- docs/agents-runtime.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/SPEC.md b/doc/SPEC.md index 671431bb..6a7039ca 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -410,7 +410,7 @@ No separate "agent API" vs. "board API." Same endpoints, different authorization ### Work Artifacts -Paperclip does **not** manage full delivery infrastructure (code repos, deployments, production runtime). It tracks task-linked artifacts (for example issue documents and attachments), while implementation and deployment remain the agent's domain. +Paperclip manages task-linked work artifacts: issue documents (rich-text plans, specs, notes attached to issues) and file attachments. Agents read and write these through the API as part of normal task execution. Full delivery infrastructure (code repos, deployments, production runtime) remains the agent's domain — Paperclip orchestrates the work, not the build pipeline. ### Open Questions @@ -512,7 +512,7 @@ Things Paperclip explicitly does **not** do: - **Not a SaaS** — single-tenant, self-hosted - **Not opinionated about Agent implementation** — any language, any framework, any runtime - **Not automatically self-healing** — surfaces problems, doesn't silently fix them -- **Does not manage work artifacts** — no repo management, no deployment, no file systems +- **Does not manage delivery infrastructure** — no repo management, no deployment, no file systems (but does manage task-linked documents and attachments) - **Does not auto-reassign work** — stale tasks are surfaced, not silently redistributed - **Does not track external revenue/expenses** — that's a future plugin. Token/LLM cost budgeting is core. diff --git a/docs/agents-runtime.md b/docs/agents-runtime.md index ba9bb122..f3672723 100644 --- a/docs/agents-runtime.md +++ b/docs/agents-runtime.md @@ -71,10 +71,11 @@ For local adapters, set: You can set: - `promptTemplate`: used for every run (first run and resumed sessions) -- `bootstrapPromptTemplate`: used only on the first run of a new session (before the agent has any prior context) Templates support variables like `{{agent.id}}`, `{{agent.name}}`, and run context values. +> **Note:** `bootstrapPromptTemplate` is deprecated and should not be used for new agents. Existing configs that use it will continue to work but should be migrated to the managed instructions bundle system. + ## 4. Session resume behavior Paperclip stores session IDs for resumable adapters. @@ -174,7 +175,7 @@ Start with least privilege where possible, and avoid exposing secrets in broad r 1. Choose adapter (e.g. `claude_local`, `codex_local`, `opencode_local`, `hermes_local`, `cursor`, or `openclaw_gateway`). 2. Set `cwd` to the target workspace (for local adapters). -3. Optionally add prompt templates (`promptTemplate` and/or `bootstrapPromptTemplate`). +3. Optionally add a prompt template (`promptTemplate`) or use the managed instructions bundle. 4. Configure heartbeat policy (timer and/or assignment wakeups). 5. Trigger a manual wakeup. 6. Confirm run succeeds and session/token usage is recorded. From eaa765118fcc85a8609f20f93fc2f34f30db9b61 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 07:23:44 -0500 Subject: [PATCH 013/118] chore: mark bootstrapPromptTemplate as deprecated Add @deprecated JSDoc and inline comments to bootstrapPromptTemplate references in agent-instructions and company-portability services. This field is superseded by the managed instructions bundle system. Co-Authored-By: Paperclip --- server/src/services/agent-instructions.ts | 1 + server/src/services/company-portability.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index 231ed839..6dcbb38f 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -9,6 +9,7 @@ const ROOT_KEY = "instructionsRootPath"; const ENTRY_KEY = "instructionsEntryFile"; const FILE_KEY = "instructionsFilePath"; const PROMPT_KEY = "promptTemplate"; +/** @deprecated Use the managed instructions bundle system instead. */ const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate"; const LEGACY_PROMPT_TEMPLATE_PATH = "promptTemplate.legacy.md"; const IGNORED_INSTRUCTIONS_FILE_NAMES = new Set([".DS_Store", "Thumbs.db", "Desktop.ini"]); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 7cfe8ffa..db4be18a 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1475,7 +1475,7 @@ function normalizePortableConfig( key === "instructionsRootPath" || key === "instructionsEntryFile" || key === "promptTemplate" || - key === "bootstrapPromptTemplate" || + key === "bootstrapPromptTemplate" || // deprecated — kept for backward compat key === "paperclipSkillSync" ) continue; if (key === "env") continue; @@ -3895,7 +3895,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { desiredSkills, ); delete adapterConfigWithSkills.promptTemplate; - delete adapterConfigWithSkills.bootstrapPromptTemplate; + delete adapterConfigWithSkills.bootstrapPromptTemplate; // deprecated delete adapterConfigWithSkills.instructionsFilePath; delete adapterConfigWithSkills.instructionsBundleMode; delete adapterConfigWithSkills.instructionsRootPath; From 0fd75aa579a56babc8d5d310201058d0518e30bd Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 07:22:24 -0500 Subject: [PATCH 014/118] fix: render mention autocomplete via portal to prevent overflow clipping The mention suggestion dropdown was getting clipped when typing at the end of a long description inside modals/dialogs because parent containers had overflow-y-auto. Render it via createPortal to document.body with fixed positioning and z-index 9999 so it always appears above all UI. Co-Authored-By: Paperclip --- ui/src/components/MarkdownEditor.tsx | 88 +++++++++++++++------------- 1 file changed, 48 insertions(+), 40 deletions(-) diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 342a74de..68469761 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -8,6 +8,7 @@ import { useState, type DragEvent, } from "react"; +import { createPortal } from "react-dom"; import { CodeMirrorEditor, MDXEditor, @@ -82,6 +83,9 @@ interface MentionState { query: string; top: number; left: number; + /** Viewport-relative coords for portal positioning */ + viewportTop: number; + viewportLeft: number; textNode: Text; atPos: number; endPos: number; @@ -155,6 +159,8 @@ function detectMention(container: HTMLElement): MentionState | null { query, top: rect.bottom - containerRect.top, left: rect.left - containerRect.left, + viewportTop: rect.bottom, + viewportLeft: rect.left, textNode: textNode as Text, atPos, endPos: offset, @@ -554,46 +560,48 @@ export const MarkdownEditor = forwardRef plugins={plugins} /> - {/* Mention dropdown */} - {mentionActive && filteredMentions.length > 0 && ( -
- {filteredMentions.map((option, i) => ( - - ))} -
- )} + {/* Mention dropdown — rendered via portal so it isn't clipped by overflow containers */} + {mentionActive && filteredMentions.length > 0 && + createPortal( +
+ {filteredMentions.map((option, i) => ( + + ))} +
, + document.body, + )} {isDragOver && canDropImage && (
Date: Thu, 26 Mar 2026 07:41:58 -0500 Subject: [PATCH 015/118] fix: enable @-mention autocomplete in new project description editor The MarkdownEditor in NewProjectDialog was not receiving mention options, so typing @ in the description field did nothing. Added agents query and mentionOptions prop to match how NewIssueDialog handles mentions. Co-Authored-By: Paperclip --- ui/src/components/NewProjectDialog.tsx | 29 ++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/ui/src/components/NewProjectDialog.tsx b/ui/src/components/NewProjectDialog.tsx index 4561ac93..afdb057a 100644 --- a/ui/src/components/NewProjectDialog.tsx +++ b/ui/src/components/NewProjectDialog.tsx @@ -1,8 +1,9 @@ -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { projectsApi } from "../api/projects"; +import { agentsApi } from "../api/agents"; import { goalsApi } from "../api/goals"; import { assetsApi } from "../api/assets"; import { queryKeys } from "../lib/queryKeys"; @@ -32,7 +33,7 @@ import { } from "@/components/ui/tooltip"; import { PROJECT_COLORS } from "@paperclipai/shared"; import { cn } from "../lib/utils"; -import { MarkdownEditor, type MarkdownEditorRef } from "./MarkdownEditor"; +import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor"; import { StatusBadge } from "./StatusBadge"; import { ChoosePathButton } from "./PathInstructionsModal"; @@ -68,6 +69,29 @@ export function NewProjectDialog() { enabled: !!selectedCompanyId && newProjectOpen, }); + const { data: agents } = useQuery({ + queryKey: queryKeys.agents.list(selectedCompanyId!), + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId && newProjectOpen, + }); + + const mentionOptions = useMemo(() => { + const options: MentionOption[] = []; + const activeAgents = [...(agents ?? [])] + .filter((agent) => agent.status !== "terminated") + .sort((a, b) => a.name.localeCompare(b.name)); + for (const agent of activeAgents) { + options.push({ + id: `agent:${agent.id}`, + name: agent.name, + kind: "agent", + agentId: agent.id, + agentIcon: agent.icon, + }); + } + return options; + }, [agents]); + const createProject = useMutation({ mutationFn: (data: Record) => projectsApi.create(selectedCompanyId!, data), @@ -250,6 +274,7 @@ export function NewProjectDialog() { onChange={setDescription} placeholder="Add description..." bordered={false} + mentions={mentionOptions} contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-[220px]" : "min-h-[120px]")} imageUploadHandler={async (file) => { const asset = await uploadDescriptionImage.mutateAsync(file); From 5ee4cd98e80a57a291f1844e4b7f8c236f791951 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 07:56:36 -0500 Subject: [PATCH 016/118] feat: move workspace info from properties panel to issue main pane Display workspace branch, path, and status in a card on the issue main pane instead of in the properties sidebar. Only shown for non-default (isolated) workspaces. Edit controls are hidden behind an Edit toggle button. Co-Authored-By: Paperclip --- ui/src/components/IssueProperties.tsx | 205 +-------------- ui/src/components/IssueWorkspaceCard.tsx | 312 +++++++++++++++++++++++ ui/src/pages/IssueDetail.tsx | 7 + 3 files changed, 321 insertions(+), 203 deletions(-) create mode 100644 ui/src/components/IssueWorkspaceCard.tsx diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 3d12e9e3..ced81b23 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,12 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { Link } from "@/lib/router"; import type { Issue } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; -import { executionWorkspacesApi } from "../api/execution-workspaces"; -import { instanceSettingsApi } from "../api/instanceSettings"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { useCompany } from "../context/CompanyContext"; @@ -21,15 +19,9 @@ import { formatDate, cn, projectUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2, Copy, Check } from "lucide-react"; +import { User, Hexagon, ArrowUpRight, Tag, Plus, Trash2 } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; -const EXECUTION_WORKSPACE_OPTIONS = [ - { value: "shared_workspace", label: "Project default" }, - { value: "isolated_workspace", label: "New isolated workspace" }, - { value: "reuse_existing", label: "Reuse existing workspace" }, -] as const; - function defaultProjectWorkspaceIdForProject(project: { workspaces?: Array<{ id: string; isPrimary: boolean }>; executionWorkspacePolicy?: { defaultProjectWorkspaceId?: string | null } | null; @@ -48,23 +40,6 @@ function defaultExecutionWorkspaceModeForProject(project: { executionWorkspacePo return "shared_workspace"; } -function issueModeForExistingWorkspace(mode: string | null | undefined) { - if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") return mode; - if (mode === "adapter_managed" || mode === "cloud_sandbox") return "agent_default"; - return "shared_workspace"; -} - -function shouldPresentExistingWorkspaceSelection(issue: Issue) { - const persistedMode = - issue.currentExecutionWorkspace?.mode - ?? issue.executionWorkspaceSettings?.mode - ?? issue.executionWorkspacePreference; - return Boolean( - issue.executionWorkspaceId && - (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), - ); -} - interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; @@ -142,49 +117,6 @@ function PropertyPicker({ ); } -/** Splits a string at `/` and `-` boundaries, inserting for natural line breaks. */ -function BreakablePath({ text }: { text: string }) { - const parts: React.ReactNode[] = []; - // Split on path separators and hyphens, keeping them in the output - const segments = text.split(/(?<=[\/-])/); - for (let i = 0; i < segments.length; i++) { - if (i > 0) parts.push(); - parts.push(segments[i]); - } - return <>{parts}; -} - -/** Displays a value with a copy-to-clipboard icon and "Copied!" feedback. */ -function CopyableValue({ value, label, mono, className }: { value: string; label?: string; mono?: boolean; className?: string }) { - const [copied, setCopied] = useState(false); - const timerRef = useRef>(undefined); - const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(value); - setCopied(true); - clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => setCopied(false), 1500); - } catch { /* noop */ } - }, [value]); - - return ( -
- - {label && {label} } - - - -
- ); -} - export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); @@ -202,10 +134,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), }); - const { data: experimentalSettings } = useQuery({ - queryKey: queryKeys.instance.experimentalSettings, - queryFn: () => instanceSettingsApi.getExperimental(), - }); const currentUserId = session?.user?.id ?? session?.session?.userId; const { data: agents } = useQuery({ @@ -275,48 +203,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp const currentProject = issue.projectId ? orderedProjects.find((project) => project.id === issue.projectId) ?? null : null; - const currentProjectExecutionWorkspacePolicy = - experimentalSettings?.enableIsolatedWorkspaces === true - ? currentProject?.executionWorkspacePolicy ?? null - : null; - const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const { data: reusableExecutionWorkspaces } = useQuery({ - queryKey: queryKeys.executionWorkspaces.list(companyId!, { - projectId: issue.projectId ?? undefined, - projectWorkspaceId: issue.projectWorkspaceId ?? undefined, - reuseEligible: true, - }), - queryFn: () => - executionWorkspacesApi.list(companyId!, { - projectId: issue.projectId ?? undefined, - projectWorkspaceId: issue.projectWorkspaceId ?? undefined, - reuseEligible: true, - }), - enabled: Boolean(companyId) && Boolean(issue.projectId), - }); - const deduplicatedReusableWorkspaces = useMemo(() => { - const workspaces = reusableExecutionWorkspaces ?? []; - const seen = new Map(); - for (const ws of workspaces) { - const key = ws.cwd ?? ws.id; - const existing = seen.get(key); - if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) { - seen.set(key, ws); - } - } - return Array.from(seen.values()); - }, [reusableExecutionWorkspaces]); - const selectedReusableExecutionWorkspace = - deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId) - ?? issue.currentExecutionWorkspace - ?? null; - const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue) - ? "reuse_existing" - : ( - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(currentProject) - ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; @@ -674,93 +560,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp {projectContent} - {currentProjectSupportsExecutionWorkspace && ( - -
- - - {currentExecutionWorkspaceSelection === "reuse_existing" && ( - - )} - - {issue.currentExecutionWorkspace && ( -
-
- Current:{" "} - - - - {" · "} - {issue.currentExecutionWorkspace.status} -
- {issue.currentExecutionWorkspace.cwd && ( - - )} - {issue.currentExecutionWorkspace.branchName && ( - - )} - {issue.currentExecutionWorkspace.repoUrl && ( - - )} -
- )} - {!issue.currentExecutionWorkspace && currentProject?.primaryWorkspace?.cwd && ( - - )} -
-
- )} - {issue.parentId && ( 0) parts.push(); + parts.push(segments[i]); + } + return <>{parts}; +} + +function CopyableInline({ value, label, mono }: { value: string; label?: string; mono?: boolean }) { + const [copied, setCopied] = useState(false); + const timerRef = useRef>(undefined); + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setCopied(false), 1500); + } catch { /* noop */ } + }, [value]); + + return ( + + {label && {label}} + + + + + + ); +} + +function workspaceModeLabel(mode: string | null | undefined) { + switch (mode) { + case "isolated_workspace": return "Isolated workspace"; + case "operator_branch": return "Operator branch"; + case "cloud_sandbox": return "Cloud sandbox"; + case "adapter_managed": return "Adapter managed"; + default: return "Workspace"; + } +} + +function statusBadge(status: string) { + const colors: Record = { + active: "bg-green-500/15 text-green-700 dark:text-green-400", + idle: "bg-muted text-muted-foreground", + in_review: "bg-blue-500/15 text-blue-700 dark:text-blue-400", + archived: "bg-muted text-muted-foreground", + }; + return ( + + {status.replace(/_/g, " ")} + + ); +} + +/* -------------------------------------------------------------------------- */ +/* Main component */ +/* -------------------------------------------------------------------------- */ + +interface IssueWorkspaceCardProps { + issue: Issue; + project: { id: string; executionWorkspacePolicy?: { enabled?: boolean; defaultMode?: string | null; defaultProjectWorkspaceId?: string | null } | null; workspaces?: Array<{ id: string; isPrimary: boolean }> } | null; + onUpdate: (data: Record) => void; +} + +export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceCardProps) { + const { selectedCompanyId } = useCompany(); + const companyId = issue.companyId ?? selectedCompanyId; + const [editing, setEditing] = useState(false); + + const { data: experimentalSettings } = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); + + const policyEnabled = experimentalSettings?.enableIsolatedWorkspaces === true + && Boolean(project?.executionWorkspacePolicy?.enabled); + + const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; + + // Only show this card for non-default workspaces + const isNonDefault = workspace && workspace.mode !== "shared_workspace"; + + const { data: reusableExecutionWorkspaces } = useQuery({ + queryKey: queryKeys.executionWorkspaces.list(companyId!, { + projectId: issue.projectId ?? undefined, + projectWorkspaceId: issue.projectWorkspaceId ?? undefined, + reuseEligible: true, + }), + queryFn: () => + executionWorkspacesApi.list(companyId!, { + projectId: issue.projectId ?? undefined, + projectWorkspaceId: issue.projectWorkspaceId ?? undefined, + reuseEligible: true, + }), + enabled: Boolean(companyId) && Boolean(issue.projectId) && editing, + }); + + const deduplicatedReusableWorkspaces = useMemo(() => { + const workspaces = reusableExecutionWorkspaces ?? []; + const seen = new Map(); + for (const ws of workspaces) { + const key = ws.cwd ?? ws.id; + const existing = seen.get(key); + if (!existing || new Date(ws.lastUsedAt) > new Date(existing.lastUsedAt)) { + seen.set(key, ws); + } + } + return Array.from(seen.values()); + }, [reusableExecutionWorkspaces]); + + const selectedReusableExecutionWorkspace = + deduplicatedReusableWorkspaces.find((w) => w.id === issue.executionWorkspaceId) + ?? workspace + ?? null; + + const currentSelection = shouldPresentExistingWorkspaceSelection(issue) + ? "reuse_existing" + : ( + issue.executionWorkspacePreference + ?? issue.executionWorkspaceSettings?.mode + ?? defaultExecutionWorkspaceModeForProject(project) + ); + + // Don't render if feature is off or workspace is default/absent + if (!policyEnabled || !isNonDefault) return null; + + return ( +
+ {/* Header row */} +
+
+ + {workspaceModeLabel(workspace.mode)} + {statusBadge(workspace.status)} +
+ +
+ + {/* Read-only info */} + {!editing && ( +
+ {workspace.branchName && ( +
+ + +
+ )} + {workspace.cwd && ( +
+ + +
+ )} + {workspace.repoUrl && ( +
+ Repo: + +
+ )} +
+ + View workspace details → + +
+
+ )} + + {/* Editing controls */} + {editing && ( +
+ + + {currentSelection === "reuse_existing" && ( + + )} + + {/* Current workspace summary when editing */} + {workspace && ( +
+
+ Current:{" "} + + + + {" · "} + {workspace.status} +
+
+ )} +
+ )} +
+ ); +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index ed23b055..12785d24 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -21,6 +21,7 @@ import { InlineEditor } from "../components/InlineEditor"; import { CommentThread } from "../components/CommentThread"; import { IssueDocumentsSection } from "../components/IssueDocumentsSection"; import { IssueProperties } from "../components/IssueProperties"; +import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard"; import { LiveRunWidget } from "../components/LiveRunWidget"; import type { MentionOption } from "../components/MarkdownEditor"; import { ScrollToBottom } from "../components/ScrollToBottom"; @@ -991,6 +992,12 @@ export function IssueDetail() {
) : null} + p.id === issue.projectId) ?? null} + onUpdate={(data) => updateIssue.mutate(data)} + /> + From dd8c1ca3b2e615298ccc8afa018119990f7a641a Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 11:01:09 -0500 Subject: [PATCH 017/118] Speed up issues page search responsiveness Co-Authored-By: Paperclip --- ui/src/components/IssuesList.tsx | 71 +++++++++++++++++++++----------- ui/src/pages/Issues.tsx | 32 ++++++-------- 2 files changed, 60 insertions(+), 43 deletions(-) diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 1eb95e22..1876492c 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; @@ -68,6 +68,7 @@ const quickFilterPresets = [ { label: "Backlog", statuses: ["backlog"] }, { label: "Done", statuses: ["done", "cancelled"] }, ]; +const ISSUE_SEARCH_COMMIT_DELAY_MS = 150; function getViewState(key: string): IssueViewState { try { @@ -174,6 +175,39 @@ interface IssuesListProps { onUpdateIssue: (id: string, data: Record) => void; } +interface IssuesSearchInputProps { + initialValue: string; + onValueCommitted: (value: string) => void; +} + +function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + const timeoutId = window.setTimeout(() => { + onValueCommitted(value); + }, ISSUE_SEARCH_COMMIT_DELAY_MS); + return () => window.clearTimeout(timeoutId); + }, [value, onValueCommitted]); + + return ( +
+ + setValue(e.target.value)} + placeholder="Search issues..." + className="pl-7 text-xs sm:text-sm" + aria-label="Search issues" + /> +
+ ); +} + export function IssuesList({ issues, isLoading, @@ -210,20 +244,12 @@ export function IssuesList({ const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); - const [debouncedIssueSearch, setDebouncedIssueSearch] = useState(issueSearch); - const normalizedIssueSearch = debouncedIssueSearch.trim(); + const normalizedIssueSearch = issueSearch.trim(); useEffect(() => { setIssueSearch(initialSearch ?? ""); }, [initialSearch]); - useEffect(() => { - const timeoutId = window.setTimeout(() => { - setDebouncedIssueSearch(issueSearch); - }, 300); - return () => window.clearTimeout(timeoutId); - }, [issueSearch]); - // Reload view state from localStorage when company changes (scopedKey changes). const prevScopedKey = useRef(scopedKey); useEffect(() => { @@ -235,6 +261,13 @@ export function IssuesList({ } }, [scopedKey, initialAssignees]); + const handleIssueSearchCommit = useCallback((nextSearch: string) => { + startTransition(() => { + setIssueSearch(nextSearch); + }); + onSearchChange?.(nextSearch); + }, [onSearchChange]); + const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; @@ -250,6 +283,7 @@ export function IssuesList({ ], queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, + placeholderData: (previousData) => previousData, }); const agentName = useCallback((id: string | null) => { @@ -333,19 +367,10 @@ export function IssuesList({ New Issue -
- - { - setIssueSearch(e.target.value); - onSearchChange?.(e.target.value); - }} - placeholder="Search issues..." - className="pl-7 text-xs sm:text-sm" - aria-label="Search issues" - /> -
+
diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index ee3d64b0..2b6e48b0 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useCallback, useRef } from "react"; +import { useEffect, useMemo, useCallback } from "react"; import { useLocation, useSearchParams } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { issuesApi } from "../api/issues"; @@ -22,28 +22,20 @@ export function Issues() { const initialSearch = searchParams.get("q") ?? ""; const participantAgentId = searchParams.get("participantAgentId") ?? undefined; - const debounceRef = useRef>(undefined); const handleSearchChange = useCallback((search: string) => { - clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - const trimmedSearch = search.trim(); - const currentSearch = new URLSearchParams(window.location.search).get("q") ?? ""; - if (currentSearch === trimmedSearch) return; + const trimmedSearch = search.trim(); + const currentSearch = new URLSearchParams(window.location.search).get("q") ?? ""; + if (currentSearch === trimmedSearch) return; - const url = new URL(window.location.href); - if (trimmedSearch) { - url.searchParams.set("q", trimmedSearch); - } else { - url.searchParams.delete("q"); - } + const url = new URL(window.location.href); + if (trimmedSearch) { + url.searchParams.set("q", trimmedSearch); + } else { + url.searchParams.delete("q"); + } - const nextUrl = `${url.pathname}${url.search}${url.hash}`; - window.history.replaceState(window.history.state, "", nextUrl); - }, 300); - }, []); - - useEffect(() => { - return () => clearTimeout(debounceRef.current); + const nextUrl = `${url.pathname}${url.search}${url.hash}`; + window.history.replaceState(window.history.state, "", nextUrl); }, []); const { data: agents } = useQuery({ From ed62d58cb2a37b2468fb7d3e9cd089ac6e495500 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 08:43:27 -0500 Subject: [PATCH 018/118] Fix headless OpenCode permission prompts Co-Authored-By: Paperclip --- packages/adapters/opencode-local/src/index.ts | 4 + .../opencode-local/src/server/execute.ts | 437 +++++++++--------- .../src/server/runtime-config.test.ts | 79 ++++ .../src/server/runtime-config.ts | 91 ++++ .../opencode-local/src/server/test.ts | 392 ++++++++-------- .../opencode-local/src/ui/build-config.ts | 1 + .../adapters/opencode-local/config-fields.tsx | 73 ++- ui/src/components/OnboardingWizard.tsx | 3 +- ui/src/components/agent-config-primitives.tsx | 2 +- 9 files changed, 652 insertions(+), 430 deletions(-) create mode 100644 packages/adapters/opencode-local/src/server/runtime-config.test.ts create mode 100644 packages/adapters/opencode-local/src/server/runtime-config.ts diff --git a/packages/adapters/opencode-local/src/index.ts b/packages/adapters/opencode-local/src/index.ts index 67fbdb4b..bbe75c26 100644 --- a/packages/adapters/opencode-local/src/index.ts +++ b/packages/adapters/opencode-local/src/index.ts @@ -22,6 +22,7 @@ Core fields: - instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt - model (string, required): OpenCode model id in provider/model format (for example anthropic/claude-sonnet-4-5) - variant (string, optional): provider-specific model variant (for example minimal|low|medium|high|max) +- dangerouslySkipPermissions (boolean, optional): inject a runtime OpenCode config that allows \`external_directory\` access without interactive prompts; defaults to true for unattended Paperclip runs - promptTemplate (string, optional): run prompt template - command (string, optional): defaults to "opencode" - extraArgs (string[], optional): additional CLI args @@ -40,4 +41,7 @@ Notes: - The adapter sets OPENCODE_DISABLE_PROJECT_CONFIG=true to prevent OpenCode from \ writing an opencode.json config file into the project working directory. Model \ selection is passed via the --model CLI flag instead. +- When \`dangerouslySkipPermissions\` is enabled, Paperclip injects a temporary \ + runtime config with \`permission.external_directory=allow\` so headless runs do \ + not stall on approval prompts. `; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 788ad835..f3a5ae50 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -23,6 +23,7 @@ import { import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; +import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -177,231 +178,239 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof entry[1] === "string", - ), - ); - await ensureCommandResolvable(command, cwd, runtimeEnv); - - await ensureOpenCodeModelConfiguredAndAvailable({ - model, - command, - cwd, - env: runtimeEnv, - }); - - const timeoutSec = asNumber(config.timeoutSec, 0); - const graceSec = asNumber(config.graceSec, 20); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - - const runtimeSessionParams = parseObject(runtime.sessionParams); - const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); - const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); - const canResumeSession = - runtimeSessionId.length > 0 && - (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); - const sessionId = canResumeSession ? runtimeSessionId : null; - if (runtimeSessionId && !canResumeSession) { - await onLog( - "stdout", - `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + try { + const runtimeEnv = Object.fromEntries( + Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), ); - } + await ensureCommandResolvable(command, cwd, runtimeEnv); - const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const resolvedInstructionsFilePath = instructionsFilePath - ? path.resolve(cwd, instructionsFilePath) - : ""; - const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; - let instructionsPrefix = ""; - if (resolvedInstructionsFilePath) { - try { - const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); - instructionsPrefix = - `${instructionsContents}\n\n` + - `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + - `Resolve any relative file references from ${instructionsDir}.\n\n`; - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - await onLog( - "stdout", - `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, - ); - } - } - - const commandNotes = (() => { - if (!resolvedInstructionsFilePath) return [] as string[]; - if (instructionsPrefix.length > 0) { - return [ - `Loaded agent instructions from ${resolvedInstructionsFilePath}`, - `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, - ]; - } - return [ - `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, - ]; - })(); - - const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); - const templateData = { - agentId: agent.id, - companyId: agent.companyId, - runId, - company: { id: agent.companyId }, - agent, - run: { id: runId, source: "on_demand" }, - context, - }; - const renderedPrompt = renderTemplate(promptTemplate, templateData); - const renderedBootstrapPrompt = - !sessionId && bootstrapPromptTemplate.trim().length > 0 - ? renderTemplate(bootstrapPromptTemplate, templateData).trim() - : ""; - const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); - const prompt = joinPromptSections([ - instructionsPrefix, - renderedBootstrapPrompt, - sessionHandoffNote, - renderedPrompt, - ]); - const promptMetrics = { - promptChars: prompt.length, - instructionsChars: instructionsPrefix.length, - bootstrapPromptChars: renderedBootstrapPrompt.length, - sessionHandoffChars: sessionHandoffNote.length, - heartbeatPromptChars: renderedPrompt.length, - }; - - const buildArgs = (resumeSessionId: string | null) => { - const args = ["run", "--format", "json"]; - if (resumeSessionId) args.push("--session", resumeSessionId); - if (model) args.push("--model", model); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - return args; - }; - - const runAttempt = async (resumeSessionId: string | null) => { - const args = buildArgs(resumeSessionId); - if (onMeta) { - await onMeta({ - adapterType: "opencode_local", - command, - cwd, - commandNotes, - commandArgs: [...args, ``], - env: redactEnvForLogs(env), - prompt, - promptMetrics, - context, - }); - } - - const proc = await runChildProcess(runId, command, args, { + await ensureOpenCodeModelConfiguredAndAvailable({ + model, + command, cwd, env: runtimeEnv, - stdin: prompt, - timeoutSec, - graceSec, - onSpawn, - onLog, }); - return { - proc, - rawStderr: proc.stderr, - parsed: parseOpenCodeJsonl(proc.stdout), - }; - }; - const toResult = ( - attempt: { - proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; - rawStderr: string; - parsed: ReturnType; - }, - clearSessionOnMissingSession = false, - ): AdapterExecutionResult => { - if (attempt.proc.timedOut) { - return { - exitCode: attempt.proc.exitCode, - signal: attempt.proc.signal, - timedOut: true, - errorMessage: `Timed out after ${timeoutSec}s`, - clearSession: clearSessionOnMissingSession, - }; + const timeoutSec = asNumber(config.timeoutSec, 0); + const graceSec = asNumber(config.graceSec, 20); + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + + const runtimeSessionParams = parseObject(runtime.sessionParams); + const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); + const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); + const canResumeSession = + runtimeSessionId.length > 0 && + (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); + const sessionId = canResumeSession ? runtimeSessionId : null; + if (runtimeSessionId && !canResumeSession) { + await onLog( + "stdout", + `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, + ); } - const resolvedSessionId = - attempt.parsed.sessionId ?? - (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null); - const resolvedSessionParams = resolvedSessionId - ? ({ - sessionId: resolvedSessionId, - cwd, - ...(workspaceId ? { workspaceId } : {}), - ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), - ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), - } as Record) - : null; + const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); + const resolvedInstructionsFilePath = instructionsFilePath + ? path.resolve(cwd, instructionsFilePath) + : ""; + const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; + let instructionsPrefix = ""; + if (resolvedInstructionsFilePath) { + try { + const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); + instructionsPrefix = + `${instructionsContents}\n\n` + + `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + + `Resolve any relative file references from ${instructionsDir}.\n\n`; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + await onLog( + "stdout", + `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, + ); + } + } - const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; - const stderrLine = firstNonEmptyLine(attempt.proc.stderr); - const rawExitCode = attempt.proc.exitCode; - const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; - const fallbackErrorMessage = - parsedError || - stderrLine || - `OpenCode exited with code ${synthesizedExitCode ?? -1}`; - const modelId = model || null; + const commandNotes = (() => { + const notes = [...preparedRuntimeConfig.notes]; + if (!resolvedInstructionsFilePath) return notes; + if (instructionsPrefix.length > 0) { + notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`); + notes.push( + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + ); + return notes; + } + notes.push( + `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ); + return notes; + })(); - return { - exitCode: synthesizedExitCode, - signal: attempt.proc.signal, - timedOut: false, - errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, - usage: { - inputTokens: attempt.parsed.usage.inputTokens, - outputTokens: attempt.parsed.usage.outputTokens, - cachedInputTokens: attempt.parsed.usage.cachedInputTokens, - }, - sessionId: resolvedSessionId, - sessionParams: resolvedSessionParams, - sessionDisplayId: resolvedSessionId, - provider: parseModelProvider(modelId), - biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), - model: modelId, - billingType: "unknown", - costUsd: attempt.parsed.costUsd, - resultJson: { - stdout: attempt.proc.stdout, - stderr: attempt.proc.stderr, - }, - summary: attempt.parsed.summary, - clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); + const templateData = { + agentId: agent.id, + companyId: agent.companyId, + runId, + company: { id: agent.companyId }, + agent, + run: { id: runId, source: "on_demand" }, + context, + }; + const renderedPrompt = renderTemplate(promptTemplate, templateData); + const renderedBootstrapPrompt = + !sessionId && bootstrapPromptTemplate.trim().length > 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); + const prompt = joinPromptSections([ + instructionsPrefix, + renderedBootstrapPrompt, + sessionHandoffNote, + renderedPrompt, + ]); + const promptMetrics = { + promptChars: prompt.length, + instructionsChars: instructionsPrefix.length, + bootstrapPromptChars: renderedBootstrapPrompt.length, + sessionHandoffChars: sessionHandoffNote.length, + heartbeatPromptChars: renderedPrompt.length, }; - }; - const initial = await runAttempt(sessionId); - const initialFailed = - !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); - if ( - sessionId && - initialFailed && - isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) - ) { - await onLog( - "stdout", - `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, - ); - const retry = await runAttempt(null); - return toResult(retry, true); + const buildArgs = (resumeSessionId: string | null) => { + const args = ["run", "--format", "json"]; + if (resumeSessionId) args.push("--session", resumeSessionId); + if (model) args.push("--model", model); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + return args; + }; + + const runAttempt = async (resumeSessionId: string | null) => { + const args = buildArgs(resumeSessionId); + if (onMeta) { + await onMeta({ + adapterType: "opencode_local", + command, + cwd, + commandNotes, + commandArgs: [...args, ``], + env: redactEnvForLogs(preparedRuntimeConfig.env), + prompt, + promptMetrics, + context, + }); + } + + const proc = await runChildProcess(runId, command, args, { + cwd, + env: runtimeEnv, + stdin: prompt, + timeoutSec, + graceSec, + onSpawn, + onLog, + }); + return { + proc, + rawStderr: proc.stderr, + parsed: parseOpenCodeJsonl(proc.stdout), + }; + }; + + const toResult = ( + attempt: { + proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; + rawStderr: string; + parsed: ReturnType; + }, + clearSessionOnMissingSession = false, + ): AdapterExecutionResult => { + if (attempt.proc.timedOut) { + return { + exitCode: attempt.proc.exitCode, + signal: attempt.proc.signal, + timedOut: true, + errorMessage: `Timed out after ${timeoutSec}s`, + clearSession: clearSessionOnMissingSession, + }; + } + + const resolvedSessionId = + attempt.parsed.sessionId ?? + (clearSessionOnMissingSession ? null : runtimeSessionId ?? runtime.sessionId ?? null); + const resolvedSessionParams = resolvedSessionId + ? ({ + sessionId: resolvedSessionId, + cwd, + ...(workspaceId ? { workspaceId } : {}), + ...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}), + ...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}), + } as Record) + : null; + + const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : ""; + const stderrLine = firstNonEmptyLine(attempt.proc.stderr); + const rawExitCode = attempt.proc.exitCode; + const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; + const fallbackErrorMessage = + parsedError || + stderrLine || + `OpenCode exited with code ${synthesizedExitCode ?? -1}`; + const modelId = model || null; + + return { + exitCode: synthesizedExitCode, + signal: attempt.proc.signal, + timedOut: false, + errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, + usage: { + inputTokens: attempt.parsed.usage.inputTokens, + outputTokens: attempt.parsed.usage.outputTokens, + cachedInputTokens: attempt.parsed.usage.cachedInputTokens, + }, + sessionId: resolvedSessionId, + sessionParams: resolvedSessionParams, + sessionDisplayId: resolvedSessionId, + provider: parseModelProvider(modelId), + biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), + model: modelId, + billingType: "unknown", + costUsd: attempt.parsed.costUsd, + resultJson: { + stdout: attempt.proc.stdout, + stderr: attempt.proc.stderr, + }, + summary: attempt.parsed.summary, + clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), + }; + }; + + const initial = await runAttempt(sessionId); + const initialFailed = + !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); + if ( + sessionId && + initialFailed && + isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) + ) { + await onLog( + "stdout", + `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, + ); + const retry = await runAttempt(null); + return toResult(retry, true); + } + + return toResult(initial); + } finally { + await preparedRuntimeConfig.cleanup(); } - - return toResult(initial); } diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts new file mode 100644 index 00000000..c5c396ac --- /dev/null +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; + +const cleanupPaths = new Set(); + +afterEach(async () => { + await Promise.all( + [...cleanupPaths].map(async (filepath) => { + await fs.rm(filepath, { recursive: true, force: true }); + cleanupPaths.delete(filepath); + }), + ); +}); + +async function makeConfigHome(initialConfig?: Record) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-")); + cleanupPaths.add(root); + const configDir = path.join(root, "opencode"); + await fs.mkdir(configDir, { recursive: true }); + if (initialConfig) { + await fs.writeFile( + path.join(configDir, "opencode.json"), + `${JSON.stringify(initialConfig, null, 2)}\n`, + "utf8", + ); + } + return root; +} + +describe("prepareOpenCodeRuntimeConfig", () => { + it("injects an external_directory allow rule by default", async () => { + const configHome = await makeConfigHome({ + permission: { + read: "allow", + }, + theme: "system", + }); + + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome }, + config: {}, + }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + + expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome); + const runtimeConfig = JSON.parse( + await fs.readFile( + path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), + "utf8", + ), + ) as Record; + expect(runtimeConfig).toMatchObject({ + theme: "system", + permission: { + read: "allow", + external_directory: "allow", + }, + }); + + await prepared.cleanup(); + cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME); + await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow(); + }); + + it("respects explicit opt-out", async () => { + const configHome = await makeConfigHome(); + const prepared = await prepareOpenCodeRuntimeConfig({ + env: { XDG_CONFIG_HOME: configHome }, + config: { dangerouslySkipPermissions: false }, + }); + + expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome }); + expect(prepared.notes).toEqual([]); + await prepared.cleanup(); + }); +}); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts new file mode 100644 index 00000000..bc903e83 --- /dev/null +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { asBoolean } from "@paperclipai/adapter-utils/server-utils"; + +type PreparedOpenCodeRuntimeConfig = { + env: Record; + notes: string[]; + cleanup: () => Promise; +}; + +function resolveXdgConfigHome(env: Record): string { + return ( + (typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()) || + (typeof process.env.XDG_CONFIG_HOME === "string" && process.env.XDG_CONFIG_HOME.trim()) || + path.join(os.homedir(), ".config") + ); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readJsonObject(filepath: string): Promise> { + try { + const raw = await fs.readFile(filepath, "utf8"); + const parsed = JSON.parse(raw); + return isPlainObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +export async function prepareOpenCodeRuntimeConfig(input: { + env: Record; + config: Record; +}): Promise { + const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true); + if (!skipPermissions) { + return { + env: input.env, + notes: [], + cleanup: async () => {}, + }; + } + + const sourceConfigDir = path.join(resolveXdgConfigHome(input.env), "opencode"); + const runtimeConfigHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-config-")); + const runtimeConfigDir = path.join(runtimeConfigHome, "opencode"); + const runtimeConfigPath = path.join(runtimeConfigDir, "opencode.json"); + + await fs.mkdir(runtimeConfigDir, { recursive: true }); + try { + await fs.cp(sourceConfigDir, runtimeConfigDir, { + recursive: true, + force: true, + errorOnExist: false, + dereference: false, + }); + } catch (err) { + if ((err as NodeJS.ErrnoException | null)?.code !== "ENOENT") { + throw err; + } + } + + const existingConfig = await readJsonObject(runtimeConfigPath); + const existingPermission = isPlainObject(existingConfig.permission) + ? existingConfig.permission + : {}; + const nextConfig = { + ...existingConfig, + permission: { + ...existingPermission, + external_directory: "allow", + }, + }; + await fs.writeFile(runtimeConfigPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8"); + + return { + env: { + ...input.env, + XDG_CONFIG_HOME: runtimeConfigHome, + }, + notes: [ + "Injected runtime OpenCode config with permission.external_directory=allow to avoid headless approval prompts.", + ], + cleanup: async () => { + await fs.rm(runtimeConfigHome, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/adapters/opencode-local/src/server/test.ts b/packages/adapters/opencode-local/src/server/test.ts index ad3957d1..1d6ef459 100644 --- a/packages/adapters/opencode-local/src/server/test.ts +++ b/packages/adapters/opencode-local/src/server/test.ts @@ -4,6 +4,7 @@ import type { AdapterEnvironmentTestResult, } from "@paperclipai/adapter-utils"; import { + asBoolean, asString, asStringArray, parseObject, @@ -14,6 +15,7 @@ import { } from "@paperclipai/adapter-utils/server-utils"; import { discoverOpenCodeModels, ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; import { parseOpenCodeJsonl } from "./parse.js"; +import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -92,224 +94,236 @@ export async function testEnvironment( // Prevent OpenCode from writing an opencode.json into the working directory. env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; - const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...env })); - - const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); - if (cwdInvalid) { + const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); + if (asBoolean(config.dangerouslySkipPermissions, true)) { checks.push({ - code: "opencode_command_skipped", - level: "warn", - message: "Skipped command check because working directory validation failed.", - detail: command, + code: "opencode_headless_permissions_enabled", + level: "info", + message: "Headless OpenCode external-directory permissions are auto-approved for unattended runs.", }); - } else { - try { - await ensureCommandResolvable(command, cwd, runtimeEnv); + } + try { + const runtimeEnv = normalizeEnv(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })); + + const cwdInvalid = checks.some((check) => check.code === "opencode_cwd_invalid"); + if (cwdInvalid) { checks.push({ - code: "opencode_command_resolvable", - level: "info", - message: `Command is executable: ${command}`, - }); - } catch (err) { - checks.push({ - code: "opencode_command_unresolvable", - level: "error", - message: err instanceof Error ? err.message : "Command is not executable", + code: "opencode_command_skipped", + level: "warn", + message: "Skipped command check because working directory validation failed.", detail: command, }); - } - } - - const canRunProbe = - checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); - - let modelValidationPassed = false; - const configuredModel = asString(config.model, "").trim(); - - if (canRunProbe && configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { + } else { + try { + await ensureCommandResolvable(command, cwd, runtimeEnv); checks.push({ - code: "opencode_models_discovered", + code: "opencode_command_resolvable", level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + message: `Command is executable: ${command}`, }); - } else { + } catch (err) { checks.push({ - code: "opencode_models_empty", + code: "opencode_command_unresolvable", level: "error", - message: "OpenCode returned no models.", - hint: "Run `opencode models` and verify provider authentication.", - }); - } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "error", - message: errMsg || "OpenCode model discovery failed.", - hint: "Run `opencode models` manually to verify provider auth and config.", + message: err instanceof Error ? err.message : "Command is not executable", + detail: command, }); } } - } else if (canRunProbe && !configuredModel) { - try { - const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); - if (discovered.length > 0) { - checks.push({ - code: "opencode_models_discovered", - level: "info", - message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, - }); + + const canRunProbe = + checks.every((check) => check.code !== "opencode_cwd_invalid" && check.code !== "opencode_command_unresolvable"); + + let modelValidationPassed = false; + const configuredModel = asString(config.model, "").trim(); + + if (canRunProbe && configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } else { + checks.push({ + code: "opencode_models_empty", + level: "error", + message: "OpenCode returned no models.", + hint: "Run `opencode models` and verify provider authentication.", + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "error", + message: errMsg || "OpenCode model discovery failed.", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } } - } catch (err) { - const errMsg = err instanceof Error ? err.message : String(err); - if (/ProviderModelNotFoundError/i.test(errMsg)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - detail: errMsg, - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else { - checks.push({ - code: "opencode_models_discovery_failed", - level: "warn", - message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", - hint: "Run `opencode models` manually to verify provider auth and config.", - }); + } else if (canRunProbe && !configuredModel) { + try { + const discovered = await discoverOpenCodeModels({ command, cwd, env: runtimeEnv }); + if (discovered.length > 0) { + checks.push({ + code: "opencode_models_discovered", + level: "info", + message: `Discovered ${discovered.length} model(s) from OpenCode providers.`, + }); + } + } catch (err) { + const errMsg = err instanceof Error ? err.message : String(err); + if (/ProviderModelNotFoundError/i.test(errMsg)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + detail: errMsg, + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else { + checks.push({ + code: "opencode_models_discovery_failed", + level: "warn", + message: errMsg || "OpenCode model discovery failed (best-effort, no model configured).", + hint: "Run `opencode models` manually to verify provider auth and config.", + }); + } } } - } - const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); - if (!configuredModel && !modelUnavailable) { - // No model configured – skip model requirement if no model-related checks exist - } else if (configuredModel && canRunProbe) { - try { - await ensureOpenCodeModelConfiguredAndAvailable({ - model: configuredModel, - command, - cwd, - env: runtimeEnv, - }); - checks.push({ - code: "opencode_model_configured", - level: "info", - message: `Configured model: ${configuredModel}`, - }); - modelValidationPassed = true; - } catch (err) { - checks.push({ - code: "opencode_model_invalid", - level: "error", - message: err instanceof Error ? err.message : "Configured model is unavailable.", - hint: "Run `opencode models` and choose a currently available provider/model ID.", - }); - } - } - - if (canRunProbe && modelValidationPassed) { - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - const variant = asString(config.variant, "").trim(); - const probeModel = configuredModel; - - const args = ["run", "--format", "json"]; - args.push("--model", probeModel); - if (variant) args.push("--variant", variant); - if (extraArgs.length > 0) args.push(...extraArgs); - - try { - const probe = await runChildProcess( - `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, - command, - args, - { + const modelUnavailable = checks.some((check) => check.code === "opencode_hello_probe_model_unavailable"); + if (!configuredModel && !modelUnavailable) { + // No model configured – skip model requirement if no model-related checks exist + } else if (configuredModel && canRunProbe) { + try { + await ensureOpenCodeModelConfiguredAndAvailable({ + model: configuredModel, + command, cwd, env: runtimeEnv, - timeoutSec: 60, - graceSec: 5, - stdin: "Respond with hello.", - onLog: async () => {}, - }, - ); + }); + checks.push({ + code: "opencode_model_configured", + level: "info", + message: `Configured model: ${configuredModel}`, + }); + modelValidationPassed = true; + } catch (err) { + checks.push({ + code: "opencode_model_invalid", + level: "error", + message: err instanceof Error ? err.message : "Configured model is unavailable.", + hint: "Run `opencode models` and choose a currently available provider/model ID.", + }); + } + } - const parsed = parseOpenCodeJsonl(probe.stdout); - const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); - const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + if (canRunProbe && modelValidationPassed) { + const extraArgs = (() => { + const fromExtraArgs = asStringArray(config.extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(config.args); + })(); + const variant = asString(config.variant, "").trim(); + const probeModel = configuredModel; - if (probe.timedOut) { - checks.push({ - code: "opencode_hello_probe_timed_out", - level: "warn", - message: "OpenCode hello probe timed out.", - hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", - }); - } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { - const summary = parsed.summary.trim(); - const hasHello = /\bhello\b/i.test(summary); - checks.push({ - code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", - level: hasHello ? "info" : "warn", - message: hasHello - ? "OpenCode hello probe succeeded." - : "OpenCode probe ran but did not return `hello` as expected.", - ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), - ...(hasHello - ? {} - : { - hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", - }), - }); - } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_model_unavailable", - level: "warn", - message: "The configured model was not found by the provider.", - ...(detail ? { detail } : {}), - hint: "Run `opencode models` and choose an available provider/model ID.", - }); - } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { - checks.push({ - code: "opencode_hello_probe_auth_required", - level: "warn", - message: "OpenCode is installed, but provider authentication is not ready.", - ...(detail ? { detail } : {}), - hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", - }); - } else { + const args = ["run", "--format", "json"]; + args.push("--model", probeModel); + if (variant) args.push("--variant", variant); + if (extraArgs.length > 0) args.push(...extraArgs); + + try { + const probe = await runChildProcess( + `opencode-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, + command, + args, + { + cwd, + env: runtimeEnv, + timeoutSec: 60, + graceSec: 5, + stdin: "Respond with hello.", + onLog: async () => {}, + }, + ); + + const parsed = parseOpenCodeJsonl(probe.stdout); + const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage); + const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim(); + + if (probe.timedOut) { + checks.push({ + code: "opencode_hello_probe_timed_out", + level: "warn", + message: "OpenCode hello probe timed out.", + hint: "Retry the probe. If this persists, run OpenCode manually in this working directory.", + }); + } else if ((probe.exitCode ?? 1) === 0 && !parsed.errorMessage) { + const summary = parsed.summary.trim(); + const hasHello = /\bhello\b/i.test(summary); + checks.push({ + code: hasHello ? "opencode_hello_probe_passed" : "opencode_hello_probe_unexpected_output", + level: hasHello ? "info" : "warn", + message: hasHello + ? "OpenCode hello probe succeeded." + : "OpenCode probe ran but did not return `hello` as expected.", + ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}), + ...(hasHello + ? {} + : { + hint: "Run `opencode run --format json` manually and prompt `Respond with hello` to inspect output.", + }), + }); + } else if (/ProviderModelNotFoundError/i.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_model_unavailable", + level: "warn", + message: "The configured model was not found by the provider.", + ...(detail ? { detail } : {}), + hint: "Run `opencode models` and choose an available provider/model ID.", + }); + } else if (OPENCODE_AUTH_REQUIRED_RE.test(authEvidence)) { + checks.push({ + code: "opencode_hello_probe_auth_required", + level: "warn", + message: "OpenCode is installed, but provider authentication is not ready.", + ...(detail ? { detail } : {}), + hint: "Run `opencode auth login` or set provider credentials, then retry the probe.", + }); + } else { + checks.push({ + code: "opencode_hello_probe_failed", + level: "error", + message: "OpenCode hello probe failed.", + ...(detail ? { detail } : {}), + hint: "Run `opencode run --format json` manually in this working directory to debug.", + }); + } + } catch (err) { checks.push({ code: "opencode_hello_probe_failed", level: "error", message: "OpenCode hello probe failed.", - ...(detail ? { detail } : {}), + detail: err instanceof Error ? err.message : String(err), hint: "Run `opencode run --format json` manually in this working directory to debug.", }); } - } catch (err) { - checks.push({ - code: "opencode_hello_probe_failed", - level: "error", - message: "OpenCode hello probe failed.", - detail: err instanceof Error ? err.message : String(err), - hint: "Run `opencode run --format json` manually in this working directory to debug.", - }); } + } finally { + await preparedRuntimeConfig.cleanup(); } return { diff --git a/packages/adapters/opencode-local/src/ui/build-config.ts b/packages/adapters/opencode-local/src/ui/build-config.ts index 0d425cf1..fa941ed2 100644 --- a/packages/adapters/opencode-local/src/ui/build-config.ts +++ b/packages/adapters/opencode-local/src/ui/build-config.ts @@ -58,6 +58,7 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record -
- - isCreate - ? set!({ instructionsFilePath: v }) - : mark("adapterConfig", "instructionsFilePath", v || undefined) - } - immediate - className={inputClass} - placeholder="/absolute/path/to/AGENTS.md" - /> - -
- + <> + {!hideInstructionsFile && ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ )} + + isCreate + ? set!({ dangerouslySkipPermissions: v }) + : mark("adapterConfig", "dangerouslySkipPermissions", v) + } + /> + ); } diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 06a053db..cd28af9f 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -325,7 +325,8 @@ export function OnboardingWizard() { command, args, url, - dangerouslySkipPermissions: adapterType === "claude_local", + dangerouslySkipPermissions: + adapterType === "claude_local" || adapterType === "opencode_local", dangerouslyBypassSandbox: adapterType === "codex_local" ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 3c0fd25b..70694f73 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -30,7 +30,7 @@ export const help: Record = { model: "Override the default model used by the adapter.", thinkingEffort: "Control model reasoning depth. Supported values vary by adapter/model.", chrome: "Enable Claude's Chrome integration by passing --chrome.", - dangerouslySkipPermissions: "Run Claude without permission prompts. Required for unattended operation.", + dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", search: "Enable Codex web search capability during runs.", workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.", From fcf3ba697409011a6881c1e649f7a175583b8e25 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 09:02:00 -0500 Subject: [PATCH 019/118] Seed Paperclip env in provisioned worktrees Co-Authored-By: Paperclip --- scripts/provision-worktree.sh | 37 +++++ .../src/__tests__/workspace-runtime.test.ts | 131 ++++++++++++++++++ 2 files changed, 168 insertions(+) diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 14a31349..0108419d 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -3,6 +3,12 @@ set -euo pipefail base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}" worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" +paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}" +paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" +paperclip_dir="$worktree_cwd/.paperclip" +worktree_config_path="$paperclip_dir/config.json" +worktree_env_path="$paperclip_dir/.env" +worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}" if [[ ! -d "$base_cwd" ]]; then echo "Base workspace does not exist: $base_cwd" >&2 @@ -14,6 +20,37 @@ if [[ ! -d "$worktree_cwd" ]]; then exit 1 fi +source_config_path="${PAPERCLIP_CONFIG:-}" +if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then + source_config_path="$base_cwd/.paperclip/config.json" +fi +if [[ -z "$source_config_path" ]]; then + source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" +fi +source_env_path="$(dirname "$source_config_path")/.env" + +mkdir -p "$paperclip_dir" + +if [[ ! -e "$worktree_config_path" && ! -L "$worktree_config_path" && -e "$source_config_path" ]]; then + ln -s "$source_config_path" "$worktree_config_path" +fi + +if [[ ! -e "$worktree_env_path" && -e "$source_env_path" ]]; then + cp "$source_env_path" "$worktree_env_path" + chmod 600 "$worktree_env_path" +fi + +tmp_env="$(mktemp "${TMPDIR:-/tmp}/paperclip-worktree-env.XXXXXX")" +if [[ -e "$worktree_env_path" ]]; then + grep -vE '^(PAPERCLIP_IN_WORKTREE|PAPERCLIP_WORKTREE_NAME)=' "$worktree_env_path" > "$tmp_env" || true +fi +{ + printf 'PAPERCLIP_IN_WORKTREE=true\n' + printf 'PAPERCLIP_WORKTREE_NAME=%s\n' "$worktree_name" +} >> "$tmp_env" +mv "$tmp_env" "$worktree_env_path" +chmod 600 "$worktree_env_path" + while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue source_path="$base_cwd/$relative_path" diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index dad02d38..6e07f6c6 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -13,6 +14,7 @@ import { stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; +import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -282,6 +284,135 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); + it("seeds repo-local Paperclip config and worktree branding when provisioning", async () => { + const repoRoot = await createTempRepo(); + const previousCwd = process.cwd(); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); + const instanceId = "worktree-base"; + const sharedConfigDir = path.join(paperclipHome, "instances", instanceId); + const sharedConfigPath = path.join(sharedConfigDir, "config.json"); + const sharedEnvPath = path.join(sharedConfigDir, ".env"); + + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + + await fs.mkdir(sharedConfigDir, { recursive: true }); + await fs.writeFile( + sharedConfigPath, + JSON.stringify( + { + $meta: { + version: 1, + updatedAt: "2026-03-26T00:00:00.000Z", + source: "doctor", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(sharedConfigDir, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sharedConfigDir, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(sharedConfigDir, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(sharedConfigDir, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(sharedConfigDir, "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile(sharedEnvPath, 'DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"\n', "utf8"); + + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.copyFile( + fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)), + path.join(repoRoot, "scripts", "provision-worktree.sh"), + ); + await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); + + try { + const workspace = 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/provision-worktree.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-885", + title: "Show worktree banner", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + const configLinkPath = path.join(workspace.cwd, ".paperclip", "config.json"); + const envPath = path.join(workspace.cwd, ".paperclip", ".env"); + const envContents = await fs.readFile(envPath, "utf8"); + + expect(await fs.readlink(configLinkPath)).toBe(sharedConfigPath); + expect(envContents).toContain('DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"'); + expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=PAP-885-show-worktree-banner"); + + process.chdir(workspace.cwd); + expect(resolvePaperclipConfigPath()).toBe(configLinkPath); + } finally { + process.chdir(previousCwd); + } + }); + it("records worktree setup and provision operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); const { recorder, operations } = createWorkspaceOperationRecorderDouble(); From c74cda1851bed2acdc51284e95d7982ef7320e1d Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 10:21:00 -0500 Subject: [PATCH 020/118] Fix worktree provision isolation Co-Authored-By: Paperclip --- scripts/provision-worktree.sh | 281 +++++++++++++++++- .../src/__tests__/workspace-runtime.test.ts | 34 ++- 2 files changed, 293 insertions(+), 22 deletions(-) diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 0108419d..ea5e0e0f 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -31,25 +31,274 @@ source_env_path="$(dirname "$source_config_path")/.env" mkdir -p "$paperclip_dir" -if [[ ! -e "$worktree_config_path" && ! -L "$worktree_config_path" && -e "$source_config_path" ]]; then - ln -s "$source_config_path" "$worktree_config_path" -fi +run_isolated_worktree_init() { + if command -v paperclipai >/dev/null 2>&1; then + paperclipai worktree init --force --name "$worktree_name" --from-config "$source_config_path" + return 0 + fi -if [[ ! -e "$worktree_env_path" && -e "$source_env_path" ]]; then - cp "$source_env_path" "$worktree_env_path" - chmod 600 "$worktree_env_path" -fi + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then + pnpm paperclipai worktree init --force --name "$worktree_name" --from-config "$source_config_path" + return 0 + fi -tmp_env="$(mktemp "${TMPDIR:-/tmp}/paperclip-worktree-env.XXXXXX")" -if [[ -e "$worktree_env_path" ]]; then - grep -vE '^(PAPERCLIP_IN_WORKTREE|PAPERCLIP_WORKTREE_NAME)=' "$worktree_env_path" > "$tmp_env" || true + return 1 +} + +write_fallback_worktree_config() { + WORKTREE_NAME="$worktree_name" \ + BASE_CWD="$base_cwd" \ + WORKTREE_CWD="$worktree_cwd" \ + PAPERCLIP_DIR="$paperclip_dir" \ + SOURCE_CONFIG_PATH="$source_config_path" \ + SOURCE_ENV_PATH="$source_env_path" \ + PAPERCLIP_WORKTREES_DIR="${PAPERCLIP_WORKTREES_DIR:-}" \ + node <<'EOF' +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const net = require("node:net"); + +function expandHomePrefix(value) { + if (!value) return value; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function nonEmpty(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function sanitizeInstanceId(value) { + const trimmed = String(value ?? "").trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +function parseEnvFile(contents) { + const entries = {}; + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!value) { + entries[key] = ""; + continue; + } + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + return entries; +} + +async function findAvailablePort(preferredPort, reserved = new Set()) { + const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0; + if (startPort > 0) { + for (let port = startPort; port < startPort + 100; port += 1) { + if (reserved.has(port)) continue; + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); + if (available) return port; + } + } + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate a port."))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +function isLoopbackHost(hostname) { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +function rewriteLocalUrlPort(rawUrl, port) { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +function resolveRuntimeLikePath(value, configPath) { + const expanded = expandHomePrefix(value); + if (path.isAbsolute(expanded)) return expanded; + return path.resolve(path.dirname(configPath), expanded); +} + +async function main() { + const worktreeName = process.env.WORKTREE_NAME; + const paperclipDir = process.env.PAPERCLIP_DIR; + const sourceConfigPath = process.env.SOURCE_CONFIG_PATH; + const sourceEnvPath = process.env.SOURCE_ENV_PATH; + const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(process.env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees")); + const instanceId = sanitizeInstanceId(worktreeName); + const instanceRoot = path.resolve(worktreeHome, "instances", instanceId); + const configPath = path.resolve(paperclipDir, "config.json"); + const envPath = path.resolve(paperclipDir, ".env"); + + let sourceConfig = null; + if (sourceConfigPath && fs.existsSync(sourceConfigPath)) { + sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8")); + } + + const sourceEnvEntries = + sourceEnvPath && fs.existsSync(sourceEnvPath) + ? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8")) + : {}; + + const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1; + const serverPort = await findAvailablePort(preferredServerPort); + const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1; + const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + + fs.rmSync(configPath, { force: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(instanceRoot, { recursive: true }); + + const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort); + const targetConfig = { + $meta: { + version: 1, + updatedAt: new Date().toISOString(), + source: "configure", + }, + ...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + embeddedPostgresPort: databasePort, + backup: { + enabled: sourceConfig?.database?.backup?.enabled ?? true, + intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60, + retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30, + dir: path.resolve(instanceRoot, "data", "backups"), + }, + }, + logging: { + mode: sourceConfig?.logging?.mode ?? "file", + logDir: path.resolve(instanceRoot, "logs"), + }, + server: { + deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted", + exposure: sourceConfig?.server?.exposure ?? "private", + host: sourceConfig?.server?.host ?? "127.0.0.1", + port: serverPort, + allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [], + serveUi: sourceConfig?.server?.serveUi ?? true, + }, + auth: { + baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto", + ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), + disableSignUp: sourceConfig?.auth?.disableSignUp ?? false, + }, + storage: { + provider: sourceConfig?.storage?.provider ?? "local_disk", + localDisk: { + baseDir: path.resolve(instanceRoot, "data", "storage"), + }, + s3: { + bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip", + region: sourceConfig?.storage?.s3?.region ?? "us-east-1", + endpoint: sourceConfig?.storage?.s3?.endpoint, + prefix: sourceConfig?.storage?.s3?.prefix ?? "", + forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false, + }, + }, + secrets: { + provider: sourceConfig?.secrets?.provider ?? "local_encrypted", + strictMode: sourceConfig?.secrets?.strictMode ?? false, + localEncrypted: { + keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + }, + }, + }; + + fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 }); + + const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY); + if (inlineMasterKey) { + fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); + fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, { + encoding: "utf8", + mode: 0o600, + }); + } else { + const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) + ? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath) + : nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath) + ? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath) + : null; + + if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) { + fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); + fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath); + fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600); + } + } + + const envLines = [ + "PAPERCLIP_HOME=" + JSON.stringify(worktreeHome), + "PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId), + "PAPERCLIP_CONFIG=" + JSON.stringify(configPath), + "PAPERCLIP_CONTEXT=" + JSON.stringify(path.resolve(worktreeHome, "context.json")), + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=" + JSON.stringify(worktreeName), + ]; + + const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET); + if (agentJwtSecret) { + envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + JSON.stringify(agentJwtSecret)); + } + + fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); +EOF +} + +if ! run_isolated_worktree_init; then + echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 + write_fallback_worktree_config fi -{ - printf 'PAPERCLIP_IN_WORKTREE=true\n' - printf 'PAPERCLIP_WORKTREE_NAME=%s\n' "$worktree_name" -} >> "$tmp_env" -mv "$tmp_env" "$worktree_env_path" -chmod 600 "$worktree_env_path" while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 6e07f6c6..6a55a72b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -126,6 +126,7 @@ afterEach(async () => { delete process.env.PAPERCLIP_CONFIG; delete process.env.PAPERCLIP_HOME; delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_WORKTREES_DIR; delete process.env.DATABASE_URL; }); @@ -284,10 +285,11 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); - it("seeds repo-local Paperclip config and worktree branding when provisioning", async () => { + it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => { const repoRoot = await createTempRepo(); const previousCwd = process.cwd(); const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); + const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-")); const instanceId = "worktree-base"; const sharedConfigDir = path.join(paperclipHome, "instances", instanceId); const sharedConfigPath = path.join(sharedConfigDir, "config.json"); @@ -295,6 +297,7 @@ describe("realizeExecutionWorkspace", () => { process.env.PAPERCLIP_HOME = paperclipHome; process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome; await fs.mkdir(sharedConfigDir, { recursive: true }); await fs.writeFile( @@ -397,17 +400,36 @@ describe("realizeExecutionWorkspace", () => { }, }); - const configLinkPath = path.join(workspace.cwd, ".paperclip", "config.json"); + const configPath = path.join(workspace.cwd, ".paperclip", "config.json"); const envPath = path.join(workspace.cwd, ".paperclip", ".env"); const envContents = await fs.readFile(envPath, "utf8"); + const configContents = JSON.parse(await fs.readFile(configPath, "utf8")); + const configStats = await fs.lstat(configPath); + const expectedInstanceId = "pap-885-show-worktree-banner"; + const expectedInstanceRoot = path.join( + isolatedWorktreeHome, + "instances", + expectedInstanceId, + ); - expect(await fs.readlink(configLinkPath)).toBe(sharedConfigPath); - expect(envContents).toContain('DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"'); + expect(configStats.isSymbolicLink()).toBe(false); + expect(configContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db")); + expect(configContents.database.embeddedPostgresDataDir).not.toBe(path.join(sharedConfigDir, "db")); + expect(configContents.server.port).not.toBe(3100); + expect(configContents.secrets.localEncrypted.keyFilePath).toBe( + path.join(expectedInstanceRoot, "secrets", "master.key"), + ); + expect(envContents).not.toContain("DATABASE_URL="); + expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`); + expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`); + expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); - expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=PAP-885-show-worktree-banner"); + expect(envContents).toContain( + `PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`, + ); process.chdir(workspace.cwd); - expect(resolvePaperclipConfigPath()).toBe(configLinkPath); + expect(resolvePaperclipConfigPath()).toBe(configPath); } finally { process.chdir(previousCwd); } From ab82e3f022b7b5e7753f6ac257a4c5c4377eddac Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 10:36:49 -0500 Subject: [PATCH 021/118] Fix worktree runtime isolation recovery Co-Authored-By: Paperclip --- server/package.json | 2 +- server/scripts/dev-watch.ts | 33 ++ server/src/__tests__/dev-watch-ignore.test.ts | 34 ++ server/src/__tests__/worktree-config.test.ts | 237 ++++++++++++ server/src/config.ts | 3 + server/src/dev-watch-ignore.ts | 21 ++ server/src/index.ts | 30 +- server/src/worktree-config.ts | 357 ++++++++++++++++++ 8 files changed, 715 insertions(+), 2 deletions(-) create mode 100644 server/scripts/dev-watch.ts create mode 100644 server/src/__tests__/dev-watch-ignore.test.ts create mode 100644 server/src/__tests__/worktree-config.test.ts create mode 100644 server/src/dev-watch-ignore.ts create mode 100644 server/src/worktree-config.ts diff --git a/server/package.json b/server/package.json index 843f9ca7..c4053237 100644 --- a/server/package.json +++ b/server/package.json @@ -33,7 +33,7 @@ ], "scripts": { "dev": "tsx src/index.ts", - "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", diff --git a/server/scripts/dev-watch.ts b/server/scripts/dev-watch.ts new file mode 100644 index 00000000..69a85245 --- /dev/null +++ b/server/scripts/dev-watch.ts @@ -0,0 +1,33 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts"; + +const require = createRequire(import.meta.url); +const tsxCliPath = require.resolve("tsx/dist/cli.mjs"); +const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--ignore", ignorePath]); + +const child = spawn( + process.execPath, + [tsxCliPath, "watch", ...ignoreArgs, "src/index.ts"], + { + cwd: serverRoot, + env: process.env, + stdio: "inherit", + }, +); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); + +child.on("error", (error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/src/__tests__/dev-watch-ignore.test.ts b/server/src/__tests__/dev-watch-ignore.test.ts new file mode 100644 index 00000000..0331f61b --- /dev/null +++ b/server/src/__tests__/dev-watch-ignore.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js"; + +describe("resolveServerDevWatchIgnorePaths", () => { + it("includes both the worktree UI paths and their real shared targets", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-")); + const sharedUiRoot = path.join(tempRoot, "shared-ui"); + const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884"); + const serverRoot = path.join(worktreeRoot, "server"); + const worktreeUiRoot = path.join(worktreeRoot, "ui"); + + fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true }); + fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true }); + fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true }); + fs.mkdirSync(serverRoot, { recursive: true }); + fs.mkdirSync(worktreeUiRoot, { recursive: true }); + + fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules")); + fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite")); + fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist")); + + const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot); + + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules"))); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite"))); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist"))); + }); +}); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts new file mode 100644 index 00000000..2ec475af --- /dev/null +++ b/server/src/__tests__/worktree-config.test.ts @@ -0,0 +1,237 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + applyRuntimePortSelectionToConfig, + maybePersistWorktreeRuntimePorts, + maybeRepairLegacyWorktreeConfigAndEnvFiles, +} from "../worktree-config.js"; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_CWD = process.cwd(); + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + process.env[key] = value; + } +}); + +function buildLegacyConfig(sharedRoot: string) { + return { + $meta: { + version: 1, + updatedAt: "2026-03-26T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres" as const, + embeddedPostgresDataDir: path.join(sharedRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sharedRoot, "data", "backups"), + }, + }, + logging: { + mode: "file" as const, + logDir: path.join(sharedRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted" as const, + exposure: "private" as const, + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit" as const, + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + storage: { + provider: "local_disk" as const, + localDisk: { + baseDir: path.join(sharedRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted" as const, + strictMode: false, + localEncrypted: { + keyFilePath: path.join(sharedRoot, "secrets", "master.key"), + }, + }, + }; +} + +describe("worktree config repair", () => { + it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-")); + const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component", + "PAPERCLIP_AGENT_JWT_SECRET=shared-secret", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_CONTEXT; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: true, + }); + + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); + + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"'); + expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`); + expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`); + expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"'); + expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); + expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component"); + }); + + it("persists runtime-selected worktree ports back into config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-")); + const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(instanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(instanceRoot, "db"), + embeddedPostgresPort: 54331, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(instanceRoot, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(instanceRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(instanceRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(instanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox"; + process.env.PAPERCLIP_HOME = isolatedHome; + process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox"; + process.env.PAPERCLIP_CONFIG = configPath; + + maybePersistWorktreeRuntimePorts({ + serverPort: 3103, + databasePort: 54335, + }); + + const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(writtenConfig.server.port).toBe(3103); + expect(writtenConfig.database.embeddedPostgresPort).toBe(54335); + expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/"); + }); + + it("can update the in-memory config without rewriting env-driven ports", () => { + const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), { + serverPort: 3104, + databasePort: 54340, + allowServerPortWrite: false, + allowDatabasePortWrite: true, + }); + + expect(changed).toBe(true); + expect(config.server.port).toBe(3100); + expect(config.database.embeddedPostgresPort).toBe(54340); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/"); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 6943af7a..4a1cc17b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; +import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js"; import { AUTH_BASE_URL_MODES, DEPLOYMENT_EXPOSURES, @@ -36,6 +37,8 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) { loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true }); } +maybeRepairLegacyWorktreeConfigAndEnvFiles(); + type DatabaseMode = "embedded-postgres" | "postgres"; export interface Config { diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts new file mode 100644 index 00000000..6e7d90ce --- /dev/null +++ b/server/src/dev-watch-ignore.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; + +function addIgnorePath(target: Set, candidate: string): void { + target.add(candidate); + try { + target.add(fs.realpathSync(candidate)); + } catch { + // Ignore paths that do not exist in the current checkout. + } +} + +export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] { + const ignorePaths = new Set(); + + for (const relativePath of ["../ui/node_modules", "../ui/.vite", "../ui/dist"]) { + addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath)); + } + + return [...ignorePaths]; +} diff --git a/server/src/index.ts b/server/src/index.ts index d4f41c6e..c37157c0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineSe import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; +import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; type BetterAuthSessionUser = { id: string; @@ -69,7 +70,7 @@ export interface StartedServer { } export async function startServer(): Promise { - const config = loadConfig(); + let config = loadConfig(); if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; } @@ -167,6 +168,18 @@ export async function startServer(): Promise { const normalized = host.trim().toLowerCase(); return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::1"; } + + function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } + } const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; @@ -233,6 +246,7 @@ export async function startServer(): Promise { let embeddedPostgresStartedByThisProcess = false; let migrationSummary: MigrationSummary = "skipped"; let activeDatabaseConnectionString: string; + let resolvedEmbeddedPostgresPort: number | null = null; let startupDbInfo: | { mode: "external-postgres"; connectionString: string } | { mode: "embedded-postgres"; dataDir: string; port: number }; @@ -395,6 +409,7 @@ export async function startServer(): Promise { db = createDb(embeddedConnectionString); logger.info("Embedded PostgreSQL ready"); activeDatabaseConnectionString = embeddedConnectionString; + resolvedEmbeddedPostgresPort = port; startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } @@ -476,6 +491,19 @@ export async function startServer(): Promise { } const listenPort = await detectPort(config.port); + if (listenPort !== config.port) { + config.port = listenPort; + } + if (resolvedEmbeddedPostgresPort !== null && resolvedEmbeddedPostgresPort !== config.embeddedPostgresPort) { + config.embeddedPostgresPort = resolvedEmbeddedPostgresPort; + } + if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) { + config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort); + } + maybePersistWorktreeRuntimePorts({ + serverPort: listenPort, + databasePort: resolvedEmbeddedPostgresPort, + }); const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const storageService = createStorageServiceFromConfig(config); const app = await createApp(db as any, { diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts new file mode 100644 index 00000000..51397b84 --- /dev/null +++ b/server/src/worktree-config.ts @@ -0,0 +1,357 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { PaperclipConfig } from "@paperclipai/shared"; +import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function expandHomePrefix(value: string): string { + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function resolveHomeAwarePath(value: string): string { + return path.resolve(expandHomePrefix(value)); +} + +function sanitizeWorktreeInstanceId(rawValue: string): string { + const trimmed = rawValue.trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +function parseEnvFile(contents: string): Record { + const entries: Record = {}; + + for (const rawLine of contents.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + + const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); + if (!match) continue; + + const [, key, rawValue] = match; + const value = rawValue.trim(); + if (!value) { + entries[key] = ""; + continue; + } + + if ( + (value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'")) + ) { + entries[key] = value.slice(1, -1); + continue; + } + + entries[key] = value.replace(/\s+#.*$/, "").trim(); + } + + return entries; +} + +function readEnvEntries(envPath: string): Record { + if (!fs.existsSync(envPath)) return {}; + return parseEnvFile(fs.readFileSync(envPath, "utf8")); +} + +function formatEnvEntries(entries: Record): string { + return [ + "# Paperclip environment variables", + "# Generated by Paperclip worktree repair", + ...Object.entries(entries).map(([key, value]) => `${key}=${JSON.stringify(value)}`), + "", + ].join("\n"); +} + +function isPathInside(candidatePath: string, rootPath: string): boolean { + const candidate = path.resolve(candidatePath); + const root = path.resolve(rootPath); + return candidate === root || candidate.startsWith(`${root}${path.sep}`); +} + +type WorktreeRuntimeContext = { + configPath: string; + envPath: string; + worktreeName: string; + instanceId: string; + homeDir: string; + instanceRoot: string; + contextPath: string; + embeddedPostgresDataDir: string; + backupDir: string; + logDir: string; + storageDir: string; + secretsKeyFilePath: string; +}; + +function resolveWorktreeRuntimeContext( + env: NodeJS.ProcessEnv, + overrideConfigPath?: string, +): WorktreeRuntimeContext | null { + if (env.PAPERCLIP_IN_WORKTREE !== "true") return null; + + const configPath = resolvePaperclipConfigPath(overrideConfigPath); + const envPath = resolvePaperclipEnvPath(configPath); + const worktreeRoot = path.resolve(path.dirname(configPath), ".."); + const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); + const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); + const homeDir = resolveHomeAwarePath( + nonEmpty(env.PAPERCLIP_HOME) ?? + nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? + "~/.paperclip-worktrees", + ); + const instanceRoot = path.resolve(homeDir, "instances", instanceId); + + return { + configPath, + envPath, + worktreeName, + instanceId, + homeDir, + instanceRoot, + contextPath: path.resolve(homeDir, "context.json"), + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + backupDir: path.resolve(instanceRoot, "data", "backups"), + logDir: path.resolve(instanceRoot, "logs"), + storageDir: path.resolve(instanceRoot, "data", "storage"), + secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + }; +} + +function writeConfigFile(configPath: string, config: PaperclipConfig): void { + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); +} + +function buildIsolatedWorktreeConfig( + config: PaperclipConfig, + context: WorktreeRuntimeContext, +): PaperclipConfig { + const nextConfig: PaperclipConfig = { + ...config, + database: { + ...config.database, + ...(config.database.mode === "embedded-postgres" + ? { + embeddedPostgresDataDir: context.embeddedPostgresDataDir, + backup: { + ...config.database.backup, + dir: context.backupDir, + }, + } + : {}), + }, + logging: { + ...config.logging, + logDir: context.logDir, + }, + storage: { + ...config.storage, + localDisk: { + ...config.storage.localDisk, + baseDir: context.storageDir, + }, + }, + secrets: { + ...config.secrets, + localEncrypted: { + ...config.secrets.localEncrypted, + keyFilePath: context.secretsKeyFilePath, + }, + }, + }; + + if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { + nextConfig.auth = { + ...config.auth, + publicBaseUrl: rewriteLocalUrlPort(config.auth.publicBaseUrl, config.server.port), + }; + } + + return nextConfig; +} + +function needsWorktreeConfigRepair( + config: PaperclipConfig, + context: WorktreeRuntimeContext, +): boolean { + if (config.database.mode === "embedded-postgres") { + if (!isPathInside(config.database.embeddedPostgresDataDir, context.instanceRoot)) { + return true; + } + if (!isPathInside(config.database.backup.dir, context.instanceRoot)) { + return true; + } + } + + if (!isPathInside(config.logging.logDir, context.instanceRoot)) { + return true; + } + if (!isPathInside(config.storage.localDisk.baseDir, context.instanceRoot)) { + return true; + } + if (!isPathInside(config.secrets.localEncrypted.keyFilePath, context.instanceRoot)) { + return true; + } + + return false; +} + +export function applyRuntimePortSelectionToConfig( + config: PaperclipConfig, + input: { + serverPort: number; + databasePort?: number | null; + allowServerPortWrite?: boolean; + allowDatabasePortWrite?: boolean; + }, +): { config: PaperclipConfig; changed: boolean } { + let changed = false; + let nextConfig = config; + + if (input.allowServerPortWrite !== false && config.server.port !== input.serverPort) { + nextConfig = { + ...nextConfig, + server: { + ...nextConfig.server, + port: input.serverPort, + }, + }; + changed = true; + } + + if ( + input.allowDatabasePortWrite !== false && + nextConfig.database.mode === "embedded-postgres" && + typeof input.databasePort === "number" && + nextConfig.database.embeddedPostgresPort !== input.databasePort + ) { + nextConfig = { + ...nextConfig, + database: { + ...nextConfig.database, + embeddedPostgresPort: input.databasePort, + }, + }; + changed = true; + } + + if (nextConfig.auth.baseUrlMode === "explicit" && nextConfig.auth.publicBaseUrl) { + const rewritten = rewriteLocalUrlPort(nextConfig.auth.publicBaseUrl, input.serverPort); + if (rewritten && rewritten !== nextConfig.auth.publicBaseUrl) { + nextConfig = { + ...nextConfig, + auth: { + ...nextConfig.auth, + publicBaseUrl: rewritten, + }, + }; + changed = true; + } + } + + return { config: nextConfig, changed }; +} + +export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { + repairedConfig: boolean; + repairedEnv: boolean; +} { + const context = resolveWorktreeRuntimeContext(process.env); + if (!context) { + return { repairedConfig: false, repairedEnv: false }; + } + + process.env.PAPERCLIP_HOME = context.homeDir; + process.env.PAPERCLIP_INSTANCE_ID = context.instanceId; + process.env.PAPERCLIP_CONFIG = context.configPath; + process.env.PAPERCLIP_CONTEXT = context.contextPath; + process.env.PAPERCLIP_WORKTREE_NAME = context.worktreeName; + + let repairedConfig = false; + if (fs.existsSync(context.configPath)) { + try { + const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; + if (needsWorktreeConfigRepair(parsed, context)) { + writeConfigFile(context.configPath, buildIsolatedWorktreeConfig(parsed, context)); + repairedConfig = true; + } + } catch { + // Leave invalid configs to the normal startup validation path. + } + } + + const existingEnvEntries = readEnvEntries(context.envPath); + const desiredEnvEntries: Record = { + ...existingEnvEntries, + PAPERCLIP_HOME: context.homeDir, + PAPERCLIP_INSTANCE_ID: context.instanceId, + PAPERCLIP_CONFIG: context.configPath, + PAPERCLIP_CONTEXT: context.contextPath, + PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_WORKTREE_NAME: context.worktreeName, + }; + + const repairedEnv = Object.entries(desiredEnvEntries).some( + ([key, value]) => existingEnvEntries[key] !== value, + ); + + if (repairedEnv) { + fs.mkdirSync(path.dirname(context.envPath), { recursive: true }); + fs.writeFileSync(context.envPath, formatEnvEntries(desiredEnvEntries), { mode: 0o600 }); + } + + return { repairedConfig, repairedEnv }; +} + +export function maybePersistWorktreeRuntimePorts(input: { + serverPort: number; + databasePort?: number | null; +}): void { + const context = resolveWorktreeRuntimeContext(process.env); + if (!context || !fs.existsSync(context.configPath)) return; + + let fileConfig: PaperclipConfig; + try { + fileConfig = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; + } catch { + return; + } + + const { config, changed } = applyRuntimePortSelectionToConfig(fileConfig, { + serverPort: input.serverPort, + databasePort: input.databasePort, + allowServerPortWrite: !nonEmpty(process.env.PORT), + allowDatabasePortWrite: !nonEmpty(process.env.DATABASE_URL), + }); + + if (changed) { + writeConfigFile(context.configPath, config); + } +} From e91da556eefcbdb7387b9b44fafd3564890496db Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 10:52:44 -0500 Subject: [PATCH 022/118] updated reamde --- README.md | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 391a0feb..f7ade1b3 100644 --- a/README.md +++ b/README.md @@ -234,16 +234,27 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. ## Roadmap -- ⚪ Get OpenClaw onboarding easier -- ⚪ Get cloud agents working e.g. Cursor / e2b agents -- ⚪ ClipMart - buy and sell entire agent companies -- ⚪ Easy agent configurations / easier to understand -- ⚪ Better support for harness engineering -- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc) -- ⚪ Better docs +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App
+## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + ## Contributing We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details. From 555f026c247f1101270fe098bbeb96f02b4766b8 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 10:56:13 -0500 Subject: [PATCH 023/118] Avoid sibling worktree port collisions Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 81 ++++++++ cli/src/commands/worktree.ts | 64 ++++++- server/src/__tests__/worktree-config.test.ts | 189 +++++++++++++++++++ server/src/worktree-config.ts | 116 +++++++++++- 4 files changed, 445 insertions(+), 5 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ca48b001..3c2079d2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -344,6 +344,87 @@ describe("worktree helpers", () => { } }); + it("avoids ports already claimed by sibling worktree instance configs", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); + const repoRoot = path.join(tempRoot, "repo"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(siblingInstanceRoot, { recursive: true }); + fs.writeFileSync( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildSourceConfig(), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(siblingInstanceRoot, "logs"), + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: ["localhost"], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(siblingInstanceRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + ); + + process.chdir(repoRoot); + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: homeDir, + }); + + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8")); + expect(config.server.port).toBe(3102); + expect(config.database.embeddedPostgresPort).not.toBe(54330); + expect(config.database.embeddedPostgresPort).not.toBe(config.server.port); + expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("defaults the seed source config to the current repo-local Paperclip config", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); const repoRoot = path.join(tempRoot, "repo"); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 7a2bd127..a528bf5b 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -465,6 +465,62 @@ async function findAvailablePort(preferredPort: number, reserved = new Set; + databasePorts: Set; +} { + const serverPorts = new Set(); + const databasePorts = new Set(); + const configPaths = new Set(); + const instancesDir = path.resolve(homeDir, "instances"); + if (existsSync(instancesDir)) { + for (const entry of readdirSync(instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === currentInstanceId) continue; + + const configPath = path.resolve(instancesDir, entry.name, "config.json"); + if (existsSync(configPath)) { + configPaths.add(configPath); + } + } + } + + const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd); + if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) { + for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json"); + if (existsSync(configPath)) { + configPaths.add(configPath); + } + } + } + + for (const configPath of configPaths) { + try { + const config = readConfig(configPath); + if (config?.server.port) { + serverPorts.add(config.server.port); + } + if (config?.database.mode === "embedded-postgres") { + databasePorts.add(config.database.embeddedPostgresPort); + } + } catch { + // Ignore malformed sibling configs. + } + } + + return { serverPorts, databasePorts }; +} + function detectGitBranchName(cwd: string): string | null { try { const value = execFileSync("git", ["branch", "--show-current"], { @@ -886,10 +942,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { rmSync(paths.instanceRoot, { recursive: true, force: true }); } + const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd); const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); - const serverPort = await findAvailablePort(preferredServerPort); + const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts); const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); - const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + const databasePort = await findAvailablePort( + preferredDbPort, + new Set([...claimedPorts.databasePorts, serverPort]), + ); const targetConfig = buildWorktreeConfig({ sourceConfig, paths, diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 2ec475af..3317a254 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -139,6 +139,195 @@ describe("worktree config repair", () => { expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component"); }); + it("avoids sibling worktree ports when repairing legacy configs", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-")); + const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(siblingInstanceRoot, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-880-thumbs-capture-for-evals-feature", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildLegacyConfig(siblingInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "data", "backups"), + }, + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(result.repairedConfig).toBe(true); + expect(repairedConfig.server.port).toBe(3102); + expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); + }); + + it("rebalances duplicate ports for already isolated worktree configs", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-")); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees"); + const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox"); + const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component"); + const paperclipDir = path.join(currentWorktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); + const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(currentInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(currentInstanceRoot, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(currentInstanceRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(currentInstanceRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + siblingConfigPath, + JSON.stringify( + { + ...buildLegacyConfig(siblingInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "data", "backups"), + }, + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(currentWorktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(result.repairedConfig).toBe(true); + expect(repairedConfig.server.port).toBe(3102); + expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); + }); + it("persists runtime-selected worktree ports back into config", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-")); const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox"); diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index 51397b84..3656b7dc 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -149,10 +149,89 @@ function writeConfigFile(configPath: string, config: PaperclipConfig): void { fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); } +function resolveRepoManagedWorktreesRoot(worktreeRoot: string): string | null { + const normalized = path.resolve(worktreeRoot); + const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`; + const index = normalized.indexOf(marker); + if (index === -1) return null; + const repoRoot = normalized.slice(0, index); + return path.resolve(repoRoot, ".paperclip", "worktrees"); +} + +function collectSiblingWorktreePorts(context: WorktreeRuntimeContext): { + serverPorts: Set; + databasePorts: Set; +} { + const serverPorts = new Set(); + const databasePorts = new Set(); + const siblingConfigPaths = new Set(); + const instancesDir = path.resolve(context.homeDir, "instances"); + if (fs.existsSync(instancesDir)) { + for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === context.instanceId) continue; + + const siblingConfigPath = path.resolve(instancesDir, entry.name, "config.json"); + if (fs.existsSync(siblingConfigPath)) { + siblingConfigPaths.add(siblingConfigPath); + } + } + } + + const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(path.dirname(context.configPath)); + if (repoManagedWorktreesRoot && fs.existsSync(repoManagedWorktreesRoot)) { + for (const entry of fs.readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const siblingConfigPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json"); + if (path.resolve(siblingConfigPath) === path.resolve(context.configPath)) continue; + if (fs.existsSync(siblingConfigPath)) { + siblingConfigPaths.add(siblingConfigPath); + } + } + } + + for (const siblingConfigPath of siblingConfigPaths) { + try { + const siblingConfig = JSON.parse(fs.readFileSync(siblingConfigPath, "utf8")) as PaperclipConfig; + if (Number.isInteger(siblingConfig.server.port) && siblingConfig.server.port > 0) { + serverPorts.add(siblingConfig.server.port); + } + if ( + siblingConfig.database.mode === "embedded-postgres" && + Number.isInteger(siblingConfig.database.embeddedPostgresPort) && + siblingConfig.database.embeddedPostgresPort > 0 + ) { + databasePorts.add(siblingConfig.database.embeddedPostgresPort); + } + } catch { + // Ignore sibling configs that are missing or malformed. + } + } + + return { serverPorts, databasePorts }; +} + +function findNextUnclaimedPort(preferredPort: number, claimedPorts: Set): number { + let port = Math.max(1, Math.trunc(preferredPort)); + while (claimedPorts.has(port)) { + port += 1; + } + return port; +} + function buildIsolatedWorktreeConfig( config: PaperclipConfig, context: WorktreeRuntimeContext, + portOverrides?: { + serverPort?: number; + databasePort?: number; + }, ): PaperclipConfig { + const serverPort = portOverrides?.serverPort ?? config.server.port; + const databasePort = + config.database.mode === "embedded-postgres" + ? portOverrides?.databasePort ?? config.database.embeddedPostgresPort + : undefined; const nextConfig: PaperclipConfig = { ...config, database: { @@ -160,6 +239,7 @@ function buildIsolatedWorktreeConfig( ...(config.database.mode === "embedded-postgres" ? { embeddedPostgresDataDir: context.embeddedPostgresDataDir, + embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort, backup: { ...config.database.backup, dir: context.backupDir, @@ -167,6 +247,10 @@ function buildIsolatedWorktreeConfig( } : {}), }, + server: { + ...config.server, + port: serverPort, + }, logging: { ...config.logging, logDir: context.logDir, @@ -190,7 +274,7 @@ function buildIsolatedWorktreeConfig( if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { nextConfig.auth = { ...config.auth, - publicBaseUrl: rewriteLocalUrlPort(config.auth.publicBaseUrl, config.server.port), + publicBaseUrl: rewriteLocalUrlPort(config.auth.publicBaseUrl, serverPort), }; } @@ -298,8 +382,34 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { if (fs.existsSync(context.configPath)) { try { const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; - if (needsWorktreeConfigRepair(parsed, context)) { - writeConfigFile(context.configPath, buildIsolatedWorktreeConfig(parsed, context)); + const siblingPorts = collectSiblingWorktreePorts(context); + const hasSiblingPortCollision = + siblingPorts.serverPorts.has(parsed.server.port) || + (parsed.database.mode === "embedded-postgres" && + siblingPorts.databasePorts.has(parsed.database.embeddedPostgresPort)); + + if (needsWorktreeConfigRepair(parsed, context) || hasSiblingPortCollision) { + const selectedServerPort = findNextUnclaimedPort( + parsed.server.port === 3100 ? 3101 : parsed.server.port, + siblingPorts.serverPorts, + ); + const selectedDatabasePort = + parsed.database.mode === "embedded-postgres" + ? findNextUnclaimedPort( + parsed.database.embeddedPostgresPort === 54329 + ? 54330 + : parsed.database.embeddedPostgresPort, + new Set([...siblingPorts.databasePorts, selectedServerPort]), + ) + : undefined; + + writeConfigFile( + context.configPath, + buildIsolatedWorktreeConfig(parsed, context, { + serverPort: selectedServerPort, + databasePort: selectedDatabasePort, + }), + ); repairedConfig = true; } } catch { From c916626cef5d2169182fcad8861c8db0231cd0e1 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 11:04:07 -0500 Subject: [PATCH 024/118] test: skip embedded postgres suites when initdb is unavailable Co-Authored-By: Paperclip --- .../company-import-export-e2e.test.ts | 73 ++------- .../__tests__/helpers/embedded-postgres.ts | 148 ++++++++++++++++++ packages/db/src/client.test.ts | 99 +++--------- packages/db/src/index.ts | 6 + packages/db/src/test-embedded-postgres.ts | 148 ++++++++++++++++++ .../heartbeat-process-recovery.test.ts | 98 ++---------- .../__tests__/helpers/embedded-postgres.ts | 148 ++++++++++++++++++ server/src/__tests__/issues-service.test.ts | 95 ++--------- server/src/__tests__/routines-e2e.test.ts | 95 ++--------- server/src/__tests__/routines-service.test.ts | 95 ++--------- 10 files changed, 547 insertions(+), 458 deletions(-) create mode 100644 cli/src/__tests__/helpers/embedded-postgres.ts create mode 100644 packages/db/src/test-embedded-postgres.ts create mode 100644 server/src/__tests__/helpers/embedded-postgres.ts diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 27334105..c543249e 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -6,33 +6,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { createStoredZipArchive } from "./helpers/zip.js"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - const execFileAsync = promisify(execFile); type ServerProcess = ReturnType; -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = net.createServer(); @@ -53,30 +35,13 @@ async function getAvailablePort(): Promise { }); } -async function startTempDatabase() { - const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db"); - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - - return { connectionString, dataDir, instance }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { @@ -265,26 +230,23 @@ async function waitForServer( ); } -describe("paperclipai company import/export e2e", () => { +describeEmbeddedPostgres("paperclipai company import/export e2e", () => { let tempRoot = ""; let configPath = ""; let exportDir = ""; let apiBase = ""; let serverProcess: ServerProcess | null = null; - let dbDataDir = ""; - let dbInstance: EmbeddedPostgresInstance | null = null; + let tempDb: Awaited> | null = null; beforeAll(async () => { tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-")); configPath = path.join(tempRoot, "config", "config.json"); exportDir = path.join(tempRoot, "exported-company"); - const db = await startTempDatabase(); - dbDataDir = db.dataDir; - dbInstance = db.instance; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-"); const port = await getAvailablePort(); - writeTestConfig(configPath, tempRoot, port, db.connectionString); + writeTestConfig(configPath, tempRoot, port, tempDb.connectionString); apiBase = `http://127.0.0.1:${port}`; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -294,7 +256,7 @@ describe("paperclipai company import/export e2e", () => { ["paperclipai", "run", "--config", configPath], { cwd: repoRoot, - env: createServerEnv(configPath, port, db.connectionString), + env: createServerEnv(configPath, port, tempDb.connectionString), stdio: ["ignore", "pipe", "pipe"], }, ); @@ -311,10 +273,7 @@ describe("paperclipai company import/export e2e", () => { afterAll(async () => { await stopServerProcess(serverProcess); - await dbInstance?.stop(); - if (dbDataDir) { - rmSync(dbDataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); if (tempRoot) { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/cli/src/__tests__/helpers/embedded-postgres.ts b/cli/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..8249d98b --- /dev/null +++ b/cli/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "@paperclipai/db"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + if (process.platform !== "darwin") { + return { supported: true }; + } + + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { + connectionString, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 752fce15..622130ac 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -1,83 +1,24 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import postgres from "postgres"; import { applyPendingMigrations, - ensurePostgresDatabase, inspectMigrations, } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -const tempPaths: string[] = []; -const runningInstances: EmbeddedPostgresInstance[] = []; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; async function createTempDatabase(): Promise { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-")); - tempPaths.push(dataDir); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - runningInstances.push(instance); - - const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminUrl, "paperclip"); - return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-"); + cleanups.push(db.cleanup); + return db.connectionString; } async function migrationHash(migrationFile: string): Promise { @@ -89,19 +30,19 @@ async function migrationHash(migrationFile: string): Promise { } afterEach(async () => { - while (runningInstances.length > 0) { - const instance = runningInstances.pop(); - if (!instance) continue; - await instance.stop(); - } - while (tempPaths.length > 0) { - const tempPath = tempPaths.pop(); - if (!tempPath) continue; - fs.rmSync(tempPath, { recursive: true, force: true }); + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); } }); -describe("applyPendingMigrations", () => { +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("applyPendingMigrations", () => { it( "applies an inserted earlier migration without replaying later legacy migrations", async () => { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5c32ab13..b5ccb5d4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,6 +11,12 @@ export { type MigrationBootstrapResult, type Db, } from "./client.js"; +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "./test-embedded-postgres.js"; export { runDatabaseBackup, runDatabaseRestore, diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts new file mode 100644 index 00000000..1a959ed5 --- /dev/null +++ b/packages/db/src/test-embedded-postgres.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + if (process.platform !== "darwin") { + return { supported: true }; + } + + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { + connectionString, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d0e3cc31..6b18d162 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,89 +1,29 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { - applyPendingMigrations, - createDb, - ensurePostgresDatabase, agents, agentWakeupRequests, companies, + createDb, heartbeatRunEvents, heartbeatRuns, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { runningProcesses } from "../adapters/index.ts"; import { heartbeatService } from "../services/heartbeat.ts"; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, instance, dataDir }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function spawnAliveProcess() { @@ -92,17 +32,14 @@ function spawnAliveProcess() { }); } -describe("heartbeat orphaned process recovery", () => { +describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; const childProcesses = new Set(); beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -125,10 +62,7 @@ describe("heartbeat orphaned process recovery", () => { } childProcesses.clear(); runningProcesses.clear(); - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedRunFixture(input?: { diff --git a/server/src/__tests__/helpers/embedded-postgres.ts b/server/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..8249d98b --- /dev/null +++ b/server/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,148 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "@paperclipai/db"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + if (process.platform !== "darwin") { + return { supported: true }; + } + + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { + connectionString, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ba27866f..1d20293b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1,99 +1,37 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, createDb, - ensurePostgresDatabase, issueComments, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("issueService.list participantAgentId", () => { +describeEmbeddedPostgres("issueService.list participantAgentId", () => { let db!: ReturnType; let svc!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-"); + db = createDb(tempDb.connectionString); svc = issueService(db); - instance = started.instance; - dataDir = started.dataDir; }, 20_000); afterEach(async () => { @@ -105,10 +43,7 @@ describe("issueService.list participantAgentId", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); it("returns issues an agent participated in across the supported signals", async () => { diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 83689724..ab5fd778 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -1,8 +1,4 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; @@ -11,11 +7,9 @@ import { activityLog, agentWakeupRequests, agents, - applyPendingMigrations, companies, companyMemberships, createDb, - ensurePostgresDatabase, heartbeatRunEvents, heartbeatRuns, instanceSettings, @@ -26,6 +20,10 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; @@ -78,82 +76,22 @@ vi.mock("../services/index.js", async () => { }; }); -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("routine routes end-to-end", () => { +describeEmbeddedPostgres("routine routes end-to-end", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -174,10 +112,7 @@ describe("routine routes end-to-end", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function createApp(actor: Record) { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index d5954246..d6aad0f2 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1,19 +1,13 @@ import { createHmac, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, companySecrets, companySecretVersions, createDb, - ensurePostgresDatabase, heartbeatRuns, issues, projects, @@ -21,85 +15,29 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; import { routineService } from "../services/routines.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("routine service live-execution coalescing", () => { +describeEmbeddedPostgres("routine service live-execution coalescing", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -117,10 +55,7 @@ describe("routine service live-execution coalescing", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedFixture(opts?: { From 874fe5ec7db203fbc69e06e95be72040029bcb18 Mon Sep 17 00:00:00 2001 From: dotta Date: Wed, 25 Mar 2026 19:30:37 -0500 Subject: [PATCH 025/118] Publish @paperclipai/ui from release automation Co-Authored-By: Paperclip --- doc/PUBLISHING.md | 16 ++++++++++++-- doc/RELEASE-AUTOMATION-SETUP.md | 1 + scripts/generate-ui-package-json.mjs | 31 ++++++++++++++++++++++++++++ ui/README.md | 11 ++++++++++ ui/package.json | 22 +++++++++++++++++--- 5 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 scripts/generate-ui-package-json.mjs create mode 100644 ui/README.md diff --git a/doc/PUBLISHING.md b/doc/PUBLISHING.md index 50a1930e..32e4b131 100644 --- a/doc/PUBLISHING.md +++ b/doc/PUBLISHING.md @@ -51,10 +51,9 @@ Public packages are discovered from: - `packages/` - `server/` +- `ui/` - `cli/` -`ui/` is ignored because it is private. - The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs), which: - finds all public packages @@ -65,6 +64,18 @@ The version rewrite step now uses [`scripts/release-package-map.mjs`](../scripts Those rewrites are temporary. The working tree is restored after publish or dry-run. +## `@paperclipai/ui` packaging + +The UI package publishes prebuilt static assets, not the source workspace. + +The `ui` package uses [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) during `prepack` to swap in a lean publish manifest that: + +- keeps the release-managed `name` and `version` +- publishes only `dist/` +- omits the source-only dependency graph from downstream installs + +After packing or publishing, `postpack` restores the development manifest automatically. + ## Version formats Paperclip uses calendar versions: @@ -135,6 +146,7 @@ This is the fastest way to restore the default install path if a stable release - [`scripts/build-npm.sh`](../scripts/build-npm.sh) - [`scripts/generate-npm-package-json.mjs`](../scripts/generate-npm-package-json.mjs) +- [`scripts/generate-ui-package-json.mjs`](../scripts/generate-ui-package-json.mjs) - [`scripts/release-package-map.mjs`](../scripts/release-package-map.mjs) - [`cli/esbuild.config.mjs`](../cli/esbuild.config.mjs) - [`doc/RELEASING.md`](RELEASING.md) diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index d987a316..25982892 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -35,6 +35,7 @@ At minimum that includes: - `paperclipai` - `@paperclipai/server` +- `@paperclipai/ui` - public packages under `packages/` ### 2.1. In npm, open each package settings page diff --git a/scripts/generate-ui-package-json.mjs b/scripts/generate-ui-package-json.mjs new file mode 100644 index 00000000..e8eac306 --- /dev/null +++ b/scripts/generate-ui-package-json.mjs @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, ".."); +const uiDir = join(repoRoot, "ui"); +const packageJsonPath = join(uiDir, "package.json"); + +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); + +const publishPackageJson = { + name: packageJson.name, + version: packageJson.version, + description: packageJson.description, + license: packageJson.license, + homepage: packageJson.homepage, + bugs: packageJson.bugs, + repository: packageJson.repository, + type: packageJson.type, + files: ["dist"], + publishConfig: { + access: "public", + }, +}; + +writeFileSync(packageJsonPath, `${JSON.stringify(publishPackageJson, null, 2)}\n`); + +console.log(" ✓ Generated publishable UI package.json"); diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..0e688669 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,11 @@ +# @paperclipai/ui + +Published static assets for the Paperclip board UI. + +## What gets published + +The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies. + +## Typical use + +Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`. diff --git a/ui/package.json b/ui/package.json index a02ddb12..c2471b4b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,13 +1,29 @@ { "name": "@paperclipai/ui", - "version": "0.0.1", - "private": true, + "version": "0.3.1", + "description": "Prebuilt Paperclip board UI assets.", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "ui" + }, "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", - "typecheck": "tsc -b" + "typecheck": "tsc -b", + "clean": "rm -rf dist tsconfig.tsbuildinfo", + "prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs", + "postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi" + }, + "publishConfig": { + "access": "public" }, "dependencies": { "@dnd-kit/core": "^6.3.1", From ab0d04ff7aa85ec9ef8b9c2c56ade9ecc2943e81 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 11:53:25 -0500 Subject: [PATCH 026/118] fix(ui): address workspace card review feedback - restore pre-run workspace configuration visibility - require explicit save/cancel for workspace edits - stabilize debounced issue search callback Co-Authored-By: Paperclip --- ui/src/components/IssueWorkspaceCard.tsx | 191 +++++++++++++++++------ ui/src/components/IssuesList.tsx | 9 +- 2 files changed, 148 insertions(+), 52 deletions(-) diff --git a/ui/src/components/IssueWorkspaceCard.tsx b/ui/src/components/IssueWorkspaceCard.tsx index bccf42ee..56484cab 100644 --- a/ui/src/components/IssueWorkspaceCard.tsx +++ b/ui/src/components/IssueWorkspaceCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link } from "@/lib/router"; import type { Issue, ExecutionWorkspace } from "@paperclipai/shared"; import { useQuery } from "@tanstack/react-query"; @@ -98,6 +98,22 @@ function workspaceModeLabel(mode: string | null | undefined) { } } +function configuredWorkspaceLabel( + selection: string | null | undefined, + reusableWorkspace: ExecutionWorkspace | null, +) { + switch (selection) { + case "isolated_workspace": + return "New isolated workspace"; + case "reuse_existing": + return reusableWorkspace?.mode === "isolated_workspace" + ? "Existing isolated workspace" + : "Reuse existing workspace"; + default: + return "Project default"; + } +} + function statusBadge(status: string) { const colors: Record = { active: "bg-green-500/15 text-green-700 dark:text-green-400", @@ -137,9 +153,6 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC const workspace = issue.currentExecutionWorkspace as ExecutionWorkspace | null | undefined; - // Only show this card for non-default workspaces - const isNonDefault = workspace && workspace.mode !== "shared_workspace"; - const { data: reusableExecutionWorkspaces } = useQuery({ queryKey: queryKeys.executionWorkspaces.list(companyId!, { projectId: issue.projectId ?? undefined, @@ -181,8 +194,51 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC ?? defaultExecutionWorkspaceModeForProject(project) ); - // Don't render if feature is off or workspace is default/absent - if (!policyEnabled || !isNonDefault) return null; + const [draftSelection, setDraftSelection] = useState(currentSelection); + const [draftExecutionWorkspaceId, setDraftExecutionWorkspaceId] = useState(issue.executionWorkspaceId ?? ""); + + useEffect(() => { + if (editing) return; + setDraftSelection(currentSelection); + setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); + }, [currentSelection, editing, issue.executionWorkspaceId]); + + const activeNonDefaultWorkspace = Boolean(workspace && workspace.mode !== "shared_workspace"); + + const configuredReusableWorkspace = + deduplicatedReusableWorkspaces.find((w) => w.id === draftExecutionWorkspaceId) + ?? (draftExecutionWorkspaceId === issue.executionWorkspaceId ? selectedReusableExecutionWorkspace : null); + + const canSaveWorkspaceConfig = draftSelection !== "reuse_existing" || draftExecutionWorkspaceId.length > 0; + + const handleSave = useCallback(() => { + if (!canSaveWorkspaceConfig) return; + onUpdate({ + executionWorkspacePreference: draftSelection, + executionWorkspaceId: draftSelection === "reuse_existing" ? draftExecutionWorkspaceId || null : null, + executionWorkspaceSettings: { + mode: + draftSelection === "reuse_existing" + ? issueModeForExistingWorkspace(configuredReusableWorkspace?.mode) + : draftSelection, + }, + }); + setEditing(false); + }, [ + canSaveWorkspaceConfig, + configuredReusableWorkspace?.mode, + draftExecutionWorkspaceId, + draftSelection, + onUpdate, + ]); + + const handleCancel = useCallback(() => { + setDraftSelection(currentSelection); + setDraftExecutionWorkspaceId(issue.executionWorkspaceId ?? ""); + setEditing(false); + }, [currentSelection, issue.executionWorkspaceId]); + + if (!policyEnabled || !project) return null; return (
@@ -190,48 +246,95 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
- {workspaceModeLabel(workspace.mode)} - {statusBadge(workspace.status)} + {activeNonDefaultWorkspace && workspace + ? workspaceModeLabel(workspace.mode) + : configuredWorkspaceLabel(currentSelection, selectedReusableExecutionWorkspace)} + {workspace ? statusBadge(workspace.status) : statusBadge("idle")} +
+
+ {editing ? ( + <> + + + + ) : ( + + )}
-
{/* Read-only info */} {!editing && (
- {workspace.branchName && ( + {workspace?.branchName && (
)} - {workspace.cwd && ( + {workspace?.cwd && (
)} - {workspace.repoUrl && ( + {workspace?.repoUrl && (
Repo:
)} -
- - View workspace details → - -
+ {!workspace && ( +
+ {currentSelection === "isolated_workspace" + ? "A fresh isolated workspace will be created when this issue runs." + : currentSelection === "reuse_existing" + ? "This issue will reuse an existing workspace when it runs." + : "This issue will use the project default workspace configuration when it runs."} +
+ )} + {currentSelection === "reuse_existing" && selectedReusableExecutionWorkspace && ( +
+ Reusing:{" "} + + + +
+ )} + {workspace && ( +
+ + View workspace details → + +
+ )}
)} @@ -240,44 +343,32 @@ export function IssueWorkspaceCard({ issue, project, onUpdate }: IssueWorkspaceC
- {currentSelection === "reuse_existing" && ( + {draftSelection === "reuse_existing" && ( setModelSearch(e.target.value)} - autoFocus - /> +
+ setModelSearch(e.target.value)} + /> + {modelSearch && ( + + )} +
+ {onDetectModel && !detectedModel && !modelSearch.trim() && ( + + )} + {value && !models.some((m) => m.id === value) && ( + + )} + {detectedModel && detectedModel !== value && ( + + )}
{allowDefault && ( + )} {groupedModels.map((group) => (
{groupByProvider && ( @@ -1392,6 +1546,7 @@ function ModelDropdown({ )} {group.entries.map((m) => (
diff --git a/ui/src/components/HermesIcon.tsx b/ui/src/components/HermesIcon.tsx new file mode 100644 index 00000000..fb02623a --- /dev/null +++ b/ui/src/components/HermesIcon.tsx @@ -0,0 +1,43 @@ +import { cn } from "../lib/utils"; + +interface HermesIconProps { + className?: string; +} + +/** + * Hermes caduceus icon — winged staff with two intertwined serpents. + * Replaces the generic Zap icon for the hermes_local adapter type. + * + * ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. + */ +export function HermesIcon({ className }: HermesIconProps) { + return ( + + {/* Central staff */} + + {/* Left serpent curves */} + + {/* Right serpent curves */} + + {/* Snake heads facing outward */} + + + {/* Wings at top of staff */} + + + {/* Wing feather details */} + + + {/* Staff sphere at top */} + + + ); +} diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 15114bf7..aaaf7c6d 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; +import { HermesIcon } from "./HermesIcon"; type AdvancedAdapterType = | "claude_local" @@ -29,7 +30,8 @@ type AdvancedAdapterType = | "opencode_local" | "pi_local" | "cursor" - | "openclaw_gateway"; + | "openclaw_gateway" + | "hermes_local"; const ADVANCED_ADAPTER_OPTIONS: Array<{ value: AdvancedAdapterType; @@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{ icon: OpenCodeLogoIcon, desc: "Local multi-provider agent", }, + { + value: "hermes_local", + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent", + }, { value: "pi_local", label: "Pi", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index cd28af9f..b3ec724e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,12 +56,14 @@ import { ChevronDown, X } from "lucide-react"; +import { HermesIcon } from "./HermesIcon"; type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" | "gemini_local" + | "hermes_local" | "opencode_local" | "pi_local" | "cursor" @@ -208,6 +210,7 @@ export function OnboardingWizard() { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; @@ -217,6 +220,8 @@ export function OnboardingWizard() { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -843,6 +848,12 @@ export function OnboardingWizard() { icon: MousePointer2, desc: "Local Cursor agent" }, + { + value: "hermes_local" as const, + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent" + }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", @@ -902,6 +913,7 @@ export function OnboardingWizard() { {(adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor") && ( diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 70694f73..e66bf4d8 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -64,6 +64,7 @@ export const adapterLabels: Record = { opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index cd52dbc1..f39167f8 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -72,6 +72,26 @@ type TranscriptBlock = status: "running" | "completed" | "error"; }>; } + | { + type: "tool_group"; + ts: string; + endTs?: string; + items: Array<{ + ts: string; + endTs?: string; + name: string; + input: unknown; + result?: string; + isError?: boolean; + status: "running" | "completed" | "error"; + }>; + } + | { + type: "stderr_group"; + ts: string; + endTs?: string; + lines: Array<{ ts: string; text: string }>; + } | { type: "stdout"; ts: string; @@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { return grouped; } +/** Group consecutive non-command tool blocks into a single tool_group accordion. */ +function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { + const grouped: TranscriptBlock[] = []; + let pending: Array["items"][number]> = []; + let groupTs: string | null = null; + let groupEndTs: string | undefined; + + const flush = () => { + if (pending.length === 0 || !groupTs) return; + grouped.push({ + type: "tool_group", + ts: groupTs, + endTs: groupEndTs, + items: pending, + }); + pending = []; + groupTs = null; + groupEndTs = undefined; + }; + + for (const block of blocks) { + if (block.type === "tool" && !isCommandTool(block.name, block.input)) { + if (!groupTs) groupTs = block.ts; + groupEndTs = block.endTs ?? block.ts; + pending.push({ + ts: block.ts, + endTs: block.endTs, + name: block.name, + input: block.input, + result: block.result, + isError: block.isError, + status: block.status, + }); + continue; + } + flush(); + grouped.push(block); + } + flush(); + return grouped; +} + export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { const blocks: TranscriptBlock[] = []; const pendingToolBlocks = new Map>(); @@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole if (shouldHideNiceModeStderr(entry.text)) { continue; } - blocks.push({ - type: "event", - ts: entry.ts, - label: "stderr", - tone: "error", - text: entry.text, - }); + // Batch consecutive stderr entries into a single group + const prev = blocks[blocks.length - 1]; + if (prev && prev.type === "stderr_group") { + prev.lines.push({ ts: entry.ts, text: entry.text }); + prev.endTs = entry.ts; + } else { + blocks.push({ + type: "stderr_group", + ts: entry.ts, + endTs: entry.ts, + lines: [{ ts: entry.ts, text: entry.text }], + }); + } continue; } @@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole } } - return groupCommandBlocks(blocks); + return groupToolBlocks(groupCommandBlocks(blocks)); } function TranscriptMessageBlock({ @@ -805,6 +873,139 @@ function TranscriptCommandGroup({ ); } +function TranscriptToolGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + const runningItem = [...block.items].reverse().find((item) => item.status === "running"); + const hasError = block.items.some((item) => item.status === "error"); + const isRunning = Boolean(runningItem); + const uniqueNames = [...new Set(block.items.map((item) => item.name))]; + const toolLabel = + uniqueNames.length === 1 + ? humanizeLabel(uniqueNames[0]) + : `${uniqueNames.length} tools`; + const title = isRunning + ? `Using ${toolLabel}` + : block.items.length === 1 + ? `Used ${toolLabel}` + : `Used ${toolLabel} (${block.items.length} calls)`; + const subtitle = runningItem + ? summarizeToolInput(runningItem.name, runningItem.input, density) + : null; + const statusTone = isRunning + ? "text-cyan-700 dark:text-cyan-300" + : "text-foreground/70"; + + return ( +
+
{ if (hasSelectedText()) return; setOpen((v) => !v); }} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > +
+ {block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => { + const isItemRunning = item.status === "running"; + const isItemError = item.status === "error"; + return ( + 0 && "-ml-1.5", + isItemRunning + ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" + : isItemError + ? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300" + : "border-border/70 bg-background text-foreground/55", + isItemRunning && "animate-pulse", + )} + > + + + ); + })} +
+
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ +
+ {open && ( +
+ {block.items.map((item, index) => ( +
+
+ + + + + {humanizeLabel(item.name)} + + + {item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"} + +
+
+
+
Input
+
+                    {formatToolPayload(item.input) || ""}
+                  
+
+ {item.result && ( +
+
Result
+
+                      {formatToolPayload(item.result)}
+                    
+
+ )} +
+
+ ))} +
+ )} +
+ ); +} + function TranscriptActivityRow({ block, density, @@ -883,6 +1084,43 @@ function TranscriptEventRow({ ); } +function TranscriptStderrGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + return ( +
+
setOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > + + {block.lines.length} log {block.lines.length === 1 ? "line" : "lines"} + + {open ? : } +
+ {open && ( +
+          {block.lines.map((line, i) => (
+            
+              {i > 0 ? "\n" : ""}
+              {line.text}
+            
+          ))}
+        
+ )} +
+ ); +} + function TranscriptStdoutRow({ block, density, @@ -1003,6 +1241,8 @@ export function RunTranscriptView({ )} {block.type === "tool" && } {block.type === "command_group" && } + {block.type === "tool_group" && } + {block.type === "stderr_group" && } {block.type === "stdout" && ( )} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index ecee1a20..8b7f2cd7 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -25,6 +25,8 @@ export const queryKeys = { configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-models", adapterType] as const, + detectModel: (companyId: string, adapterType: string) => + ["agents", companyId, "detect-model", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c0bed886..e2800642 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin const isLive = run.status === "running" || run.status === "queued"; const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; - const summary = run.resultJson + const summaryRaw = run.resultJson ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; + // Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines + const summary = useMemo(() => { + if (!summaryRaw) return ""; + const lines = summaryRaw + .replace(/^#{1,6}\s+/gm, "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```")); + const excerpt: string[] = []; + let chars = 0; + for (const line of lines) { + if (excerpt.length >= 3 || chars + line.length > 280) break; + excerpt.push(line); + chars += line.length; + } + return excerpt.join(" "); + }, [summaryRaw]); + return (
@@ -2351,6 +2369,7 @@ function AgentSkillsTab({ const queryClient = useQueryClient(); const [skillDraft, setSkillDraft] = useState([]); const [lastSavedSkills, setLastSavedSkills] = useState([]); + const [unmanagedOpen, setUnmanagedOpen] = useState(false); const lastSavedSkillsRef = useRef([]); const hasHydratedSkillSnapshotRef = useRef(false); const skipNextSkillAutosaveRef = useRef(true); @@ -2680,12 +2699,19 @@ function AgentSkillsTab({ {unmanagedSkillRows.length > 0 && (
-
+
setUnmanagedOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }} + > - User-installed skills, not managed by Paperclip + ({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip + {unmanagedOpen ? : }
- {unmanagedSkillRows.map(renderSkillRow)} + {unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
)} diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index b14112c4..a157f777 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -26,6 +26,7 @@ const adapterLabels: Record = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index e288babe..ec0d5e1d 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -20,11 +20,12 @@ const adapterLabels: Record = { pi_local: "Pi (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); function dateTime(value: string) { return new Date(value).toLocaleString(); diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index b8787be2..69415db6 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", From 582f4ceaf4a03486fd4e1b2df7e051d582fea15b Mon Sep 17 00:00:00 2001 From: HenkDz Date: Sat, 28 Mar 2026 11:35:58 +0100 Subject: [PATCH 040/118] fix: address Hermes adapter review feedback --- server/src/adapters/registry.ts | 2 +- server/src/routes/agents.ts | 2 +- ui/src/api/agents.ts | 8 ++++---- ui/src/components/AgentConfigForm.tsx | 6 +++++- ui/src/pages/AgentDetail.tsx | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 0314c435..3db7cdf9 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -227,7 +227,7 @@ export function listServerAdapters(): ServerAdapterModule[] { export async function detectAdapterModel( type: string, -): Promise<{ model: string | null; provider: string | null; source: string | null } | null> { +): Promise<{ model: string; provider: string; source: string } | null> { const adapter = adaptersByType.get(type); if (!adapter?.detectModel) return null; const detected = await adapter.detectModel(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index e414fca6..b4964578 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -677,7 +677,7 @@ export function agentRoutes(db: Db) { const type = req.params.type as string; const detected = await detectAdapterModel(type); - res.json(detected ?? { model: null, provider: null, source: null }); + res.json(detected); }); router.post( diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index b81a59e9..ec090b43 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -28,9 +28,9 @@ export interface AdapterModel { } export interface DetectedAdapterModel { - model: string | null; - provider: string | null; - source: string | null; + model: string; + provider: string; + source: string; } export interface ClaudeLoginResult { @@ -166,7 +166,7 @@ export const agentsApi = { `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, ), detectModel: (companyId: string, type: string) => - api.get( + api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, ), testEnvironment: ( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 3b586642..b9781a05 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -757,6 +757,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { return result.data?.model ?? null; } : undefined} + detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined} /> {fetchedModelsError && (

@@ -1341,6 +1342,7 @@ function ModelDropdown({ creatable, detectedModel, onDetectModel, + detectModelLabel, }: { models: AdapterModel[]; value: string; @@ -1353,6 +1355,7 @@ function ModelDropdown({ creatable?: boolean; detectedModel?: string | null; onDetectModel?: () => Promise; + detectModelLabel?: string; }) { const [modelSearch, setModelSearch] = useState(""); const [detectingModel, setDetectingModel] = useState(false); @@ -1440,6 +1443,7 @@ function ModelDropdown({ placeholder={creatable ? "Search models... (type to create)" : "Search models..."} value={modelSearch} onChange={(e) => setModelSearch(e.target.value)} + autoFocus /> {modelSearch && ( )} {value && !models.some((m) => m.id === value) && ( diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index e2800642..3e19d294 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1086,7 +1086,7 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin .replace(/^#{1,6}\s+/gm, "") .split("\n") .map((l) => l.trim()) - .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```")); + .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l)); const excerpt: string[] = []; let chars = 0; for (const line of lines) { From caef115b9551c02fb4476229175109db01cc41f2 Mon Sep 17 00:00:00 2001 From: lockfile-bot Date: Sat, 28 Mar 2026 11:46:21 +0000 Subject: [PATCH 041/118] chore(lockfile): refresh pnpm-lock.yaml --- pnpm-lock.yaml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf66edb7..19c9ffc4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -504,8 +504,8 @@ importers: specifier: ^5.1.0 version: 5.2.1 hermes-paperclip-adapter: - specifier: 0.1.1 - version: 0.1.1 + specifier: ^0.2.0 + version: 0.2.0 jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) @@ -639,6 +639,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + hermes-paperclip-adapter: + specifier: ^0.2.0 + version: 0.2.0 lexical: specifier: 0.35.0 version: 0.35.0 @@ -2040,8 +2043,8 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - '@paperclipai/adapter-utils@0.3.1': - resolution: {integrity: sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==} + '@paperclipai/adapter-utils@2026.325.0': + resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==} '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -4468,8 +4471,8 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hermes-paperclip-adapter@0.1.1: - resolution: {integrity: sha512-kbdX349VxExSkVL8n4RwTpP9fUBf2yWpsTsJp02X12A9NynRJatlpYqt0vEkFyE/X7qEXqdJvpBm9tlvUHahsA==} + hermes-paperclip-adapter@0.2.0: + resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==} engines: {node: '>=20.0.0'} html-encoding-sniffer@6.0.0: @@ -7740,7 +7743,7 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} - '@paperclipai/adapter-utils@0.3.1': {} + '@paperclipai/adapter-utils@2026.325.0': {} '@paralleldrive/cuid2@2.3.1': dependencies: @@ -10337,9 +10340,9 @@ snapshots: help-me@5.0.0: {} - hermes-paperclip-adapter@0.1.1: + hermes-paperclip-adapter@0.2.0: dependencies: - '@paperclipai/adapter-utils': 0.3.1 + '@paperclipai/adapter-utils': 2026.325.0 picocolors: 1.1.1 html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): From 01fb97e8da0533be1aa8ade627a0c51e95f89b03 Mon Sep 17 00:00:00 2001 From: Mikhail Batukhtin <6481198+remdev@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:19:26 +0300 Subject: [PATCH 042/118] fix(codex-local): handle spawn error event in CodexRpcClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the `codex` binary is absent from PATH, Node.js emits an `error` event on the ChildProcess. Because `CodexRpcClient` only subscribed to `exit` and `data` events, the `error` event was unhandled — causing Node to throw it as an uncaught exception and crash the server. Add an `error` handler in the constructor that rejects all pending RPC requests and clears the queue. This makes a missing `codex` binary a recoverable condition: `fetchCodexRpcQuota()` rejects, `getQuotaWindows()` catches the error and returns `{ ok: false }`, and the server stays up. The fix mirrors the existing pattern in `runChildProcess` (packages/adapter-utils/src/server-utils.ts) which already handles `ENOENT` the same way for the main task execution path. --- packages/adapters/codex-local/src/server/quota.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/adapters/codex-local/src/server/quota.ts b/packages/adapters/codex-local/src/server/quota.ts index 7bc771e4..3c0ac3bf 100644 --- a/packages/adapters/codex-local/src/server/quota.ts +++ b/packages/adapters/codex-local/src/server/quota.ts @@ -432,6 +432,13 @@ class CodexRpcClient { } this.pending.clear(); }); + this.proc.on("error", (err: Error) => { + for (const request of this.pending.values()) { + clearTimeout(request.timer); + request.reject(err); + } + this.pending.clear(); + }); } private onStdout(chunk: string) { From c98af52590565835d8b5eb8dafd3ea1f907851b1 Mon Sep 17 00:00:00 2001 From: Mikhail Batukhtin <6481198+remdev@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:41:25 +0300 Subject: [PATCH 043/118] test(codex-local): regression for CodexRpcClient spawn ENOENT Add a Vitest case that mocks `node:child_process.spawn` so the child emits `error` (ENOENT) after the constructor attaches listeners. `getQuotaWindows()` must resolve with `ok: false` instead of leaving an unhandled `error` event on the process. Register `packages/adapters/codex-local` in the root Vitest workspace. Document in DEVELOPING.md that a missing `codex` binary should not take down the API server during quota polling. --- doc/DEVELOPING.md | 2 + .../src/server/quota-spawn-error.test.ts | 57 +++++++++++++++++++ .../adapters/codex-local/vitest.config.ts | 7 +++ vitest.config.ts | 9 ++- 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/codex-local/src/server/quota-spawn-error.test.ts create mode 100644 packages/adapters/codex-local/vitest.config.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7864b90e..639c967e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -134,6 +134,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins - `~/.paperclip/instances/default/companies//codex-home` +If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary). + ## Worktree-local Instances When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts new file mode 100644 index 00000000..a2349d84 --- /dev/null +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mockSpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const cp = await importOriginal(); + return { + ...cp, + spawn: (...args: Parameters) => mockSpawn(...args) as ReturnType, + }; +}); + +import { getQuotaWindows } from "./quota.js"; + +function createChildThatErrorsOnMicrotask(err: Error): ChildProcess { + const child = new EventEmitter() as ChildProcess; + const stream = Object.assign(new EventEmitter(), { + setEncoding: () => {}, + }); + Object.assign(child, { + stdout: stream, + stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }), + stdin: { write: vi.fn(), end: vi.fn() }, + kill: vi.fn(), + }); + queueMicrotask(() => { + child.emit("error", err); + }); + return child; +} + +describe("CodexRpcClient spawn failures", () => { + beforeEach(() => { + mockSpawn.mockReset(); + }); + + it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => { + const enoent = Object.assign(new Error("spawn codex ENOENT"), { + code: "ENOENT", + errno: -2, + syscall: "spawn codex", + path: "codex", + }); + mockSpawn.mockImplementation(() => createChildThatErrorsOnMicrotask(enoent)); + + const result = await getQuotaWindows(); + + expect(result.ok).toBe(false); + expect(result.windows).toEqual([]); + expect(result.error).toContain("Codex app-server"); + expect(result.error).toContain("spawn codex ENOENT"); + }); +}); diff --git a/packages/adapters/codex-local/vitest.config.ts b/packages/adapters/codex-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/codex-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9bf83928..3ec05081 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"], + projects: [ + "packages/db", + "packages/adapters/codex-local", + "packages/adapters/opencode-local", + "server", + "ui", + "cli", + ], }, }); From dc3aa8f31f7512ff9dc1911cb841a2d3ce223e6c Mon Sep 17 00:00:00 2001 From: Mikhail Batukhtin <6481198+remdev@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:13:39 +0300 Subject: [PATCH 044/118] test(codex-local): isolate quota spawn test from host CODEX_HOME After the mocked RPC spawn fails, getQuotaWindows() still calls readCodexToken(). Use an empty mkdtemp directory for CODEX_HOME for the duration of the test so we never read ~/.codex/auth.json or call WHAM. --- .../src/server/quota-spawn-error.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts index a2349d84..85d1e44c 100644 --- a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -1,6 +1,9 @@ import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { ChildProcess } from "node:child_process"; -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn(), @@ -34,8 +37,33 @@ function createChildThatErrorsOnMicrotask(err: Error): ChildProcess { } describe("CodexRpcClient spawn failures", () => { + let previousCodexHome: string | undefined; + let isolatedCodexHome: string | undefined; + beforeEach(() => { mockSpawn.mockReset(); + // After the RPC path fails, getQuotaWindows() calls readCodexToken() which + // reads $CODEX_HOME/auth.json (default ~/.codex). Point CODEX_HOME at an + // empty temp directory so we never hit real host auth or the WHAM network. + previousCodexHome = process.env.CODEX_HOME; + isolatedCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-spawn-test-")); + process.env.CODEX_HOME = isolatedCodexHome; + }); + + afterEach(() => { + if (isolatedCodexHome) { + try { + fs.rmSync(isolatedCodexHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } + isolatedCodexHome = undefined; + } + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } }); it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => { From 5d538d4792a159d456f6eca4027818d5b3ac0903 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:56:12 -0500 Subject: [PATCH 045/118] Add Paperclip commit metrics script Co-Authored-By: Paperclip --- package.json | 3 +- scripts/paperclip-commit-metrics.ts | 712 ++++++++++++++++++++++++++++ 2 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 scripts/paperclip-commit-metrics.ts diff --git a/package.json b/package.json index 749cc8d0..9433fbeb 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed", "evals:smoke": "cd evals/promptfoo && npx promptfoo@0.103.3 eval", "test:release-smoke": "npx playwright test --config tests/release-smoke/playwright.config.ts", - "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed" + "test:release-smoke:headed": "npx playwright test --config tests/release-smoke/playwright.config.ts --headed", + "metrics:paperclip-commits": "tsx scripts/paperclip-commit-metrics.ts" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts new file mode 100644 index 00000000..e23cff2f --- /dev/null +++ b/scripts/paperclip-commit-metrics.ts @@ -0,0 +1,712 @@ +#!/usr/bin/env npx tsx + +import { execFile } from "node:child_process"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const DEFAULT_QUERY = "\"Co-Authored-By: Paperclip \""; +const DEFAULT_CACHE_FILE = path.resolve("data/paperclip-commit-metrics-cache.json"); +const DEFAULT_SEARCH_START = "2008-01-01T00:00:00Z"; +const SEARCH_WINDOW_LIMIT = 900; +const MIN_WINDOW_MS = 60_000; +const DEFAULT_STATS_FETCH_LIMIT = 250; +const DEFAULT_STATS_CONCURRENCY = 4; +const DEFAULT_SEARCH_FIELD = "committer-date"; +const PAPERCLIP_EMAIL = "noreply@paperclip.ing"; +const PAPERCLIP_NAME = "paperclip"; + +interface CliOptions { + cacheFile: string; + end: Date; + includePrivate: boolean; + json: boolean; + query: string; + refreshSearch: boolean; + refreshStats: boolean; + searchField: "author-date" | "committer-date"; + start: Date; + statsConcurrency: number; + statsFetchLimit: number; + skipStats: boolean; +} + +interface SearchCommitItem { + author: { + login?: string; + } | null; + commit: { + author: { + date: string; + email: string | null; + name: string | null; + } | null; + message: string; + }; + html_url: string; + repository: { + full_name: string; + html_url: string; + }; + sha: string; +} + +interface CommitStats { + additions: number; + deletions: number; + total: number; +} + +interface CachedCommit { + authorEmail: string | null; + authorLogin: string | null; + authorName: string | null; + committedAt: string | null; + contributors: ContributorRecord[]; + htmlUrl: string; + repositoryFullName: string; + repositoryUrl: string; + sha: string; +} + +interface CachedCommitStats extends CommitStats { + fetchedAt: string; +} + +interface ContributorRecord { + displayName: string; + email: string | null; + key: string; + login: string | null; +} + +interface WindowCacheEntry { + completedAt: string; + key: string; + shas: string[]; + totalCount: number; +} + +interface CacheFile { + commits: Record; + queryKey: string; + searchField: CliOptions["searchField"]; + stats: Record; + updatedAt: string | null; + version: number; + windows: Record; +} + +interface SearchResponse { + incomplete_results: boolean; + items: SearchCommitItem[]; + total_count: number; +} + +interface SearchWindowResult { + shas: Set; + totalCount: number; +} + +interface Summary { + cacheFile: string; + contributors: { + count: number; + sample: ContributorRecord[]; + }; + detectedQuery: string; + lineStats: { + additions: number; + complete: boolean; + coveredCommits: number; + deletions: number; + missingCommits: number; + totalChanges: number; + }; + range: { + end: string; + searchField: CliOptions["searchField"]; + start: string; + }; + repos: { + count: number; + sample: string[]; + }; + statsFetch: { + fetchedThisRun: number; + skipped: boolean; + }; + totals: { + commits: number; + }; +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + const cache = await loadCache(options.cacheFile, options); + const client = new GitHubClient(await resolveGitHubToken()); + + const { shas } = await searchWindow(client, cache, options, options.start, options.end); + const sortedShas = [...shas].sort(); + + let fetchedThisRun = 0; + if (!options.skipStats) { + fetchedThisRun = await enrichCommitStats(client, cache, options, sortedShas); + } + + cache.updatedAt = new Date().toISOString(); + await saveCache(options.cacheFile, cache); + + const summary = buildSummary(cache, options, sortedShas, fetchedThisRun); + if (options.json) { + console.log(JSON.stringify(summary, null, 2)); + return; + } + + printSummary(summary); +} + +function parseArgs(argv: string[]): CliOptions { + const options: CliOptions = { + cacheFile: DEFAULT_CACHE_FILE, + end: new Date(), + includePrivate: false, + json: false, + query: DEFAULT_QUERY, + refreshSearch: false, + refreshStats: false, + searchField: DEFAULT_SEARCH_FIELD, + start: new Date(DEFAULT_SEARCH_START), + statsConcurrency: DEFAULT_STATS_CONCURRENCY, + statsFetchLimit: DEFAULT_STATS_FETCH_LIMIT, + skipStats: false, + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + switch (arg) { + case "--cache-file": + options.cacheFile = requireValue(argv, ++index, arg); + break; + case "--end": + options.end = parseDateArg(requireValue(argv, ++index, arg), arg); + break; + case "--include-private": + options.includePrivate = true; + break; + case "--json": + options.json = true; + break; + case "--query": + options.query = requireValue(argv, ++index, arg); + break; + case "--refresh-search": + options.refreshSearch = true; + break; + case "--refresh-stats": + options.refreshStats = true; + break; + case "--search-field": { + const value = requireValue(argv, ++index, arg); + if (value !== "author-date" && value !== "committer-date") { + throw new Error(`Invalid --search-field value: ${value}`); + } + options.searchField = value; + break; + } + case "--skip-stats": + options.skipStats = true; + break; + case "--start": + options.start = parseDateArg(requireValue(argv, ++index, arg), arg); + break; + case "--stats-concurrency": + options.statsConcurrency = parsePositiveInt(requireValue(argv, ++index, arg), arg); + break; + case "--stats-fetch-limit": + options.statsFetchLimit = parseNonNegativeInt(requireValue(argv, ++index, arg), arg); + break; + case "--help": + printHelp(); + process.exit(0); + break; + default: + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (Number.isNaN(options.start.getTime()) || Number.isNaN(options.end.getTime())) { + throw new Error("Invalid start or end date"); + } + if (options.start >= options.end) { + throw new Error("--start must be earlier than --end"); + } + + return options; +} + +function requireValue(argv: string[], index: number, flag: string): string { + const value = argv[index]; + if (!value) { + throw new Error(`Missing value for ${flag}`); + } + return value; +} + +function parseDateArg(value: string, flag: string): Date { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + throw new Error(`Invalid date for ${flag}: ${value}`); + } + return parsed; +} + +function parsePositiveInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid positive integer for ${flag}: ${value}`); + } + return parsed; +} + +function parseNonNegativeInt(value: string, flag: string): number { + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`Invalid non-negative integer for ${flag}: ${value}`); + } + return parsed; +} + +function printHelp() { + console.log(`Usage: tsx scripts/paperclip-commit-metrics.ts [options] + +Options: + --start ISO date/time lower bound (default: ${DEFAULT_SEARCH_START}) + --end ISO date/time upper bound (default: now) + --query Commit search string (default: ${DEFAULT_QUERY}) + --search-field author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD}) + --include-private Include repos visible to the current token + --cache-file Cache path (default: ${DEFAULT_CACHE_FILE}) + --skip-stats Skip additions/deletions enrichment + --stats-fetch-limit Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT}) + --stats-concurrency Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY}) + --refresh-search Ignore cached search windows + --refresh-stats Re-fetch cached commit stats + --json Print JSON summary + --help Show this help +`); +} + +async function resolveGitHubToken(): Promise { + const envToken = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + if (envToken) { + return envToken; + } + + const { stdout } = await execFileAsync("gh", ["auth", "token"]); + const token = stdout.trim(); + if (!token) { + throw new Error("Unable to resolve a GitHub token. Set GITHUB_TOKEN/GH_TOKEN or run `gh auth login`."); + } + return token; +} + +async function loadCache(cacheFile: string, options: CliOptions): Promise { + try { + const raw = await fs.readFile(cacheFile, "utf8"); + const parsed = JSON.parse(raw) as CacheFile; + if (parsed.version !== 1 || parsed.queryKey !== buildQueryKey(options) || parsed.searchField !== options.searchField) { + return createEmptyCache(options); + } + return parsed; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return createEmptyCache(options); + } + throw error; + } +} + +function createEmptyCache(options: CliOptions): CacheFile { + return { + commits: {}, + queryKey: buildQueryKey(options), + searchField: options.searchField, + stats: {}, + updatedAt: null, + version: 1, + windows: {}, + }; +} + +function buildQueryKey(options: CliOptions): string { + const visibility = options.includePrivate ? "all" : "public"; + return JSON.stringify({ + query: options.query, + searchField: options.searchField, + visibility, + }); +} + +async function saveCache(cacheFile: string, cache: CacheFile): Promise { + await fs.mkdir(path.dirname(cacheFile), { recursive: true }); + await fs.writeFile(cacheFile, JSON.stringify(cache, null, 2), "utf8"); +} + +async function searchWindow( + client: GitHubClient, + cache: CacheFile, + options: CliOptions, + start: Date, + end: Date, +): Promise { + const windowKey = makeWindowKey(start, end); + if (!options.refreshSearch) { + const cached = cache.windows[windowKey]; + if (cached) { + return { shas: new Set(cached.shas), totalCount: cached.totalCount }; + } + } + + const firstPage = await searchPage(client, options, start, end, 1, 100); + if (firstPage.incomplete_results) { + throw new Error(`GitHub returned incomplete search results for window ${windowKey}`); + } + + if (firstPage.total_count > SEARCH_WINDOW_LIMIT) { + const durationMs = end.getTime() - start.getTime(); + if (durationMs <= MIN_WINDOW_MS) { + throw new Error( + `Search window ${windowKey} still has ${firstPage.total_count} results after splitting to ${durationMs}ms.`, + ); + } + + const midpoint = new Date(start.getTime() + Math.floor(durationMs / 2)); + const left = await searchWindow(client, cache, options, start, midpoint); + const right = await searchWindow(client, cache, options, new Date(midpoint.getTime() + 1), end); + const shas = new Set([...left.shas, ...right.shas]); + + cache.windows[windowKey] = { + completedAt: new Date().toISOString(), + key: windowKey, + shas: [...shas], + totalCount: shas.size, + }; + + return { shas, totalCount: shas.size }; + } + + const pageCount = Math.ceil(firstPage.total_count / 100); + const shas = new Set(); + ingestSearchItems(cache, firstPage.items, shas); + + for (let page = 2; page <= pageCount; page += 1) { + const response = await searchPage(client, options, start, end, page, 100); + ingestSearchItems(cache, response.items, shas); + } + + cache.windows[windowKey] = { + completedAt: new Date().toISOString(), + key: windowKey, + shas: [...shas], + totalCount: firstPage.total_count, + }; + + return { shas, totalCount: firstPage.total_count }; +} + +async function searchPage( + client: GitHubClient, + options: CliOptions, + start: Date, + end: Date, + page: number, + perPage: number, +): Promise { + const searchQuery = buildSearchQuery(options, start, end); + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + q: searchQuery, + }); + + return client.getJson(`/search/commits?${params.toString()}`); +} + +function buildSearchQuery(options: CliOptions, start: Date, end: Date): string { + const qualifiers = [`${options.searchField}:${formatQueryDate(start)}..${formatQueryDate(end)}`]; + if (!options.includePrivate) { + qualifiers.push("is:public"); + } + return `${options.query} ${qualifiers.join(" ")}`.trim(); +} + +function formatQueryDate(value: Date): string { + return value.toISOString().replace(".000Z", "Z"); +} + +function ingestSearchItems(cache: CacheFile, items: SearchCommitItem[], shas: Set) { + for (const item of items) { + shas.add(item.sha); + cache.commits[item.sha] = { + authorEmail: item.commit.author?.email ?? null, + authorLogin: item.author?.login ?? null, + authorName: item.commit.author?.name ?? null, + committedAt: item.commit.author?.date ?? null, + contributors: extractContributors(item), + htmlUrl: item.html_url, + repositoryFullName: item.repository.full_name, + repositoryUrl: item.repository.html_url, + sha: item.sha, + }; + } +} + +function extractContributors(item: SearchCommitItem): ContributorRecord[] { + const contributors = new Map(); + + const primaryAuthor = normalizeContributor({ + email: item.commit.author?.email ?? null, + login: item.author?.login ?? null, + name: item.commit.author?.name ?? null, + }); + if (primaryAuthor) { + contributors.set(primaryAuthor.key, primaryAuthor); + } + + const coAuthorPattern = /^co-authored-by:\s*(.+?)\s*<([^>]+)>\s*$/gim; + for (const match of item.commit.message.matchAll(coAuthorPattern)) { + const contributor = normalizeContributor({ + email: match[2] ?? null, + login: null, + name: match[1] ?? null, + }); + if (contributor) { + contributors.set(contributor.key, contributor); + } + } + + return [...contributors.values()]; +} + +function normalizeContributor(input: { + email: string | null; + login: string | null; + name: string | null; +}): ContributorRecord | null { + const email = normalizeOptional(input.email); + const login = normalizeOptional(input.login); + const displayName = normalizeOptional(input.name) ?? login ?? email; + + if (!displayName && !email && !login) { + return null; + } + if ((email && email === PAPERCLIP_EMAIL) || (displayName && displayName.toLowerCase() === PAPERCLIP_NAME)) { + return null; + } + + const key = login ? `login:${login}` : email ? `email:${email}` : `name:${displayName!.toLowerCase()}`; + return { + displayName: displayName ?? email ?? login ?? "unknown", + email, + key, + login, + }; +} + +function normalizeOptional(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +async function enrichCommitStats( + client: GitHubClient, + cache: CacheFile, + options: CliOptions, + shas: string[], +): Promise { + const pending = shas.filter((sha) => options.refreshStats || !cache.stats[sha]).slice(0, options.statsFetchLimit); + let nextIndex = 0; + let fetched = 0; + + const workers = Array.from({ length: Math.min(options.statsConcurrency, pending.length) }, async () => { + while (true) { + const currentIndex = nextIndex; + nextIndex += 1; + const sha = pending[currentIndex]; + if (!sha) { + return; + } + const commit = cache.commits[sha]; + if (!commit) { + continue; + } + const stats = await fetchCommitStats(client, commit.repositoryFullName, sha); + cache.stats[sha] = { + ...stats, + fetchedAt: new Date().toISOString(), + }; + fetched += 1; + } + }); + + await Promise.all(workers); + return fetched; +} + +async function fetchCommitStats(client: GitHubClient, repositoryFullName: string, sha: string): Promise { + const response = await client.getJson<{ stats?: CommitStats }>( + `/repos/${repositoryFullName}/commits/${sha}`, + ); + return { + additions: response.stats?.additions ?? 0, + deletions: response.stats?.deletions ?? 0, + total: response.stats?.total ?? 0, + }; +} + +function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fetchedThisRun: number): Summary { + const repoNames = new Set(); + const contributors = new Map(); + let additions = 0; + let deletions = 0; + let coveredCommits = 0; + + for (const sha of shas) { + const commit = cache.commits[sha]; + if (!commit) { + continue; + } + repoNames.add(commit.repositoryFullName); + for (const contributor of commit.contributors) { + contributors.set(contributor.key, contributor); + } + + const stats = cache.stats[sha]; + if (stats) { + additions += stats.additions; + deletions += stats.deletions; + coveredCommits += 1; + } + } + + const contributorSample = [...contributors.values()] + .sort((left, right) => left.displayName.localeCompare(right.displayName)) + .slice(0, 10); + const repoSample = [...repoNames].sort((left, right) => left.localeCompare(right)).slice(0, 10); + + return { + cacheFile: options.cacheFile, + contributors: { + count: contributors.size, + sample: contributorSample, + }, + detectedQuery: buildSearchQuery(options, options.start, options.end), + lineStats: { + additions, + complete: coveredCommits === shas.length, + coveredCommits, + deletions, + missingCommits: shas.length - coveredCommits, + totalChanges: additions + deletions, + }, + range: { + end: options.end.toISOString(), + searchField: options.searchField, + start: options.start.toISOString(), + }, + repos: { + count: repoNames.size, + sample: repoSample, + }, + statsFetch: { + fetchedThisRun, + skipped: options.skipStats, + }, + totals: { + commits: shas.length, + }, + }; +} + +function printSummary(summary: Summary) { + console.log("Paperclip commit metrics"); + console.log(`Query: ${summary.detectedQuery}`); + console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`); + console.log(`Commits: ${summary.totals.commits}`); + console.log(`Distinct repos: ${summary.repos.count}`); + console.log(`Distinct contributors: ${summary.contributors.count}`); + console.log( + `Line stats: +${summary.lineStats.additions} / -${summary.lineStats.deletions} / ${summary.lineStats.totalChanges} total`, + ); + console.log( + `Line stat coverage: ${summary.lineStats.coveredCommits}/${summary.totals.commits}` + + (summary.lineStats.complete ? " (complete)" : " (partial; rerun to hydrate more commits)"), + ); + console.log(`Stats fetched this run: ${summary.statsFetch.fetchedThisRun}${summary.statsFetch.skipped ? " (skipped)" : ""}`); + console.log(`Cache: ${summary.cacheFile}`); + + if (summary.repos.sample.length > 0) { + console.log(`Sample repos: ${summary.repos.sample.join(", ")}`); + } + if (summary.contributors.sample.length > 0) { + console.log( + `Sample contributors: ${summary.contributors.sample + .map((contributor) => contributor.login ?? contributor.displayName) + .join(", ")}`, + ); + } +} + +function makeWindowKey(start: Date, end: Date): string { + return `${start.toISOString()}..${end.toISOString()}`; +} + +class GitHubClient { + private readonly apiBase = "https://api.github.com"; + private readonly token: string; + + constructor(token: string) { + this.token = token; + } + + async getJson(pathname: string): Promise { + while (true) { + const response = await fetch(`${this.apiBase}${pathname}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.token}`, + "User-Agent": "paperclip-commit-metrics", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (response.ok) { + return (await response.json()) as T; + } + + const remaining = response.headers.get("x-ratelimit-remaining"); + const resetAt = response.headers.get("x-ratelimit-reset"); + if ((response.status === 403 || response.status === 429) && remaining === "0" && resetAt) { + const waitMs = Math.max(Number.parseInt(resetAt, 10) * 1000 - Date.now() + 1_000, 1_000); + console.error(`GitHub rate limit hit for ${pathname}; waiting ${Math.ceil(waitMs / 1000)}s...`); + await sleep(waitMs); + continue; + } + + const body = await response.text(); + throw new Error(`GitHub API request failed (${response.status}) for ${pathname}: ${body}`); + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); From a3537a86e38fb31fbea1be569ec263149d37d332 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 18:22:15 -0500 Subject: [PATCH 046/118] Add filtered Paperclip commit exports Co-Authored-By: Paperclip --- scripts/paperclip-commit-metrics.ts | 162 +++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 1 deletion(-) diff --git a/scripts/paperclip-commit-metrics.ts b/scripts/paperclip-commit-metrics.ts index e23cff2f..5f989ca1 100644 --- a/scripts/paperclip-commit-metrics.ts +++ b/scripts/paperclip-commit-metrics.ts @@ -21,8 +21,11 @@ const PAPERCLIP_NAME = "paperclip"; interface CliOptions { cacheFile: string; end: Date; + excludeOwners: string[]; + exportFormat: "csv" | "json"; includePrivate: boolean; json: boolean; + output: string | null; query: string; refreshSearch: boolean; refreshStats: boolean; @@ -130,6 +133,9 @@ interface Summary { searchField: CliOptions["searchField"]; start: string; }; + filters: { + excludedOwners: string[]; + }; repos: { count: number; sample: string[]; @@ -159,7 +165,13 @@ async function main() { cache.updatedAt = new Date().toISOString(); await saveCache(options.cacheFile, cache); - const summary = buildSummary(cache, options, sortedShas, fetchedThisRun); + const filteredShas = sortFilteredShas(cache, filterShas(cache, sortedShas, options)); + const summary = buildSummary(cache, options, filteredShas, fetchedThisRun); + + if (options.output) { + await writeExport(options.output, options.exportFormat, cache, filteredShas, summary); + } + if (options.json) { console.log(JSON.stringify(summary, null, 2)); return; @@ -172,8 +184,11 @@ function parseArgs(argv: string[]): CliOptions { const options: CliOptions = { cacheFile: DEFAULT_CACHE_FILE, end: new Date(), + excludeOwners: [], + exportFormat: "csv", includePrivate: false, json: false, + output: null, query: DEFAULT_QUERY, refreshSearch: false, refreshStats: false, @@ -193,12 +208,26 @@ function parseArgs(argv: string[]): CliOptions { case "--end": options.end = parseDateArg(requireValue(argv, ++index, arg), arg); break; + case "--exclude-owner": + options.excludeOwners.push(requireValue(argv, ++index, arg).toLowerCase()); + break; + case "--export-format": { + const value = requireValue(argv, ++index, arg); + if (value !== "csv" && value !== "json") { + throw new Error(`Invalid --export-format value: ${value}`); + } + options.exportFormat = value; + break; + } case "--include-private": options.includePrivate = true; break; case "--json": options.json = true; break; + case "--output": + options.output = requireValue(argv, ++index, arg); + break; case "--query": options.query = requireValue(argv, ++index, arg); break; @@ -288,10 +317,13 @@ Options: --query Commit search string (default: ${DEFAULT_QUERY}) --search-field author-date | committer-date (default: ${DEFAULT_SEARCH_FIELD}) --include-private Include repos visible to the current token + --exclude-owner Exclude repositories owned by this GitHub owner/org (repeatable) --cache-file Cache path (default: ${DEFAULT_CACHE_FILE}) --skip-stats Skip additions/deletions enrichment --stats-fetch-limit Max uncached commit stats to fetch this run (default: ${DEFAULT_STATS_FETCH_LIMIT}) --stats-concurrency Parallel commit stat requests (default: ${DEFAULT_STATS_CONCURRENCY}) + --output Write the full filtered result set to a file + --export-format csv | json for --output exports (default: csv) --refresh-search Ignore cached search windows --refresh-stats Re-fetch cached commit stats --json Print JSON summary @@ -443,6 +475,39 @@ function buildSearchQuery(options: CliOptions, start: Date, end: Date): string { return `${options.query} ${qualifiers.join(" ")}`.trim(); } +function filterShas(cache: CacheFile, shas: string[], options: CliOptions): string[] { + if (options.excludeOwners.length === 0) { + return shas; + } + + const excludedOwners = new Set(options.excludeOwners); + return shas.filter((sha) => { + const commit = cache.commits[sha]; + if (!commit) { + return false; + } + return !excludedOwners.has(getRepoOwner(commit.repositoryFullName)); + }); +} + +function sortFilteredShas(cache: CacheFile, shas: string[]): string[] { + return [...shas].sort((leftSha, rightSha) => { + const left = cache.commits[leftSha]; + const right = cache.commits[rightSha]; + const leftTime = left?.committedAt ? Date.parse(left.committedAt) : 0; + const rightTime = right?.committedAt ? Date.parse(right.committedAt) : 0; + if (rightTime !== leftTime) { + return rightTime - leftTime; + } + + const repoCompare = (left?.repositoryFullName ?? "").localeCompare(right?.repositoryFullName ?? ""); + if (repoCompare !== 0) { + return repoCompare; + } + return leftSha.localeCompare(rightSha); + }); +} + function formatQueryDate(value: Date): string { return value.toISOString().replace(".000Z", "Z"); } @@ -521,6 +586,10 @@ function normalizeOptional(value: string | null | undefined): string | null { return trimmed ? trimmed : null; } +function getRepoOwner(repositoryFullName: string): string { + return repositoryFullName.split("/", 1)[0]?.toLowerCase() ?? ""; +} + async function enrichCommitStats( client: GitHubClient, cache: CacheFile, @@ -617,6 +686,9 @@ function buildSummary(cache: CacheFile, options: CliOptions, shas: string[], fet searchField: options.searchField, start: options.start.toISOString(), }, + filters: { + excludedOwners: [...options.excludeOwners].sort(), + }, repos: { count: repoNames.size, sample: repoSample, @@ -635,6 +707,9 @@ function printSummary(summary: Summary) { console.log("Paperclip commit metrics"); console.log(`Query: ${summary.detectedQuery}`); console.log(`Range: ${summary.range.start} -> ${summary.range.end} (${summary.range.searchField})`); + if (summary.filters.excludedOwners.length > 0) { + console.log(`Excluded owners: ${summary.filters.excludedOwners.join(", ")}`); + } console.log(`Commits: ${summary.totals.commits}`); console.log(`Distinct repos: ${summary.repos.count}`); console.log(`Distinct contributors: ${summary.contributors.count}`); @@ -660,6 +735,91 @@ function printSummary(summary: Summary) { } } +async function writeExport( + outputPath: string, + format: CliOptions["exportFormat"], + cache: CacheFile, + shas: string[], + summary: Summary, +): Promise { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + if (format === "json") { + const report = { + summary, + commits: shas.map((sha) => buildExportRow(cache, sha)), + }; + await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8"); + return; + } + + const header = [ + "committedAt", + "repository", + "repositoryUrl", + "sha", + "commitUrl", + "authorLogin", + "authorName", + "authorEmail", + "contributors", + "additions", + "deletions", + "totalChanges", + ]; + const rows = [header.join(",")]; + for (const sha of shas) { + const row = buildExportRow(cache, sha); + rows.push( + [ + row.committedAt, + row.repository, + row.repositoryUrl, + row.sha, + row.commitUrl, + row.authorLogin, + row.authorName, + row.authorEmail, + row.contributors, + String(row.additions), + String(row.deletions), + String(row.totalChanges), + ] + .map(escapeCsv) + .join(","), + ); + } + await fs.writeFile(outputPath, `${rows.join("\n")}\n`, "utf8"); +} + +function buildExportRow(cache: CacheFile, sha: string) { + const commit = cache.commits[sha]; + if (!commit) { + throw new Error(`Missing cached commit for sha ${sha}`); + } + const stats = cache.stats[sha]; + return { + additions: stats?.additions ?? 0, + authorEmail: commit.authorEmail ?? "", + authorLogin: commit.authorLogin ?? "", + authorName: commit.authorName ?? "", + commitUrl: commit.htmlUrl, + committedAt: commit.committedAt ?? "", + contributors: commit.contributors.map((contributor) => contributor.login ?? contributor.displayName).join(" | "), + deletions: stats?.deletions ?? 0, + repository: commit.repositoryFullName, + repositoryUrl: commit.repositoryUrl, + sha: commit.sha, + totalChanges: stats?.total ?? 0, + }; +} + +function escapeCsv(value: string): string { + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + function makeWindowKey(start: Date, end: Date): string { return `${start.toISOString()}..${end.toISOString()}`; } From f83a77f41fdeb26a8c53d3b0f7a67a419eb8fc82 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 21:34:40 -0500 Subject: [PATCH 047/118] Add cli/README.md with absolute image URLs for npm The root README uses relative doc/assets/ paths which work on GitHub but break on npmjs.com since those files aren't in the published tarball. This adds a cli-specific README with absolute raw.githubusercontent.com URLs so images render on npm. Co-Authored-By: Paperclip --- cli/README.md | 290 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 cli/README.md diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..c5a6ce13 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,290 @@ +

+ Paperclip — runs your business +

+ +

+ Quickstart · + Docs · + GitHub · + Discord +

+ +

+ MIT License + Stars + Discord +

+ +
+ +
+ +
+ +
+ +## What is Paperclip? + +# Open-source orchestration for zero-human companies + +**If OpenClaw is an _employee_, Paperclip is the _company_** + +Paperclip is a Node.js server and React UI that orchestrates a team of AI agents to run a business. Bring your own agents, assign goals, and track your agents' work and costs from one dashboard. + +It looks like a task manager — but under the hood it has org charts, budgets, governance, goal alignment, and agent coordination. + +**Manage business goals, not pull requests.** + +| | Step | Example | +| ------ | --------------- | ------------------------------------------------------------------ | +| **01** | Define the goal | _"Build the #1 AI note-taking app to $1M MRR."_ | +| **02** | Hire the team | CEO, CTO, engineers, designers, marketers — any bot, any provider. | +| **03** | Approve and run | Review strategy. Set budgets. Hit go. Monitor from the dashboard. | + +
+ +> **COMING SOON: Clipmart** — Download and run entire companies with one click. Browse pre-built company templates — full org structures, agent configs, and skills — and import them into your Paperclip instance in seconds. + +
+ +
+ + + + + + + + + + +
Works
with
OpenClaw
OpenClaw
Claude
Claude Code
Codex
Codex
Cursor
Cursor
Bash
Bash
HTTP
HTTP
+ +If it can receive a heartbeat, it's hired. + +
+ +
+ +## Paperclip is right for you if + +- ✅ You want to build **autonomous AI companies** +- ✅ You **coordinate many different agents** (OpenClaw, Codex, Claude, Cursor) toward a common goal +- ✅ You have **20 simultaneous Claude Code terminals** open and lose track of what everyone is doing +- ✅ You want agents running **autonomously 24/7**, but still want to audit work and chime in when needed +- ✅ You want to **monitor costs** and enforce budgets +- ✅ You want a process for managing agents that **feels like using a task manager** +- ✅ You want to manage your autonomous businesses **from your phone** + +
+ +## Features + + + + + + + + + + + + + + + + + +
+

🔌 Bring Your Own Agent

+Any agent, any runtime, one org chart. If it can receive a heartbeat, it's hired. +
+

🎯 Goal Alignment

+Every task traces back to the company mission. Agents know what to do and why. +
+

💓 Heartbeats

+Agents wake on a schedule, check work, and act. Delegation flows up and down the org chart. +
+

💰 Cost Control

+Monthly budgets per agent. When they hit the limit, they stop. No runaway costs. +
+

🏢 Multi-Company

+One deployment, many companies. Complete data isolation. One control plane for your portfolio. +
+

🎫 Ticket System

+Every conversation traced. Every decision explained. Full tool-call tracing and immutable audit log. +
+

🛡️ Governance

+You're the board. Approve hires, override strategy, pause or terminate any agent — at any time. +
+

📊 Org Chart

+Hierarchies, roles, reporting lines. Your agents have a boss, a title, and a job description. +
+

📱 Mobile Ready

+Monitor and manage your autonomous businesses from anywhere. +
+ +
+ +## Problems Paperclip solves + +| Without Paperclip | With Paperclip | +| ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| ❌ You have 20 Claude Code tabs open and can't track which one does what. On reboot you lose everything. | ✅ Tasks are ticket-based, conversations are threaded, sessions persist across reboots. | +| ❌ You manually gather context from several places to remind your bot what you're actually doing. | ✅ Context flows from the task up through the project and company goals — your agent always knows what to do and why. | +| ❌ Folders of agent configs are disorganized and you're re-inventing task management, communication, and coordination between agents. | ✅ Paperclip gives you org charts, ticketing, delegation, and governance out of the box — so you run a company, not a pile of scripts. | +| ❌ Runaway loops waste hundreds of dollars of tokens and max your quota before you even know what happened. | ✅ Cost tracking surfaces token budgets and throttles agents when they're out. Management prioritizes with budgets. | +| ❌ You have recurring jobs (customer support, social, reports) and have to remember to manually kick them off. | ✅ Heartbeats handle regular work on a schedule. Management supervises. | +| ❌ You have an idea, you have to find your repo, fire up Claude Code, keep a tab open, and babysit it. | ✅ Add a task in Paperclip. Your coding agent works on it until it's done. Management reviews their work. | + +
+ +## Why Paperclip is special + +Paperclip handles the hard orchestration details correctly. + +| | | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **Atomic execution.** | Task checkout and budget enforcement are atomic, so no double-work and no runaway spend. | +| **Persistent agent state.** | Agents resume the same task context across heartbeats instead of restarting from scratch. | +| **Runtime skill injection.** | Agents can learn Paperclip workflows and project context at runtime, without retraining. | +| **Governance with rollback.** | Approval gates are enforced, config changes are revisioned, and bad changes can be rolled back safely. | +| **Goal-aware execution.** | Tasks carry full goal ancestry so agents consistently see the "why," not just a title. | +| **Portable company templates.** | Export/import orgs, agents, and skills with secret scrubbing and collision handling. | +| **True multi-company isolation.** | Every entity is company-scoped, so one deployment can run many companies with separate data and audit trails. | + +
+ +## What Paperclip is not + +| | | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| **Not a chatbot.** | Agents have jobs, not chat windows. | +| **Not an agent framework.** | We don't tell you how to build agents. We tell you how to run a company made of them. | +| **Not a workflow builder.** | No drag-and-drop pipelines. Paperclip models companies — with org charts, goals, budgets, and governance. | +| **Not a prompt manager.** | Agents bring their own prompts, models, and runtimes. Paperclip manages the organization they work in. | +| **Not a single-agent tool.** | This is for teams. If you have one agent, you probably don't need Paperclip. If you have twenty — you definitely do. | +| **Not a code review tool.** | Paperclip orchestrates work, not pull requests. Bring your own review process. | + +
+ +## Quickstart + +Open source. Self-hosted. No Paperclip account required. + +```bash +npx paperclipai onboard --yes +``` + +Or manually: + +```bash +git clone https://github.com/paperclipai/paperclip.git +cd paperclip +pnpm install +pnpm dev +``` + +This starts the API server at `http://localhost:3100`. An embedded PostgreSQL database is created automatically — no setup required. + +> **Requirements:** Node.js 20+, pnpm 9.15+ + +
+ +## FAQ + +**What does a typical setup look like?** +Locally, a single Node.js process manages an embedded Postgres and local file storage. For production, point it at your own Postgres and deploy however you like. Configure projects, agents, and goals — the agents take care of the rest. + +If you're a solo-entreprenuer you can use Tailscale to access Paperclip on the go. Then later you can deploy to e.g. Vercel when you need it. + +**Can I run multiple companies?** +Yes. A single deployment can run an unlimited number of companies with complete data isolation. + +**How is Paperclip different from agents like OpenClaw or Claude Code?** +Paperclip _uses_ those agents. It orchestrates them into a company — with org charts, budgets, goals, governance, and accountability. + +**Why should I use Paperclip instead of just pointing my OpenClaw to Asana or Trello?** +Agent orchestration has subtleties in how you coordinate who has work checked out, how to maintain sessions, monitoring costs, establishing governance - Paperclip does this for you. + +(Bring-your-own-ticket-system is on the Roadmap) + +**Do agents run continuously?** +By default, agents run on scheduled heartbeats and event-based triggers (task assignment, @-mentions). You can also hook in continuous agents like OpenClaw. You bring your agent and Paperclip coordinates. + +
+ +## Development + +```bash +pnpm dev # Full dev (API + UI, watch mode) +pnpm dev:once # Full dev without file watching +pnpm dev:server # Server only +pnpm build # Build all +pnpm typecheck # Type checking +pnpm test:run # Run tests +pnpm db:generate # Generate DB migration +pnpm db:migrate # Apply migrations +``` + +See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide. + +
+ +## Roadmap + +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App + +
+ +## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + +## Contributing + +We welcome contributions. See the [contributing guide](https://github.com/paperclipai/paperclip/blob/master/CONTRIBUTING.md) for details. + +
+ +## Community + +- [Discord](https://discord.gg/m4HZY7xNG3) — Join the community +- [GitHub Issues](https://github.com/paperclipai/paperclip/issues) — bugs and feature requests +- [GitHub Discussions](https://github.com/paperclipai/paperclip/discussions) — ideas and RFC + +
+ +## License + +MIT © 2026 Paperclip + +## Star History + +[![Star History Chart](https://api.star-history.com/image?repos=paperclipai/paperclip&type=date&legend=top-left)](https://www.star-history.com/?repos=paperclipai%2Fpaperclip&type=date&legend=top-left) + +
+ +--- + +

+ +

+ +

+ Open source under MIT. Built for people who want to run companies, not babysit agents. +

From 54b05d6d68f1a2b768e91196b1d696bab8aae02a Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 21:44:15 -0500 Subject: [PATCH 048/118] Make onboarding reruns preserve existing config Co-Authored-By: Paperclip --- README.md | 2 + cli/README.md | 2 + cli/src/__tests__/onboard.test.ts | 105 ++++++++++++++++++++++++++++++ cli/src/commands/onboard.ts | 75 ++++++++++++++++++++- docs/cli/setup-commands.md | 4 ++ docs/start/quickstart.md | 2 + 6 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 cli/src/__tests__/onboard.test.ts diff --git a/README.md b/README.md index f7ade1b3..42f24cd1 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + Or manually: ```bash diff --git a/cli/README.md b/cli/README.md index c5a6ce13..08f36ad4 100644 --- a/cli/README.md +++ b/cli/README.md @@ -177,6 +177,8 @@ Open source. Self-hosted. No Paperclip account required. npx paperclipai onboard --yes ``` +If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings. + Or manually: ```bash diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts new file mode 100644 index 00000000..a5ffe44a --- /dev/null +++ b/cli/src/__tests__/onboard.test.ts @@ -0,0 +1,105 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { onboard } from "../commands/onboard.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const ORIGINAL_ENV = { ...process.env }; + +function createExistingConfigFixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-onboard-")); + const runtimeRoot = path.join(root, "runtime"); + const configPath = path.join(root, ".paperclip", "config.json"); + const config: PaperclipConfig = { + $meta: { + version: 1, + updatedAt: "2026-03-29T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(runtimeRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(runtimeRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(runtimeRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(runtimeRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(runtimeRoot, "secrets", "master.key"), + }, + }, + }; + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + + return { configPath, configText: fs.readFileSync(configPath, "utf8") }; +} + +describe("onboard", () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; + delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + }); + + afterEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + it("preserves an existing config when rerun without flags", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); + + it("preserves an existing config when rerun with --yes", async () => { + const fixture = createExistingConfigFixture(); + + await onboard({ config: fixture.configPath, yes: true, invokedByRun: true }); + + expect(fs.readFileSync(fixture.configPath, "utf8")).toBe(fixture.configText); + expect(fs.existsSync(`${fixture.configPath}.backup`)).toBe(false); + expect(fs.existsSync(path.join(path.dirname(fixture.configPath), ".env"))).toBe(true); + }); +}); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 523484f3..d470354f 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -244,11 +244,12 @@ export async function onboard(opts: OnboardOptions): Promise { ), ); + let existingConfig: PaperclipConfig | null = null; if (configExists(opts.config)) { - p.log.message(pc.dim(`${configPath} exists, updating config`)); + p.log.message(pc.dim(`${configPath} exists`)); try { - readConfig(opts.config); + existingConfig = readConfig(opts.config); } catch (err) { p.log.message( pc.yellow( @@ -258,6 +259,76 @@ export async function onboard(opts: OnboardOptions): Promise { } } + if (existingConfig) { + p.log.message( + pc.dim("Existing Paperclip install detected; keeping the current configuration unchanged."), + ); + p.log.message(pc.dim(`Use ${pc.cyan("paperclipai configure")} if you want to change settings.`)); + + const jwtSecret = ensureAgentJwtSecret(configPath); + const envFilePath = resolveAgentJwtEnvFile(configPath); + if (jwtSecret.created) { + p.log.success(`Created ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } else if (process.env.PAPERCLIP_AGENT_JWT_SECRET?.trim()) { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} from environment`); + } else { + p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); + } + + const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath); + if (keyResult.status === "created") { + p.log.success(`Created local secrets key file at ${pc.dim(keyResult.path)}`); + } else if (keyResult.status === "existing") { + p.log.message(pc.dim(`Using existing local secrets key file at ${keyResult.path}`)); + } + + p.note( + [ + "Existing config preserved", + `Database: ${existingConfig.database.mode}`, + existingConfig.llm ? `LLM: ${existingConfig.llm.provider}` : "LLM: not configured", + `Logging: ${existingConfig.logging.mode} -> ${existingConfig.logging.logDir}`, + `Server: ${existingConfig.server.deploymentMode}/${existingConfig.server.exposure} @ ${existingConfig.server.host}:${existingConfig.server.port}`, + `Allowed hosts: ${existingConfig.server.allowedHostnames.length > 0 ? existingConfig.server.allowedHostnames.join(", ") : "(loopback only)"}`, + `Auth URL mode: ${existingConfig.auth.baseUrlMode}${existingConfig.auth.publicBaseUrl ? ` (${existingConfig.auth.publicBaseUrl})` : ""}`, + `Storage: ${existingConfig.storage.provider}`, + `Secrets: ${existingConfig.secrets.provider} (strict mode ${existingConfig.secrets.strictMode ? "on" : "off"})`, + "Agent auth: PAPERCLIP_AGENT_JWT_SECRET configured", + ].join("\n"), + "Configuration ready", + ); + + p.note( + [ + `Run: ${pc.cyan("paperclipai run")}`, + `Reconfigure later: ${pc.cyan("paperclipai configure")}`, + `Diagnose setup: ${pc.cyan("paperclipai doctor")}`, + ].join("\n"), + "Next commands", + ); + + let shouldRunNow = opts.run === true || opts.yes === true; + if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) { + const answer = await p.confirm({ + message: "Start Paperclip now?", + initialValue: true, + }); + if (!p.isCancel(answer)) { + shouldRunNow = answer; + } + } + + if (shouldRunNow && !opts.invokedByRun) { + process.env.PAPERCLIP_OPEN_ON_LISTEN = "true"; + const { runCommand } = await import("./run.js"); + await runCommand({ config: configPath, repair: true, yes: true }); + return; + } + + p.outro("Existing Paperclip setup is ready."); + return; + } + let setupMode: SetupMode = "quickstart"; if (opts.yes) { p.log.message(pc.dim("`--yes` enabled: using Quickstart defaults.")); diff --git a/docs/cli/setup-commands.md b/docs/cli/setup-commands.md index 7dc5cd6a..448ab7bb 100644 --- a/docs/cli/setup-commands.md +++ b/docs/cli/setup-commands.md @@ -33,6 +33,8 @@ Interactive first-time setup: pnpm paperclipai onboard ``` +If Paperclip is already configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to change settings on an existing install. + First prompt: 1. `Quickstart` (recommended): local defaults (embedded database, no LLM provider, local disk storage, default secrets) @@ -50,6 +52,8 @@ Non-interactive defaults + immediate start (opens browser on server listen): pnpm paperclipai onboard --yes ``` +On an existing install, `--yes` now preserves the current config and just starts Paperclip with that setup. + ## `paperclipai doctor` Health checks with optional auto-repair: diff --git a/docs/start/quickstart.md b/docs/start/quickstart.md index 1ad30fcd..2abe538b 100644 --- a/docs/start/quickstart.md +++ b/docs/start/quickstart.md @@ -13,6 +13,8 @@ npx paperclipai onboard --yes This walks you through setup, configures your environment, and gets Paperclip running. +If you already have a Paperclip install, rerunning `onboard` keeps your current config and data paths intact. Use `paperclipai configure` if you want to edit settings. + To start Paperclip again later: ```sh From 19b6adc415f767979f78f71f884e5b91c44f4a3d Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 22:00:55 -0500 Subject: [PATCH 049/118] Use exported tsx CLI entrypoint Co-Authored-By: Paperclip --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 749cc8d0..e2095929 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "db:migrate": "pnpm --filter @paperclipai/db migrate", "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", "db:backup": "./scripts/backup-db.sh", - "paperclipai": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts", + "paperclipai": "tsx cli/src/index.ts", "build:npm": "./scripts/build-npm.sh", "release": "./scripts/release.sh", "release:canary": "./scripts/release.sh canary", From b75ac76b13ca2fa29469c62037e800e5fcf3e48a Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 13:00:25 -0500 Subject: [PATCH 050/118] Add project workspaces tab Co-Authored-By: Paperclip --- ui/src/App.tsx | 1 + ui/src/lib/project-workspaces-tab.test.ts | 198 ++++++++++++++++++++++ ui/src/lib/project-workspaces-tab.ts | 108 ++++++++++++ ui/src/pages/ProjectDetail.tsx | 162 +++++++++++++++++- 4 files changed, 465 insertions(+), 4 deletions(-) create mode 100644 ui/src/lib/project-workspaces-tab.test.ts create mode 100644 ui/src/lib/project-workspaces-tab.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 38b5f4bc..c7e75642 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -144,6 +144,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts new file mode 100644 index 00000000..e111e154 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace } from "@paperclipai/shared"; +import { buildProjectWorkspaceSummaries } from "./project-workspaces-tab"; + +function createProjectWorkspace(overrides: Partial): ProjectWorkspace { + return { + id: overrides.id ?? "workspace-default", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + name: overrides.name ?? "paperclip", + sourceType: overrides.sourceType ?? "local_path", + cwd: overrides.cwd ?? "/repo", + repoUrl: overrides.repoUrl ?? null, + repoRef: overrides.repoRef ?? null, + defaultRef: overrides.defaultRef ?? null, + visibility: overrides.visibility ?? "default", + setupCommand: overrides.setupCommand ?? null, + cleanupCommand: overrides.cleanupCommand ?? null, + remoteProvider: overrides.remoteProvider ?? null, + remoteWorkspaceRef: overrides.remoteWorkspaceRef ?? null, + sharedWorkspaceKey: overrides.sharedWorkspaceKey ?? null, + metadata: overrides.metadata ?? null, + isPrimary: overrides.isPrimary ?? false, + runtimeServices: overrides.runtimeServices ?? [], + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + }; +} + +function createIssue(overrides: Partial): Issue { + return { + id: overrides.id ?? "issue-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? null, + goalId: overrides.goalId ?? null, + parentId: overrides.parentId ?? null, + title: overrides.title ?? "Issue", + description: overrides.description ?? null, + status: overrides.status ?? "todo", + priority: overrides.priority ?? "medium", + assigneeAgentId: overrides.assigneeAgentId ?? null, + assigneeUserId: overrides.assigneeUserId ?? null, + checkoutRunId: overrides.checkoutRunId ?? null, + executionRunId: overrides.executionRunId ?? null, + executionAgentNameKey: overrides.executionAgentNameKey ?? null, + executionLockedAt: overrides.executionLockedAt ?? null, + createdByAgentId: overrides.createdByAgentId ?? null, + createdByUserId: overrides.createdByUserId ?? null, + issueNumber: overrides.issueNumber ?? null, + identifier: overrides.identifier ?? null, + requestDepth: overrides.requestDepth ?? 0, + billingCode: overrides.billingCode ?? null, + assigneeAdapterOverrides: overrides.assigneeAdapterOverrides ?? null, + executionWorkspaceId: overrides.executionWorkspaceId ?? null, + executionWorkspacePreference: overrides.executionWorkspacePreference ?? null, + executionWorkspaceSettings: overrides.executionWorkspaceSettings ?? null, + startedAt: overrides.startedAt ?? null, + completedAt: overrides.completedAt ?? null, + cancelledAt: overrides.cancelledAt ?? null, + hiddenAt: overrides.hiddenAt ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-20T00:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-20T00:00:00Z"), + } as Issue; +} + +function createExecutionWorkspace(overrides: Partial): ExecutionWorkspace { + return { + id: overrides.id ?? "exec-1", + companyId: overrides.companyId ?? "company-1", + projectId: overrides.projectId ?? "project-1", + projectWorkspaceId: overrides.projectWorkspaceId ?? "workspace-default", + sourceIssueId: overrides.sourceIssueId ?? null, + mode: overrides.mode ?? "isolated_workspace", + strategyType: overrides.strategyType ?? "git_worktree", + name: overrides.name ?? "PAP-893", + status: overrides.status ?? "active", + cwd: overrides.cwd ?? "/repo/.worktrees/PAP-893", + repoUrl: overrides.repoUrl ?? null, + baseRef: overrides.baseRef ?? "public-gh/master", + branchName: overrides.branchName ?? "PAP-893-workspaces-tab", + providerType: overrides.providerType ?? "git_worktree", + providerRef: overrides.providerRef ?? null, + derivedFromExecutionWorkspaceId: overrides.derivedFromExecutionWorkspaceId ?? null, + lastUsedAt: overrides.lastUsedAt ?? new Date("2026-03-26T10:00:00Z"), + openedAt: overrides.openedAt ?? new Date("2026-03-26T09:00:00Z"), + closedAt: overrides.closedAt ?? null, + cleanupEligibleAt: overrides.cleanupEligibleAt ?? null, + cleanupReason: overrides.cleanupReason ?? null, + metadata: overrides.metadata ?? null, + createdAt: overrides.createdAt ?? new Date("2026-03-26T09:00:00Z"), + updatedAt: overrides.updatedAt ?? new Date("2026-03-26T09:30:00Z"), + }; +} + +describe("buildProjectWorkspaceSummaries", () => { + const primaryWorkspace = createProjectWorkspace({ + id: "workspace-default", + isPrimary: true, + name: "paperclip", + }); + const featureWorkspace = createProjectWorkspace({ + id: "workspace-feature", + name: "feature-checkout", + repoRef: "feature/workspaces", + updatedAt: new Date("2026-03-25T09:00:00Z"), + }); + const project = { + workspaces: [primaryWorkspace, featureWorkspace], + primaryWorkspace, + } satisfies Pick; + + it("groups isolated execution workspace issues ahead of shared non-primary workspace issues", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-primary", + projectWorkspaceId: primaryWorkspace.id, + updatedAt: new Date("2026-03-26T08:00:00Z"), + }), + createIssue({ + id: "issue-feature-older", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-800", + updatedAt: new Date("2026-03-25T10:00:00Z"), + }), + createIssue({ + id: "issue-feature-newer", + projectWorkspaceId: featureWorkspace.id, + identifier: "PAP-801", + updatedAt: new Date("2026-03-25T11:00:00Z"), + }), + createIssue({ + id: "issue-exec", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-1", + identifier: "PAP-893", + updatedAt: new Date("2026-03-26T11:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-1", + name: "PAP-893", + branchName: "PAP-893-workspaces-tab", + lastUsedAt: new Date("2026-03-26T10:30:00Z"), + }), + ], + }); + + expect(summaries).toHaveLength(2); + expect(summaries[0]).toMatchObject({ + key: "execution:exec-1", + kind: "execution_workspace", + workspaceName: "PAP-893", + branchName: "PAP-893-workspaces-tab", + executionWorkspaceId: "exec-1", + }); + expect(summaries[0]?.issues.map((issue) => issue.id)).toEqual(["issue-exec"]); + + expect(summaries[1]).toMatchObject({ + key: "project:workspace-feature", + kind: "project_workspace", + workspaceName: "feature-checkout", + branchName: "feature/workspaces", + projectWorkspaceId: "workspace-feature", + }); + expect(summaries[1]?.issues.map((issue) => issue.id)).toEqual([ + "issue-feature-newer", + "issue-feature-older", + ]); + }); + + it("does not duplicate non-primary workspace issues when an execution workspace owns them", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-exec-derived", + projectWorkspaceId: featureWorkspace.id, + executionWorkspaceId: "exec-2", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-2", + projectWorkspaceId: featureWorkspace.id, + name: "feature-branch run", + }), + ], + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.key).toBe("execution:exec-2"); + }); +}); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts new file mode 100644 index 00000000..fef9a6d0 --- /dev/null +++ b/ui/src/lib/project-workspaces-tab.ts @@ -0,0 +1,108 @@ +import type { ExecutionWorkspace, Issue, Project } from "@paperclipai/shared"; + +type ProjectWorkspaceLike = Pick; + +export interface ProjectWorkspaceSummary { + key: string; + kind: "execution_workspace" | "project_workspace"; + workspaceId: string; + workspaceName: string; + branchName: string | null; + lastUpdatedAt: Date; + projectWorkspaceId: string | null; + executionWorkspaceId: string | null; + issues: Issue[]; +} + +function toDate(value: Date | string | null | undefined): Date | null { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function maxDate(...values: Array): Date { + let latest = new Date(0); + for (const value of values) { + const date = toDate(value); + if (date && date.getTime() > latest.getTime()) latest = date; + } + return latest; +} + +function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null { + return project.primaryWorkspace?.id + ?? project.workspaces.find((workspace) => workspace.isPrimary)?.id + ?? project.workspaces[0]?.id + ?? null; +} + +export function buildProjectWorkspaceSummaries(input: { + project: ProjectWorkspaceLike; + issues: Issue[]; + executionWorkspaces: ExecutionWorkspace[]; +}): ProjectWorkspaceSummary[] { + const primaryId = primaryWorkspaceId(input.project); + const executionWorkspacesById = new Map( + input.executionWorkspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const projectWorkspacesById = new Map( + input.project.workspaces.map((workspace) => [workspace.id, workspace] as const), + ); + const summaries = new Map(); + + for (const issue of input.issues) { + if (issue.executionWorkspaceId) { + const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); + if (!executionWorkspace) continue; + + const existing = summaries.get(`execution:${executionWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`execution:${executionWorkspace.id}`, { + key: `execution:${executionWorkspace.id}`, + kind: "execution_workspace", + workspaceId: executionWorkspace.id, + workspaceName: executionWorkspace.name, + branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null, + lastUpdatedAt: maxDate( + existing?.lastUpdatedAt, + executionWorkspace.lastUsedAt, + executionWorkspace.updatedAt, + issue.updatedAt, + ), + projectWorkspaceId: executionWorkspace.projectWorkspaceId ?? issue.projectWorkspaceId ?? null, + executionWorkspaceId: executionWorkspace.id, + issues: nextIssues, + }); + continue; + } + + if (!issue.projectWorkspaceId || issue.projectWorkspaceId === primaryId) continue; + const projectWorkspace = projectWorkspacesById.get(issue.projectWorkspaceId); + if (!projectWorkspace) continue; + + const existing = summaries.get(`project:${projectWorkspace.id}`); + const nextIssues = [...(existing?.issues ?? []), issue].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); + + summaries.set(`project:${projectWorkspace.id}`, { + key: `project:${projectWorkspace.id}`, + kind: "project_workspace", + workspaceId: projectWorkspace.id, + workspaceName: projectWorkspace.name, + branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, + lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), + projectWorkspaceId: projectWorkspace.id, + executionWorkspaceId: null, + issues: nextIssues, + }); + } + + return [...summaries.values()].sort((a, b) => { + const diff = b.lastUpdatedAt.getTime() - a.lastUpdatedAt.getTime(); + return diff !== 0 ? diff : a.workspaceName.localeCompare(b.workspaceName); + }); +} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 0691d93a..095d1b89 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,8 +1,10 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; -import { useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; +import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared"; +import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace, type Issue, type Project } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; +import { executionWorkspacesApi } from "../api/execution-workspaces"; +import { instanceSettingsApi } from "../api/instanceSettings"; import { projectsApi } from "../api/projects"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; @@ -20,14 +22,17 @@ import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; -import { projectRouteRef, cn } from "../lib/utils"; +import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; +import { projectRouteRef } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; +import { Clock3, GitBranch, Rows3 } from "lucide-react"; /* ── Top-level tab types ── */ -type ProjectBaseTab = "overview" | "list" | "configuration" | "budget"; +type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget"; type ProjectPluginTab = `plugin:${string}`; type ProjectTab = ProjectBaseTab | ProjectPluginTab; @@ -44,6 +49,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu if (tab === "configuration") return "configuration"; if (tab === "budget") return "budget"; if (tab === "issues") return "list"; + if (tab === "workspaces") return "workspaces"; return null; } @@ -200,6 +206,88 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan ); } +function ProjectWorkspacesContent({ + summaries, +}: { + summaries: ReturnType; +}) { + if (summaries.length === 0) { + return

No non-default workspace activity yet.

; + } + + return ( +
+ {summaries.map((summary) => { + const visibleIssues = summary.issues.slice(0, 3); + const hiddenIssueCount = Math.max(summary.issues.length - visibleIssues.length, 0); + + return ( +
+
+
+
+ {summary.executionWorkspaceId ? ( + + {summary.workspaceName} + + ) : ( +
{summary.workspaceName}
+ )} + + {summary.kind === "execution_workspace" ? "Isolated workspace" : "Project workspace"} + +
+ +
+ + + {summary.branchName ?? "No branch info"} + + + + {summary.issues.length} linked {summary.issues.length === 1 ? "issue" : "issues"} + +
+ +
+ {visibleIssues.map((issue) => ( + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} + + ))} + {hiddenIssueCount > 0 ? ( + + ... and {hiddenIssueCount} more + + ) : null} +
+
+ +
+ + {timeAgo(summary.lastUpdatedAt)} +
+
+
+ ); + })} +
+ ); +} + /* ── Main project page ── */ export function ProjectDetail() { @@ -241,6 +329,10 @@ export function ProjectDetail() { const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const projectLookupRef = project?.id ?? routeProjectRef; const resolvedCompanyId = project?.companyId ?? selectedCompanyId; + const experimentalSettingsQuery = useQuery({ + queryKey: queryKeys.instance.experimentalSettings, + queryFn: () => instanceSettingsApi.getExperimental(), + }); const { slots: pluginDetailSlots, isLoading: pluginDetailSlotsLoading, @@ -259,6 +351,39 @@ export function ProjectDetail() { [pluginDetailSlots], ); const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null; + const isolatedWorkspacesEnabled = experimentalSettingsQuery.data?.enableIsolatedWorkspaces === true; + const workspaceTabProjectId = project?.id ?? null; + const { data: workspaceTabIssues = [], isLoading: isWorkspaceTabIssuesLoading, error: workspaceTabIssuesError } = useQuery({ + queryKey: workspaceTabProjectId && resolvedCompanyId + ? queryKeys.issues.listByProject(resolvedCompanyId, workspaceTabProjectId) + : ["issues", "__workspace-tab__", "disabled"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }), + enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled), + }); + const { + data: workspaceTabExecutionWorkspaces = [], + isLoading: isWorkspaceTabExecutionWorkspacesLoading, + error: workspaceTabExecutionWorkspacesError, + } = useQuery({ + queryKey: workspaceTabProjectId && resolvedCompanyId + ? queryKeys.executionWorkspaces.list(resolvedCompanyId, { projectId: workspaceTabProjectId }) + : ["execution-workspaces", "__workspace-tab__", "disabled"], + queryFn: () => executionWorkspacesApi.list(resolvedCompanyId!, { projectId: workspaceTabProjectId! }), + enabled: Boolean(resolvedCompanyId && workspaceTabProjectId && isolatedWorkspacesEnabled), + }); + const workspaceSummaries = useMemo(() => { + if (!project || !isolatedWorkspacesEnabled) return []; + return buildProjectWorkspaceSummaries({ + project, + issues: workspaceTabIssues, + executionWorkspaces: workspaceTabExecutionWorkspaces, + }); + }, [project, isolatedWorkspacesEnabled, workspaceTabIssues, workspaceTabExecutionWorkspaces]); + const showWorkspacesTab = isolatedWorkspacesEnabled && workspaceSummaries.length > 0; + const workspaceTabDecisionLoaded = + experimentalSettingsQuery.isFetched && + (!isolatedWorkspacesEnabled || (!isWorkspaceTabIssuesLoading && !isWorkspaceTabExecutionWorkspacesLoading)); + const workspaceTabError = (workspaceTabIssuesError ?? workspaceTabExecutionWorkspacesError) as Error | null; useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; @@ -345,6 +470,10 @@ export function ProjectDetail() { navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true }); return; } + if (activeTab === "workspaces") { + navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true }); + return; + } if (activeTab === "list") { if (filter) { navigate(`/projects/${canonicalProjectRef}/issues/${filter}`, { replace: true }); @@ -455,6 +584,10 @@ export function ProjectDetail() { return ; } + if (activeTab === "workspaces" && workspaceTabDecisionLoaded && !showWorkspacesTab) { + return ; + } + // Redirect bare /projects/:id to cached tab or default /issues if (routeProjectRef && activeTab === null) { let cachedTab: string | null = null; @@ -470,6 +603,12 @@ export function ProjectDetail() { if (cachedTab === "budget") { return ; } + if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) { + return ; + } + if (cachedTab === "workspaces" && !workspaceTabDecisionLoaded) { + return ; + } if (isProjectPluginTab(cachedTab)) { return ; } @@ -491,6 +630,8 @@ export function ProjectDetail() { } if (tab === "overview") { navigate(`/projects/${canonicalProjectRef}/overview`); + } else if (tab === "workspaces") { + navigate(`/projects/${canonicalProjectRef}/workspaces`); } else if (tab === "budget") { navigate(`/projects/${canonicalProjectRef}/budget`); } else if (tab === "configuration") { @@ -561,6 +702,7 @@ export function ProjectDetail() { items={[ { value: "list", label: "Issues" }, { value: "overview", label: "Overview" }, + ...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []), { value: "configuration", label: "Configuration" }, { value: "budget", label: "Budget" }, ...pluginTabItems.map((item) => ({ @@ -589,6 +731,18 @@ export function ProjectDetail() { )} + {activeTab === "workspaces" ? ( + workspaceTabDecisionLoaded ? ( + workspaceTabError ? ( +

{workspaceTabError.message}

+ ) : ( + + ) + ) : ( +

Loading workspaces...

+ ) + ) : null} + {activeTab === "configuration" && (
Date: Thu, 26 Mar 2026 16:17:33 -0500 Subject: [PATCH 051/118] Adjust workspace row columns Co-Authored-By: Paperclip --- ui/src/pages/ProjectDetail.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 095d1b89..df928f02 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary, type ExecutionWorkspace, type Issue, type Project } from "@paperclipai/shared"; +import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared"; import { budgetsApi } from "../api/budgets"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -226,8 +226,8 @@ function ProjectWorkspacesContent({ key={summary.key} className="border-b border-border px-4 py-3 last:border-b-0" > -
-
+
+
{summary.executionWorkspaceId ? (
+
-
+
+
+ Issues +
+
{visibleIssues.map((issue) => (
-
+
{timeAgo(summary.lastUpdatedAt)}
From 0ff778ec298aeb277696a375564f394045bbadb8 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:20:40 -0500 Subject: [PATCH 052/118] Exclude default shared workspaces from tab Co-Authored-By: Paperclip --- ui/src/lib/project-workspaces-tab.test.ts | 27 +++++++++++++++++++++++ ui/src/lib/project-workspaces-tab.ts | 15 +++++++++++++ 2 files changed, 42 insertions(+) diff --git a/ui/src/lib/project-workspaces-tab.test.ts b/ui/src/lib/project-workspaces-tab.test.ts index e111e154..a037a7eb 100644 --- a/ui/src/lib/project-workspaces-tab.test.ts +++ b/ui/src/lib/project-workspaces-tab.test.ts @@ -195,4 +195,31 @@ describe("buildProjectWorkspaceSummaries", () => { expect(summaries).toHaveLength(1); expect(summaries[0]?.key).toBe("execution:exec-2"); }); + + it("excludes issues that only use the default shared workspace", () => { + const summaries = buildProjectWorkspaceSummaries({ + project, + issues: [ + createIssue({ + id: "issue-default-shared", + projectWorkspaceId: primaryWorkspace.id, + executionWorkspaceId: "exec-shared-default", + updatedAt: new Date("2026-03-26T12:00:00Z"), + }), + ], + executionWorkspaces: [ + createExecutionWorkspace({ + id: "exec-shared-default", + mode: "shared_workspace", + strategyType: "project_primary", + projectWorkspaceId: primaryWorkspace.id, + branchName: null, + baseRef: null, + providerType: "local_fs", + }), + ], + }); + + expect(summaries).toHaveLength(0); + }); }); diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index fef9a6d0..abc5a6f9 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -36,6 +36,16 @@ function primaryWorkspaceId(project: ProjectWorkspaceLike): string | null { ?? null; } +function isDefaultSharedExecutionWorkspace(input: { + executionWorkspace: ExecutionWorkspace; + issue: Issue; + primaryWorkspaceId: string | null; +}) { + const linkedProjectWorkspaceId = + input.executionWorkspace.projectWorkspaceId ?? input.issue.projectWorkspaceId ?? null; + return input.executionWorkspace.mode === "shared_workspace" && linkedProjectWorkspaceId === input.primaryWorkspaceId; +} + export function buildProjectWorkspaceSummaries(input: { project: ProjectWorkspaceLike; issues: Issue[]; @@ -54,6 +64,11 @@ export function buildProjectWorkspaceSummaries(input: { if (issue.executionWorkspaceId) { const executionWorkspace = executionWorkspacesById.get(issue.executionWorkspaceId); if (!executionWorkspace) continue; + if (isDefaultSharedExecutionWorkspace({ + executionWorkspace, + issue, + primaryWorkspaceId: primaryId, + })) continue; const existing = summaries.get(`execution:${executionWorkspace.id}`); const nextIssues = [...(existing?.issues ?? []), issue].sort( From b7b5d8dae3105c67608540c5f6fc50ac8f7385c1 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:38:17 -0500 Subject: [PATCH 053/118] Polish workspace issue badges Co-Authored-By: Paperclip --- ui/src/pages/ProjectDetail.tsx | 28 +++++----------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index df928f02..df2ac9e5 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -28,7 +28,7 @@ import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { Clock3, GitBranch, Rows3 } from "lucide-react"; +import { Clock3, GitBranch } from "lucide-react"; /* ── Top-level tab types ── */ @@ -228,49 +228,31 @@ function ProjectWorkspacesContent({ >
-
- {summary.executionWorkspaceId ? ( - - {summary.workspaceName} - - ) : ( -
{summary.workspaceName}
- )} - - {summary.kind === "execution_workspace" ? "Isolated workspace" : "Project workspace"} - -
+
{summary.workspaceName}
{summary.branchName ?? "No branch info"} - - - {summary.issues.length} linked {summary.issues.length === 1 ? "issue" : "issues"} -
- Issues + Issues ({summary.issues.length})
{visibleIssues.map((issue) => ( {issue.identifier ?? issue.id.slice(0, 8)} - {issue.title} + {issue.title} ))} {hiddenIssueCount > 0 ? ( From 15e0e2ece9509d6fae47942002e390376ac06ada Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 17:10:38 -0500 Subject: [PATCH 054/118] Add workspace path copy control Co-Authored-By: Paperclip --- ui/src/lib/project-workspaces-tab.ts | 3 +++ ui/src/pages/ProjectDetail.tsx | 14 +++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/project-workspaces-tab.ts b/ui/src/lib/project-workspaces-tab.ts index abc5a6f9..1f5846c5 100644 --- a/ui/src/lib/project-workspaces-tab.ts +++ b/ui/src/lib/project-workspaces-tab.ts @@ -7,6 +7,7 @@ export interface ProjectWorkspaceSummary { kind: "execution_workspace" | "project_workspace"; workspaceId: string; workspaceName: string; + cwd: string | null; branchName: string | null; lastUpdatedAt: Date; projectWorkspaceId: string | null; @@ -80,6 +81,7 @@ export function buildProjectWorkspaceSummaries(input: { kind: "execution_workspace", workspaceId: executionWorkspace.id, workspaceName: executionWorkspace.name, + cwd: executionWorkspace.cwd ?? null, branchName: executionWorkspace.branchName ?? executionWorkspace.baseRef ?? null, lastUpdatedAt: maxDate( existing?.lastUpdatedAt, @@ -108,6 +110,7 @@ export function buildProjectWorkspaceSummaries(input: { kind: "project_workspace", workspaceId: projectWorkspace.id, workspaceName: projectWorkspace.name, + cwd: projectWorkspace.cwd ?? null, branchName: projectWorkspace.repoRef ?? projectWorkspace.defaultRef ?? null, lastUpdatedAt: maxDate(existing?.lastUpdatedAt, projectWorkspace.updatedAt, issue.updatedAt), projectWorkspaceId: projectWorkspace.id, diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index df2ac9e5..6f938848 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -16,6 +16,7 @@ import { useToast } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties"; +import { CopyText } from "../components/CopyText"; import { InlineEditor } from "../components/InlineEditor"; import { StatusBadge } from "../components/StatusBadge"; import { BudgetPolicyCard } from "../components/BudgetPolicyCard"; @@ -28,7 +29,7 @@ import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots"; -import { Clock3, GitBranch } from "lucide-react"; +import { Clock3, Copy, GitBranch } from "lucide-react"; /* ── Top-level tab types ── */ @@ -236,6 +237,17 @@ function ProjectWorkspacesContent({ {summary.branchName ?? "No branch info"}
+ + {summary.cwd ? ( +
+ + {summary.cwd} + + + + +
+ ) : null}
From bb1732dd11f9393defdc4b5496a78d47e2b68409 Mon Sep 17 00:00:00 2001 From: dotta Date: Sat, 28 Mar 2026 09:51:58 -0500 Subject: [PATCH 055/118] Add project workspace detail page Co-Authored-By: Paperclip --- ui/src/App.tsx | 2 + ui/src/lib/utils.ts | 8 + ui/src/pages/ProjectDetail.tsx | 24 +- ui/src/pages/ProjectWorkspaceDetail.tsx | 557 ++++++++++++++++++++++++ 4 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 ui/src/pages/ProjectWorkspaceDetail.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c7e75642..ac1e6040 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -11,6 +11,7 @@ import { Agents } from "./pages/Agents"; import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; import { ProjectDetail } from "./pages/ProjectDetail"; +import { ProjectWorkspaceDetail } from "./pages/ProjectWorkspaceDetail"; import { Issues } from "./pages/Issues"; import { IssueDetail } from "./pages/IssueDetail"; import { Routines } from "./pages/Routines"; @@ -144,6 +145,7 @@ function boardRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index dcab46c1..76e18846 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -165,3 +165,11 @@ export function projectRouteRef(project: { id: string; urlKey?: string | null; n export function projectUrl(project: { id: string; urlKey?: string | null; name?: string | null }): string { return `/projects/${projectRouteRef(project)}`; } + +/** Build a project workspace URL scoped under its project. */ +export function projectWorkspaceUrl( + project: { id: string; urlKey?: string | null; name?: string | null }, + workspaceId: string, +): string { + return `${projectUrl(project)}/workspaces/${workspaceId}`; +} diff --git a/ui/src/pages/ProjectDetail.tsx b/ui/src/pages/ProjectDetail.tsx index 6f938848..54a1bc48 100644 --- a/ui/src/pages/ProjectDetail.tsx +++ b/ui/src/pages/ProjectDetail.tsx @@ -24,7 +24,7 @@ import { IssuesList } from "../components/IssuesList"; import { PageSkeleton } from "../components/PageSkeleton"; import { PageTabBar } from "../components/PageTabBar"; import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab"; -import { projectRouteRef } from "../lib/utils"; +import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; import { timeAgo } from "../lib/timeAgo"; import { Tabs } from "@/components/ui/tabs"; import { PluginLauncherOutlet } from "@/plugins/launchers"; @@ -208,8 +208,10 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan } function ProjectWorkspacesContent({ + projectRef, summaries, }: { + projectRef: string; summaries: ReturnType; }) { if (summaries.length === 0) { @@ -275,9 +277,21 @@ function ProjectWorkspacesContent({
-
- - {timeAgo(summary.lastUpdatedAt)} +
+ + {summary.kind === "project_workspace" ? "Configure workspace" : "View workspace"} + +
+ + {timeAgo(summary.lastUpdatedAt)} +
@@ -735,7 +749,7 @@ export function ProjectDetail() { workspaceTabError ? (

{workspaceTabError.message}

) : ( - + ) ) : (

Loading workspaces...

diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx new file mode 100644 index 00000000..1a3411e0 --- /dev/null +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -0,0 +1,557 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "@/lib/router"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { ProjectWorkspace } from "@paperclipai/shared"; +import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { ChoosePathButton } from "../components/PathInstructionsModal"; +import { projectsApi } from "../api/projects"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; +import { queryKeys } from "../lib/queryKeys"; +import { projectRouteRef, projectWorkspaceUrl } from "../lib/utils"; + +type WorkspaceFormState = { + name: string; + sourceType: ProjectWorkspaceSourceType; + cwd: string; + repoUrl: string; + repoRef: string; + defaultRef: string; + visibility: ProjectWorkspaceVisibility; + setupCommand: string; + cleanupCommand: string; + remoteProvider: string; + remoteWorkspaceRef: string; + sharedWorkspaceKey: string; +}; + +type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"]; +type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; + +const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [ + { value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." }, + { value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." }, + { value: "git_repo", label: "Remote git repo", description: "A repo URL with optional refs and local checkout." }, + { value: "remote_managed", label: "Remote-managed workspace", description: "A hosted workspace tracked by external reference." }, +]; + +const VISIBILITY_OPTIONS: Array<{ value: ProjectWorkspaceVisibility; label: string }> = [ + { value: "default", label: "Default" }, + { value: "advanced", label: "Advanced" }, +]; + +function isSafeExternalUrl(value: string | null | undefined) { + if (!value) return false; + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function isAbsolutePath(value: string) { + return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value); +} + +function readText(value: string | null | undefined) { + return value ?? ""; +} + +function formStateFromWorkspace(workspace: ProjectWorkspace): WorkspaceFormState { + return { + name: workspace.name, + sourceType: workspace.sourceType, + cwd: readText(workspace.cwd), + repoUrl: readText(workspace.repoUrl), + repoRef: readText(workspace.repoRef), + defaultRef: readText(workspace.defaultRef), + visibility: workspace.visibility, + setupCommand: readText(workspace.setupCommand), + cleanupCommand: readText(workspace.cleanupCommand), + remoteProvider: readText(workspace.remoteProvider), + remoteWorkspaceRef: readText(workspace.remoteWorkspaceRef), + sharedWorkspaceKey: readText(workspace.sharedWorkspaceKey), + }; +} + +function normalizeText(value: string) { + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function buildWorkspacePatch(initialState: WorkspaceFormState, nextState: WorkspaceFormState) { + const patch: Record = {}; + const maybeAssign = (key: keyof WorkspaceFormState, transform?: (value: string) => unknown) => { + const initialValue = initialState[key]; + const nextValue = nextState[key]; + if (initialValue === nextValue) return; + patch[key] = transform ? transform(nextValue) : nextValue; + }; + + maybeAssign("name", normalizeText); + maybeAssign("sourceType"); + maybeAssign("cwd", normalizeText); + maybeAssign("repoUrl", normalizeText); + maybeAssign("repoRef", normalizeText); + maybeAssign("defaultRef", normalizeText); + maybeAssign("visibility"); + maybeAssign("setupCommand", normalizeText); + maybeAssign("cleanupCommand", normalizeText); + maybeAssign("remoteProvider", normalizeText); + maybeAssign("remoteWorkspaceRef", normalizeText); + maybeAssign("sharedWorkspaceKey", normalizeText); + + return patch; +} + +function validateWorkspaceForm(form: WorkspaceFormState) { + const cwd = normalizeText(form.cwd); + const repoUrl = normalizeText(form.repoUrl); + const remoteWorkspaceRef = normalizeText(form.remoteWorkspaceRef); + + if (form.sourceType === "remote_managed") { + if (!remoteWorkspaceRef && !repoUrl) { + return "Remote-managed workspaces require a remote workspace ref or repo URL."; + } + } else if (!cwd && !repoUrl) { + return "Workspace requires at least one local path or repo URL."; + } + + if (cwd && (form.sourceType === "local_path" || form.sourceType === "non_git_path") && !isAbsolutePath(cwd)) { + return "Local workspace path must be absolute."; + } + + if (repoUrl) { + try { + new URL(repoUrl); + } catch { + return "Repo URL must be a valid URL."; + } + } + + return null; +} + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +function DetailRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export function ProjectWorkspaceDetail() { + const { companyPrefix, projectId, workspaceId } = useParams<{ + companyPrefix?: string; + projectId: string; + workspaceId: string; + }>(); + const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [form, setForm] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const routeProjectRef = projectId ?? ""; + const routeWorkspaceId = workspaceId ?? ""; + + const routeCompanyId = useMemo(() => { + if (!companyPrefix) return null; + const requestedPrefix = companyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix)?.id ?? null; + }, [companies, companyPrefix]); + + const lookupCompanyId = routeCompanyId ?? selectedCompanyId ?? undefined; + const projectQuery = useQuery({ + queryKey: [...queryKeys.projects.detail(routeProjectRef), lookupCompanyId ?? null], + queryFn: () => projectsApi.get(routeProjectRef, lookupCompanyId), + enabled: routeProjectRef.length > 0, + }); + + const project = projectQuery.data ?? null; + const workspace = useMemo( + () => project?.workspaces.find((item) => item.id === routeWorkspaceId) ?? null, + [project, routeWorkspaceId], + ); + const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; + const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); + const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); + + useEffect(() => { + if (!project?.companyId || project.companyId === selectedCompanyId) return; + setSelectedCompanyId(project.companyId, { source: "route_sync" }); + }, [project?.companyId, selectedCompanyId, setSelectedCompanyId]); + + useEffect(() => { + if (!workspace) return; + setForm(formStateFromWorkspace(workspace)); + setErrorMessage(null); + }, [workspace]); + + useEffect(() => { + if (!project) return; + setBreadcrumbs([ + { label: "Projects", href: "/projects" }, + { label: project.name, href: `/projects/${canonicalProjectRef}` }, + { label: "Workspaces", href: `/projects/${canonicalProjectRef}/workspaces` }, + { label: workspace?.name ?? routeWorkspaceId }, + ]); + }, [setBreadcrumbs, project, canonicalProjectRef, workspace?.name, routeWorkspaceId]); + + useEffect(() => { + if (!project) return; + if (routeProjectRef === canonicalProjectRef) return; + navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true }); + }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]); + + const invalidateProject = () => { + if (!project) return; + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(project.urlKey) }); + if (lookupCompanyId) { + queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(lookupCompanyId) }); + } + }; + + const updateWorkspace = useMutation({ + mutationFn: (patch: Record) => + projectsApi.updateWorkspace(project!.id, routeWorkspaceId, patch, lookupCompanyId), + onSuccess: () => { + invalidateProject(); + setErrorMessage(null); + }, + onError: (error) => { + setErrorMessage(error instanceof Error ? error.message : "Failed to save workspace."); + }, + }); + + const setPrimaryWorkspace = useMutation({ + mutationFn: () => projectsApi.updateWorkspace(project!.id, routeWorkspaceId, { isPrimary: true }, lookupCompanyId), + onSuccess: () => { + invalidateProject(); + setErrorMessage(null); + }, + onError: (error) => { + setErrorMessage(error instanceof Error ? error.message : "Failed to update workspace."); + }, + }); + + if (projectQuery.isLoading) return

Loading workspace…

; + if (projectQuery.error) { + return ( +

+ {projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load workspace"} +

+ ); + } + if (!project || !workspace || !form || !initialState) { + return

Workspace not found for this project.

; + } + + const saveChanges = () => { + const validationError = validateWorkspaceForm(form); + if (validationError) { + setErrorMessage(validationError); + return; + } + const patch = buildWorkspacePatch(initialState, form); + if (Object.keys(patch).length === 0) return; + updateWorkspace.mutate(patch); + }; + + const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null; + + return ( +
+
+ +
+ {workspace.isPrimary ? "Primary workspace" : "Secondary workspace"} +
+
+ +
+
+
+
+
+
+ Project workspace +
+

{workspace.name}

+

+ Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace + checkout behavior and let you override setup or cleanup commands when one workspace needs special handling. +

+
+ {!workspace.isPrimary ? ( + + ) : ( +
+ + This is the project’s primary codebase workspace. +
+ )} +
+ + + +
+ + setForm((current) => current ? { ...current, name: event.target.value } : current)} + placeholder="Workspace name" + /> + + + + + +
+ +
+ + + + +
+ + setForm((current) => current ? { ...current, cwd: event.target.value } : current)} + placeholder="/absolute/path/to/workspace" + /> + +
+ +
+
+ +
+ + setForm((current) => current ? { ...current, repoUrl: event.target.value } : current)} + placeholder="https://github.com/org/repo" + /> + + + setForm((current) => current ? { ...current, repoRef: event.target.value } : current)} + placeholder="origin/main" + /> + +
+ +
+ + setForm((current) => current ? { ...current, defaultRef: event.target.value } : current)} + placeholder="origin/main" + /> + + + setForm((current) => current ? { ...current, sharedWorkspaceKey: event.target.value } : current)} + placeholder="frontend" + /> + +
+ +
+ + setForm((current) => current ? { ...current, remoteProvider: event.target.value } : current)} + placeholder="codespaces" + /> + + + setForm((current) => current ? { ...current, remoteWorkspaceRef: event.target.value } : current)} + placeholder="workspace-123" + /> + +
+ +
+ +