From d156c87665f15da21bde91380c789856b496e5eb Mon Sep 17 00:00:00 2001 From: httpsVishu Date: Sat, 25 Apr 2026 15:58:11 +0000 Subject: [PATCH] fix: support fs.watch on non-recursive platforms and make better-sqlite3 optional --- package-lock.json | 4 +- src/providers/opencode/provider.ts | 51 ++++++++++++++++++++++---- src/providers/shared/watch.ts | 42 +++++++++++++++------ tests/opencode-database.test.ts | 11 +++++- tests/opencode-fixtures.ts | 59 ++++++++++++++++++++++++++---- tests/opencode-provider.test.ts | 4 +- 6 files changed, 140 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4098575..2a20199 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agentprobe/core", - "version": "0.1.5", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agentprobe/core", - "version": "0.1.5", + "version": "0.2.0", "license": "MIT", "dependencies": { "better-sqlite3": "^12.8.0", diff --git a/src/providers/opencode/provider.ts b/src/providers/opencode/provider.ts index 0c9a2ca..788bb9d 100644 --- a/src/providers/opencode/provider.ts +++ b/src/providers/opencode/provider.ts @@ -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, @@ -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; @@ -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 { diff --git a/src/providers/shared/watch.ts b/src/providers/shared/watch.ts index e44e885..a326676 100644 --- a/src/providers/shared/watch.ts +++ b/src/providers/shared/watch.ts @@ -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)); }); @@ -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); + } + } +} diff --git a/tests/opencode-database.test.ts b/tests/opencode-database.test.ts index 72fa696..2b0ea33 100644 --- a/tests/opencode-database.test.ts +++ b/tests/opencode-database.test.ts @@ -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; let ocDb: OpenCodeDatabase; diff --git a/tests/opencode-fixtures.ts b/tests/opencode-fixtures.ts index a8b1d8d..b1f609d 100644 --- a/tests/opencode-fixtures.ts +++ b/tests/opencode-fixtures.ts @@ -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 ( @@ -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 } = {}, @@ -73,7 +118,7 @@ export function seedSession( } export function seedMessage( - db: Database.Database, + db: BetterSqlite3.Database, id: string, sessionId: string, data: Record, @@ -86,7 +131,7 @@ export function seedMessage( } export function seedPart( - db: Database.Database, + db: BetterSqlite3.Database, id: string, messageId: string, sessionId: string, diff --git a/tests/opencode-provider.test.ts b/tests/opencode-provider.test.ts index d7c15b8..aa2c86e 100644 --- a/tests/opencode-provider.test.ts +++ b/tests/opencode-provider.test.ts @@ -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; beforeEach(() => {