Compare commits

..

1 Commits

Author SHA1 Message Date
Filip Hejmowski db86d03b47 fix(core): tolerate missing workspace names 2026-08-10 22:09:16 +00:00
5 changed files with 51 additions and 124 deletions
@@ -5,6 +5,9 @@ export default {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
const columns = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`workspace\`)`)
const name = columns.some((column) => column.name === "name") ? "`name`" : "''"
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
@@ -19,7 +22,7 @@ export default {
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, ${name}, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
@@ -8,6 +8,7 @@ import { Effect, Layer } from "effect"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import { migrations } from "@opencode-ai/core/database/migration.gen"
import workspaceNameMigration from "@opencode-ai/core/database/migration/20260410174513_workspace-name"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
@@ -319,6 +320,37 @@ describe("DatabaseMigration", () => {
)
})
test("defaults missing workspace names during legacy workspace rebuild", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`
CREATE TABLE workspace (
id text PRIMARY KEY,
type text NOT NULL,
branch text,
directory text,
extra text,
project_id text NOT NULL
)
`)
yield* db.run(sql`
INSERT INTO workspace (id, type, branch, directory, extra, project_id)
VALUES ('wrk_legacy', 'remote', 'main', '/repo', '{}', 'proj_legacy')
`)
yield* DatabaseMigration.applyOnly(db, [workspaceNameMigration])
expect(yield* db.get(sql`SELECT id, name, directory, extra FROM workspace`)).toEqual({
id: "wrk_legacy",
name: "",
directory: "/repo",
extra: "{}",
})
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => {
await run(
Effect.gen(function* () {
+1 -9
View File
@@ -517,15 +517,7 @@ export type TuiSlots = {
}
export type TuiEventBus = {
on: <Type extends Event["type"]>(
type: Type,
handler: (event: Extract<Event, { type: Type }>, metadata: TuiEventMetadata) => void,
) => () => void
}
export type TuiEventMetadata = {
directory: string
workspace: string | undefined
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => () => void
}
export type TuiDispose = () => void | Promise<void>
@@ -1,19 +1,13 @@
import type { Event } from "@opencode-ai/sdk/v2"
import type { TuiAttentionSoundName, TuiEventMetadata, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
const id = "internal:notifications"
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
function matchesSession(api: TuiPluginApi, sessionID: string, metadata: TuiEventMetadata) {
const session = api.state.session.get(sessionID)
return session?.directory === metadata.directory && session.workspaceID === metadata.workspace
}
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
const session = sessionID ? api.state.session.get(sessionID) : undefined
if (!session) return
const isSubagent = session?.parentID !== undefined
void api.attention.notify({
title: session?.title,
@@ -38,41 +32,32 @@ const tui: TuiPlugin = async (api) => {
const questions = new Set<string>()
const permissions = new Set<string>()
api.event.on("question.asked", (event, metadata) => {
if (!matchesSession(api, event.properties.sessionID, metadata)) return
if (!api.state.session.question(event.properties.sessionID).some((item) => item.id === event.properties.id)) return
api.event.on("question.asked", (event) => {
if (questions.has(event.properties.id)) return
questions.add(event.properties.id)
notify(api, event.properties.sessionID, "Question needs input", "question")
})
api.event.on("question.replied", (event, metadata) => {
if (!matchesSession(api, event.properties.sessionID, metadata)) return
api.event.on("question.replied", (event) => {
questions.delete(event.properties.requestID)
})
api.event.on("question.rejected", (event, metadata) => {
if (!matchesSession(api, event.properties.sessionID, metadata)) return
api.event.on("question.rejected", (event) => {
questions.delete(event.properties.requestID)
})
api.event.on("permission.asked", (event, metadata) => {
if (!matchesSession(api, event.properties.sessionID, metadata)) return
if (!api.state.session.permission(event.properties.sessionID).some((item) => item.id === event.properties.id))
return
api.event.on("permission.asked", (event) => {
if (permissions.has(event.properties.id)) return
permissions.add(event.properties.id)
notify(api, event.properties.sessionID, "Permission needs input", "permission")
})
api.event.on("permission.replied", (event, metadata) => {
if (!matchesSession(api, event.properties.sessionID, metadata)) return
api.event.on("permission.replied", (event) => {
permissions.delete(event.properties.requestID)
})
api.event.on("session.status", (event, metadata) => {
api.event.on("session.status", (event) => {
const sessionID = event.properties.sessionID
if (!matchesSession(api, sessionID, metadata)) return
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
active.add(sessionID)
errored.delete(sessionID)
@@ -92,10 +77,9 @@ const tui: TuiPlugin = async (api) => {
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
})
api.event.on("session.error", (event, metadata) => {
api.event.on("session.error", (event) => {
const sessionID = event.properties.sessionID
if (!sessionID) return
if (!matchesSession(api, sessionID, metadata)) return
if (!active.has(sessionID)) return
errored.add(sessionID)
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
@@ -4,14 +4,9 @@ import type { Event, PermissionRequest, QuestionRequest, Session } from "@openco
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
async function setup(input: { auto?: boolean } = {}) {
async function setup() {
const notifications: TuiAttentionNotifyInput[] = []
const handlers = new Map<
Event["type"],
((event: Event, metadata: { directory: string; workspace: string | undefined }) => void)[]
>()
const permissions: Record<string, PermissionRequest[]> = {}
const questions: Record<string, QuestionRequest[]> = {}
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
const session = (id: string, title: string, parentID?: string): Session => ({
id,
title,
@@ -38,18 +33,9 @@ async function setup(input: { auto?: boolean } = {}) {
},
},
event: {
on: <Type extends Event["type"]>(
type: Type,
handler: (
event: Extract<Event, { type: Type }>,
metadata: { directory: string; workspace: string | undefined },
) => void,
) => {
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
const list = handlers.get(type) ?? []
const wrapped = handler as (
event: Event,
metadata: { directory: string; workspace: string | undefined },
) => void
const wrapped = handler as (event: Event) => void
list.push(wrapped)
handlers.set(type, list)
return () => {
@@ -63,8 +49,6 @@ async function setup(input: { auto?: boolean } = {}) {
state: {
session: {
get: (sessionID: string) => sessions[sessionID],
permission: (sessionID: string) => permissions[sessionID] ?? [],
question: (sessionID: string) => questions[sessionID] ?? [],
},
},
}),
@@ -74,30 +58,8 @@ async function setup(input: { auto?: boolean } = {}) {
return {
notifications,
emit(event: Event, metadata = { directory: "/workspace", workspace: undefined as string | undefined }) {
if (event.type === "permission.asked" && !input.auto) {
permissions[event.properties.sessionID] = [
...(permissions[event.properties.sessionID] ?? []).filter((item) => item.id !== event.properties.id),
event.properties,
]
}
if (event.type === "permission.replied") {
permissions[event.properties.sessionID] = (permissions[event.properties.sessionID] ?? []).filter(
(item) => item.id !== event.properties.requestID,
)
}
if (event.type === "question.asked") {
questions[event.properties.sessionID] = [
...(questions[event.properties.sessionID] ?? []).filter((item) => item.id !== event.properties.id),
event.properties,
]
}
if (event.type === "question.replied" || event.type === "question.rejected") {
questions[event.properties.sessionID] = (questions[event.properties.sessionID] ?? []).filter(
(item) => item.id !== event.properties.requestID,
)
}
for (const handler of handlers.get(event.type) ?? []) handler(event, metadata)
emit(event: Event) {
for (const handler of handlers.get(event.type) ?? []) handler(event)
},
}
}
@@ -174,52 +136,6 @@ describe("internal notifications TUI plugin", () => {
])
})
test("suppresses auto-approved permission requests", async () => {
const harness = await setup({ auto: true })
harness.emit({ id: "event-1", type: "permission.asked", properties: permission("permission-1") })
expect(harness.notifications).toEqual([])
})
test("ignores events outside the session location", async () => {
const harness = await setup()
const foreign = { directory: "/other", workspace: undefined }
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") }, foreign)
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") }, foreign)
harness.emit(
{ id: "event-2b", type: "permission.asked", properties: permission("permission-2") },
{ directory: "/workspace", workspace: "other-workspace" },
)
harness.emit(
{
id: "event-3",
type: "session.status",
properties: { sessionID: "session", status: { type: "busy" } },
},
foreign,
)
harness.emit(
{
id: "event-4",
type: "session.error",
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
},
foreign,
)
harness.emit(
{
id: "event-5",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
},
foreign,
)
expect(harness.notifications).toEqual([])
})
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
const harness = await setup()