fix(tui): tui resiliency when workspace is dead, disable directory filter in session list (#23013)

This commit is contained in:
James Long
2026-04-16 23:35:36 -04:00
committed by GitHub
parent fbbab9d6c8
commit 0bedea52b1
9 changed files with 595 additions and 330 deletions
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
name: "Project",
init: () => {
const sdk = useSDK()
const defaultPath = {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path
const [store, setStore] = createStore({
project: {
id: undefined as string | undefined,
},
instance: {
path: {
home: "",
state: "",
config: "",
worktree: "",
directory: sdk.directory ?? "",
} satisfies Path,
path: defaultPath,
},
workspace: {
current: undefined as string | undefined,
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
])
batch(() => {
setStore("instance", "path", reconcile(path.data!))
setStore("instance", "path", reconcile(path.data || defaultPath))
setStore("project", "id", project.data?.id)
})
}
@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, createEffect, on } from "solid-js"
import { batch, onMount } from "solid-js"
import { Log } from "@/util"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const project = useProject()
const sdk = useSDK()
const fullSyncedSessions = new Set<string>()
let syncedWorkspace = project.workspace.current()
event.subscribe((event) => {
switch (event.type) {
case "server.instance.disposed":
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const exit = useExit()
const args = useArgs()
async function bootstrap() {
console.log("bootstrapping")
async function bootstrap(input: { fatal?: boolean } = {}) {
const fatal = input.fatal ?? true
const workspace = project.workspace.current()
if (workspace !== syncedWorkspace) {
fullSyncedSessions.clear()
syncedWorkspace = workspace
}
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const sessionListPromise = sdk.client.session
.list({ start: start })
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
await exit(e)
if (fatal) {
await exit(e)
} else {
throw e
}
})
}
const fullSyncedSessions = new Set<string>()
createEffect(
on(
() => project.workspace.current(),
() => {
fullSyncedSessions.clear()
void bootstrap()
},
),
)
onMount(() => {
void bootstrap()
})
const result = {
data: store,
@@ -181,23 +181,30 @@ export function Session() {
const sdk = useSDK()
createEffect(async () => {
await sdk.client.session
.get({ sessionID: route.sessionID }, { throwOnError: true })
.then((x) => {
project.workspace.set(x.data?.workspaceID)
})
.then(() => sync.session.sync(route.sessionID))
.then(() => {
if (scroll) scroll.scrollBy(100_000)
})
.catch((e) => {
console.error(e)
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
return navigate({ type: "home" })
const previousWorkspace = project.workspace.current()
const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
if (!result.data) {
toast.show({
message: `Session not found: ${route.sessionID}`,
variant: "error",
})
navigate({ type: "home" })
return
}
if (result.data.workspaceID !== previousWorkspace) {
project.workspace.set(result.data.workspaceID)
// Sync all the data for this workspace. Note that this
// workspace may not exist anymore which is why this is not
// fatal. If it doesn't we still want to show the session
// (which will be non-interactive)
try {
await sync.bootstrap({ fatal: false })
} catch (e) {}
}
await sync.session.sync(route.sessionID)
if (scroll) scroll.scrollBy(100_000)
})
// Handle initial prompt from fork
+4 -3
View File
@@ -6,7 +6,6 @@ import { Decimal } from "decimal.js"
import z from "zod"
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
import { Flag } from "../flag/flag"
import { Installation } from "../installation"
import { InstallationVersion } from "../installation/version"
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
@@ -713,8 +712,10 @@ export function* list(input?: {
if (input?.workspaceID) {
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
}
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
@@ -1,280 +0,0 @@
/** @jsxImportSource @opentui/solid */
import { afterEach, describe, expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { ArgsProvider } from "../../../src/cli/cmd/tui/context/args"
import { ExitProvider } from "../../../src/cli/cmd/tui/context/exit"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { SyncProvider, useSync } from "../../../src/cli/cmd/tui/context/sync"
const sighup = new Set(process.listeners("SIGHUP"))
afterEach(() => {
for (const fn of process.listeners("SIGHUP")) {
if (!sighup.has(fn)) process.off("SIGHUP", fn)
}
})
function json(data: unknown) {
return new Response(JSON.stringify(data), {
headers: {
"content-type": "application/json",
},
})
}
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function data(workspace?: string | null) {
const tag = workspace ?? "root"
return {
session: {
id: "ses_1",
title: `session-${tag}`,
workspaceID: workspace ?? undefined,
time: {
updated: 1,
},
},
message: {
info: {
id: "msg_1",
sessionID: "ses_1",
role: "assistant",
time: {
created: 1,
completed: 1,
},
},
parts: [
{
id: "part_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: `part-${tag}`,
},
],
},
todo: [
{
id: `todo-${tag}`,
content: `todo-${tag}`,
status: "pending",
priority: "medium",
},
],
diff: [
{
file: `${tag}.ts`,
patch: "",
additions: 0,
deletions: 0,
},
],
}
}
type Hit = {
path: string
workspace?: string
}
function createFetch(log: Hit[]) {
return Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const req = new Request(input, init)
const url = new URL(req.url)
const workspace = url.searchParams.get("workspace") ?? req.headers.get("x-opencode-workspace") ?? undefined
log.push({
path: url.pathname,
workspace,
})
if (url.pathname === "/config/providers") {
return json({ providers: [], default: {} })
}
if (url.pathname === "/provider") {
return json({ all: [], default: {}, connected: [] })
}
if (url.pathname === "/experimental/console") {
return json({})
}
if (url.pathname === "/agent") {
return json([])
}
if (url.pathname === "/config") {
return json({})
}
if (url.pathname === "/project/current") {
return json({ id: `proj-${workspace ?? "root"}` })
}
if (url.pathname === "/path") {
return json({
state: `/tmp/${workspace ?? "root"}/state`,
config: `/tmp/${workspace ?? "root"}/config`,
worktree: "/tmp/worktree",
directory: `/tmp/${workspace ?? "root"}`,
})
}
if (url.pathname === "/session") {
return json([])
}
if (url.pathname === "/command") {
return json([])
}
if (url.pathname === "/lsp") {
return json([])
}
if (url.pathname === "/mcp") {
return json({})
}
if (url.pathname === "/experimental/resource") {
return json({})
}
if (url.pathname === "/formatter") {
return json([])
}
if (url.pathname === "/session/status") {
return json({})
}
if (url.pathname === "/provider/auth") {
return json({})
}
if (url.pathname === "/vcs") {
return json({ branch: "main" })
}
if (url.pathname === "/experimental/workspace") {
return json([{ id: "ws_a" }, { id: "ws_b" }])
}
if (url.pathname === "/session/ses_1") {
return json(data(workspace).session)
}
if (url.pathname === "/session/ses_1/message") {
return json([data(workspace).message])
}
if (url.pathname === "/session/ses_1/todo") {
return json(data(workspace).todo)
}
if (url.pathname === "/session/ses_1/diff") {
return json(data(workspace).diff)
}
throw new Error(`unexpected request: ${req.method} ${url.pathname}`)
},
{ preconnect: fetch.preconnect.bind(fetch) },
) satisfies typeof fetch
}
async function mount(log: Hit[]) {
let project!: ReturnType<typeof useProject>
let sync!: ReturnType<typeof useSync>
let done!: () => void
const ready = new Promise<void>((resolve) => {
done = resolve
})
const app = await testRender(() => (
<SDKProvider
url="http://test"
directory="/tmp/root"
fetch={createFetch(log)}
events={{ subscribe: async () => () => {} }}
>
<ArgsProvider continue={false}>
<ExitProvider>
<ProjectProvider>
<SyncProvider>
<Probe
onReady={(ctx) => {
project = ctx.project
sync = ctx.sync
done()
}}
/>
</SyncProvider>
</ProjectProvider>
</ExitProvider>
</ArgsProvider>
</SDKProvider>
))
await ready
return { app, project, sync }
}
async function waitBoot(log: Hit[], workspace?: string) {
await wait(() => log.some((item) => item.path === "/experimental/workspace"))
if (!workspace) return
await wait(() => log.some((item) => item.path === "/project/current" && item.workspace === workspace))
}
function Probe(props: {
onReady: (ctx: { project: ReturnType<typeof useProject>; sync: ReturnType<typeof useSync> }) => void
}) {
const project = useProject()
const sync = useSync()
onMount(() => {
props.onReady({ project, sync })
})
return <box />
}
describe("SyncProvider", () => {
test("re-runs bootstrap requests when the active workspace changes", async () => {
const log: Hit[] = []
const { app, project } = await mount(log)
try {
await waitBoot(log)
log.length = 0
project.workspace.set("ws_a")
await waitBoot(log, "ws_a")
expect(log.some((item) => item.path === "/path" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/config" && item.workspace === "ws_a")).toBe(true)
expect(log.some((item) => item.path === "/command" && item.workspace === "ws_a")).toBe(true)
} finally {
app.renderer.destroy()
}
})
test("clears full-sync cache when the active workspace changes", async () => {
const log: Hit[] = []
const { app, project, sync } = await mount(log)
try {
await waitBoot(log)
log.length = 0
project.workspace.set("ws_a")
await waitBoot(log, "ws_a")
expect(project.workspace.current()).toBe("ws_a")
log.length = 0
await sync.session.sync("ses_1")
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
project.workspace.set("ws_b")
await waitBoot(log, "ws_b")
expect(project.workspace.current()).toBe("ws_b")
log.length = 0
await sync.session.sync("ses_1")
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
} finally {
app.renderer.destroy()
}
})
})