Skip to content
Open
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 44 additions & 7 deletions src/providers/opencode/provider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import betterSqlite3 from "better-sqlite3";
import { createRequire } from "node:module";
import type { Database as SqliteDatabase } from "better-sqlite3";

import {
type DiscoveryInput,
type DiscoveryResult,
Expand All @@ -25,17 +27,19 @@ import { createOpenCodeDatabase, type OpenCodeDatabase, type SessionStats } from
import type { SessionRow } from "./schemas";
import { createOpenCodeWatch } from "./watch";

const req = createRequire(import.meta.url);

export interface OpenCodeOptions {
dbPath?: string;
sourceLabel?: string;
sessionWindowMs?: number;
watch?: false | { pollIntervalMs?: number };
/** @internal for testing — inject an open database */
_testDb?: betterSqlite3.Database;
_testDb?: SqliteDatabase;
}

interface ProviderState {
db: betterSqlite3.Database | undefined;
db: SqliteDatabase | undefined;
ocDb: OpenCodeDatabase | undefined;
projectIds: string[] | undefined;
workspaceKey: string | undefined;
Expand Down Expand Up @@ -81,12 +85,45 @@ function openDb(state: ProviderState, options: OpenCodeOptions): void {
}
const dbPath = options.dbPath ?? OPENCODE_DB_PATH_DEFAULT;
try {
state.db = new betterSqlite3(dbPath, { readonly: true });
state.db = openSqliteDb(dbPath);
state.ocDb = createOpenCodeDatabase(state.db);
} catch {
state.db = undefined;
state.ocDb = undefined;
} catch (error) {
if (isUnavailable(error)) {
// Native dep not installed or not built — OpenCode provider silently disabled.
state.db = undefined;
state.ocDb = undefined;
return;
}
// File permission error, corrupt DB, etc. — propagate to caller.
throw error;
}
}

function isUnavailable(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
const code = hasCode(error) ? error.code : undefined;
return code === "MODULE_NOT_FOUND" || code === "ERR_DLOPEN_FAILED";
}

function hasCode(value: object): value is { code: unknown } {
return "code" in value;
}

function isSqliteCtor(
value: unknown,
): value is (dbPath: string, opts?: { readonly?: boolean }) => SqliteDatabase {
return typeof value === "function";
}

function openSqliteDb(dbPath: string): SqliteDatabase {
const loaded: unknown = req("better-sqlite3");
if (!isSqliteCtor(loaded)) {
throw new Error("better-sqlite3 did not export a usable constructor");
}
const ctor: (dbPath: string, opts?: { readonly?: boolean }) => SqliteDatabase = loaded;
return ctor(dbPath, { readonly: true });
}

function disconnectState(state: ProviderState, options: OpenCodeOptions): void {
Expand Down
42 changes: 31 additions & 11 deletions src/providers/shared/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,14 @@ export function createProviderWatch(
onEvent: () => void,
onError: (error: Error) => void,
): { close(): void } {
let watcher: FSWatcher;
try {
watcher = fsWatch(watchPath, { recursive: true }, (_event, filename) => {
if (shouldEmit && typeof filename === "string" && !shouldEmit(filename)) {
return;
}
onEvent();
});
} catch (error) {
throw toError(error);
}
const handler = (_event: string | null, filename: string | Buffer | null): void => {
if (shouldEmit && typeof filename === "string" && !shouldEmit(filename)) {
return;
}
onEvent();
};

const watcher = openWatcher(watchPath, handler);
watcher.on("error", (error) => {
onError(toError(error));
});
Expand All @@ -59,3 +55,27 @@ export function createProviderWatch(
subscribe,
};
}

function openWatcher(
watchPath: string,
handler: (event: string | null, filename: string | Buffer | null) => void,
): FSWatcher {
try {
// Attempt recursive watch — supported on macOS, Windows, and Linux kernel ≥5.1 via inotify.
// Probing at runtime is more accurate than a hard-coded platform allowlist.
return fsWatch(watchPath, { recursive: true }, handler);
} catch {
// recursive: true unsupported on this platform/kernel — warn and fall back so
// at least top-level changes are caught.
console.warn(
`[agentprobe] fs.watch recursive mode unavailable for "${watchPath}": ` +
`only top-level directory changes will be detected. ` +
`Upgrade to Linux kernel 5.1+ or use macOS/Windows for full nested-change support.`,
);
try {
return fsWatch(watchPath, {}, handler);
} catch (fallbackError) {
throw toError(fallbackError);
}
}
}
11 changes: 9 additions & 2 deletions tests/opencode-database.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createOpenCodeDatabase, type OpenCodeDatabase } from "@/providers/opencode/database";
import { createTestDb, seedMessage, seedPart, seedProject, seedSession } from "./opencode-fixtures";
import {
createTestDb,
hasSqlite,
seedMessage,
seedPart,
seedProject,
seedSession,
} from "./opencode-fixtures";

const MS_PER_DAY = 86_400_000;

describe("opencode database", () => {
(hasSqlite ? describe : describe.skip)("opencode database", () => {
let rawDb: ReturnType<typeof createTestDb>;
let ocDb: OpenCodeDatabase;

Expand Down
59 changes: 52 additions & 7 deletions tests/opencode-fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,49 @@
/** Shared test fixtures for OpenCode provider tests. */
import Database from "better-sqlite3";
import { createRequire } from "node:module";
import type BetterSqlite3 from "better-sqlite3";

type DatabaseConstructor = typeof BetterSqlite3;

const req = createRequire(import.meta.url);

let Database: DatabaseConstructor | undefined;
try {
const loaded: unknown = req("better-sqlite3");
if (!isCtor(loaded)) throw new Error("better-sqlite3 did not export a constructor");
new loaded(":memory:").close();
Database = loaded;
} catch (error) {
// Swallow only unavailability errors — absent package, unbuilt native binary,
// or the bindings-package "Could not locate the bindings file" message.
if (!isUnavailableError(error)) {
throw error;
}
}

export const hasSqlite = Database !== undefined;

function isCtor(value: unknown): value is DatabaseConstructor {
return typeof value === "function";
}

function isUnavailableError(error: unknown): boolean {
if (typeof error !== "object" || error === null) {
return false;
}
const code = hasCode(error) ? error.code : undefined;
if (code === "MODULE_NOT_FOUND" || code === "ERR_DLOPEN_FAILED") {
return true;
}
const message = hasMessage(error) ? error.message : "";
return String(message).includes("Could not locate the bindings file");
}

function hasCode(value: object): value is { code: unknown } {
return "code" in value;
}

function hasMessage(value: object): value is { message: unknown } {
return "message" in value;
}

const OPENCODE_SCHEMA = `
CREATE TABLE project (
Expand Down Expand Up @@ -39,20 +83,21 @@ const OPENCODE_SCHEMA = `
);
`;

export function createTestDb(): Database.Database {
export function createTestDb(): BetterSqlite3.Database {
if (!Database) throw new Error("better-sqlite3 is not available");
const db = new Database(":memory:");
db.exec(OPENCODE_SCHEMA);
return db;
}

export function seedProject(db: Database.Database, id: string, worktree: string): void {
export function seedProject(db: BetterSqlite3.Database, id: string, worktree: string): void {
db.prepare(
"INSERT INTO project (id, worktree, time_created, time_updated) VALUES (?, ?, ?, ?)",
).run(id, worktree, Date.now(), Date.now());
}

export function seedSession(
db: Database.Database,
db: BetterSqlite3.Database,
id: string,
projectId: string,
opts: { parentId?: string; title?: string; directory?: string; timeUpdated?: number } = {},
Expand All @@ -73,7 +118,7 @@ export function seedSession(
}

export function seedMessage(
db: Database.Database,
db: BetterSqlite3.Database,
id: string,
sessionId: string,
data: Record<string, unknown>,
Expand All @@ -86,7 +131,7 @@ export function seedMessage(
}

export function seedPart(
db: Database.Database,
db: BetterSqlite3.Database,
id: string,
messageId: string,
sessionId: string,
Expand Down
4 changes: 2 additions & 2 deletions tests/opencode-provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { PROVIDER_KINDS } from "@/core/providers";
import { openCode } from "@/providers/opencode/provider";
import { createTestDb, seedMessage, seedProject } from "./opencode-fixtures";
import { createTestDb, hasSqlite, seedMessage, seedProject } from "./opencode-fixtures";

describe("opencode provider", () => {
(hasSqlite ? describe : describe.skip)("opencode provider", () => {
let db: ReturnType<typeof createTestDb>;

beforeEach(() => {
Expand Down