From 8552ed3556d34a0a6512a2afb884478ffe412402 Mon Sep 17 00:00:00 2001 From: High-cla Date: Sun, 7 Jun 2026 16:30:07 +0800 Subject: [PATCH 1/2] fix: update plugin for OpenCode 1.16.0+ compatibility and add schema migration --- packages/opencode-plugin/package.json | 3 +- packages/opencode-plugin/plugin/codenomad.ts | 37 +++--- .../plugin/lib/background-process.ts | 2 +- packages/server/src/workspaces/migration.ts | 118 ++++++++++++++++++ packages/server/src/workspaces/runtime.ts | 5 + 5 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 packages/server/src/workspaces/migration.ts diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json index dbfd1a832..0a83e658e 100644 --- a/packages/opencode-plugin/package.json +++ b/packages/opencode-plugin/package.json @@ -5,6 +5,7 @@ "license": "MIT", "type": "module", "main": "dist/codenomad.js", + "oc-plugin": "dist/codenomad.js", "files": [ "dist", "README.md" @@ -13,7 +14,7 @@ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json" }, "dependencies": { - "@opencode-ai/plugin": "1.3.7" + "@opencode-ai/plugin": "^1.16.0" }, "devDependencies": { "@types/node": "^22.18.0", diff --git a/packages/opencode-plugin/plugin/codenomad.ts b/packages/opencode-plugin/plugin/codenomad.ts index 61d1827f0..850526334 100644 --- a/packages/opencode-plugin/plugin/codenomad.ts +++ b/packages/opencode-plugin/plugin/codenomad.ts @@ -1,14 +1,10 @@ -import type { PluginInput } from "@opencode-ai/plugin" +import type { Plugin } from "@opencode-ai/plugin" import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client.js" import { createBackgroundProcessTools } from "./lib/background-process.js" let voiceModeEnabled = false -export async function CodeNomadPlugin(input: PluginInput): Promise<{ - tool: ReturnType - "chat.message": CodeNomadChatMessageHook - event: CodeNomadEventHook -}> { +const CodeNomadPlugin: Plugin = async (input) => { const config = getCodeNomadConfig() const client = createCodeNomadClient(config) const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory }) @@ -34,27 +30,28 @@ export async function CodeNomadPlugin(input: PluginInput): Promise<{ tool: { ...backgroundProcessTools, }, - async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) { - if (!voiceModeEnabled) { - return - } - - output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n") + experimental: { + chat: { + system: { + transform: async ( + _input: { sessionID?: string }, + output: { system: string[] }, + ) => { + if (!voiceModeEnabled) return + output.system.push(buildVoiceModePrompt()) + }, + }, + }, }, - async event(input: { event: any }) { + async event(input) { const opencodeEvent = input?.event if (!opencodeEvent || typeof opencodeEvent !== "object") return - }, } } -type CodeNomadChatMessageHook = ( - _input: { sessionID: string }, - output: { message: { system?: string } }, -) => Promise - -type CodeNomadEventHook = (input: { event: any }) => Promise +export const server: Plugin = CodeNomadPlugin +export default CodeNomadPlugin function buildVoiceModePrompt(): string { return [ diff --git a/packages/opencode-plugin/plugin/lib/background-process.ts b/packages/opencode-plugin/plugin/lib/background-process.ts index 6840737d6..bba886dd9 100644 --- a/packages/opencode-plugin/plugin/lib/background-process.ts +++ b/packages/opencode-plugin/plugin/lib/background-process.ts @@ -1,5 +1,5 @@ import path from "path" -import { tool } from "@opencode-ai/plugin/tool" +import { tool } from "@opencode-ai/plugin" import { createCodeNomadRequester, type CodeNomadConfig } from "./request.js" type BackgroundProcess = { diff --git a/packages/server/src/workspaces/migration.ts b/packages/server/src/workspaces/migration.ts new file mode 100644 index 000000000..776942bc4 --- /dev/null +++ b/packages/server/src/workspaces/migration.ts @@ -0,0 +1,118 @@ +import { spawnSync } from "child_process" + +export interface SchemaColumn { + cid: number + name: string + type: string + notnull: boolean + dflt_value: string | null + pk: boolean +} + +/** + * Check if the OpenCode database needs migration for the `session_message.seq` column. + * + * Issue #31204: `NOT NULL constraint failed: session_message.seq` because the column + * lacks `DEFAULT 0`. OpenCode v1.16.2+ creates the schema with `DEFAULT 0`, but + * databases created by older versions may have the column with no default or be + * missing the column entirely. + * + * The function is intentionally non-blocking — if anything fails (binary not found, + * DB not accessible) it returns `false` gracefully. + * + * @param binaryPath Path to the OpenCode binary (used to locate the database) + * @returns `true` if a schema change was applied, `false` if nothing was needed + * or the migration could not be performed. + */ +export function checkAndFixOpencodeSchema(binaryPath: string): boolean { + // ── Step 1: locate the database path ────────────────────────────── + const pathResult = spawnSync(binaryPath, ["db", "path"], { + encoding: "utf8", + timeout: 10000, + }) + if (pathResult.status !== 0 || !pathResult.stdout.trim()) { + // Binary may not support `db path` or there is no database yet. + return false + } + const dbPath = pathResult.stdout.trim() + + // ── Step 2: inspect session_message table schema ────────────────── + const schemaResult = spawnSync( + binaryPath, + ["db", "PRAGMA table_info(session_message);", "--format", "json"], + { encoding: "utf8", timeout: 10000 }, + ) + if (schemaResult.status !== 0) { + return false // table may not exist yet + } + + let columns: SchemaColumn[] + try { + columns = JSON.parse(schemaResult.stdout) as SchemaColumn[] + } catch { + return false // unparseable output – skip + } + + const seqColumn = columns.find((c) => c.name === "seq") + + // ── Case A: column is missing entirely → simple ALTER TABLE ────── + if (!seqColumn) { + const addResult = spawnSync( + binaryPath, + ["db", "ALTER TABLE session_message ADD COLUMN seq INTEGER NOT NULL DEFAULT 0;"], + { encoding: "utf8", timeout: 10000 }, + ) + return addResult.status === 0 + } + + // ── Case B: column exists but has no default → recreate table ── + // SQLite does not support ALTER COLUMN, so we must use the table + // recreation pattern (CREATE … INSERT … DROP … RENAME). + if (seqColumn.dflt_value !== "0") { + return fixSeqColumnMissingDefault(binaryPath) + } + + // ── Column already has DEFAULT 0 – nothing to do ──────────────── + return false +} + +/** + * Perform a full table recreation for `session_message` when the `seq` + * column exists but lacks `DEFAULT 0`. + * + * Uses the `opencode db` command (instead of the `sqlite3` CLI directly) + * so the migration works even when `sqlite3` is not in PATH. + */ +function fixSeqColumnMissingDefault(binaryPath: string): boolean { + const sql = [ + "PRAGMA foreign_keys=off;", + "BEGIN TRANSACTION;", + "CREATE TABLE session_message_v2 (", + "id TEXT NOT NULL,", + "session_id TEXT NOT NULL,", + "type TEXT NOT NULL,", + "time_created INTEGER NOT NULL,", + "time_updated INTEGER NOT NULL,", + "data TEXT NOT NULL,", + "seq INTEGER NOT NULL DEFAULT 0,", + "PRIMARY KEY (id)", + ");", + "INSERT INTO session_message_v2", + "(id, session_id, type, time_created, time_updated, data, seq)", + "SELECT", + "id, session_id, type, time_created, time_updated, data,", + "COALESCE(seq, 0)", + "FROM session_message;", + "DROP TABLE session_message;", + "ALTER TABLE session_message_v2 RENAME TO session_message;", + "COMMIT;", + "PRAGMA foreign_keys=on;", + ].join(" ") + + const result = spawnSync(binaryPath, ["db", sql], { + encoding: "utf8", + timeout: 30000, + }) + + return result.status === 0 +} diff --git a/packages/server/src/workspaces/runtime.ts b/packages/server/src/workspaces/runtime.ts index efc77f9a1..72eaf0e88 100644 --- a/packages/server/src/workspaces/runtime.ts +++ b/packages/server/src/workspaces/runtime.ts @@ -4,6 +4,7 @@ import path from "path" import { EventBus } from "../events/bus" import { LogLevel, WorkspaceLogEntry } from "../api-types" import { Logger } from "../logger" +import { checkAndFixOpencodeSchema } from "./migration.js" import { buildSpawnSpec, buildWslSignalSpec } from "./spawn" const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i @@ -54,6 +55,10 @@ export class WorkspaceRuntime { async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise; getLastOutput: () => string }> { this.validateFolder(options.folder) + // Run schema migration before spawning OpenCode to prevent + // NOT NULL constraint failures on session_message.seq (issue #31204) + checkAndFixOpencodeSchema(options.binaryPath) + const logLevel = typeof options.logLevel === "string" ? options.logLevel.toUpperCase() : "DEBUG" const args = ["serve", "--port", "0", "--print-logs", "--log-level", logLevel] const env = { ...process.env, ...(options.environment ?? {}) } From 613d46c5ff2dbab7db99643284732788b78659ae Mon Sep 17 00:00:00 2001 From: High-cla Date: Sun, 7 Jun 2026 16:36:08 +0800 Subject: [PATCH 2/2] test: add integration tests for migration module - Tests database path detection - Verifies PRAGMA schema inspection - Confirms no-op when seq already has DEFAULT 0 - Validates graceful failure on missing binary --- .../workspaces/__tests__/migration.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/server/src/workspaces/__tests__/migration.test.ts diff --git a/packages/server/src/workspaces/__tests__/migration.test.ts b/packages/server/src/workspaces/__tests__/migration.test.ts new file mode 100644 index 000000000..eb48fb0cd --- /dev/null +++ b/packages/server/src/workspaces/__tests__/migration.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import { execSync } from "child_process" + +describe("checkAndFixOpencodeSchema", () => { + const opencodeBinary = process.platform === "win32" ? "opencode.cmd" : "opencode" + + it("detects the OpenCode database path exists", () => { + const dbPath = execSync(`"${opencodeBinary}" db path`, { encoding: "utf8" }).trim() + assert.ok(dbPath.length > 0, "Database path should not be empty") + assert.ok(dbPath.endsWith("opencode.db"), `Path should end with opencode.db, got: ${dbPath}`) + }) + + it("detects session_message table schema via PRAGMA", () => { + const output = execSync(`"${opencodeBinary}" db "PRAGMA table_info(session_message);" --format json`, { + encoding: "utf8", + }).trim() + const columns = JSON.parse(output) + const seq = columns.find((c: any) => c.name === "seq") + + assert.ok(seq, "seq column should exist") + assert.equal(seq.type, "INTEGER", "seq column type should be INTEGER") + assert.equal(seq.notnull, 1, "seq column should be NOT NULL") + assert.equal(seq.dflt_value, "0", "seq column should have DEFAULT 0") + }) + + it("correctly identifies that migration is NOT needed (returns false = no-op)", async () => { + // The function returns false when schema is already correct (no migration needed) + // true = migration was applied, false = nothing needed or failure + const { checkAndFixOpencodeSchema } = await import("../migration.js") + const result = checkAndFixOpencodeSchema(opencodeBinary) + assert.equal(result, false, "Should return false when schema is already correct (nothing to migrate)") + + // Verify the database is unchanged by re-checking + const output = execSync(`"${opencodeBinary}" db "PRAGMA table_info(session_message);" --format json`, { + encoding: "utf8", + }).trim() + const columns = JSON.parse(output) + const seq = columns.find((c: any) => c.name === "seq") + assert.equal(seq.notnull, 1) + assert.equal(seq.dflt_value, "0") + }) + + it("gracefully handles non-existent binary (returns false)", async () => { + const { checkAndFixOpencodeSchema } = await import("../migration.js") + const result = checkAndFixOpencodeSchema("nonexistent-binary-that-definitely-does-not-exist") + assert.equal(result, false, "Should return false when binary doesn't exist") + }) +})