From 14c7efa0687686021462e5724be5499a98a596da Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:06:53 -0500 Subject: [PATCH] fix(workspaces): enable UI hot reload by default (#12612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Managed worktrees can run a Paperclip development server for each task > - The managed runtime used the built UI when its service did not set the UI development middleware option > - This made new UI source changes require a manual build instead of a hot reload > - The runtime must supply the development default while it must keep an explicit operator choice > - This pull request enables the UI development middleware for new managed Paperclip development services > - The benefit is that UI edits appear in the managed worktree browser without a manual build ## Linked Issues or Issue Description **What happened?** A new managed Paperclip development worktree served the built UI by default. An operator had to set `PAPERCLIP_UI_DEV_MIDDLEWARE=true` before UI source changes could hot reload. **Expected behavior** New managed Paperclip development worktrees must enable the UI development middleware by default. An explicit `PAPERCLIP_UI_DEV_MIDDLEWARE=false` value must continue to disable it. **Steps to reproduce** 1. Start a managed Paperclip development service without `PAPERCLIP_UI_DEV_MIDDLEWARE`. 2. Open its UI. 3. Change a UI source file. 4. Observe that the browser does not receive the change until the UI is built again. **Paperclip version or commit** This was reproduced on `317394456` from `master`. **Deployment mode** Local development with a managed worktree runtime. ## What Changed - Set `PAPERCLIP_UI_DEV_MIDDLEWARE=true` for managed `paperclip-dev` services when the service does not set a value. - Keep explicit service values, including `false`. - Add a regression test and document the default and the opt-out. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/workspace-runtime.test.ts -t "enables UI dev middleware by default"` - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,397 passing tests. Four existing runtime-port tests could not use ports `42000` and `52000` because a live managed runtime owns those ports on this host. The new regression test passed separately. ## Risks - Risk is low. The change applies only to managed services named `paperclip-dev`. - A service can keep the built UI by setting `PAPERCLIP_UI_DEV_MIDDLEWARE=false`. - There is no database or API contract change. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, `gpt-5.6-sol`, hosted Codex context window, high reasoning, tool use, code execution, and multi-file repository editing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEVELOPING.md | 4 +- .../src/__tests__/workspace-runtime.test.ts | 53 +++++++++++++++++++ server/src/services/workspace-runtime.ts | 13 +++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index a147b6a35b..a6ede01759 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -733,7 +733,9 @@ Lease recovery is bounded and explicit. Another issue may reclaim the lane once For Tailscale HTTPS exposure, readiness includes stable listener-ownership checks for every requested loopback port (the app and, when configured, its Vite HMR companion). Each listener must belong to the spawned managed process group; an unrelated listener that races onto either reserved port fails the start closed before the broker is asked to expose it. -In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to the managed runtime's loopback host. The browser still derives the HMR hostname from the public HTTPS page, so listener containment does not break remote hot reload. +Managed `paperclip-dev` worktree services enable `PAPERCLIP_UI_DEV_MIDDLEWARE=true` by default, so newly started worktrees hot-reload UI source changes. Managed HTTPS services use this default only when they publish the Paperclip Vite HMR companion listener, which is the default exposure configuration. A service or adapter can explicitly set the variable to `false` when it intentionally needs to exercise the built UI bundle. + +In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to the managed runtime's loopback host. The browser still derives the HMR hostname from the public HTTPS page, and exposed runtimes use secure WebSockets, so listener containment does not break remote hot reload. When a workspace service runs Paperclip for browser OAuth QA, configure its `expose.urlTemplate` with the canonical URL the browser can reach. Paperclip preserves explicit `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL` settings; otherwise it uses a valid exposed HTTPS origin (or loopback HTTP) as the managed runtime fallback for Better Auth and `/api/tools/oauth/callback`. Internal service names such as `http://paperclip-dev:` are rejected unless that hostname is genuinely the browser route. Use a unique origin per isolated worktree. See [Execution Workspaces And Runtime Services](../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#browser-reachable-origins-for-oauth-qa) for configuration and verification. diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index fdbeff3012..23e260b685 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -4401,6 +4401,59 @@ describe("ensureRuntimeServicesForRun", () => { expect(services).toEqual([]); }); + it("enables UI dev middleware by default for managed Paperclip worktree runtimes", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-ui-dev-")); + const workspace = buildWorkspace(workspaceRoot); + const serviceScript = + "const http=require('node:http');" + + "http.createServer((req,res)=>{" + + "if(req.url==='/api/health'){res.setHeader('content-type','application/json');" + + "res.end(JSON.stringify({status:'ok'}));return;}" + + "res.end(process.env.PAPERCLIP_UI_DEV_MIDDLEWARE||'missing');" + + "}).listen(Number(process.env.PORT),'127.0.0.1');"; + + try { + const [runtime] = await startRuntimeServicesForWorkspaceControl({ + actor: { id: "agent-1", name: "Codex Coder", companyId: "company-1" }, + issue: null, + workspace, + executionWorkspaceId: "execution-workspace-ui-dev", + config: { + workspaceRuntime: { + services: [{ + name: "paperclip-dev", + command: `${JSON.stringify(process.execPath)} -e ${JSON.stringify(serviceScript)}`, + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "execution_workspace", + stopPolicy: { type: "manual" }, + }], + }, + }, + adapterEnv: {}, + }); + + await expect(fetch(`${runtime!.url}/ui-mode`).then((response) => response.text())) + .resolves.toBe("true"); + } finally { + await stopRuntimeServicesForExecutionWorkspace({ + executionWorkspaceId: "execution-workspace-ui-dev", + workspaceCwd: workspaceRoot, + }); + await fs.rm(workspaceRoot, { recursive: true, force: true }); + } + }); + it("injects isolated browser callback origins into separate worktree runtimes", async () => { const firstRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-origin-first-")); const secondRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-origin-second-")); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 71222e50af..e96f793bac 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -6116,6 +6116,19 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P ...sanitizeRuntimeServiceBaseEnv(process.env), ...runtimeEnvOverrides, } as Record; + // Managed Paperclip worktrees are development environments, so their UI + // should track source edits without each project repeating this setting. + // An HTTPS profile must publish the companion HMR listener before it can use + // this default. Otherwise, leave the value unset so dev-runner keeps its + // built-UI safeguard. Keep every explicit service/adapter value. + const uiDevMiddlewareHasTransport = + !exposureConfig || exposureConfig.includePaperclipViteHmr; + if ( + uiDevMiddlewareHasTransport + && isPaperclipDevRuntimeService({ serviceName, command }) + ) { + env.PAPERCLIP_UI_DEV_MIDDLEWARE ??= "true"; + } if (port) { const portEnvKey = asString(portConfig.envKey, "PORT"); env[portEnvKey] = String(port);