From c1a02497b0a593b35364aa16452bec97d3b86ad9 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:47:29 -0500 Subject: [PATCH] [codex] fix worktree dev dependency ergonomics (#3743) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Local development needs to work cleanly across linked git worktrees because Paperclip itself leans on worktree-based engineering workflows > - Dev-mode asset routing, Vite watch behavior, and workspace package links are part of that day-to-day control-plane ergonomics > - The current branch had a small but coherent set of worktree/dev-tooling fixes that are independent from both the issue UI changes and the heartbeat runtime changes > - This pull request isolates those environment fixes into a standalone branch that can merge without carrying unrelated product work > - The benefit is a smoother multi-worktree developer loop with fewer stale links and less noisy dev watching ## What Changed - Serve dev public assets before the HTML shell and add a routing test that locks that behavior in. - Ignore UI test files in the Vite dev watch helper so the dev server does less unnecessary work. - Update `ensure-workspace-package-links.ts` to relink stale workspace dependencies whenever a workspace `node_modules` directory exists, instead of only inside linked-worktree detection paths. ## Verification - `pnpm vitest run server/src/__tests__/app-vite-dev-routing.test.ts ui/src/lib/vite-watch.test.ts` - `node cli/node_modules/tsx/dist/cli.mjs scripts/ensure-workspace-package-links.ts` ## Risks - The asset routing change is low risk but sits near app shell behavior, so a regression would show up as broken static assets in dev mode. - The workspace-link repair now runs in more cases, so the main risk is doing unexpected relinks when a checkout has intentionally unusual workspace symlink state. ## Model Used - OpenAI Codex, GPT-5-based coding agent in the Codex CLI environment. Exact backend model deployment ID was not exposed in-session. Tool-assisted editing and shell execution were used. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --- scripts/ensure-workspace-package-links.ts | 21 ++++---------- .../__tests__/app-vite-dev-routing.test.ts | 27 +++++++++++++++++ server/src/__tests__/http-log-policy.test.ts | 1 + server/src/app.ts | 7 ++++- server/src/middleware/http-log-policy.ts | 1 + ui/src/lib/vite-watch.test.ts | 29 +++++++++++++++++++ ui/src/lib/vite-watch.ts | 29 +++++++++++++++++++ ui/vite.config.ts | 4 +-- 8 files changed, 101 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/app-vite-dev-routing.test.ts create mode 100644 ui/src/lib/vite-watch.test.ts create mode 100644 ui/src/lib/vite-watch.ts diff --git a/scripts/ensure-workspace-package-links.ts b/scripts/ensure-workspace-package-links.ts index 17a9990945..8e26d521a3 100644 --- a/scripts/ensure-workspace-package-links.ts +++ b/scripts/ensure-workspace-package-links.ts @@ -1,6 +1,6 @@ #!/usr/bin/env -S node --import tsx import fs from "node:fs/promises"; -import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; import { repoRoot } from "./dev-service-profile.ts"; @@ -43,20 +43,6 @@ function discoverWorkspacePackagePaths(rootDir: string): Map { return packagePaths; } -function isLinkedGitWorktreeCheckout(rootDir: string) { - const gitMetadataPath = path.join(rootDir, ".git"); - if (!existsSync(gitMetadataPath)) return false; - - const stat = lstatSync(gitMetadataPath); - if (!stat.isFile()) return false; - - return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); -} - -if (!isLinkedGitWorktreeCheckout(repoRoot)) { - process.exit(0); -} - const workspacePackagePaths = discoverWorkspacePackagePaths(repoRoot); const workspaceDirs = Array.from( new Set( @@ -67,6 +53,11 @@ const workspaceDirs = Array.from( ).sort(); function findWorkspaceLinkMismatches(workspaceDir: string): WorkspaceLinkMismatch[] { + const nodeModulesDir = path.join(repoRoot, workspaceDir, "node_modules"); + if (!existsSync(nodeModulesDir)) { + return []; + } + const packageJson = readJsonFile(path.join(repoRoot, workspaceDir, "package.json")); const dependencies = { ...(packageJson.dependencies as Record | undefined), diff --git a/server/src/__tests__/app-vite-dev-routing.test.ts b/server/src/__tests__/app-vite-dev-routing.test.ts new file mode 100644 index 0000000000..cc13ec837e --- /dev/null +++ b/server/src/__tests__/app-vite-dev-routing.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import type { Request } from "express"; +import { shouldServeViteDevHtml } from "../app.js"; + +function createRequest(path: string, acceptsResult: string | false): Request { + return { + path, + accepts: () => acceptsResult, + } as unknown as Request; +} + +describe("shouldServeViteDevHtml", () => { + it("serves HTML shell for document requests", () => { + expect(shouldServeViteDevHtml(createRequest("/", "html"))).toBe(true); + expect(shouldServeViteDevHtml(createRequest("/issues/abc", "html"))).toBe(true); + }); + + it("skips public assets even when the client accepts */*", () => { + expect(shouldServeViteDevHtml(createRequest("/sw.js", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/site.webmanifest", "html"))).toBe(false); + }); + + it("skips vite asset requests", () => { + expect(shouldServeViteDevHtml(createRequest("/@vite/client", "html"))).toBe(false); + expect(shouldServeViteDevHtml(createRequest("/src/main.tsx", "html"))).toBe(false); + }); +}); diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index 5f21836ade..ec140c3e87 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -56,6 +56,7 @@ describe("shouldSilenceHttpSuccessLog", () => { expect(shouldSilenceHttpSuccessLog("GET", "/@fs/Users/dotta/paperclip/ui/src/main.tsx", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/src/App.tsx?t=123", 200)).toBe(true); expect(shouldSilenceHttpSuccessLog("GET", "/site.webmanifest", 200)).toBe(true); + expect(shouldSilenceHttpSuccessLog("GET", "/sw.js", 200)).toBe(true); }); it("keeps normal successful application requests", () => { diff --git a/server/src/app.ts b/server/src/app.ts index 9f9c9ccfe3..c60de55526 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -70,6 +70,7 @@ const VITE_DEV_STATIC_PATHS = new Set([ "/favicon.ico", "/favicon.svg", "/site.webmanifest", + "/sw.js", ]); export function resolveViteHmrPort(serverPort: number): number { @@ -79,7 +80,7 @@ export function resolveViteHmrPort(serverPort: number): number { return Math.max(1_024, serverPort - 10_000); } -function shouldServeViteDevHtml(req: ExpressRequest): boolean { +export function shouldServeViteDevHtml(req: ExpressRequest): boolean { const pathname = req.path; if (VITE_DEV_STATIC_PATHS.has(pathname)) return false; if (VITE_DEV_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return false; @@ -347,6 +348,7 @@ export async function createApp( if (opts.uiMode === "vite-dev") { const uiRoot = path.resolve(__dirname, "../../ui"); + const publicUiRoot = path.resolve(uiRoot, "public"); const hmrPort = resolveViteHmrPort(opts.serverPort); const { createServer: createViteServer } = await import("vite"); const vite = await createViteServer({ @@ -369,6 +371,9 @@ export async function createApp( }); const renderViteHtml = viteHtmlRenderer; + if (fs.existsSync(publicUiRoot)) { + app.use(express.static(publicUiRoot, { index: false })); + } app.get(/.*/, async (req, res, next) => { if (!shouldServeViteDevHtml(req)) { next(); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index fee44fee1a..d652ee38d0 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -25,6 +25,7 @@ const SILENCED_SUCCESS_STATIC_PREFIXES = [ const SILENCED_SUCCESS_STATIC_PATHS = new Set([ "/favicon.ico", "/site.webmanifest", + "/sw.js", ]); function normalizePath(url: string): string { diff --git a/ui/src/lib/vite-watch.test.ts b/ui/src/lib/vite-watch.test.ts new file mode 100644 index 0000000000..c4b456b62a --- /dev/null +++ b/ui/src/lib/vite-watch.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { createUiDevWatchOptions, shouldIgnoreUiDevWatchPath } from "./vite-watch"; + +describe("shouldIgnoreUiDevWatchPath", () => { + it("ignores test-only files and folders", () => { + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/components/IssuesList.test.tsx")).toBe(true); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/lib/issue-tree.spec.ts")).toBe(true); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/__tests__/helpers.ts")).toBe(true); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/tests/helpers.ts")).toBe(true); + }); + + it("keeps runtime source files watchable", () => { + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/components/IssuesList.tsx")).toBe(false); + expect(shouldIgnoreUiDevWatchPath("/repo/ui/src/pages/IssueDetail.tsx")).toBe(false); + }); +}); + +describe("createUiDevWatchOptions", () => { + it("preserves the WSL /mnt polling fallback", () => { + expect(createUiDevWatchOptions("/mnt/c/paperclip")).toMatchObject({ + usePolling: true, + interval: 1000, + }); + }); + + it("always includes the ignored-path predicate", () => { + expect(createUiDevWatchOptions("/Users/dotta/paperclip")).toHaveProperty("ignored"); + }); +}); diff --git a/ui/src/lib/vite-watch.ts b/ui/src/lib/vite-watch.ts new file mode 100644 index 0000000000..f5792a2e19 --- /dev/null +++ b/ui/src/lib/vite-watch.ts @@ -0,0 +1,29 @@ +const TEST_DIRECTORY_NAMES = new Set([ + "__tests__", + "_tests", + "test", + "tests", +]); + +const TEST_FILE_BASENAME_RE = /\.(test|spec)\.[^/]+$/i; + +export function shouldIgnoreUiDevWatchPath(watchedPath: string): boolean { + const normalizedPath = String(watchedPath).replaceAll("\\", "/"); + if (normalizedPath.length === 0) return false; + + const segments = normalizedPath.split("/"); + const basename = segments.at(-1) ?? normalizedPath; + + return segments.some((segment) => TEST_DIRECTORY_NAMES.has(segment)) + || TEST_FILE_BASENAME_RE.test(basename); +} + +export function createUiDevWatchOptions(currentWorkingDirectory: string) { + return { + ignored: shouldIgnoreUiDevWatchPath, + // WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works. + ...(currentWorkingDirectory.startsWith("/mnt/") + ? { usePolling: true, interval: 1000 } + : {}), + }; +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index a680f60730..27f481a401 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,6 +2,7 @@ import path from "path"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import { createUiDevWatchOptions } from "./src/lib/vite-watch"; export default defineConfig(({ mode }) => ({ plugins: [react(), tailwindcss()], @@ -23,8 +24,7 @@ export default defineConfig(({ mode }) => ({ }, server: { port: 5173, - // WSL2 /mnt/ drives don't support inotify — fall back to polling so HMR works - watch: process.cwd().startsWith("/mnt/") ? { usePolling: true, interval: 1000 } : undefined, + watch: createUiDevWatchOptions(process.cwd()), proxy: { "/api": { target: "http://localhost:3100",