Fix board creation failing silently on read-only filesystems (Vercel)#14
Open
gnirpaz wants to merge 2 commits into
Open
Fix board creation failing silently on read-only filesystems (Vercel)#14gnirpaz wants to merge 2 commits into
gnirpaz wants to merge 2 commits into
Conversation
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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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.
Use ensureWritableDir for the temp fallback path, matching the preferred path check, so an existing but unwritable directory is not cached.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Symptom
On production (agentboard.sh, deployed on Vercel), clicking Create board does nothing — no board, no error.
Root cause (reproduced locally)
Two compounding bugs:
getDataDir()defaulted to~/.agentboard/data. On serverless platforms like Vercel the filesystem is read-only except the temp dir, so every write (createBoard,createTask, …) throwsEROFS/EACCESandPOST /api/boardsreturns 500. I reproduced this against the real storage by pointing the data dir at an unwritable path: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/dataisn't writable, fall back to<os.tmpdir()>/agentboard/data(resolved once and cached). An explicitAGENTBOARD_DATA_DIRstill wins. A warning is logged noting the fallback is ephemeral on serverless. Verified: with an unwritable home dir,createBoardnow succeeds andlistBoardsreturns the new board.createBoardnow shows the server's error message (or a network error), with a disabledCreating…state, instead of silently failing.eslint,tsc --noEmit, andbun testall pass.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_DIRpointed 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/datais writable. It still honorsAGENTBOARD_DATA_DIR, then tries the home path (mkdir + write check), then falls back toos.tmpdir()/agentboard/datawith 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
createBoardflow addscreating/errorstate: buttons show Creating… and disable while the request runs; non-OK responses and network errors displaytext-destructivemessages (including servererror.messagewhen present) instead of only navigating ondata.ok.Reviewed by Cursor Bugbot for commit d091148. Bugbot is set up for automated code reviews on this repo. Configure here.