Skip to content

feat: add SQLite session reading support for OpenCode v1.2+#569

Open
chr1syy wants to merge 5 commits intoRunMaestro:mainfrom
chr1syy:opencode-sqlite-session-support-rebased
Open

feat: add SQLite session reading support for OpenCode v1.2+#569
chr1syy wants to merge 5 commits intoRunMaestro:mainfrom
chr1syy:opencode-sqlite-session-support-rebased

Conversation

@chr1syy
Copy link
Collaborator

@chr1syy chr1syy commented Mar 13, 2026

Summary

  • Adds SQLite session reading support for OpenCode v1.2+, which switched from JSON files to a SQLite database for session storage
  • Implements dual-format detection: automatically reads from SQLite when available, falls back to legacy JSON format
  • Parses OpenCode's SQLite schema to extract sessions, messages, and tool calls into Maestro's session model

Test plan

  • Verify OpenCode v1.2+ sessions are detected and loaded from SQLite database
  • Verify legacy JSON-based OpenCode sessions still load correctly
  • Verify graceful fallback when neither format is available

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added SQLite-backed session storage with automatic JSON fallback; merged session listings now include aggregated message/part totals.
  • Bug Fixes

    • Prevent deletion of database-backed sessions; JSON-backed deletion unchanged.
    • Read operations prefer SQLite with JSON fallback; SSH-remote sessions remain JSON-only.
    • Session paths and logs now indicate whether a session is from SQLite or JSON.
  • Documentation

    • Reformatted release notes for clarity; no content changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds SQLite-backed session storage (v1.2+) with JSON fallback; reads SQLite first, merges/deduplicates JSON results, preserves JSON-only remote/SSH behavior, blocks deletion for DB-backed sessions, and introduces DB helpers, row types, and migration-safe listing/loading logic.

Changes

Cohort / File(s) Summary
SQLite Session Storage Integration
src/main/storage/opencode-session-storage.ts
Implements SQLite support and migration: new row/type defs (SqliteSessionRow, SqliteMessageRow, ...), DB helpers (openOpenCodeDb, tableExists, safeJsonParse), data-dir helpers, SQLite listing/loading (listSessionsSqlite, loadSessionMessagesSqlite, convertSqliteSessionRows), JSON fallback (listSessionsJson), merge/dedup logic preferring SQLite, updated getSessionPath, and deletion prohibition for DB-backed sessions.
Path/Remote Handling Adjustments
src/main/storage/opencode-session-storage.ts
Normalizes path matching (trailing separators platform-aware), treats SSH-remote paths as JSON-only (no remote SQLite), and updates preview selection/duration calculations accordingly.
Docs Release Notes Reformatting
docs/releases.md
Reflowed and reformatted release notes (marker/style changes) without semantic changes.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Consumer
    participant Orchestrator as listSessions
    participant SQLite as SQLite DB
    participant JSON as JSON Storage
    participant Merger as Merge/Dedup

    Client->>Orchestrator: Request session list(project)
    Orchestrator->>SQLite: openOpenCodeDb() & query sessions
    SQLite-->>Orchestrator: SQLiteSessionRows
    Orchestrator->>JSON: listSessionsJson(project) (fallback)
    JSON-->>Orchestrator: JSON sessions
    Orchestrator->>Merger: Merge results
    Merger->>Merger: Deduplicate by sessionId, prefer SQLite
    Merger-->>Orchestrator: Combined session list
    Orchestrator-->>Client: Return merged, sorted sessions
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding SQLite session reading support for OpenCode v1.2+, which is the primary focus of the substantial code changes in the storage module.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link

greptile-apps bot commented Mar 13, 2026

Greptile Summary

This PR adds SQLite session reading support for OpenCode v1.2+, which switched from JSON file-based storage to a SQLite database (opencode.db). The implementation auto-detects the SQLite database, reads from it first, and falls back to the legacy JSON format — merging and deduplicating sessions from both sources during the migration window.

Key changes:

  • Refactors getOpenCodeStorageDir() into getOpenCodeDataDir() (base dir) + separate helpers for the JSON subdirectory and the SQLite path
  • Adds openOpenCodeDb(), safeJsonParse(), and tableExists() SQLite utility helpers
  • Adds listSessionsSqlite() and loadSessionMessagesSqlite() private methods that mirror the existing JSON methods
  • Merges both data sources in listSessions() with SQLite as the authoritative source
  • Blocks deleteMessagePair() for SQLite-backed sessions (read-only access is correct here)
  • SSH remote sessions continue to use the JSON path only

