Skip to content
Closed
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
3 changes: 2 additions & 1 deletion packages/opencode-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"license": "MIT",
"type": "module",
"main": "dist/codenomad.js",
"oc-plugin": "dist/codenomad.js",
"files": [
"dist",
"README.md"
Expand All @@ -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",
Expand Down
37 changes: 17 additions & 20 deletions packages/opencode-plugin/plugin/codenomad.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createBackgroundProcessTools>
"chat.message": CodeNomadChatMessageHook
event: CodeNomadEventHook
}> {
const CodeNomadPlugin: Plugin = async (input) => {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
const backgroundProcessTools = createBackgroundProcessTools(config, { baseDir: input.directory })
Expand All @@ -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<void>

type CodeNomadEventHook = (input: { event: any }) => Promise<void>
export const server: Plugin = CodeNomadPlugin
export default CodeNomadPlugin

function buildVoiceModePrompt(): string {
return [
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode-plugin/plugin/lib/background-process.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
49 changes: 49 additions & 0 deletions packages/server/src/workspaces/__tests__/migration.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
118 changes: 118 additions & 0 deletions packages/server/src/workspaces/migration.ts
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 5 additions & 0 deletions packages/server/src/workspaces/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,6 +55,10 @@ export class WorkspaceRuntime {
async launch(options: LaunchOptions): Promise<{ pid: number; port: number; exitPromise: Promise<ProcessExitInfo>; 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 ?? {}) }
Expand Down
Loading