mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
fix(tui): tui resiliency when workspace is dead, disable directory filter in session list (#23013)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user