Issues found:

  • Critical (openOpenCodeDb, line 228): db.pragma('journal_mode = WAL') is a write operation and will throw on a read-only connection in better-sqlite3. The resulting exception is caught and null is returned, meaning all SQLite reading silently fails for every user. The opened-but-not-returned Database handle is also never closed (resource leak). The pragma is unnecessary — SQLite detects WAL mode from the file header automatically.
  • Logic bug (convertSqliteSessionRows, line 896): The user-message fallback for the session preview never sets foundPreview = true, so firstMessage is overwritten by every successive user message processed before the first assistant response. Sessions with multiple early user messages will display the wrong preview text.
  • LIKE wildcard injection (listSessionsSqlite, line 794): The directory path is interpolated into a LIKE pattern without escaping % or _, which could match unintended rows for paths that contain those characters.

Confidence Score: 2/5

  • Not safe to merge — the WAL pragma bug silently disables all SQLite reading, making the primary new feature a no-op for every user.
  • The critical db.pragma('journal_mode = WAL') bug means the entire SQLite path is broken by default: openOpenCodeDb will always catch the thrown exception and return null, causing every call to fall back silently to JSON. OpenCode v1.2+ users would see no sessions (or only legacy JSON sessions) despite the intent of this PR. Two additional logic bugs (preview overwrite, unescaped LIKE) compound the issue. The fallback to JSON prevents a total crash, but the feature as shipped does not work.
  • src/main/storage/opencode-session-storage.ts — specifically openOpenCodeDb (line 228), convertSqliteSessionRows (line 896–908), and listSessionsSqlite (line 794–796).

Important Files Changed

Filename Overview
src/main/storage/opencode-session-storage.ts Adds SQLite session reading for OpenCode v1.2+. Three issues found: (1) critical — db.pragma('journal_mode = WAL') throws on the read-only connection, silently breaking all SQLite access and leaking the DB handle; (2) logic bug — user-message preview is overwritten for every user message rather than being locked after the first; (3) LIKE wildcards in the directory filter are not escaped.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[listSessions / readSessionMessages] --> B{sshConfig?}
    B -- yes --> C[listSessionsRemote / loadSessionMessagesRemote\nJSON over SSH]
    B -- no --> D[openOpenCodeDb\ncheck opencode.db exists]
    D -- file absent --> E[listSessionsJson / loadSessionMessages\nJSON fallback]
    D -- file present --> F[db.pragma journal_mode=WAL\n⚠️ throws on read-only connection]
    F -- throws --> G[catch: log warn, return null\n⚠️ DB handle leaked]
    G --> E
    F -- succeeds --> H[tableExists: session + project]
    H -- missing --> E
    H -- present --> I[Query project table\npath matching + global project]
    I --> J[Query session rows\nIN matchingIds + LIKE directory\n⚠️ unescaped % and _]
    J --> K[convertSqliteSessionRows\nload messages + parts per session\n⚠️ preview overwritten for each user msg]
    K --> L{jsonSessions also present?}
    L -- yes --> M[Merge: SQLite authoritative\nadd JSON-only sessions\nsort by modifiedAt]
    L -- no --> N[Return SQLite sessions only]
    M --> O[Return merged sessions]
    E --> P[Return JSON sessions]
Loading

Last reviewed commit: 1ac2855

Comment on lines +227 to +228
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
db.pragma('journal_mode = WAL');
Copy link

Choose a reason for hiding this comment

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

WAL pragma throws on read-only connection, silently breaks all SQLite reading

PRAGMA journal_mode = WAL is a write operation. When better-sqlite3 opens a database with { readonly: true }, any write pragma throws SqliteError: attempt to write a readonly database. Since the error is caught by the catch block in openOpenCodeDb, the function will always return null for every user who has a SQLite database, causing a silent full regression to the JSON path (or returning no sessions).

There are also two secondary problems caused by this:

  1. Resource leak: If the pragma throws, the db handle opened on line 227 is never closed because there is no finally block in openOpenCodeDb. Every attempted call leaks a database file handle.
  2. Unnecessary pragma: A reader does not need to set journal_mode. SQLite automatically detects WAL-mode databases from the file header. Simply removing this line is the correct fix.
Suggested change
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
db.pragma('journal_mode = WAL');
const db = new Database(dbPath, { readonly: true, fileMustExist: true });
return db;

Comment on lines +896 to +908
// Fall back to first user message if no assistant text found
if (!foundPreview && data.role === 'user' && hasPartTable) {
const parts = db
.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
.all(msg.id) as Array<{ data: string }>;
for (const part of parts) {
const partData = safeJsonParse<SqlitePartData>(part.data);
if (partData?.type === 'text' && partData.text?.trim()) {
firstMessage = partData.text;
break;
}
}
}
Copy link

Choose a reason for hiding this comment

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

User preview gets overwritten for every user message before the first assistant response

The user-message fallback block (lines 896-908) never sets foundPreview = true. As a result, the guard !foundPreview && data.role === 'user' remains true for every user message encountered while scanning, so firstMessage is silently overwritten on each pass. If a session begins with two consecutive user messages (e.g. multi-turn context followed by an actual prompt), the preview shown in the session list will be the last user message before the first assistant response, not the first.

