Skip to content
Merged
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
1 change: 1 addition & 0 deletions .opencode/opencode.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"packages/opencode/migration/*": "deny",
},
},
"plugin": ["../daytona.ts"],
"mcp": {},
"tools": {
"github-triage": false,
Expand Down
347 changes: 338 additions & 9 deletions bun.lock

Large diffs are not rendered by default.

199 changes: 199 additions & 0 deletions daytona.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import type { Daytona, Sandbox } from "@daytonaio/sdk"
import type { Plugin } from "@opencode-ai/plugin"
import { join } from "node:path"
import { fileURLToPath } from "node:url"
import { tmpdir } from "node:os"
import { access, copyFile, mkdir } from "node:fs/promises"

let client: Promise<Daytona> | undefined

let daytona = function daytona(): Promise<Daytona> {
if (client == null) {
client = import("@daytonaio/sdk").then(
({ Daytona }) =>
new Daytona({
apiKey: "dtn_d63c206564ef49d4104ec2cd755e561bb3665beed8fd7d7ab2c5f7a2186965f0",
}),
)
}
return client
}



const preview = new Map<string, { url: string; token: string }>()
const repo = "/home/daytona/workspace/repo"
const root = "/home/daytona/workspace"
const localbin = "/home/daytona/opencode"
const installbin = "/home/daytona/.opencode/bin/opencode"
const health = "http://127.0.0.1:3096/global/health"

const local = fileURLToPath(
new URL("./packages/opencode/dist/opencode-linux-x64-baseline/bin/opencode", import.meta.url),
)

async function exists(file: string) {
return access(file)
.then(() => true)
.catch(() => false)
}

function sh(value: string) {
return `'${value.replace(/'/g, `"'"'"`)}'`
}

// Internally Daytona uses axios, which tries to overwrite stack
// traces when a failure happens. That path fails in Bun, however, so
// when something goes wrong you only see a very obscure error.
async function withSandbox<T>(name: string, fn: (sandbox: Sandbox) => Promise<T>) {
const stack = Error.captureStackTrace
// @ts-expect-error temporary compatibility hack for Daytona's axios stack handling in Bun
Error.captureStackTrace = undefined
try {
return await fn(await (await daytona()).get(name))
} finally {
Error.captureStackTrace = stack
}
}

export const DaytonaWorkspacePlugin: Plugin = async ({ experimental_workspace, worktree, project }) => {
experimental_workspace.register("daytona", {
name: "Daytona",
description: "Create a remote Daytona workspace",
configure(config) {
return config
},
async create(config, env) {
const temp = join(tmpdir(), `opencode-daytona-${Date.now()}`)

console.log("creating sandbox...")

const sandbox = await (
await daytona()
).create({
name: config.name,
snapshot: "daytona-large",
envVars: env,
})

console.log("creating ssh...")

const ssh = await withSandbox(config.name, (sandbox) => sandbox.createSshAccess())
console.log("daytona:", ssh.sshCommand)

const run = async (command: string) => {
console.log("sandbox:", command)
const result = await sandbox.process.executeCommand(command)
if (result.result) process.stdout.write(result.result)
if (result.exitCode === 0) return result
throw new Error(result.result || `sandbox command failed: ${command}`)
}

const wait = async () => {
for (let i = 0; i < 60; i++) {
const result = await sandbox.process.executeCommand(`curl -fsS ${sh(health)}`)
if (result.exitCode === 0) {
if (result.result) process.stdout.write(result.result)
return
}
console.log(`waiting for server (${i + 1}/60)`)
await Bun.sleep(1000)
}

const log = await sandbox.process.executeCommand(`test -f /tmp/opencode.log && cat /tmp/opencode.log || true`)
throw new Error(log.result || "daytona workspace server did not become ready in time")
}

const dir = join(temp, "repo")
const tar = join(temp, "repo.tgz")
const source = `file://${worktree}`
await mkdir(temp, { recursive: true })
const args = ["clone", "--depth", "1", "--no-local"]
if (config.branch) args.push("--branch", config.branch)
args.push(source, dir)

console.log("git cloning...")

const clone = Bun.spawn(["git", ...args], {
cwd: tmpdir(),
stdout: "pipe",
stderr: "pipe",
})
const code = await clone.exited
if (code !== 0) throw new Error(await new Response(clone.stderr).text())

const configPackage = join(worktree, ".opencode", "package.json")
// if (await exists(configPackage)) {
// console.log("copying config package...")
// await mkdir(join(dir, ".opencode"), { recursive: true })
// await copyFile(configPackage, join(dir, ".opencode", "package.json"))
// }

console.log("tarring...")

const packed = Bun.spawn(["tar", "-czf", tar, "-C", temp, "repo"], {
stdout: "ignore",
stderr: "pipe",
})
if ((await packed.exited) !== 0) throw new Error(await new Response(packed.stderr).text())

console.log("uploading files...")

await sandbox.fs.uploadFile(tar, "repo.tgz")

const have = await exists(local)
console.log("local", local)
if (have) {
console.log("uploading local binary...")
await sandbox.fs.uploadFile(local, "opencode")
}

console.log("bootstrapping workspace...")
await run(`rm -rf ${sh(repo)} && mkdir -p ${sh(root)} && tar -xzf \"$HOME/repo.tgz\" -C \"$HOME/workspace\"`)

if (have) {
await run(`chmod +x ${sh(localbin)}`)
} else {
await run(
`mkdir -p \"$HOME/.opencode/bin\" && OPENCODE_INSTALL_DIR=\"$HOME/.opencode/bin\" curl -fsSL https://opencode.ai/install | bash`,
)
}

await run(`printf \"%s\\n\" ${sh(project.id)} > ${sh(`${repo}/.git/opencode`)}`)

console.log("starting server...")
await run(
`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 &`,
)

console.log("waiting for server...")
await wait()
},
async remove(config) {
const sandbox = await (await daytona()).get(config.name).catch(() => undefined)
if (!sandbox) return
await (await daytona()).delete(sandbox)
preview.delete(config.name)
},
async target(config) {
let link = preview.get(config.name)
if (!link) {
link = await withSandbox(config.name, (sandbox) => sandbox.getPreviewLink(3096))
preview.set(config.name, link)
}
return {
type: "remote",
url: link.url,
headers: {
"x-daytona-preview-token": link.token,
"x-daytona-skip-preview-warning": "true",
"x-opencode-directory": repo,
},
}
},
})

return {}
}

export default DaytonaWorkspacePlugin
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@daytona/sdk": "0.167.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
Expand Down
19 changes: 11 additions & 8 deletions packages/opencode/src/cli/cmd/tui/context/project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
})
}
Expand Down
32 changes: 18 additions & 14 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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,
Expand Down
39 changes: 23 additions & 16 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions packages/opencode/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand Down
Loading
Loading