Skip to content

Commit 0bedea5

Browse files
authored
fix(tui): tui resiliency when workspace is dead, disable directory filter in session list (#23013)
1 parent fbbab9d commit 0bedea5

9 files changed

Lines changed: 595 additions & 330 deletions

File tree

.opencode/opencode.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"packages/opencode/migration/*": "deny",
1111
},
1212
},
13+
"plugin": ["../daytona.ts"],
1314
"mcp": {},
1415
"tools": {
1516
"github-triage": false,

bun.lock

Lines changed: 338 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

daytona.ts

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import type { Daytona, Sandbox } from "@daytonaio/sdk"
2+
import type { Plugin } from "@opencode-ai/plugin"
3+
import { join } from "node:path"
4+
import { fileURLToPath } from "node:url"
5+
import { tmpdir } from "node:os"
6+
import { access, copyFile, mkdir } from "node:fs/promises"
7+
8+
let client: Promise<Daytona> | undefined
9+
10+
let daytona = function daytona(): Promise<Daytona> {
11+
if (client == null) {
12+
client = import("@daytonaio/sdk").then(
13+
({ Daytona }) =>
14+
new Daytona({
15+
apiKey: "dtn_d63c206564ef49d4104ec2cd755e561bb3665beed8fd7d7ab2c5f7a2186965f0",
16+
}),
17+
)
18+
}
19+
return client
20+
}
21+
22+
23+
24+
const preview = new Map<string, { url: string; token: string }>()
25+
const repo = "/home/daytona/workspace/repo"
26+
const root = "/home/daytona/workspace"
27+
const localbin = "/home/daytona/opencode"
28+
const installbin = "/home/daytona/.opencode/bin/opencode"
29+
const health = "http://127.0.0.1:3096/global/health"
30+
31+
const local = fileURLToPath(
32+
new URL("./packages/opencode/dist/opencode-linux-x64-baseline/bin/opencode", import.meta.url),
33+
)
34+
35+
async function exists(file: string) {
36+
return access(file)
37+
.then(() => true)
38+
.catch(() => false)
39+
}
40+
41+
function sh(value: string) {
42+
return `'${value.replace(/'/g, `"'"'"`)}'`
43+
}
44+
45+
// Internally Daytona uses axios, which tries to overwrite stack
46+
// traces when a failure happens. That path fails in Bun, however, so
47+
// when something goes wrong you only see a very obscure error.
48+
async function withSandbox<T>(name: string, fn: (sandbox: Sandbox) => Promise<T>) {
49+
const stack = Error.captureStackTrace
50+
// @ts-expect-error temporary compatibility hack for Daytona's axios stack handling in Bun
51+
Error.captureStackTrace = undefined
52+
try {
53+
return await fn(await (await daytona()).get(name))
54+
} finally {
55+
Error.captureStackTrace = stack
56+
}
57+
}
58+
59+
export const DaytonaWorkspacePlugin: Plugin = async ({ experimental_workspace, worktree, project }) => {
60+
experimental_workspace.register("daytona", {
61+
name: "Daytona",
62+
description: "Create a remote Daytona workspace",
63+
configure(config) {
64+
return config
65+
},
66+
async create(config, env) {
67+
const temp = join(tmpdir(), `opencode-daytona-${Date.now()}`)
68+
69+
console.log("creating sandbox...")
70+
71+
const sandbox = await (
72+
await daytona()
73+
).create({
74+
name: config.name,
75+
snapshot: "daytona-large",
76+
envVars: env,
77+
})
78+
79+
console.log("creating ssh...")
80+
81+
const ssh = await withSandbox(config.name, (sandbox) => sandbox.createSshAccess())
82+
console.log("daytona:", ssh.sshCommand)
83+
84+
const run = async (command: string) => {
85+
console.log("sandbox:", command)
86+
const result = await sandbox.process.executeCommand(command)
87+
if (result.result) process.stdout.write(result.result)
88+
if (result.exitCode === 0) return result
89+
throw new Error(result.result || `sandbox command failed: ${command}`)
90+
}
91+
92+
const wait = async () => {
93+
for (let i = 0; i < 60; i++) {
94+
const result = await sandbox.process.executeCommand(`curl -fsS ${sh(health)}`)
95+
if (result.exitCode === 0) {
96+
if (result.result) process.stdout.write(result.result)
97+
return
98+
}
99+
console.log(`waiting for server (${i + 1}/60)`)
100+
await Bun.sleep(1000)
101+
}
102+
103+
const log = await sandbox.process.executeCommand(`test -f /tmp/opencode.log && cat /tmp/opencode.log || true`)
104+
throw new Error(log.result || "daytona workspace server did not become ready in time")
105+
}
106+
107+
const dir = join(temp, "repo")
108+
const tar = join(temp, "repo.tgz")
109+
const source = `file://${worktree}`
110+
await mkdir(temp, { recursive: true })
111+
const args = ["clone", "--depth", "1", "--no-local"]
112+
if (config.branch) args.push("--branch", config.branch)
113+
args.push(source, dir)
114+
115+
console.log("git cloning...")
116+
117+
const clone = Bun.spawn(["git", ...args], {
118+
cwd: tmpdir(),
119+
stdout: "pipe",
120+
stderr: "pipe",
121+
})
122+
const code = await clone.exited
123+
if (code !== 0) throw new Error(await new Response(clone.stderr).text())
124+
125+
const configPackage = join(worktree, ".opencode", "package.json")
126+
// if (await exists(configPackage)) {
127+
// console.log("copying config package...")
128+
// await mkdir(join(dir, ".opencode"), { recursive: true })
129+
// await copyFile(configPackage, join(dir, ".opencode", "package.json"))
130+
// }
131+
132+
console.log("tarring...")
133+
134+
const packed = Bun.spawn(["tar", "-czf", tar, "-C", temp, "repo"], {
135+
stdout: "ignore",
136+
stderr: "pipe",
137+
})
138+
if ((await packed.exited) !== 0) throw new Error(await new Response(packed.stderr).text())
139+
140+
console.log("uploading files...")
141+
142+
await sandbox.fs.uploadFile(tar, "repo.tgz")
143+
144+
const have = await exists(local)
145+
console.log("local", local)
146+
if (have) {
147+
console.log("uploading local binary...")
148+
await sandbox.fs.uploadFile(local, "opencode")
149+
}
150+
151+
console.log("bootstrapping workspace...")
152+
await run(`rm -rf ${sh(repo)} && mkdir -p ${sh(root)} && tar -xzf \"$HOME/repo.tgz\" -C \"$HOME/workspace\"`)
153+
154+
if (have) {
155+
await run(`chmod +x ${sh(localbin)}`)
156+
} else {
157+
await run(
158+
`mkdir -p \"$HOME/.opencode/bin\" && OPENCODE_INSTALL_DIR=\"$HOME/.opencode/bin\" curl -fsSL https://opencode.ai/install | bash`,
159+
)
160+
}
161+
162+
await run(`printf \"%s\\n\" ${sh(project.id)} > ${sh(`${repo}/.git/opencode`)}`)
163+
164+
console.log("starting server...")
165+
await run(
166+
`cd ${sh(repo)} && exe=${sh(localbin)} && if [ ! -x \"$exe\" ]; then exe=${sh(installbin)}; fi && nohup env \"$exe\" serve --hostname 0.0.0.0 --port 3096 >/tmp/opencode.log 2>&1 </dev/null &`,
167+
)
168+
169+
console.log("waiting for server...")
170+
await wait()
171+
},
172+
async remove(config) {
173+
const sandbox = await (await daytona()).get(config.name).catch(() => undefined)
174+
if (!sandbox) return
175+
await (await daytona()).delete(sandbox)
176+
preview.delete(config.name)
177+
},
178+
async target(config) {
179+
let link = preview.get(config.name)
180+
if (!link) {
181+
link = await withSandbox(config.name, (sandbox) => sandbox.getPreviewLink(3096))
182+
preview.set(config.name, link)
183+
}
184+
return {
185+
type: "remote",
186+
url: link.url,
187+
headers: {
188+
"x-daytona-preview-token": link.token,
189+
"x-daytona-skip-preview-warning": "true",
190+
"x-opencode-directory": repo,
191+
},
192+
}
193+
},
194+
})
195+
196+
return {}
197+
}
198+
199+
export default DaytonaWorkspacePlugin

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
},
9696
"dependencies": {
9797
"@aws-sdk/client-s3": "3.933.0",
98+
"@daytona/sdk": "0.167.0",
9899
"@opencode-ai/plugin": "workspace:*",
99100
"@opencode-ai/script": "workspace:*",
100101
"@opencode-ai/sdk": "workspace:*",

packages/opencode/src/cli/cmd/tui/context/project.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,21 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
1010
name: "Project",
1111
init: () => {
1212
const sdk = useSDK()
13+
14+
const defaultPath = {
15+
home: "",
16+
state: "",
17+
config: "",
18+
worktree: "",
19+
directory: sdk.directory ?? "",
20+
} satisfies Path
21+
1322
const [store, setStore] = createStore({
1423
project: {
1524
id: undefined as string | undefined,
1625
},
1726
instance: {
18-
path: {
19-
home: "",
20-
state: "",
21-
config: "",
22-
worktree: "",
23-
directory: sdk.directory ?? "",
24-
} satisfies Path,
27+
path: defaultPath,
2528
},
2629
workspace: {
2730
current: undefined as string | undefined,
@@ -38,7 +41,7 @@ export const { use: useProject, provider: ProjectProvider } = createSimpleContex
3841
])
3942

4043
batch(() => {
41-
setStore("instance", "path", reconcile(path.data!))
44+
setStore("instance", "path", reconcile(path.data || defaultPath))
4245
setStore("project", "id", project.data?.id)
4346
})
4447
}

packages/opencode/src/cli/cmd/tui/context/sync.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { createSimpleContext } from "./helper"
2727
import type { Snapshot } from "@/snapshot"
2828
import { useExit } from "./exit"
2929
import { useArgs } from "./args"
30-
import { batch, createEffect, on } from "solid-js"
30+
import { batch, onMount } from "solid-js"
3131
import { Log } from "@/util"
3232
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
3333

@@ -108,6 +108,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
108108
const project = useProject()
109109
const sdk = useSDK()
110110

111+
const fullSyncedSessions = new Set<string>()
112+
let syncedWorkspace = project.workspace.current()
113+
111114
event.subscribe((event) => {
112115
switch (event.type) {
113116
case "server.instance.disposed":
@@ -350,9 +353,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
350353
const exit = useExit()
351354
const args = useArgs()
352355

353-
async function bootstrap() {
354-
console.log("bootstrapping")
356+
async function bootstrap(input: { fatal?: boolean } = {}) {
357+
const fatal = input.fatal ?? true
355358
const workspace = project.workspace.current()
359+
if (workspace !== syncedWorkspace) {
360+
fullSyncedSessions.clear()
361+
syncedWorkspace = workspace
362+
}
356363
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
357364
const sessionListPromise = sdk.client.session
358365
.list({ start: start })
@@ -441,20 +448,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
441448
name: e instanceof Error ? e.name : undefined,
442449
stack: e instanceof Error ? e.stack : undefined,
443450
})
444-
await exit(e)
451+
if (fatal) {
452+
await exit(e)
453+
} else {
454+
throw e
455+
}
445456
})
446457
}
447458

448-
const fullSyncedSessions = new Set<string>()
449-
createEffect(
450-
on(
451-
() => project.workspace.current(),
452-
() => {
453-
fullSyncedSessions.clear()
454-
void bootstrap()
455-
},
456-
),
457-
)
459+
onMount(() => {
460+
void bootstrap()
461+
})
458462

459463
const result = {
460464
data: store,

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -181,23 +181,30 @@ export function Session() {
181181
const sdk = useSDK()
182182

183183
createEffect(async () => {
184-
await sdk.client.session
185-
.get({ sessionID: route.sessionID }, { throwOnError: true })
186-
.then((x) => {
187-
project.workspace.set(x.data?.workspaceID)
188-
})
189-
.then(() => sync.session.sync(route.sessionID))
190-
.then(() => {
191-
if (scroll) scroll.scrollBy(100_000)
192-
})
193-
.catch((e) => {
194-
console.error(e)
195-
toast.show({
196-
message: `Session not found: ${route.sessionID}`,
197-
variant: "error",
198-
})
199-
return navigate({ type: "home" })
184+
const previousWorkspace = project.workspace.current()
185+
const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true })
186+
if (!result.data) {
187+
toast.show({
188+
message: `Session not found: ${route.sessionID}`,
189+
variant: "error",
200190
})
191+
navigate({ type: "home" })
192+
return
193+
}
194+
195+
if (result.data.workspaceID !== previousWorkspace) {
196+
project.workspace.set(result.data.workspaceID)
197+
198+
// Sync all the data for this workspace. Note that this
199+
// workspace may not exist anymore which is why this is not
200+
// fatal. If it doesn't we still want to show the session
201+
// (which will be non-interactive)
202+
try {
203+
await sync.bootstrap({ fatal: false })
204+
} catch (e) {}
205+
}
206+
await sync.session.sync(route.sessionID)
207+
if (scroll) scroll.scrollBy(100_000)
201208
})
202209

203210
// Handle initial prompt from fork

packages/opencode/src/session/session.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { Decimal } from "decimal.js"
66
import z from "zod"
77
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
88
import { Flag } from "../flag/flag"
9-
import { Installation } from "../installation"
109
import { InstallationVersion } from "../installation/version"
1110

1211
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage"
@@ -713,8 +712,10 @@ export function* list(input?: {
713712
if (input?.workspaceID) {
714713
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
715714
}
716-
if (input?.directory) {
717-
conditions.push(eq(SessionTable.directory, input.directory))
715+
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
716+
if (input?.directory) {
717+
conditions.push(eq(SessionTable.directory, input.directory))
718+
}
718719
}
719720
if (input?.roots) {
720721
conditions.push(isNull(SessionTable.parent_id))

0 commit comments

Comments
 (0)