By contrast, the analogous JSON code in listSessionsJson uses a separate firstUserMessage variable that is set once and never overwritten. The SQLite path should mirror that behaviour.

Suggested change
// Fall back to first user message if no assistant text found
if (!foundPreview && data.role === 'user' && hasPartTable) {
const parts = db
.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
.all(msg.id) as Array<{ data: string }>;
for (const part of parts) {
const partData = safeJsonParse<SqlitePartData>(part.data);
if (partData?.type === 'text' && partData.text?.trim()) {
firstMessage = partData.text;
break;
}
}
}
// Fall back to first user message if no assistant text found
if (!foundPreview && data.role === 'user' && hasPartTable) {
const parts = db
.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
.all(msg.id) as Array<{ data: string }>;
for (const part of parts) {
const partData = safeJsonParse<SqlitePartData>(part.data);
if (partData?.type === 'text' && partData.text?.trim()) {
firstMessage = partData.text;
foundPreview = true; // lock in the first user message
break;
}
}
}

Comment on lines +794 to +796
"SELECT id, project_id, directory, title, version, time_created, time_updated, summary_additions, summary_deletions, summary_files FROM session WHERE project_id = 'global' AND (directory = ? OR directory LIKE ?) ORDER BY time_updated DESC"
)
.all(normalizedPath, normalizedPath + '/%') as SqliteSessionRow[];
Copy link

Choose a reason for hiding this comment

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

Unescaped LIKE wildcards in directory path query

normalizedPath + '/%' is passed directly as a LIKE pattern. If the project directory path contains the SQLite LIKE wildcard characters % or _ (uncommon but valid in Unix paths), the query will match unintended rows. For example, a path like /home/user/my_project would match /home/user/myXproject/ as well.

The fix is to escape the literal path before appending /%:

const escapedPath = normalizedPath.replace(/[%_]/g, '\\$&');
// then use:
.all(normalizedPath, escapedPath + '/%', '\\')
// and the LIKE clause becomes: directory LIKE ? ESCAPE ?

Or, if the SQLite schema stores paths in a normalised form that avoids these characters in practice, add a comment explaining why the unescaped form is safe here.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/main/storage/opencode-session-storage.ts (2)

882-908: N+1 query pattern when extracting message previews.

For each session, this loops through messages and queries parts individually to find preview text. Consider loading all parts in a single query per session or using a JOIN/subquery to reduce round-trips. This is acceptable for local SQLite but may become noticeable with many sessions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 882 - 908, The
current preview extraction does N+1 queries by calling the part SELECT inside
the message loop (see variables foundPreview, firstMessage, hasPartTable and
safeJsonParse usage); change to fetch all parts for the session in one query
before iterating messages (e.g. SELECT data, message_id FROM part WHERE
message_id IN (...session message ids...) ORDER BY time_created ASC or a
JOIN/subquery) and then group/lookup parts by message_id in memory so the inner
loops over messages just scan the preloaded parts instead of running
db.prepare(...).all(...) per message.

227-228: WAL pragma on read-only connection has no effect.

Setting journal_mode = WAL on a read-only connection is a no-op since the database cannot be modified. While harmless, it's misleading. Consider removing it or adding a comment explaining intent (e.g., if you expect to switch to read-write later).

🔧 Suggested fix
 		const db = new Database(dbPath, { readonly: true, fileMustExist: true });
-		db.pragma('journal_mode = WAL');
 		return db;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 227 - 228, The
