Skip to content

Fix board creation failing silently on read-only filesystems (Vercel)#14

Open
gnirpaz wants to merge 2 commits into
mainfrom
fix-readonly-fs-and-silent-create-errors
Open

Fix board creation failing silently on read-only filesystems (Vercel)#14
gnirpaz wants to merge 2 commits into
mainfrom
fix-readonly-fs-and-silent-create-errors

Conversation

@gnirpaz
Copy link
Copy Markdown
Member

@gnirpaz gnirpaz commented May 28, 2026

Symptom

On production (agentboard.sh, deployed on Vercel), clicking Create board does nothing — no board, no error.

Root cause (reproduced locally)

Two compounding bugs:

  1. Storage writes fail on a read-only filesystem. getDataDir() defaulted to ~/.agentboard/data. On serverless platforms like Vercel the filesystem is read-only except the temp dir, so every write (createBoard, createTask, …) throws EROFS/EACCES and POST /api/boards returns 500. I reproduced this against the real storage by pointing the data dir at an unwritable path:
    createBoard threw -> API returns 500 -> UI does nothing
    error code: ENOTDIR | mkdir '/etc/hostname/data/boards/prod-test/agents'
    
  2. The UI swallows the failure. The landing page only navigated if (data.ok), so a 500 produced no feedback at all — exactly "does nothing."

(Note: locally everything works — POST /api/boards → 201, GET → 200 — which is why this only manifests in the read-only production environment.)

Fix

  • getDataDir() writable fallback — if ~/.agentboard/data isn't writable, fall back to <os.tmpdir()>/agentboard/data (resolved once and cached). An explicit AGENTBOARD_DATA_DIR still wins. A warning is logged noting the fallback is ephemeral on serverless. Verified: with an unwritable home dir, createBoard now succeeds and listBoards returns the new board.
  • UI surfaces errorscreateBoard now shows the server's error message (or a network error), with a disabled Creating… state, instead of silently failing.

eslint, tsc --noEmit, and bun test all pass.

⚠️ Follow-up needed: durable persistence

This unblocks the create flow, but the temp-dir fallback is per-instance and ephemeral on Vercel — boards created on the hosted site won't reliably persist across cold starts/instances. Agentboard is fundamentally a local-first, filesystem-storage app; for the hosted site to durably store data it needs a real backend (Vercel KV/Postgres/Blob, or a mounted volume) or a AGENTBOARD_DATA_DIR pointed at durable storage. Happy to implement whichever direction you prefer — let me know.

https://claude.ai/code/session_014r13EsBf8Kz8YGfD6VxDtS


Generated by Claude Code


Note

Medium Risk
Changes where all filesystem-backed API data is stored on serverless (ephemeral temp dir) and alters core storage resolution; UI-only risk is low but production persistence remains unreliable until a durable backend is configured.

Overview
Fixes Create board appearing to do nothing on read-only hosts (e.g. Vercel) by making storage pick a writable data directory and by surfacing create failures in the UI.

getDataDir() no longer assumes ~/.agentboard/data is writable. It still honors AGENTBOARD_DATA_DIR, then tries the home path (mkdir + write check), then falls back to os.tmpdir()/agentboard/data with a one-time cache and a console warning that serverless temp storage is ephemeral. If neither location is writable, it throws with a clear message instead of failing on every API write.

The home page createBoard flow adds creating / error state: buttons show Creating… and disable while the request runs; non-OK responses and network errors display text-destructive messages (including server error.message when present) instead of only navigating on data.ok.

Reviewed by Cursor Bugbot for commit d091148. Bugbot is set up for automated code reviews on this repo. Configure here.

Creating a board did nothing in production (agentboard.sh on Vercel). Two
compounding bugs:

1. Storage writes failed on read-only filesystems. getDataDir() defaulted
   to ~/.agentboard/data, which is not writable on serverless platforms
   like Vercel (only the temp dir is). Every write (createBoard, createTask,
   ...) threw EROFS/EACCES, so POST /api/boards returned 500. Reproduced
   locally: pointing the data dir at an unwritable path throws ENOTDIR on
   mkdir. Now getDataDir() falls back to <os.tmpdir()>/agentboard/data when
   the home dir isn't writable (resolved once and cached), with an explicit
   AGENTBOARD_DATA_DIR override still winning. A warning notes that the
   fallback is ephemeral on serverless and a durable path is needed for
   persistence.

2. The UI swallowed the failure. The landing page only navigated
   `if (data.ok)`, so a 500 produced no feedback at all — "does nothing."
   createBoard now surfaces the server error message (or a network error),
   shows a disabled "Creating…" state, and clears on retry.

Verified locally: POST /api/boards returns 201 and GET returns 200; with an
unwritable home dir, createBoard now succeeds against the temp fallback.

https://claude.ai/code/session_014r13EsBf8Kz8YGfD6VxDtS
@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agentboard Ready Ready Preview, Comment May 28, 2026 11:38pm

Request Review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Fallback path skips writability check unlike preferred path
    • The fallback path now uses ensureWritableDir before caching, matching the preferred path and preventing an existing but unwritable temp directory from being returned.
