Describe the workspaces feature recently introduced in this project
Describe the workspaces feature recently introduced in this project
/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx
/home/jm/data/code/opencode/packages/opencode/test/control-plane/workspace-sync.test.ts
/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace.ts
/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace.sql.ts
/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace-router-middleware.ts
/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace-context.ts
/home/jm/data/code/opencode/packages/opencode/test/control-plane/workspace-server-sse.test.ts
/home/jm/data/code/opencode/packages/opencode/src/server/routes/workspace.ts
/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/workspace-serve.tsFound 131 matches (showing first 100)
/home/jm/data/code/opencode/packages/opencode/src/project/project.ts:
Line 49: start: z.string().optional().describe("Startup script to run when creating a new workspace (worktree)"),
/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/tui/worker.ts:
Line 47: const startEventStream = (input: { directory: string; workspaceID?: string }) => {
Line 63: experimental_workspaceID: input.workspaceID,
Line 139: async setWorkspace(input: { workspaceID?: string }) {
Line 140: startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/tui/thread.ts:
Line 45: setWorkspace: (workspaceID) => {
Line 46: void client.call("setWorkspace", { workspaceID })
/home/jm/data/code/opencode/packages/opencode/test/control-plane/workspace-sync.test.ts:
Line 7: import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
Line 49: describe("control-plane/workspace.startSyncing", () => {
Line 50: test("syncs only remote workspaces and emits remote SSE events", async () => {
Line 51: const { Workspace } = await import("../../src/control-plane/workspace")
Line 55: const id1 = Identifier.descending("workspace")
Line 56: const id2 = Identifier.descending("workspace")
/home/jm/data/code/opencode/packages/opencode/test/control-plane/session-proxy-middleware.test.ts:
Line 6: import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
Line 8: import { WorkspaceContext } from "../../src/control-plane/workspace-context"
Line 20: workspace?: "first" | "second"
Line 40: : new URL(input, "http://workspace.test").toString()
Line 57: const id1 = Identifier.descending("workspace")
Line 58: const id2 = Identifier.descending("workspace")
Line 83: const { WorkspaceRouterMiddleware } = await import("../../src/control-plane/workspace-router-middleware")
Line 95: ...Found 81 matches
/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/tui/thread.ts:
Line 45: setWorkspace: (workspaceID) => {
Line 46: void client.call("setWorkspace", { workspaceID })
/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/tui/worker.ts:
Line 139: async setWorkspace(input: { workspaceID?: string }) {
/home/jm/data/code/opencode/packages/opencode/test/control-plane/workspace-sync.test.ts:
Line 7: import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
Line 20: const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
Line 51: const { Workspace } = await import("../../src/control-plane/workspace")
Line 60: .insert(WorkspaceTable)
Line 91: const sync = Workspace.startSyncing(project)
/home/jm/data/code/opencode/packages/opencode/test/control-plane/session-proxy-middleware.test.ts:
Line 6: import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
Line 8: import { WorkspaceContext } from "../../src/control-plane/workspace-context"
Line 24: const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
Line 62: .insert(WorkspaceTable)
Line 83: const { WorkspaceRouterMiddleware } = await import("../../src/control-plane/workspace-router-middleware")
Line 84: const app = new Hono().use(WorkspaceRouterMiddleware)
Line 94: WorkspaceContext.provide({
/home/jm/data/code/opencode/packages/opencode/src/index.ts:
Line 16: import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
Line 153: cli = cli.command(WorkspaceServeCommand)
/home/jm/data/code/opencode/packages/opencode/src/storage/schema.ts:
Line 5: export { WorkspaceTable } from "../control-plane/workspace.sql"
/home/jm/data/code/opencode/packages/opencode/src/session/index.ts:
Line 25: import { WorkspaceContext } from "../control-plane/workspace-context"
Line 304: workspaceID: Works...<path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace.ts</path>
<type>file</type>
<content>1: import z from "zod"
2: import { Identifier } from "@/id/id"
3: import { fn } from "@/util/fn"
4: import { Database, eq } from "@/storage/db"
5: import { Project } from "@/project/project"
6: import { BusEvent } from "@/bus/bus-event"
7: import { GlobalBus } from "@/bus/global"
8: import { Log } from "@/util/log"
9: import { WorkspaceTable } from "./workspace.sql"
10: import { getAdaptor } from "./adaptors"
11: import { WorkspaceInfo } from "./types"
12: import { parseSSE } from "./sse"
13:
14: export namespace Workspace {
15: export const Event = {
16: Ready: BusEvent.define(
17: "workspace.ready",
18: z.object({
19: name: z.string(),
20: }),
21: ),
22: Failed: BusEvent.define(
23: "workspace.failed",
24: z.object({
25: message: z.string(),
26: }),
27: ),
28: }
29:
30: export const Info = WorkspaceInfo.meta({
31: ref: "Workspace",
32: })
33: export type Info = z.infer<typeof Info>
34:
35: function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
36: return {
37: id: row.id,
38: type: row.type,
39: branch: row.branch,
40: name: row.name,
41: directory: row.directory,
42: extra: row.extra,
43: projectID: row.project_id,
44: }
45: }
46:
47: const CreateInput = z.object({
48: id: Identifier.schema("workspace").optional(),
49: type: Info.shape.type,
50: branch: Info.shape.branch,
51: projectID: Info.shape.projectID,
52: extra: Info.shape.extra,
53: })
54:
55: export const create = fn(CreateInput, async (input) => {
56: const id = Identifier.ascending("workspace", input.id)
57: const adaptor = await getAdaptor(input.type)
58:
59: const config = await adaptor.configure({ ...input, id, name: null, directory: null })
60:
61: const info: Info = {
62: id,
63: ...<path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/types.ts</path>
<type>file</type>
<content>1: import z from "zod"
2: import { Identifier } from "@/id/id"
3:
4: export const WorkspaceInfo = z.object({
5: id: Identifier.schema("workspace"),
6: type: z.string(),
7: branch: z.string().nullable(),
8: name: z.string().nullable(),
9: directory: z.string().nullable(),
10: extra: z.unknown().nullable(),
11: projectID: z.string(),
12: })
13: export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
14:
15: export type Adaptor = {
16: configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
17: create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
18: remove(config: WorkspaceInfo): Promise<void>
19: fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise<Response>
20: }
(End of file - total 20 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace.sql.ts</path>
<type>file</type>
<content>1: import { sqliteTable, text } from "drizzle-orm/sqlite-core"
2: import { ProjectTable } from "@/project/project.sql"
3:
4: export const WorkspaceTable = sqliteTable("workspace", {
5: id: text().primaryKey(),
6: type: text().notNull(),
7: branch: text(),
8: name: text(),
9: directory: text(),
10: extra: text({ mode: "json" }),
11: project_id: text()
12: .notNull()
13: .references(() => ProjectTable.id, { onDelete: "cascade" }),
14: })
(End of file - total 14 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/server/routes/workspace.ts</path>
<type>file</type>
<content>1: import { Hono } from "hono"
2: import { describeRoute, resolver, validator } from "hono-openapi"
3: import z from "zod"
4: import { Workspace } from "../../control-plane/workspace"
5: import { Instance } from "../../project/instance"
6: import { errors } from "../error"
7: import { lazy } from "../../util/lazy"
8:
9: export const WorkspaceRoutes = lazy(() =>
10: new Hono()
11: .post(
12: "/",
13: describeRoute({
14: summary: "Create workspace",
15: description: "Create a workspace for the current project.",
16: operationId: "experimental.workspace.create",
17: responses: {
18: 200: {
19: description: "Workspace created",
20: content: {
21: "application/json": {
22: schema: resolver(Workspace.Info),
23: },
24: },
25: },
26: ...errors(400),
27: },
28: }),
29: validator(
30: "json",
31: Workspace.create.schema.omit({
32: projectID: true,
33: }),
34: ),
35: async (c) => {
36: const body = c.req.valid("json")
37: const workspace = await Workspace.create({
38: projectID: Instance.project.id,
39: ...body,
40: })
41: return c.json(workspace)
42: },
43: )
44: .get(
45: "/",
46: describeRoute({
47: summary: "List workspaces",
48: description: "List all workspaces.",
49: operationId: "experimental.workspace.list",
50: responses: {
51: 200: {
52: description: "Workspaces",
53: content: {
54: "application/json": {
55: schema: resolver(z.array(Workspace.Info)),
56: },
57: },
58: },
59: },
60: }),
61: async (c) => ...<path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/adaptors</path>
<type>directory</type>
<entries>
index.ts
worktree.ts
(2 entries)
</entries><path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace-server/server.ts</path>
<type>file</type>
<content>1: import { Hono } from "hono"
2: import { Instance } from "../../project/instance"
3: import { InstanceBootstrap } from "../../project/bootstrap"
4: import { SessionRoutes } from "../../server/routes/session"
5: import { WorkspaceServerRoutes } from "./routes"
6: import { WorkspaceContext } from "../workspace-context"
7:
8: export namespace WorkspaceServer {
9: export function App() {
10: const session = new Hono()
11: .use(async (c, next) => {
12: // Right now, we need handle all requests because we don't
13: // have syncing. In the future all GET requests will handled
14: // by the control plane
15: //
16: // if (c.req.method === "GET") return c.notFound()
17: await next()
18: })
19: .route("/", SessionRoutes())
20:
21: return new Hono()
22: .use(async (c, next) => {
23: const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
24: const raw = c.req.query("directory") || c.req.header("x-opencode-directory")
25: if (workspaceID == null) {
26: throw new Error("workspaceID parameter is required")
27: }
28: if (raw == null) {
29: throw new Error("directory parameter is required")
30: }
31:
32: const directory = (() => {
33: try {
34: return decodeURIComponent(raw)
35: } catch {
36: return raw
37: }
38: })()
39:
40: return WorkspaceContext.provide({
41: workspaceID,
42: async fn() {
43: return Instance.provide({
44: directory,
45: init: InstanceBootstrap,
46: async fn() {
47: return next()
48: },
49: })
50: },
51: })
52: })
53: .route("/sessi...<path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace-router-middleware.ts</path>
<type>file</type>
<content>1: import { Instance } from "@/project/instance"
2: import type { MiddlewareHandler } from "hono"
3: import { Installation } from "../installation"
4: import { getAdaptor } from "./adaptors"
5: import { Workspace } from "./workspace"
6: import { WorkspaceContext } from "./workspace-context"
7:
8: // This middleware forwards all non-GET requests if the workspace is a
9: // remote. The remote workspace needs to handle session mutations
10: async function routeRequest(req: Request) {
11: // Right now, we need to forward all requests to the workspace
12: // because we don't have syncing. In the future all GET requests
13: // which don't mutate anything will be handled locally
14: //
15: // if (req.method === "GET") return
16:
17: if (!WorkspaceContext.workspaceID) return
18:
19: const workspace = await Workspace.get(WorkspaceContext.workspaceID)
20: if (!workspace) {
21: return new Response(`Workspace not found: ${WorkspaceContext.workspaceID}`, {
22: status: 500,
23: headers: {
24: "content-type": "text/plain; charset=utf-8",
25: },
26: })
27: }
28:
29: const adaptor = await getAdaptor(workspace.type)
30:
31: return adaptor.fetch(workspace, `${new URL(req.url).pathname}${new URL(req.url).search}`, {
32: method: req.method,
33: body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
34: signal: req.signal,
35: headers: req.headers,
36: })
37: }
38:
39: export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
40: // Only available in development for now
41: if (!Installation.isLocal()) {
42: return next()
43: }
44:
45: const response = await routeRequest(c.req.raw)
46: if (response) {
47: return response
48: }
49: return next()
50: }
(End of file - total 50 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/adaptors/worktree.ts</path>
<type>file</type>
<content>1: import z from "zod"
2: import { Worktree } from "@/worktree"
3: import { type Adaptor, WorkspaceInfo } from "../types"
4:
5: const Config = WorkspaceInfo.extend({
6: name: WorkspaceInfo.shape.name.unwrap(),
7: branch: WorkspaceInfo.shape.branch.unwrap(),
8: directory: WorkspaceInfo.shape.directory.unwrap(),
9: })
10:
11: type Config = z.infer<typeof Config>
12:
13: export const WorktreeAdaptor: Adaptor = {
14: async configure(info) {
15: const worktree = await Worktree.makeWorktreeInfo(info.name ?? undefined)
16: return {
17: ...info,
18: name: worktree.name,
19: branch: worktree.branch,
20: directory: worktree.directory,
21: }
22: },
23: async create(info) {
24: const config = Config.parse(info)
25: const bootstrap = await Worktree.createFromInfo({
26: name: config.name,
27: directory: config.directory,
28: branch: config.branch,
29: })
30: return bootstrap()
31: },
32: async remove(info) {
33: const config = Config.parse(info)
34: await Worktree.remove({ directory: config.directory })
35: },
36: async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
37: const config = Config.parse(info)
38: const { WorkspaceServer } = await import("../workspace-server/server")
39: const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal")
40: const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
41: headers.set("x-opencode-directory", config.directory)
42:
43: const request = new Request(url, { ...init, headers })
44: return WorkspaceServer.App().fetch(request)
45: },
46: }
(End of file - total 46 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/adaptors/index.ts</path>
<type>file</type>
<content>1: import { lazy } from "@/util/lazy"
2: import type { Adaptor } from "../types"
3:
4: const ADAPTORS: Record<string, () => Promise<Adaptor>> = {
5: worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
6: }
7:
8: export function getAdaptor(type: string): Promise<Adaptor> {
9: return ADAPTORS[type]()
10: }
11:
12: export function installAdaptor(type: string, adaptor: Adaptor) {
13: // This is experimental: mostly used for testing right now, but we
14: // will likely allow this in the future. Need to figure out the
15: // TypeScript story
16:
17: // @ts-expect-error we force the builtin types right now, but we
18: // will implement a way to extend the types for custom adaptors
19: ADAPTORS[type] = () => adaptor
20: }
(End of file - total 20 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace-server/routes.ts</path>
<type>file</type>
<content>1: import { GlobalBus } from "../../bus/global"
2: import { Hono } from "hono"
3: import { streamSSE } from "hono/streaming"
4:
5: export function WorkspaceServerRoutes() {
6: return new Hono().get("/event", async (c) => {
7: c.header("X-Accel-Buffering", "no")
8: c.header("X-Content-Type-Options", "nosniff")
9: return streamSSE(c, async (stream) => {
10: const send = async (event: unknown) => {
11: await stream.writeSSE({
12: data: JSON.stringify(event),
13: })
14: }
15: const handler = async (event: { directory?: string; payload: unknown }) => {
16: await send(event.payload)
17: }
18: GlobalBus.on("event", handler)
19: await send({ type: "server.connected", properties: {} })
20: const heartbeat = setInterval(() => {
21: void send({ type: "server.heartbeat", properties: {} })
22: }, 10_000)
23:
24: await new Promise<void>((resolve) => {
25: stream.onAbort(() => {
26: clearInterval(heartbeat)
27: GlobalBus.off("event", handler)
28: resolve()
29: })
30: })
31: })
32: })
33: }
(End of file - total 33 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/control-plane/workspace-context.ts</path>
<type>file</type>
<content>1: import { Context } from "../util/context"
2:
3: interface Context {
4: workspaceID?: string
5: }
6:
7: const context = Context.create<Context>("workspace")
8:
9: export const WorkspaceContext = {
10: async provide<R>(input: { workspaceID?: string; fn: () => R }): Promise<R> {
11: return context.provide({ workspaceID: input.workspaceID }, async () => {
12: return input.fn()
13: })
14: },
15:
16: get workspaceID() {
17: try {
18: return context.use().workspaceID
19: } catch (e) {
20: return undefined
21: }
22: },
23: }
(End of file - total 23 lines)
</content><path>/home/jm/data/code/opencode/packages/opencode/test/control-plane/workspace-sync.test.ts</path>
<type>file</type>
<content>1: import { afterEach, describe, expect, mock, test } from "bun:test"
2: import { Identifier } from "../../src/id/id"
3: import { Log } from "../../src/util/log"
4: import { tmpdir } from "../fixture/fixture"
5: import { Project } from "../../src/project/project"
6: import { Database } from "../../src/storage/db"
7: import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
8: import { GlobalBus } from "../../src/bus/global"
9: import { resetDatabase } from "../fixture/db"
10: import * as adaptors from "../../src/control-plane/adaptors"
11: import type { Adaptor } from "../../src/control-plane/types"
12:
13: afterEach(async () => {
14: mock.restore()
15: await resetDatabase()
16: })
17:
18: Log.init({ print: false })
19:
20: const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
21:
22: const TestAdaptor: Adaptor = {
23: configure(config) {
24: return config
25: },
26: async create() {
27: throw new Error("not used")
28: },
29: async remove() {},
30: async fetch(_config: unknown, _input: RequestInfo | URL, _init?: RequestInit) {
31: const body = new ReadableStream<Uint8Array>({
32: start(controller) {
33: const encoder = new TextEncoder()
34: controller.enqueue(encoder.encode('data: {"type":"remote.ready","properties":{}}\n\n'))
35: controller.close()
36: },
37: })
38: return new Response(body, {
39: status: 200,
40: headers: {
41: "content-type": "text/event-stream",
42: },
43: })
44: },
45: }
46:
47: adaptors.installAdaptor("testing", TestAdaptor)
48:
49: describe("control-plane/workspace.startSyncing", () => {
50: test("syncs only remote workspaces and emits remote SSE events", async () => {
51: const { Workspace } = await import("../../src/control-plane/workspace")...<path>/home/jm/data/code/opencode/packages/opencode/src/cli/cmd/workspace-serve.ts</path>
<type>file</type>
<content>1: import { cmd } from "./cmd"
2: import { withNetworkOptions, resolveNetworkOptions } from "../network"
3: import { WorkspaceServer } from "../../control-plane/workspace-server/server"
4:
5: export const WorkspaceServeCommand = cmd({
6: command: "workspace-serve",
7: builder: (yargs) => withNetworkOptions(yargs),
8: describe: "starts a remote workspace event server",
9: handler: async (args) => {
10: const opts = await resolveNetworkOptions(args)
11: const server = WorkspaceServer.Listen(opts)
12: console.log(`workspace event server listening on http://${server.hostname}:${server.port}/event`)
13: await new Promise(() => {})
14: await server.stop()
15: },
16: })
(End of file - total 16 lines)
</content>No files found<path>/home/jm/data/code/opencode/packages/opencode/src/session/index.ts</path>
<type>file</type>
<content>530: },
531: )
532:
533: export function* list(input?: {
534: directory?: string
535: workspaceID?: string
536: roots?: boolean
537: start?: number
538: search?: string
539: limit?: number
540: }) {
541: const project = Instance.project
542: const conditions = [eq(SessionTable.project_id, project.id)]
543:
544: if (WorkspaceContext.workspaceID) {
545: conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID))
546: }
547: if (input?.directory) {
548: conditions.push(eq(SessionTable.directory, input.directory))
549: }
550: if (input?.roots) {
551: conditions.push(isNull(SessionTable.parent_id))
552: }
553: if (input?.start) {
554: conditions.push(gte(SessionTable.time_updated, input.start))
555: }
556: if (input?.search) {
557: conditions.push(like(SessionTable.title, `%${input.search}%`))
558: }
559:
(Showing lines 530-559 of 886. Use offset=560 to continue.)
</content><path>/home/jm/data/code/opencode/packages/opencode/src/worktree/index.ts</path>
<type>file</type>
<content>530: }
531:
532: const directory = await canonical(input.directory)
533: const primary = await canonical(Instance.worktree)
534: if (directory === primary) {
535: throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
536: }
537:
538: const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
539: if (list.exitCode !== 0) {
540: throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
541: }
542:
543: const lines = outputText(list.stdout)
544: .split("\n")
545: .map((line) => line.trim())
546: const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
547: if (!line) return acc
548: if (line.startsWith("worktree ")) {
549: acc.push({ path: line.slice("worktree ".length).trim() })
550: return acc
551: }
552: const current = acc[acc.length - 1]
553: if (!current) return acc
554: if (line.startsWith("branch ")) {
555: current.branch = line.slice("branch ".length).trim()
556: }
557: return acc
558: }, [])
559:
560: const entry = await (async () => {
561: for (const item of entries) {
562: if (!item.path) continue
563: const key = await canonical(item.path)
564: if (key === directory) return item
565: }
566: })()
567: if (!entry?.path) {
568: throw new ResetFailedError({ message: "Worktree not found" })
569: }
570:
571: const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
572: if (remoteList.exitCode !== 0) {
573: throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
574: }
575:
576: const remotes = outputText(remoteList.stdout)
577: .split("\n")
578: ...Based on my analysis of the codebase, here's a description of the workspaces feature recently introduced in the OpenCode project:
Workspaces are an experimental feature that enables creating isolated environments (currently using Git worktrees) for a project. Each workspace has its own session context, allowing multiple concurrent development contexts within the same project.
The feature is organized under src/control-plane/ with these key components:
| Component | Purpose |
|---|---|
workspace.ts |
CRUD operations, event syncing, lifecycle management |
workspace.sql.ts |
Drizzle schema (workspace table) |
workspace-context.ts |
Async context for workspace-scoped operations |
workspace-router-middleware.ts |
Forwards requests to remote workspaces |
workspace-server/ |
Standalone SSE server for remote workspace events |
adaptors/ |
Pluggable backend implementations (currently worktree) |
types.ts |
WorkspaceInfo schema and Adaptor interface |
// workspace.sql.ts
WorkspaceTable = sqliteTable("workspace", {
id: text().primaryKey(),
type: text().notNull(), // "worktree" currently
branch: text(),
name: text(),
directory: text(),
extra: text({ mode: "json" }), // extensible metadata
project_id: text().references(() => ProjectTable.id, { onDelete: "cascade" }),
})
| Method | Path | Operation ID |
|---|---|---|
POST |
/experimental/workspace/ |
experimental.workspace.create |
GET |
/experimental/workspace/ |
experimental.workspace.list |
DELETE |
/experimental/workspace/:id |
experimental.workspace.remove |
The Adaptor interface allows pluggable workspace backends:
type Adaptor = {
configure(input: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise<void>
remove(config: WorkspaceInfo): Promise<void>
fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise<Response>
}
Currently only worktree is implemented, which uses git worktree to create isolated directory checkouts. The system supports installing custom adaptors via installAdaptor() (used in testing).
Session Scoping: Sessions can be filtered by workspace_id; the WorkspaceContext provides the current workspace ID within request scope.
Remote Syncing: Workspace.startSyncing() connects to remote workspace servers via SSE to receive events. Only non-worktree workspaces are synced (line 135: .filter((space) => space.type !== "worktree")).
Request Forwarding: WorkspaceRouterMiddleware forwards all requests to remote workspaces (currently both GET and mutating; comments indicate future plan to handle GET locally).
Workspace Server: A standalone server (workspace-serve CLI command) that streams events via SSE at /event, used by remote workspaces to push state changes back to the control plane.
setWorkspace() method in the worker allows switching workspace contextSession.list() filters by WorkspaceContext.workspaceIDInstallation.isLocal() check)opencode workspace-serve --hostname <host> --port <port>
# Starts the remote workspace event server
Can you use the @researcher, @explore, and/or any other agents nessecary to describe how the git workspace and changes could be managed from a cloudflare worker/durable object type configuration, performing or tracking these workspace diffs dynamically against a remote self hosted git repository and git daemon
Use the above message and context to generate a prompt and call the task tool with subagent: researcher
Use the above message and context to generate a prompt and call the task tool with subagent: explore
task_id: ses_31f95d8f5ffedxP36XPbbRdOvB (for resuming to continue this task if needed)
<task_result>
基于对 OpenCode 代码库的深入探索,我为您提供一个全面的架构分析摘要:
## 1. 当前 Cloudflare 集成点
**已存在的集成:**
- **Cloudflare AI Gateway**:在 `/src/provider/provider.ts` 中实现了 AI 提供商集成,支持通过环境变量配置 `CLOUDFLARE_GATEWAY_ID`、`CLOUDFLARE_ACCOUNT_ID` 和 `CLOUDFLARE_API_TOKEN`
- **Cloudflare Workers AI**:同一文件中集成了 Cloudflare 的 AI 推理服务
**缺失的集成:**
- 没有 Cloudflare Workers 或 Durable Objects 的实现
- 没有 `wrangler.toml` 配置文件
- 没有 Cloudflare Pages 部署配置
- 没有边缘存储(KV、R2)或边缘数据库(D1)的使用
## 2. Git 工作区管理模式
**当前架构:**
- **工作区管理系统**(`src/worktree/index.ts`):
- 支持创建、删除和重置 git 工作区
- 自动生成随机工作区名称(形容词-名词组合)
- 支持启动脚本执行
- 与项目实例和引导系统集成
- **工作区适配器**(`src/control-plane/adaptors/worktree.ts`):
- 实现 `Adaptor` 接口,支持 `configure`、`create`、`remove`、`fetch` 方法
- 将工作区请求路由到本地工作区服务器
- 支持远程工作区事件服务器
- **工作区路由中间件**(`src/control-plane/workspace-router-middleware.ts`):
- 自动转发非 GET 请求到远程工作区
- 处理工作区上下文和会话管理
- **远程工作区事件服务器**(`src/cli/cmd/workspace-serve.ts`):
- 基于 Hono 的 HTTP 服务器
- 支持网络配置选项
- 提供工作区事件流
## 3. 实时同步机制
**SSE(Server-Sent Events)实现:**
- **SSE 解析器**(`src/control-plane/sse.ts`):
- 解析 SSE 数据流,支持 `data`、`id`、`retry` 字段
- 处理 JSON 和文本事件
- 支持中断信号和流控制
- **全局事件总线**(`src/bus/global.ts`):
- 基于 EventEmitter 的事件发布/订阅系统
- 支持跨模块事件通信
- 集成到工作区同步系统
- **工作区事件循环**(`src/control-plane/workspace.ts`):
- 持续轮询远程工作区事件端点
- 处理事件重连和错误恢复
- 将事件路由到全局事件总线
- **实时心跳机制**:
- 每 10 秒发送心跳事件
- 每 30 秒主事件流心跳
- 防止超时和连接中断
## 4. 会话和工作区生命周期管理
**会话管理**(`src/session/index.ts`):
- 完整的会话 CRUD 操作
- 支持父/子会话关系
- 会话状态跟踪(创建、更新、归档)
- 会话分享和权限控制
**工作区生命周期**(`src/control-plane/workspace.ts`):
- 工作区创建、列出、删除操作
- 工作区类型适配器模式
- 工作区事件同步
- 工作区配置管理
**项目管理**(`src/project/project.ts`):
- 项目发现和初始化
- Git VCS 集成
- 项目配置和命令管理
- 沙箱环境支持
## 5. 关键架构模式和抽象
**适配器模式**:
- 工作区类型通过适配器接口抽象
- 支持动态工作区类型扩展
- 隔离具体实现细节
**上下文模式**:
- 工作区上下文提供请求级状态
- 支持嵌套上下文提供
- 线程安全的状态管理
**事件驱动架构**:
- 全局事件总线实现松耦合通信
- 支持跨模块事件传播
- 工作区事件同步基于此模式
**数据库抽象**:
- SQLite 数据库(Drizzle ORM)
- 模式迁移管理
- 事...task_id: ses_31f92c889ffe7Ok4yRZedhtn5i (for resuming to continue this task if needed)
<task_result>
Now let me provide a summary of my findings and recommendations:
# Dynamic Git Workspace Management with Cloudflare Workers + Durable Objects - Technical Analysis
Based on my deep research of the OpenCode codebase and Cloudflare platform capabilities, here's my comprehensive technical analysis:
## Key Findings
### 1. **Current OpenCode Architecture**
- Uses a pluggable `Adaptor` interface for workspace implementations
- Has `WorktreeAdaptor` for local git worktrees
- Uses SSE for real-time workspace events
- Has `WorkspaceRouterMiddleware` for routing requests to appropriate adaptors
- Simple database schema for workspace metadata
### 2. **Cloudflare Platform Capabilities**
- **Durable Objects**: Perfect for per-workspace state coordination (single-threaded, strongly consistent)
- **R2 Storage**: Ideal for git objects and large files (S3-compatible, no egress fees)
- **D1 Database**: Good for workspace metadata and diff tracking
- **Workers**: Edge compute with WebSocket hibernation for real-time updates
- **git-on-cloudflare**: Existing open-source implementation of Git Smart HTTP v2 on Cloudflare
## Recommended Architecture
### Hybrid Approach: Edge Coordination + Optional Git Daemon
```
┌─────────────────┐ ┌─────────────────────────────────┐
│ OpenCode │ │ Cloudflare Platform │
│ Client │ │ │
│ │ │ ┌─────────────────────────┐ │
│ ┌──────────┐ │ │ │ Entry Worker │ │
│ │Workspace │ │ │ │ (Route/Proxy/Auth) │ │
│ │Context │───┼───►│ └──────────┬────────────┘ │
│ └──────────┘ │ │ │ │
│ │ │ ▼ │
│ ┌──────────┐ │ │ ┌─────────────────────────┐ │
│ │Adaptor │ │ │ │ Durable Object │ │
│ │Interface │ │ │ │ (Per-works...Based on my research of the codebase and Cloudflare platform capabilities, here's a comprehensive technical analysis for implementing dynamic git workspace management using Cloudflare Workers and Durable Objects.
The proposed architecture extends OpenCode's existing Adaptor pattern to support cloud-native git workspace management while maintaining compatibility with the current local worktree implementation.
┌─────────────────────────────────────────────────────────────────────┐
│ OpenCode Client │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ WorkspaceContext │ Adaptor Interface │ SSE Events │ │
│ └────────────────────┴─────────────────────┴────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Cloudflare Edge │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Entry Worker (Router/Auth) │ │
│ │ • Route requests to appropriate Durable Object │ │
│ │ • Handle authentication and rate limiting │ │
│ │ • WebSocket upgrade for real-time updates │ │
│ └──────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Durable Object (Per-Workspace) │ │
│ │ • Strong consistency for workspace state │ │
│ │ • Git operation coordination │ │
│ │ • Real-time event broadcasting via WebSocket │ │
│ │ • Diff computation and caching │ │
│ └──────────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────┴───────────────────────────────┐ │
│ │ Storage Layer │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │
│ │ │ R2 │ │ D1 │ │ KV │ │ │
│ │ │ Git Objects │ │ Metadata │ │ Config/Cache │ │ │
│ │ │ & Files │ │ & Diffs │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼ (optional)
┌─────────────────────────────────────────────────────────────────────┐
│ Self-Hosted Git Daemon │
│ • Smart HTTP v2 protocol │
│ • Large repository handling │
│ • Advanced git operations (rebase, merge, etc.) │
└─────────────────────────────────────────────────────────────────────┘
// src/control-plane/types.ts - Extended
import { z } from "zod"
export const CloudflareWorkspaceInfo = z.object({
// Base workspace info
id: Identifier.schema("workspace"),
type: z.literal("cloudflare-worker"),
branch: z.string().nullable(),
name: z.string().nullable(),
directory: z.string().nullable(),
extra: z.unknown().nullable(),
projectID: z.string(),
// Cloudflare-specific fields
cfAccountId: z.string(),
cfWorkerName: z.string(),
cfDurableObjectId: z.string(),
gitRemoteUrl: z.string(),
lastSyncAt: z.number().optional(),
diffHash: z.string().optional(),
})
export type CloudflareWorkspaceInfo = z.infer<typeof CloudflareWorkspaceInfo>
export interface CloudflareAdaptor extends Adaptor {
// Git operations
getGitUrl(config: CloudflareWorkspaceInfo): Promise<string>
fetchDiff(config: CloudflareWorkspaceInfo, baseRef: string, headRef: string): Promise<GitDiff>
createBranch(config: CloudflareWorkspaceInfo, branchName: string): Promise<void>
commitChanges(config: CloudflareWorkspaceInfo, message: string, files: FileChange[]): Promise<string>
// Real-time updates
subscribeToChanges(config: CloudflareWorkspaceInfo): WebSocket
getWorkspaceEvents(config: CloudflareWorkspaceInfo): ReadableStream<WorkspaceEvent>
// State management
getWorkspaceState(config: CloudflareWorkspaceInfo): Promise<WorkspaceState>
updateWorkspaceState(config: CloudflareWorkspaceInfo, state: Partial<WorkspaceState>): Promise<void>
}
export interface GitDiff {
baseRef: string
headRef: string
files: DiffFile[]
summary: DiffSummary
computedAt: number
}
export interface DiffFile {
path: string
status: 'added' | 'modified' | 'deleted' | 'renamed'
additions: number
deletions: number
patch?: string
}
export interface DiffSummary {
filesChanged: number
additions: number
deletions: number
}
export interface WorkspaceState {
currentBranch: string
lastCommit: string
isDirty: boolean
stagedFiles: string[]
untrackedFiles: string[]
lastSyncAt: number
}
-- Add to workspace table or create new table
CREATE TABLE workspace_diff (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
base_ref TEXT NOT NULL,
head_ref TEXT NOT NULL,
diff_hash TEXT NOT NULL,
files_changed INTEGER NOT NULL,
additions INTEGER NOT NULL,
deletions INTEGER NOT NULL,
computed_at INTEGER NOT NULL,
expires_at INTEGER,
INDEX workspace_diff_workspace_idx ON workspace_diff(workspace_id),
INDEX workspace_diff_hash_idx ON workspace_diff(diff_hash)
);
CREATE TABLE workspace_event (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
INDEX workspace_event_workspace_idx ON workspace_event(workspace_id),
INDEX workspace_event_created_idx ON workspace_event(created_at)
);
Objective: Basic integration with Cloudflare Workers API
// src/control-plane/adaptors/cloudflare.ts
import { type Adaptor, CloudflareWorkspaceInfo } from "../types"
export const CloudflareAdaptor: CloudflareAdaptor = {
async configure(info) {
// Validate Cloudflare credentials
// Generate unique workspace identifiers
// Set up Durable Object binding
return {
...info,
cfDurableObjectId: generateDurableObjectId(info.id),
lastSyncAt: Date.now(),
}
},
async create(info) {
const config = CloudflareWorkspaceInfo.parse(info)
// 1. Create Durable Object for workspace
await createDurableObject(config.cfAccountId, config.cfDurableObjectId)
// 2. Initialize git repository in R2
await initializeGitRepo(config)
// 3. Set up event streaming
await setupEventStreaming(config)
// 4. Store initial state
await storeWorkspaceState(config, {
currentBranch: config.branch || 'main',
lastCommit: '',
isDirty: false,
stagedFiles: [],
untrackedFiles: [],
lastSyncAt: Date.now(),
})
},
async remove(config) {
// 1. Clean up Durable Object
await deleteDurableObject(config.cfAccountId, config.cfDurableObjectId)
// 2. Remove git data from R2
await cleanupGitData(config)
// 3. Remove metadata from D1
await removeWorkspaceMetadata(config.id)
},
async fetch(config, input, init) {
// Route request to Durable Object
const durableObjectUrl = getDurableObjectUrl(config)
const request = new Request(durableObjectUrl + input, init)
return fetch(request)
},
// Cloudflare-specific methods
async getGitUrl(config) {
return `https://${config.cfWorkerName}.${config.cfAccountId}.workers.dev/git`
},
async fetchDiff(config, baseRef, headRef) {
// Use Durable Object to compute diff
const durableObject = getDurableObjectStub(config.cfDurableObjectId)
return durableObject.fetch('/diff', {
method: 'POST',
body: JSON.stringify({ baseRef, headRef }),
}).then(r => r.json())
},
async subscribeToChanges(config) {
// WebSocket connection to Durable Object
const wsUrl = `wss://${config.cfWorkerName}.${config.cfAccountId}.workers.dev/ws?workspace=${config.id}`
return new WebSocket(wsUrl)
},
}
Objective: Per-workspace state coordination and git operations
// src/control-plane/workspace-server/cloudflare-do.ts
import { DurableObject } from 'cloudflare:workers'
export class WorkspaceDurableObject extends DurableObject {
private workspaceId: string
private state: WorkspaceState
private connections: Set<WebSocket>
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env)
this.workspaceId = ctx.id.toString()
this.connections = new Set()
this.state = this.loadState()
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
// WebSocket upgrade for real-time updates
if (url.pathname === '/ws') {
return this.handleWebSocket(request)
}
// Git operations
if (url.pathname.startsWith('/git/')) {
return this.handleGitOperation(request)
}
// Diff computation
if (url.pathname === '/diff') {
return this.handleDiffComputation(request)
}
// State management
if (url.pathname === '/state') {
return this.handleStateManagement(request)
}
return new Response('Not Found', { status: 404 })
}
private async handleWebSocket(request: Request): Promise<Response> {
const pair = new WebSocketPair()
const [client, server] = Object.values(pair)
server.accept()
this.connections.add(server)
server.addEventListener('close', () => {
this.connections.delete(server)
})
// Send current state on connect
server.send(JSON.stringify({
type: 'state',
payload: this.state,
}))
return new Response(null, {
status: 101,
webSocket: client,
})
}
private async handleGitOperation(request: Request): Promise<Response> {
const operation = new URL(request.url).pathname.split('/git/')[1]
switch (operation) {
case 'clone':
return this.handleClone(request)
case 'fetch':
return this.handleFetch(request)
case 'diff':
return this.handleDiff(request)
case 'commit':
return this.handleCommit(request)
case 'branch':
return this.handleBranch(request)
default:
return new Response('Unknown git operation', { status: 400 })
}
}
private async handleClone(request: Request): Promise<Response> {
const { remoteUrl, branch } = await request.json()
// Clone repository to R2
const repo = await this.cloneToR2(remoteUrl, branch)
// Update state
this.state = {
...this.state,
currentBranch: branch || 'main',
lastCommit: repo.headCommit,
lastSyncAt: Date.now(),
}
// Broadcast update
this.broadcastState()
return Response.json({ success: true, commit: repo.headCommit })
}
private async handleDiffComputation(request: Request): Promise<Response> {
const { baseRef, headRef } = await request.json()
// Check cache first
const cacheKey = `diff:${baseRef}:${headRef}`
const cached = await this.ctx.storage.get(cacheKey)
if (cached) {
return Response.json(cached)
}
// Compute diff
const diff = await this.computeDiff(baseRef, headRef)
// Cache result
await this.ctx.storage.put(cacheKey, diff, {
expirationTtl: 3600, // 1 hour
})
return Response.json(diff)
}
private async broadcastState() {
const message = JSON.stringify({
type: 'state',
payload: this.state,
})
for (const ws of this.connections) {
try {
ws.send(message)
} catch {
this.connections.delete(ws)
}
}
}
private loadState(): WorkspaceState {
// Load from Durable Object storage
return this.ctx.storage.get('state') || {
currentBranch: 'main',
lastCommit: '',
isDirty: false,
stagedFiles: [],
untrackedFiles: [],
lastSyncAt: 0,
}
}
private async saveState() {
await this.ctx.storage.put('state', this.state)
}
}
Objective: Edge-native git operations using R2 storage
// src/control-plane/workspace-server/git-operations.ts
import { R2Bucket } from '@cloudflare/workers-types'
export class EdgeGitOperations {
constructor(
private r2: R2Bucket,
private workspaceId: string,
) {}
async clone(remoteUrl: string, branch: string = 'main'): Promise<GitRepo> {
// 1. Fetch git objects from remote
const objects = await this.fetchGitObjects(remoteUrl)
// 2. Store objects in R2
await this.storeGitObjects(objects)
// 3. Create refs
await this.createRefs(branch, objects.headCommit)
// 4. Checkout working directory
await this.checkoutWorkingDirectory(branch)
return {
headCommit: objects.headCommit,
branch,
lastSync: Date.now(),
}
}
async diff(baseRef: string, headRef: string): Promise<GitDiff> {
// 1. Get commit objects
const baseCommit = await this.getCommit(baseRef)
const headCommit = await this.getCommit(headRef)
// 2. Compare trees
const baseTree = await this.getTree(baseCommit.tree)
const headTree = await this.getTree(headCommit.tree)
// 3. Compute diff
const diff = await this.computeTreeDiff(baseTree, headTree)
return {
baseRef,
headRef,
files: diff.files,
summary: diff.summary,
computedAt: Date.now(),
}
}
async commit(message: string, files: FileChange[]): Promise<string> {
// 1. Update working directory files in R2
await this.updateWorkingDirectory(files)
// 2. Create tree object
const tree = await this.createTree(files)
// 3. Create commit object
const commit = await this.createCommit(tree, message)
// 4. Update ref
await this.updateRef('HEAD', commit.hash)
return commit.hash
}
private async fetchGitObjects(remoteUrl: string): Promise<GitObjects> {
// Implement Smart HTTP v2 protocol
// This is complex - consider using git-on-cloudflare or delegating to self-hosted daemon
throw new Error('Not implemented - requires git protocol implementation')
}
private async computeTreeDiff(baseTree: GitTree, headTree: GitTree): Promise<GitDiff> {
// Compare trees and compute file-level diffs
const files: DiffFile[] = []
let additions = 0
let deletions = 0
// Implementation depends on git object format
// This is a simplified version
return {
files,
summary: {
filesChanged: files.length,
additions,
deletions,
},
}
}
}
Objective: Seamless integration with existing workspace system
// src/control-plane/workspace-router-middleware.ts - Extended
import { WorkspaceRouterMiddleware as BaseMiddleware } from "./workspace-router-middleware"
export const CloudflareWorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => {
// Check if current workspace is cloudflare type
const workspaceID = WorkspaceContext.workspaceID
if (!workspaceID) return next()
const workspace = await Workspace.get(workspaceID)
if (!workspace || workspace.type !== 'cloudflare-worker') {
return next()
}
// Route to Cloudflare adaptor
const adaptor = await getAdaptor('cloudflare-worker')
const response = await adaptor.fetch(workspace, c.req.url, {
method: c.req.method,
headers: c.req.raw.headers,
body: c.req.method === 'GET' ? undefined : await c.req.arrayBuffer(),
})
return new Response(response.body, {
status: response.status,
headers: response.headers,
})
}
Challenge: Implementing Smart HTTP v2 in Cloudflare Workers is complex due to CPU time limits.
Solutions:
git-on-cloudflare open-source implementationRecommendation: Start with Option B (self-hosted daemon) for full git support, then gradually implement edge-native operations for common use cases.
R2 Storage Structure:
/workspace-{id}/
├── objects/ # Git objects (packfiles)
├── refs/ # Branch references
├── working/ # Working directory files
└── cache/ # Computed diffs and metadata
D1 Tables:
workspace_metadata: Workspace configurationworkspace_diffs: Computed diff cacheworkspace_events: Event log for syncWebSocket Hibernation Pattern:
Caching Strategy:
Diff Computation:
Estimated Costs (per 1000 workspaces):
Optimization:
Adaptor interface with Cloudflare-specific methodsCloudflareAdaptor with proxy to existing workspace serverWorktreeAdaptor remains unchangedCloudflareAdaptor implements same Adaptor interfaceWorkspaceRouterMiddleware routes based on workspace typecloudflare-workerThis architecture provides a scalable, cost-effective solution for dynamic git workspace management while maintaining compatibility with OpenCode's existing workspace system. The incremental implementation approach allows for gradual rollout and validation at each phase.