call to db.pragma('journal_mode = WAL') is ineffective on a read-only connection
and is misleading; remove that line (the db.pragma('journal_mode = WAL') call)
from the code where the Database is constructed with readonly: true (the
Database db = new Database(..., { readonly: true, ... }) block), or
alternatively guard it so you only set journal_mode when opening the Database in
read-write mode and add a brief comment explaining the intent if you expect to
switch to write mode later.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 1420-1425: The code currently returns OPENCODE_DB_PATH merely if
the DB file exists, which is incorrect for sessions stored only as JSON;
instead, update the logic in the function that currently checks
fsSync.existsSync(OPENCODE_DB_PATH) to first verify the given sessionId actually
exists in the SQLite store before returning OPENCODE_DB_PATH: open the SQLite DB
at OPENCODE_DB_PATH (or use the existing DB client), run a single existence
query against the sessions/messages table for the given sessionId (e.g., SELECT
1 FROM sessions WHERE id = ? or equivalent), and only return OPENCODE_DB_PATH if
the query indicates the session is present; otherwise fall back to
this.getMessageDir(sessionId).
- Line 962: The early-return of null when messageRows.length === 0 should be
replaced with an explicit empty-result object so callers can distinguish
"session exists but has no messages" from "session not found"; update the
function that currently checks messageRows (the method containing the
messageRows.length === 0 check) to return the same shape/type as a normal
successful response but with an empty messages array (and any other expected
fields populated/empty), and adjust any downstream handling (the caller that
currently falls back to JSON loading) to treat null as "not found" only and
treat the empty-result object as a valid, empty session.
- Around line 792-796: The LIKE pattern using normalizedPath can misbehave if
normalizedPath contains SQL wildcards (%) or (_) — update the code that builds
the parameters for the db.prepare call (the assignment to globalSessions) to
escape SQL wildcard characters in normalizedPath before using it in the LIKE
pattern (e.g., create an escapedPath by replacing % and _ (and the escape char
itself) with escaped versions, then pass escapedPath and escapedPath + '/%' as
parameters and include an ESCAPE clause in the SQL), so the db.prepare("... LIKE
? ...", ...) call uses the escaped values and the SQL includes ESCAPE '\' to
ensure only the appended '/%' acts as the wildcard.

---

Nitpick comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 882-908: The current preview extraction does N+1 queries by
calling the part SELECT inside the message loop (see variables foundPreview,
firstMessage, hasPartTable and safeJsonParse usage); change to fetch all parts
for the session in one query before iterating messages (e.g. SELECT data,
message_id FROM part WHERE message_id IN (...session message ids...) ORDER BY
time_created ASC or a JOIN/subquery) and then group/lookup parts by message_id
in memory so the inner loops over messages just scan the preloaded parts instead
of running db.prepare(...).all(...) per message.
- Around line 227-228: The call to db.pragma('journal_mode = WAL') is
ineffective on a read-only connection and is misleading; remove that line (the
db.pragma('journal_mode = WAL') call) from the code where the Database is
constructed with readonly: true (the Database db = new Database(..., { readonly:
true, ... }) block), or alternatively guard it so you only set journal_mode when
opening the Database in read-write mode and add a brief comment explaining the
intent if you expect to switch to write mode later.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6cca3101-f991-44d3-8d25-8d481c771320

📥 Commits

Reviewing files that changed from the base of the PR and between cd52f6c and 1ac2855.

📒 Files selected for processing (1)
  • src/main/storage/opencode-session-storage.ts

- Remove WAL pragma on readonly connection (was silently breaking all SQLite reads)
- Set foundPreview=true for user message fallback to prevent overwrite
- Escape LIKE wildcards in directory path queries
- Return empty result instead of null for sessions with no messages
- Check session existence in SQLite before returning DB path in getSessionPath
- Fix prettier formatting in docs/releases.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/main/storage/opencode-session-storage.ts (1)

957-974: Consider: Ambiguous result for non-existent sessions.

When messageRows.length === 0, the function returns an empty result object. However, this doesn't distinguish between:

  1. Session exists in SQLite but has no messages yet
  2. Session doesn't exist in SQLite at all

Both cases return the same empty result, which could cause the caller to incorrectly assume the session exists.

For readSessionMessages and getSearchableMessages, this is likely fine since they'll just return empty results. However, for deleteMessagePair at line 1467, this matters because an empty result triggers JSON fallback.

If this distinction is important, consider adding a session existence check:

💡 Optional fix to verify session existence
 			const messageRows = db
 				.prepare(
 					'SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ? ORDER BY time_created ASC'
 				)
 				.all(sessionId) as SqliteMessageRow[];
 
-			// Session exists in SQLite but has no messages yet — return empty result
+			// Check if session actually exists in SQLite (might have no messages yet)
 			if (messageRows.length === 0) {
+				const sessionExists = tableExists(db, 'session')
+					? db.prepare('SELECT 1 FROM session WHERE id = ? LIMIT 1').get(sessionId)
+					: null;
+				if (!sessionExists) {
+					return null; // Session not in SQLite, caller should try JSON
+				}
+				// Session exists but has no messages yet
 				return {
 					messages: [],
 					parts: new Map(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 957 - 974, The
current early return when messageRows.length === 0 conflates "session exists but
has no messages" with "session does not exist", which affects callers like
deleteMessagePair and JSON fallback; update readSessionMessages (and similarly
getSearchableMessages) to first check session existence (e.g., query the session
table for the given sessionId using the same DB connection) and return a
distinct result or error when the session is missing (for example null or a
flag) instead of the empty-message payload; adjust deleteMessagePair to handle
the new absent-session signal so it doesn't wrongly trigger JSON fallback.
docs/releases.md (1)

201-201: Minor: Capitalize "GitHub" correctly.

"Github" should be "GitHub" (capital H) per official branding.

-🌳 Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to `@petersilberman`.
+🌳 GitHub Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to `@petersilberman`.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/releases.md` at line 201, Update the release note sentence that
currently reads "Github Worktree support was added." to use the correct branding
"GitHub Worktree support was added." Locate the exact sentence containing
"Github Worktree support was added." in the docs/releases.md content and change
"Github" to "GitHub" while preserving the rest of the text and punctuation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 1463-1476: The code only blocks deletion when
loadSessionMessagesSqlite(sessionId) returns messages.length > 0, letting
empty-but-existing SQLite sessions fall through to JSON deletion; change the
guard to treat any non-null/non-undefined sqliteResult as an existing OpenCode
SQLite session. Update the condition in the block that calls
loadSessionMessagesSqlite(sessionId) (referencing sqliteResult and sessionId) to
check for sqliteResult truthiness (e.g., if (sqliteResult) or explicit !== null)
and keep the same logger.warn and return error path so no SQLite-backed
session—empty or not—is allowed to proceed to JSON deletion.

---

Nitpick comments:
In `@docs/releases.md`:
- Line 201: Update the release note sentence that currently reads "Github
Worktree support was added." to use the correct branding "GitHub Worktree
support was added." Locate the exact sentence containing "Github Worktree
support was added." in the docs/releases.md content and change "Github" to
"GitHub" while preserving the rest of the text and punctuation.

In `@src/main/storage/opencode-session-storage.ts`:
- Around line 957-974: The current early return when messageRows.length === 0
conflates "session exists but has no messages" with "session does not exist",
which affects callers like deleteMessagePair and JSON fallback; update
readSessionMessages (and similarly getSearchableMessages) to first check session
existence (e.g., query the session table for the given sessionId using the same
DB connection) and return a distinct result or error when the session is missing
(for example null or a flag) instead of the empty-message payload; adjust
deleteMessagePair to handle the new absent-session signal so it doesn't wrongly
trigger JSON fallback.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4523c3b8-eb3c-4272-99e3-9f4e61534ba4

📥 Commits

Reviewing files that changed from the base of the PR and between 1ac2855 and 7c0bff4.

📒 Files selected for processing (2)
  • docs/releases.md
  • src/main/storage/opencode-session-storage.ts

…y ones

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/main/storage/opencode-session-storage.ts (1)

848-911: Consider batching queries to avoid N+1 pattern.

The current implementation queries messages for each session row, then queries parts for individual messages when searching for preview text. For a user with many sessions, this results in O(sessions × messages) database queries.

If performance becomes an issue, consider using JOINs or batch queries to load all needed data in fewer round trips.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 848 - 911, The
code performs N+1 queries inside the messages loop by calling the part table per
message to build a preview; refactor the block around hasMessageTable/messages
and the part SELECTs so parts are fetched in bulk (e.g., a single query that
JOINs part to message or a single IN(...) query for all message ids) and then
map parts back to their message ids before iterating; keep the existing logic
using safeJsonParse/SqlitePartData and the foundPreview/firstMessage selection
but remove the per-message db.prepare('SELECT data FROM part...').
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 963-974: The empty-message branch currently returns an empty
session result whenever messageRows.length === 0, but must first verify the
session exists in SQLite to allow JSON fallback; update the logic in the
function that reads messages (the code path handling messageRows and used by
readSessionMessages()) to run a lightweight existence check (e.g., SELECT 1 FROM
sessions WHERE id = ? or reuse any existing getSession/getSessionById helper)
before returning, and if the session row is not found return null (so
loadSessionMessages() can be attempted), otherwise return the empty result
object exactly as now; reference messageRows, readSessionMessages(),
loadSessionMessages() when making the change.

---

Nitpick comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 848-911: The code performs N+1 queries inside the messages loop by
calling the part table per message to build a preview; refactor the block around
hasMessageTable/messages and the part SELECTs so parts are fetched in bulk
(e.g., a single query that JOINs part to message or a single IN(...) query for
all message ids) and then map parts back to their message ids before iterating;
keep the existing logic using safeJsonParse/SqlitePartData and the
foundPreview/firstMessage selection but remove the per-message
db.prepare('SELECT data FROM part...').

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c7681756-ca2c-441d-93dc-1cfcd8c2259d

📥 Commits

Reviewing files that changed from the base of the PR and between 7c0bff4 and 1cd03ee.

📒 Files selected for processing (1)
  • src/main/storage/opencode-session-storage.ts

When no messages are found for a sessionId, check the session table
to distinguish "session exists but empty" from "session not in SQLite"
before deciding whether to block JSON fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 768-796: The descendant checks and SQL LIKE prefix currently
hardcode '/' causing Windows misses; update all uses of '/' to use path.sep (or
construct a platform-aware sep variable) when trimming trailing separators
(replace(/\/+$/) -> replace(new RegExp(`${path.sep.replace('\\','\\\\')}+$`)),
when comparing prefixes (storedPath + '/' and normalizedPath + '/' -> storedPath
+ path.sep and normalizedPath + path.sep) and when building the escapedPath LIKE
pattern (escapedPath + '/%' -> escapedPath + path.sep + '%'). Apply these
changes around the storedPath/normalizedPath comparisons and the global-sessions
query where hasGlobalProject, escapedPath, and the SQL .all(...) call are used
so subdirectory matches work on Windows.
- Around line 223-231: The current try/catch around new Database(dbPath, ...)
swallows all SQLite errors and returns null; change it to only return null for
expected "file not found"/missing DB cases (e.g., when
!fsSync.existsSync(dbPath) or when fileMustExist triggers a known ENOENT), but
for any other unexpected errors (corruption, permission, query errors) capture
and rethrow: import and call captureException(error, { extra: { dbPath } }) and
logger.warn(...) then rethrow the error so it bubbles to Sentry/upper layers
instead of falling back to JSON; apply the same change pattern to the other
analogous blocks referenced (the Database creation/use sites you noted around
the other ranges) and preserve LOG_CONTEXT in logs.
- Around line 797-817: After merging globalSessions into the sessions array,
ensure the combined array is re-sorted so newest sessions come first;
specifically, sort the combined sessions (the local variable sessions that you
push global entries into) by the session timestamp field (e.g., created_at or
createdAt) in descending order before calling convertSqliteSessionRows. Locate
the merge logic that references globalSessions and sessions in
listSessions()/opencode-session-storage.ts and add a sort on sessions
(newest-first) right before the return/convertSqliteSessionRows(...) call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 40c901c1-f9f1-4891-af0b-49a51446b5c9

📥 Commits

Reviewing files that changed from the base of the PR and between 1cd03ee and 5999a66.

📒 Files selected for processing (1)
  • src/main/storage/opencode-session-storage.ts

Comment on lines +865 to +909
// Aggregate stats and find preview message
let foundPreview = false;
for (const msg of messages) {
const data = safeJsonParse<SqliteMessageData>(msg.data);
if (!data) continue;

if (data.tokens) {
totalInputTokens += data.tokens.input || 0;
totalOutputTokens += data.tokens.output || 0;
totalCacheReadTokens += data.tokens.cache?.read || 0;
totalCacheWriteTokens += data.tokens.cache?.write || 0;
}
if (data.cost) {
totalCost += data.cost;
}

// Get preview from first assistant message with text
if (!foundPreview && data.role === 'assistant' && hasPartTable) {
const parts = db
.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
.all(msg.id) as Array<{ data: string }>;
for (const part of parts) {
const partData = safeJsonParse<SqlitePartData>(part.data);
if (partData?.type === 'text' && partData.text?.trim()) {
firstMessage = partData.text;
foundPreview = true;
break;
}
}
}

// Fall back to first user message if no assistant text found
if (!foundPreview && data.role === 'user' && hasPartTable) {
const parts = db
.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
.all(msg.id) as Array<{ data: string }>;
for (const part of parts) {
const partData = safeJsonParse<SqlitePartData>(part.data);
if (partData?.type === 'text' && partData.text?.trim()) {
firstMessage = partData.text;
foundPreview = true;
break;
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assistant previews never override the first user preview.

The user fallback sets foundPreview = true, so later assistant text is skipped entirely. That makes SQLite-backed previews diverge from the JSON path below, which prefers the first assistant response and only falls back to user text if no assistant text exists.

- Use platform-aware path separators (path.sep) instead of hardcoded '/'
  for trailing separator stripping and subdirectory prefix comparisons,
  fixing Windows path matching in listSessions and findProjectId
- Extract TRAILING_SEP_RE constant to avoid repeated regex construction
- Improve openOpenCodeDb error handling: report unexpected SQLite errors
  (corruption, permissions) to Sentry and rethrow instead of silently
  falling back to JSON; file-not-found still returns null
- Re-sort sessions after merging global project sessions so combined
  results maintain newest-first ordering by time_updated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/main/storage/opencode-session-storage.ts (1)

890-918: ⚠️ Potential issue | 🟡 Minor

Logic bug: User preview blocks assistant preview.

The preview selection processes messages in chronological order (ASC). When a user message with text is encountered first, foundPreview is set to true, preventing subsequent assistant messages from being considered. This contradicts the intended priority (assistant response > user message) documented in the JSON path at line 1168-1169.

🐛 Proposed fix to check all messages before setting foundPreview
 				// Aggregate stats and find preview message
-				let foundPreview = false;
+				let assistantPreview = '';
+				let userPreview = '';
 				for (const msg of messages) {
 					const data = safeJsonParse<SqliteMessageData>(msg.data);
 					if (!data) continue;

 					if (data.tokens) {
 						totalInputTokens += data.tokens.input || 0;
 						totalOutputTokens += data.tokens.output || 0;
 						totalCacheReadTokens += data.tokens.cache?.read || 0;
 						totalCacheWriteTokens += data.tokens.cache?.write || 0;
 					}
 					if (data.cost) {
 						totalCost += data.cost;
 					}

 					// Get preview from first assistant message with text
-					if (!foundPreview && data.role === 'assistant' && hasPartTable) {
+					if (!assistantPreview && data.role === 'assistant' && hasPartTable) {
 						const parts = db
 							.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
 							.all(msg.id) as Array<{ data: string }>;
 						for (const part of parts) {
 							const partData = safeJsonParse<SqlitePartData>(part.data);
 							if (partData?.type === 'text' && partData.text?.trim()) {
-								firstMessage = partData.text;
-								foundPreview = true;
+								assistantPreview = partData.text;
 								break;
 							}
 						}
+						if (assistantPreview) break; // Found assistant preview, stop scanning
 					}

 					// Fall back to first user message if no assistant text found
-					if (!foundPreview && data.role === 'user' && hasPartTable) {
+					if (!userPreview && data.role === 'user' && hasPartTable) {
 						const parts = db
 							.prepare('SELECT data FROM part WHERE message_id = ? ORDER BY time_created ASC')
 							.all(msg.id) as Array<{ data: string }>;
 						for (const part of parts) {
 							const partData = safeJsonParse<SqlitePartData>(part.data);
 							if (partData?.type === 'text' && partData.text?.trim()) {
-								firstMessage = partData.text;
-								foundPreview = true;
+								userPreview = partData.text;
 								break;
 							}
 						}
 					}
 				}
+				// Priority: assistant response > user message > title
+				firstMessage = assistantPreview || userPreview || row.title || '';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 890 - 918, The
current logic sets foundPreview as soon as any user text is found, which blocks
later assistant text from being chosen; change to prefer assistant responses by
collecting a candidate user preview (e.g., candidateUserPreview) instead of
setting foundPreview when data.role === 'user', keep the existing assistant
branch to set firstMessage and foundPreview immediately when data.role ===
'assistant', and after processing all messages (or after the surrounding loop
over messages) only set firstMessage = candidateUserPreview if no assistant
preview (foundPreview) was found; reference symbols: foundPreview, data.role,
firstMessage, candidateUserPreview (new), safeJsonParse, hasPartTable, msg.id.
🧹 Nitpick comments (3)
src/main/storage/opencode-session-storage.ts (3)

536-549: Inconsistent path separator usage for Windows compatibility.

This method uses hardcoded / for path normalization (lines 539-540) and subdirectory matching (line 546), while the SQLite methods use platform-aware path.sep and TRAILING_SEP_RE. This inconsistency could cause session matching to fail on Windows with JSON storage.

♻️ Proposed fix for platform consistency
 	private sessionMatchesPath(sessionDirectory: string | undefined, projectPath: string): boolean {
 		if (!sessionDirectory) return false;

-		const normalizedSessionDir = path.resolve(sessionDirectory).replace(/\/+$/, '');
-		const normalizedProjectPath = path.resolve(projectPath).replace(/\/+$/, '');
+		const normalizedSessionDir = path.resolve(sessionDirectory).replace(TRAILING_SEP_RE, '');
+		const normalizedProjectPath = path.resolve(projectPath).replace(TRAILING_SEP_RE, '');

 		// Exact match
 		if (normalizedSessionDir === normalizedProjectPath) return true;

 		// Session is in a subdirectory of the project
-		if (normalizedSessionDir.startsWith(normalizedProjectPath + '/')) return true;
+		if (normalizedSessionDir.startsWith(normalizedProjectPath + path.sep)) return true;

 		return false;
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 536 - 549, The
sessionMatchesPath function uses hardcoded '/' when trimming trailing separators
and when checking subdirectory startsWith; update it to use platform-aware
separators by replacing the regex /\/+$/ with a RegExp built from path.sep (or
reuse TRAILING_SEP_RE if available) to trim trailing separators from
normalizedSessionDir and normalizedProjectPath, and change the subdirectory
check to use normalizedProjectPath + path.sep instead of '/' so that
sessionMatchesPath, normalizedSessionDir and normalizedProjectPath behave
consistently with the SQLite methods on Windows.

1059-1064: Same issue: Catch block masks unexpected errors.

Similar to listSessionsSqlite, this catch-all returns null for any error, preventing Sentry from capturing unexpected failures. Consider distinguishing expected errors (schema mismatch) from unexpected ones (corruption, memory).

♻️ Proposed fix
 		} catch (error) {
-			logger.warn(`Error loading messages from OpenCode SQLite: ${error}`, LOG_CONTEXT);
-			return null;
+			const errStr = String(error);
+			if (errStr.includes('no such table') || errStr.includes('no such column')) {
+				logger.info(`OpenCode SQLite schema mismatch for messages, falling back to JSON: ${error}`, LOG_CONTEXT);
+				return null;
+			}
+			logger.warn(`Unexpected error loading messages from OpenCode SQLite: ${error}`, LOG_CONTEXT);
+			captureException(error instanceof Error ? error : new Error(String(error)), {
+				extra: { sessionId },
+			});
+			throw error;
 		} finally {
 			db.close();
 		}

Based on learnings: "For unexpected errors, rethrow to let Sentry capture them."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 1059 - 1064, The
catch-all in the message-loading function in
src/main/storage/opencode-session-storage.ts currently logs the error via
logger.warn and returns null (same pattern as listSessionsSqlite), which masks
unexpected failures; update the catch to detect expected schema/migration errors
(e.g., check error.message or Sqlite-specific error codes) and only swallow
those by logging and returning null, but rethrow any other/unexpected errors so
Sentry can capture them; keep the finally block to always call db.close().

827-832: Consider distinguishing expected vs unexpected errors in catch block.

The current catch-all returns null for every error, including unexpected ones (e.g., malformed queries, memory issues). This prevents Sentry from capturing actionable errors.

Per coding guidelines, expected errors (missing tables, empty results) should allow fallback, but unexpected errors should be captured and potentially rethrown.

♻️ Proposed fix to capture unexpected errors
 		} catch (error) {
-			logger.warn(`Error reading OpenCode SQLite database: ${error}`, LOG_CONTEXT);
-			return null;
+			// Schema mismatch is expected during migrations — allow JSON fallback
+			const errStr = String(error);
+			if (errStr.includes('no such table') || errStr.includes('no such column')) {
+				logger.info(`OpenCode SQLite schema mismatch, falling back to JSON: ${error}`, LOG_CONTEXT);
+				return null;
+			}
+			// Unexpected error — report to Sentry and rethrow
+			logger.warn(`Unexpected error reading OpenCode SQLite database: ${error}`, LOG_CONTEXT);
+			captureException(error instanceof Error ? error : new Error(String(error)), {
+				extra: { projectPath },
+			});
+			throw error;
 		} finally {
 			db.close();
 		}

Based on learnings: "Do not silently swallow errors with try-catch blocks that only log. Let exceptions bubble up to Sentry for error tracking in production."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/storage/opencode-session-storage.ts` around lines 827 - 832, The
catch-all in the read routine that calls logger.warn(..., LOG_CONTEXT) and
returns null should distinguish expected SQLite "not found"/empty-result errors
from unexpected failures: update the catch to inspect the thrown error (e.g.,
error.code or error.message for patterns like "SQLITE_ERROR", "no such table",
or known benign cases) and only return null for those expected cases; for any
other/unrecognized errors, log with logger.error (including the error object)
and rethrow (or forward to Sentry) so they surface to monitoring. Keep the
existing finally block that calls db.close() unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 890-918: The current logic sets foundPreview as soon as any user
text is found, which blocks later assistant text from being chosen; change to
prefer assistant responses by collecting a candidate user preview (e.g.,
candidateUserPreview) instead of setting foundPreview when data.role === 'user',
keep the existing assistant branch to set firstMessage and foundPreview
immediately when data.role === 'assistant', and after processing all messages
(or after the surrounding loop over messages) only set firstMessage =
candidateUserPreview if no assistant preview (foundPreview) was found; reference
symbols: foundPreview, data.role, firstMessage, candidateUserPreview (new),
safeJsonParse, hasPartTable, msg.id.

---

Nitpick comments:
In `@src/main/storage/opencode-session-storage.ts`:
- Around line 536-549: The sessionMatchesPath function uses hardcoded '/' when
trimming trailing separators and when checking subdirectory startsWith; update
it to use platform-aware separators by replacing the regex /\/+$/ with a RegExp
built from path.sep (or reuse TRAILING_SEP_RE if available) to trim trailing
separators from normalizedSessionDir and normalizedProjectPath, and change the
subdirectory check to use normalizedProjectPath + path.sep instead of '/' so
that sessionMatchesPath, normalizedSessionDir and normalizedProjectPath behave
consistently with the SQLite methods on Windows.
- Around line 1059-1064: The catch-all in the message-loading function in
src/main/storage/opencode-session-storage.ts currently logs the error via
logger.warn and returns null (same pattern as listSessionsSqlite), which masks
unexpected failures; update the catch to detect expected schema/migration errors
(e.g., check error.message or Sqlite-specific error codes) and only swallow
those by logging and returning null, but rethrow any other/unexpected errors so
Sentry can capture them; keep the finally block to always call db.close().
- Around line 827-832: The catch-all in the read routine that calls
logger.warn(..., LOG_CONTEXT) and returns null should distinguish expected
SQLite "not found"/empty-result errors from unexpected failures: update the
catch to inspect the thrown error (e.g., error.code or error.message for
patterns like "SQLITE_ERROR", "no such table", or known benign cases) and only
return null for those expected cases; for any other/unrecognized errors, log
with logger.error (including the error object) and rethrow (or forward to
Sentry) so they surface to monitoring. Keep the existing finally block that
calls db.close() unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0bffa123-3e7f-4a57-a815-632d7681c812

📥 Commits

Reviewing files that changed from the base of the PR and between 5999a66 and 2aed882.

📒 Files selected for processing (1)
  • src/main/storage/opencode-session-storage.ts

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.

1 participant