From b987db041740b026adbbe2acfdc454fea9802efe Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 16:42:44 -0700 Subject: [PATCH 1/2] [luv-337] fix: route Gemini sessions by full UUID, not 8-char filename prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini sessions linked from the dashboard project page were 404ing because `SessionFile.sessionId` was pulled from the filename regex match group (8-hex prefix), but the session detail route at `app/project/[name]/session/[sessionId]/page.tsx:42` rejects non-UUIDs and calls `notFound()`. The same sessions loaded fine from the hooks/activity table because hook stdin carries the full UUID directly. Fix: in `lib/gemini-projects.ts` `scanGeminiSessions()`, read the JSONL metadata header (line 1, ~1 KB) via a new async `readFirstLine` helper — mirrors the pattern in `lib/codex-projects.ts:46-62`. Parse `meta.sessionId` and validate against a local `UUID_RE`, then plumb the full UUID through `GeminiSessionMeta.sessionId` to both `getGeminiSessionsForCwd` and `getGeminiSessionsByEncodedName`. When the header is missing, malformed, or carries a non-UUID value, leave `sessionId` undefined so the row renders un-linked (`SessionsList` already handles this) rather than producing a clickable 404 link. New unit suite `__tests__/lib/gemini-projects.test.ts` pins all four behaviors (full-UUID propagation, malformed JSON, missing field, non-UUID value). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + __tests__/lib/gemini-projects.test.ts | 113 ++++++++++++++++++++++++++ lib/gemini-projects.ts | 88 ++++++++++++++------ 3 files changed, 178 insertions(+), 24 deletions(-) create mode 100644 __tests__/lib/gemini-projects.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e6d746..c0560d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.0.10-beta.10 — 2026-05-09 ### Fixes +- Read full session UUID from each Gemini JSONL's metadata header at project-page session-listing time (`lib/gemini-projects.ts`), so links route to a valid `[sessionId]` segment instead of the 8-hex filename prefix that the session detail route's `UUID_RE` check rejects (404). Hooks-section links were already correct because hook stdin carries the full UUID; this aligns the projects-section with that path (#337). - Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/` 404 for OpenCode-only sessions and merging same-cwd OpenCode + other-CLI rows on the Projects page (#335). - `.failproofai/policies/workflow-policies.mjs`: drop the `## Unreleased` section; new `release-prep-check` policy + updated `changelog-check` instruct the agent to put entries under a dated `## ` heading so each PR ships release-ready, and all four workflow policies now anchor command-phrase matches to shell boundaries to avoid false-positives from HEREDOC bodies (#335). diff --git a/__tests__/lib/gemini-projects.test.ts b/__tests__/lib/gemini-projects.test.ts new file mode 100644 index 00000000..c9b4eca2 --- /dev/null +++ b/__tests__/lib/gemini-projects.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + getGeminiSessionsForCwd, + getGeminiSessionsByEncodedName, +} from "@/lib/gemini-projects"; + +const FULL_UUID = "89eb30b0-27c0-4ea3-a5b9-06fc00085610"; +const PREFIX = FULL_UUID.slice(0, 8); + +function header(sessionId: string, startTime = "2026-05-09T12:00:00.000Z"): string { + return JSON.stringify({ + sessionId, + projectHash: "deadbeef", + startTime, + lastUpdated: startTime, + kind: "chat", + }); +} + +describe("lib/gemini-projects", () => { + let originalRoot: string | undefined; + let fakeRoot: string; + + function makeProject(basename: string, cwd: string) { + const projectDir = join(fakeRoot, basename); + mkdirSync(join(projectDir, "chats"), { recursive: true }); + writeFileSync(join(projectDir, ".project_root"), cwd); + return projectDir; + } + + function writeSession(projectDir: string, prefix: string, body: string) { + // Filename matches `session-YYYY-MM-DDTHH-MM-<8hex>.jsonl` (gemini-cli v0.40.1). + const filename = `session-2026-05-09T12-00-${prefix}.jsonl`; + writeFileSync(join(projectDir, "chats", filename), body); + return filename; + } + + beforeEach(() => { + originalRoot = process.env.GEMINI_SESSIONS_DIR; + fakeRoot = mkdtempSync(join(tmpdir(), "gemini-projects-")); + process.env.GEMINI_SESSIONS_DIR = fakeRoot; + }); + + afterEach(() => { + if (originalRoot === undefined) delete process.env.GEMINI_SESSIONS_DIR; + else process.env.GEMINI_SESSIONS_DIR = originalRoot; + rmSync(fakeRoot, { recursive: true, force: true }); + }); + + it("exposes the full UUID from the JSONL metadata header — not the 8-char filename prefix", async () => { + // Regression for #337: the project page rendered links with an 8-hex + // sessionId, which the session detail route's UUID_RE check rejected (404). + // Building the link from the metadata header's full UUID round-trips. + const cwd = "/home/u/dev-purge"; + const projectDir = makeProject("dev-purge", cwd); + writeSession(projectDir, PREFIX, header(FULL_UUID) + "\n"); + + const byCwd = await getGeminiSessionsForCwd(cwd); + expect(byCwd).toHaveLength(1); + expect(byCwd[0].sessionId).toBe(FULL_UUID); + expect(byCwd[0].sessionId).not.toBe(PREFIX); + + const byName = await getGeminiSessionsByEncodedName("-home-u-dev-purge"); + expect(byName.cwd).toBe(cwd); + expect(byName.sessions).toHaveLength(1); + expect(byName.sessions[0].sessionId).toBe(FULL_UUID); + }); + + it("leaves sessionId undefined when the metadata header is malformed JSON", async () => { + // Un-parseable header → un-linked row in the dashboard. Better than a + // clickable link to the 8-hex prefix that 404s. + const cwd = "/home/u/broken"; + const projectDir = makeProject("broken", cwd); + writeSession(projectDir, PREFIX, "not json\n"); + + const byCwd = await getGeminiSessionsForCwd(cwd); + expect(byCwd).toHaveLength(1); + expect(byCwd[0].sessionId).toBeUndefined(); + + const byName = await getGeminiSessionsByEncodedName("-home-u-broken"); + expect(byName.sessions).toHaveLength(1); + expect(byName.sessions[0].sessionId).toBeUndefined(); + }); + + it("leaves sessionId undefined when the metadata header lacks a sessionId field", async () => { + const cwd = "/home/u/headerless"; + const projectDir = makeProject("headerless", cwd); + writeSession( + projectDir, + PREFIX, + JSON.stringify({ projectHash: "x", startTime: "2026-05-09T12:00:00.000Z" }) + "\n", + ); + + const byCwd = await getGeminiSessionsForCwd(cwd); + expect(byCwd).toHaveLength(1); + expect(byCwd[0].sessionId).toBeUndefined(); + }); + + it("leaves sessionId undefined when meta.sessionId is not a UUID", async () => { + const cwd = "/home/u/nonuuid"; + const projectDir = makeProject("nonuuid", cwd); + writeSession(projectDir, PREFIX, header("not-a-uuid") + "\n"); + + const byCwd = await getGeminiSessionsForCwd(cwd); + expect(byCwd).toHaveLength(1); + expect(byCwd[0].sessionId).toBeUndefined(); + }); +}); diff --git a/lib/gemini-projects.ts b/lib/gemini-projects.ts index a3acdb55..49071fbd 100644 --- a/lib/gemini-projects.ts +++ b/lib/gemini-projects.ts @@ -21,7 +21,7 @@ * missing `~/.gemini/` returns `[]`, malformed JSONL falls open without * surfacing the session. */ -import { readdir, readFile, stat } from "node:fs/promises"; +import { open, readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import type { ProjectFolder, SessionFile } from "./projects"; @@ -34,6 +34,13 @@ import { logWarn } from "./logger"; /** Filename pattern for a Gemini session JSONL: * `session--<8-hex-uuid-prefix>.jsonl`. */ const SESSION_FILE_RE = /^session-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2})-([0-9a-f]{8})\.jsonl$/i; +/** Full UUID — the filename only embeds the first 8 hex chars; the rest is on + * the JSONL metadata header line. The session detail route requires a full + * UUID, so links built from the truncated filename prefix 404. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** Metadata header sits on line 1 and is well under 1 KB; 4 KB covers it + * comfortably without slurping a multi-MB transcript. */ +const FIRST_LINE_CHUNK_BYTES = 4 * 1024; /** Override for tests. Defaults to the live Gemini session-state root. */ function getGeminiTmpRoot(): string { @@ -46,6 +53,10 @@ interface GeminiSessionMeta { sessionFilename: string; cwd: string; fileMtime: Date; + /** Full UUID parsed from the JSONL metadata header (line 1). Undefined when + * the header is missing, malformed, or carries a non-UUID `sessionId`; + * callers fall through to rendering an un-linked row. */ + sessionId?: string; } async function safeReaddir(dir: string) { @@ -64,6 +75,40 @@ async function statMtime(path: string): Promise { } } +/** Read the first newline-delimited line of `filePath` without slurping the + * rest. Mirrors `lib/codex-projects.ts`'s readFirstLine; the Gemini metadata + * header is always on line 1. */ +async function readFirstLine(filePath: string): Promise { + let fh: Awaited> | null = null; + try { + fh = await open(filePath, "r"); + const buf = Buffer.alloc(FIRST_LINE_CHUNK_BYTES); + const { bytesRead } = await fh.read(buf, 0, FIRST_LINE_CHUNK_BYTES, 0); + if (bytesRead === 0) return null; + const slice = buf.subarray(0, bytesRead); + const nl = slice.indexOf(0x0a); // '\n' + const end = nl === -1 ? bytesRead : nl; + return slice.subarray(0, end).toString("utf-8"); + } catch { + return null; + } finally { + if (fh) await fh.close().catch(() => {}); + } +} + +/** Extract a full-UUID `sessionId` from a JSONL metadata header line. Returns + * undefined on parse failure, missing field, or a non-UUID value. */ +function extractFullSessionId(line: string | null): string | undefined { + if (!line) return undefined; + try { + const meta = JSON.parse(line) as { sessionId?: unknown }; + if (typeof meta.sessionId !== "string") return undefined; + return UUID_RE.test(meta.sessionId) ? meta.sessionId : undefined; + } catch { + return undefined; + } +} + /** Read `.project_root` to recover the absolute cwd for a basename folder. * Returns null if missing or empty (caller treats the folder as un-mappable). */ async function readProjectRoot(projectDir: string): Promise { @@ -101,7 +146,8 @@ async function scanGeminiSessions(): Promise { const filePath = join(chatsDir, f.name); const mtime = await statMtime(filePath); if (!mtime) continue; - out.push({ filePath, sessionFilename: f.name, cwd, fileMtime: mtime }); + const sessionId = extractFullSessionId(await readFirstLine(filePath)); + out.push({ filePath, sessionFilename: f.name, cwd, fileMtime: mtime, sessionId }); } }), 16, @@ -140,17 +186,14 @@ export async function getGeminiProjects(): Promise { export async function getGeminiSessionsForCwd(cwd: string): Promise { const sessions = await scanGeminiSessions(); const matches = sessions.filter((s) => s.cwd === cwd); - const files: SessionFile[] = matches.map((s) => { - const m = s.sessionFilename.match(SESSION_FILE_RE); - return { - name: s.sessionFilename, - path: s.filePath, - lastModified: s.fileMtime, - lastModifiedFormatted: formatDate(s.fileMtime), - sessionId: m ? m[2] : undefined, - cli: "gemini", - }; - }); + const files: SessionFile[] = matches.map((s) => ({ + name: s.sessionFilename, + path: s.filePath, + lastModified: s.fileMtime, + lastModifiedFormatted: formatDate(s.fileMtime), + sessionId: s.sessionId, + cli: "gemini", + })); files.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); return files; } @@ -180,17 +223,14 @@ export async function getGeminiSessionsByEncodedName(name: string): Promise { - const m = s.sessionFilename.match(SESSION_FILE_RE); - return { - name: s.sessionFilename, - path: s.filePath, - lastModified: s.fileMtime, - lastModifiedFormatted: formatDate(s.fileMtime), - sessionId: m ? m[2] : undefined, - cli: "gemini" as const, - }; - }); + const sessions = matches.map((s) => ({ + name: s.sessionFilename, + path: s.filePath, + lastModified: s.fileMtime, + lastModifiedFormatted: formatDate(s.fileMtime), + sessionId: s.sessionId, + cli: "gemini" as const, + })); sessions.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); return { cwd: uniqueCwds[0], sessions }; } From 4ede86713d0f0cae84a7c26af64d277cd593ac4c Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Sat, 9 May 2026 16:46:23 -0700 Subject: [PATCH 2/2] =?UTF-8?q?[luv-337]=20docs:=20correct=20PR=20number?= =?UTF-8?q?=20in=20changelog=20entry=20(#337=20=E2=86=92=20#336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit caught the mismatch on PR #336. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0560d73..fcae67e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## 0.0.10-beta.10 — 2026-05-09 ### Fixes -- Read full session UUID from each Gemini JSONL's metadata header at project-page session-listing time (`lib/gemini-projects.ts`), so links route to a valid `[sessionId]` segment instead of the 8-hex filename prefix that the session detail route's `UUID_RE` check rejects (404). Hooks-section links were already correct because hook stdin carries the full UUID; this aligns the projects-section with that path (#337). +- Read full session UUID from each Gemini JSONL's metadata header at project-page session-listing time (`lib/gemini-projects.ts`), so links route to a valid `[sessionId]` segment instead of the 8-hex filename prefix that the session detail route's `UUID_RE` check rejects (404). Hooks-section links were already correct because hook stdin carries the full UUID; this aligns the projects-section with that path (#336). - Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/` 404 for OpenCode-only sessions and merging same-cwd OpenCode + other-CLI rows on the Projects page (#335). - `.failproofai/policies/workflow-policies.mjs`: drop the `## Unreleased` section; new `release-prep-check` policy + updated `changelog-check` instruct the agent to put entries under a dated `## ` heading so each PR ships release-ready, and all four workflow policies now anchor command-phrase matches to shell boundaries to avoid false-positives from HEREDOC bodies (#335).