Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (#336).
- Route OpenCode project pages by encoded cwd (`encodeFolderName(worktree)`) instead of opencode's project name / basename, fixing the dashboard `/project/<slug>` 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 `## <version> — <YYYY-MM-DD>` 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).

Expand Down
113 changes: 113 additions & 0 deletions __tests__/lib/gemini-projects.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
88 changes: 64 additions & 24 deletions lib/gemini-projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,6 +34,13 @@ import { logWarn } from "./logger";
/** Filename pattern for a Gemini session JSONL:
* `session-<ISO-timestamp-with-dashes>-<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 {
Expand All @@ -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) {
Expand All @@ -64,6 +75,40 @@ async function statMtime(path: string): Promise<Date | null> {
}
}

/** 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<string | null> {
let fh: Awaited<ReturnType<typeof open>> | 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<string | null> {
Expand Down Expand Up @@ -101,7 +146,8 @@ async function scanGeminiSessions(): Promise<GeminiSessionMeta[]> {
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,
Expand Down Expand Up @@ -140,17 +186,14 @@ export async function getGeminiProjects(): Promise<ProjectFolder[]> {
export async function getGeminiSessionsForCwd(cwd: string): Promise<SessionFile[]> {
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;
}
Expand Down Expand Up @@ -180,17 +223,14 @@ export async function getGeminiSessionsByEncodedName(name: string): Promise<Gemi
if (uniqueCwds.length !== 1) {
return { cwd: null, sessions: [] };
}
const sessions = 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" 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 };
}
Expand Down