Preview (d0911480a3)
diff --git a/src/app/page.tsx b/src/app/page.tsx
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -14,12 +14,13 @@
   // with the dev default so the server render and first client render match,
   // then corrected to the real origin after mount.
   const [baseUrl, setBaseUrl] = useState("http://localhost:4040");
+  const [error, setError] = useState<string | null>(null);
+  const [creating, setCreating] = useState(false);
   const router = useRouter();
 
   useEffect(() => {
     // window.location is only available client-side; syncing after mount keeps
     // the SSR/first-client render in agreement (no hydration mismatch).
-    // eslint-disable-next-line react-hooks/set-state-in-effect
     setBaseUrl(window.location.origin);
   }, []);
 
@@ -36,14 +37,25 @@
   const createBoard = async () => {
     const name = prompt("Board name:");
     if (!name) return;
-    const res = await fetch("/api/boards", {
-      method: "POST",
-      headers: { "Content-Type": "application/json" },
-      body: JSON.stringify({ name }),
-    });
-    const data = await res.json();
-    if (data.ok) {
-      router.push(`/boards/${data.data.id}`);
+    setError(null);
+    setCreating(true);
+    try {
+      const res = await fetch("/api/boards", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ name }),
+      });
+      const data = await res.json().catch(() => null);
+      if (res.ok && data?.ok) {
+        router.push(`/boards/${data.data.id}`);
+        return;
+      }
+      // Surface the failure instead of silently doing nothing.
+      setError(data?.error?.message || `Could not create board (HTTP ${res.status}). Please try again.`);
+    } catch {
+      setError("Could not reach the server. Check your connection and try again.");
+    } finally {
+      setCreating(false);
     }
   };
 
@@ -79,9 +91,10 @@
                 </button>
               ))}
             </div>
-            <Button onClick={createBoard} variant="outline" className="mt-4">
-              Create another board
+            <Button onClick={createBoard} variant="outline" className="mt-4" disabled={creating}>
+              {creating ? "Creating…" : "Create another board"}
             </Button>
+            {error && <p className="mt-3 text-sm text-destructive">{error}</p>}
           </div>
         </div>
       </div>
@@ -104,9 +117,10 @@
             </p>
           </div>
 
-          <Button onClick={createBoard} size="lg">
-            Create your first board
+          <Button onClick={createBoard} size="lg" disabled={creating}>
+            {creating ? "Creating…" : "Create your first board"}
           </Button>
+          {error && <p className="text-sm text-destructive">{error}</p>}
 
           <div className="text-left">
             <p className="text-sm text-muted-foreground mb-2">Or create via API:</p>

diff --git a/src/lib/server-utils.ts b/src/lib/server-utils.ts
--- a/src/lib/server-utils.ts
+++ b/src/lib/server-utils.ts
@@ -1,6 +1,54 @@
+import fs from "fs";
 import path from "path";
 import os from "os";
 
+let cachedDataDir: string | null = null;
+
+function ensureWritableDir(dir: string): boolean {
+  try {
+    fs.mkdirSync(dir, { recursive: true });
+    fs.accessSync(dir, fs.constants.W_OK);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * Resolves the directory used for file-system JSON storage.
+ *
+ * Resolution order:
+ * 1. `AGENTBOARD_DATA_DIR` if set — explicit config always wins.
+ * 2. `~/.agentboard/data` when that location is writable (local/self-hosted).
+ * 3. `<os.tmpdir()>/agentboard/data` as a fallback when the home dir is
+ *    read-only — e.g. serverless platforms like Vercel, where only the temp
+ *    dir is writable. Without this, every write (createBoard, createTask, …)
+ *    throws EROFS/EACCES, the API returns 500, and the UI appears to "do
+ *    nothing." NOTE: the temp dir is ephemeral and per-instance on serverless,
+ *    so data is not durable there — a real backend is needed for persistence.
+ */
 export function getDataDir(): string {
-  return process.env.AGENTBOARD_DATA_DIR || path.join(os.homedir(), ".agentboard", "data");
+  if (process.env.AGENTBOARD_DATA_DIR) return process.env.AGENTBOARD_DATA_DIR;
+  if (cachedDataDir) return cachedDataDir;
+
+  const preferred = path.join(os.homedir(), ".agentboard", "data");
+  if (ensureWritableDir(preferred)) {
+    cachedDataDir = preferred;
+    return cachedDataDir;
+  }
+
+  const fallback = path.join(os.tmpdir(), "agentboard", "data");
+  if (ensureWritableDir(fallback)) {
+    console.warn(
+      `[agentboard] data dir ${preferred} is not writable; falling back to ${fallback}. ` +
+        `Data is ephemeral here (e.g. serverless) — set AGENTBOARD_DATA_DIR to a durable path for persistence.`,
+    );
+    cachedDataDir = fallback;
+    return cachedDataDir;
+  }
+
+  throw new Error(
+    `[agentboard] no writable data directory (tried ${preferred} and ${fallback}). ` +
+      `Set AGENTBOARD_DATA_DIR to a writable path.`,
+  );
 }

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 8444c99. Configure here.

Comment thread src/lib/server-utils.ts Outdated
Use ensureWritableDir for the temp fallback path, matching the preferred
path check, so an existing but unwritable directory is not cached.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants