diff --git a/packages/opencode/migration/20260225215848_workspace/migration.sql b/packages/opencode/migration/20260225215848_workspace/migration.sql new file mode 100644 index 000000000..5b1b4e5a4 --- /dev/null +++ b/packages/opencode/migration/20260225215848_workspace/migration.sql @@ -0,0 +1,7 @@ +CREATE TABLE `workspace` ( + `id` text PRIMARY KEY, + `branch` text, + `project_id` text NOT NULL, + `config` text NOT NULL, + CONSTRAINT `fk_workspace_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE +); diff --git a/packages/opencode/migration/20260225215848_workspace/snapshot.json b/packages/opencode/migration/20260225215848_workspace/snapshot.json new file mode 100644 index 000000000..12fb93bee --- /dev/null +++ b/packages/opencode/migration/20260225215848_workspace/snapshot.json @@ -0,0 +1,1009 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "1f1dbf2d-bf66-4b25-8af4-4ba7633b7e40", + "prevIds": [ + "d2736e43-700f-4e9e-8151-9f2f0d967bc8" + ], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "config", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": [ + "message_id" + ], + "tableTo": "message", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": [ + "project_id" + ], + "tableTo": "project", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": [ + "session_id" + ], + "tableTo": "session", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": [ + "email", + "url" + ], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": [ + "session_id", + "position" + ], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": [ + "project_id" + ], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": [ + "session_id" + ], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index bee2c8f71..8f4bb0144 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,6 +2,9 @@ import { Server } from "../../server/server" import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "../../flag/flag" +import { Workspace } from "../../control-plane/workspace" +import { Project } from "../../project/project" +import { Installation } from "../../installation" export const ServeCommand = cmd({ command: "serve", @@ -14,7 +17,15 @@ export const ServeCommand = cmd({ const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + + let workspaceSync: Array> = [] + // Only available in development right now + if (Installation.isLocal()) { + workspaceSync = Project.list().map((project) => Workspace.startSyncing(project)) + } + await new Promise(() => {}) await server.stop() + await Promise.all(workspaceSync.map((item) => item.stop())) }, }) diff --git a/packages/opencode/src/cli/cmd/workspace-serve.ts b/packages/opencode/src/cli/cmd/workspace-serve.ts index 9b47defd3..cb5c304e4 100644 --- a/packages/opencode/src/cli/cmd/workspace-serve.ts +++ b/packages/opencode/src/cli/cmd/workspace-serve.ts @@ -1,59 +1,16 @@ import { cmd } from "./cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" -import { Installation } from "../../installation" +import { WorkspaceServer } from "../../control-plane/workspace-server/server" export const WorkspaceServeCommand = cmd({ command: "workspace-serve", builder: (yargs) => withNetworkOptions(yargs), - describe: "starts a remote workspace websocket server", + describe: "starts a remote workspace event server", handler: async (args) => { const opts = await resolveNetworkOptions(args) - const server = Bun.serve<{ id: string }>({ - hostname: opts.hostname, - port: opts.port, - fetch(req, server) { - const url = new URL(req.url) - if (url.pathname === "/ws") { - const id = Bun.randomUUIDv7() - if (server.upgrade(req, { data: { id } })) return - return new Response("Upgrade failed", { status: 400 }) - } - - if (url.pathname === "/health") { - return new Response("ok", { - status: 200, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }) - } - - return new Response( - JSON.stringify({ - service: "workspace-server", - ws: `ws://${server.hostname}:${server.port}/ws`, - }), - { - status: 200, - headers: { - "content-type": "application/json; charset=utf-8", - }, - }, - ) - }, - websocket: { - open(ws) { - ws.send(JSON.stringify({ type: "ready", id: ws.data.id })) - }, - message(ws, msg) { - const text = typeof msg === "string" ? msg : msg.toString() - ws.send(JSON.stringify({ type: "message", id: ws.data.id, text })) - }, - close() {}, - }, - }) - - console.log(`workspace websocket server listening on ws://${server.hostname}:${server.port}/ws`) + const server = WorkspaceServer.Listen(opts) + console.log(`workspace event server listening on http://${server.hostname}:${server.port}/event`) await new Promise(() => {}) + await server.stop() }, }) diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts new file mode 100644 index 000000000..77e1f53c6 --- /dev/null +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -0,0 +1,10 @@ +import { WorktreeAdaptor } from "./worktree" +import type { Config } from "../config" +import type { Adaptor } from "./types" + +export function getAdaptor(config: Config): Adaptor { + switch (config.type) { + case "worktree": + return WorktreeAdaptor + } +} diff --git a/packages/opencode/src/control-plane/adaptors/types.ts b/packages/opencode/src/control-plane/adaptors/types.ts new file mode 100644 index 000000000..47a0405a5 --- /dev/null +++ b/packages/opencode/src/control-plane/adaptors/types.ts @@ -0,0 +1,7 @@ +import type { Config } from "../config" + +export type Adaptor = { + create(from: T, branch?: string | null): Promise<{ config: T; init: () => Promise }> + remove(from: T): Promise + request(from: T, method: string, url: string, data?: BodyInit, signal?: AbortSignal): Promise +} diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts new file mode 100644 index 000000000..e355bb770 --- /dev/null +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -0,0 +1,26 @@ +import { Worktree } from "@/worktree" +import type { Config } from "../config" +import type { Adaptor } from "./types" + +type WorktreeConfig = Extract + +export const WorktreeAdaptor: Adaptor = { + async create(_from: WorktreeConfig, _branch: string) { + const next = await Worktree.create(undefined) + return { + config: { + type: "worktree", + directory: next.directory, + }, + // Hack for now: `Worktree.create` puts all its async code in a + // `setTimeout` so it doesn't use this, but we should change that + init: async () => {}, + } + }, + async remove(config: WorktreeConfig) { + await Worktree.remove({ directory: config.directory }) + }, + async request(_from: WorktreeConfig, _method: string, _url: string, _data?: BodyInit, _signal?: AbortSignal) { + throw new Error("worktree does not support request") + }, +} diff --git a/packages/opencode/src/control-plane/config.ts b/packages/opencode/src/control-plane/config.ts new file mode 100644 index 000000000..73dbc4bdb --- /dev/null +++ b/packages/opencode/src/control-plane/config.ts @@ -0,0 +1,10 @@ +import z from "zod" + +export const Config = z.discriminatedUnion("type", [ + z.object({ + directory: z.string(), + type: z.literal("worktree"), + }), +]) + +export type Config = z.infer diff --git a/packages/opencode/src/control-plane/session-proxy-middleware.ts b/packages/opencode/src/control-plane/session-proxy-middleware.ts new file mode 100644 index 000000000..df2591017 --- /dev/null +++ b/packages/opencode/src/control-plane/session-proxy-middleware.ts @@ -0,0 +1,46 @@ +import { Instance } from "@/project/instance" +import type { MiddlewareHandler } from "hono" +import { Installation } from "../installation" +import { getAdaptor } from "./adaptors" +import { Workspace } from "./workspace" + +// This middleware forwards all non-GET requests if the workspace is a +// remote. The remote workspace needs to handle session mutations +async function proxySessionRequest(req: Request) { + if (req.method === "GET") return + if (!Instance.directory.startsWith("wrk_")) return + + const workspace = await Workspace.get(Instance.directory) + if (!workspace) { + return new Response(`Workspace not found: ${Instance.directory}`, { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + if (workspace.config.type === "worktree") return + + const url = new URL(req.url) + const body = req.method === "HEAD" ? undefined : await req.arrayBuffer() + return getAdaptor(workspace.config).request( + workspace.config, + req.method, + `${url.pathname}${url.search}`, + body, + req.signal, + ) +} + +export const SessionProxyMiddleware: MiddlewareHandler = async (c, next) => { + // Only available in development for now + if (!Installation.isLocal()) { + return next() + } + + const response = await proxySessionRequest(c.req.raw) + if (response) { + return response + } + return next() +} diff --git a/packages/opencode/src/control-plane/sse.ts b/packages/opencode/src/control-plane/sse.ts new file mode 100644 index 000000000..003093a00 --- /dev/null +++ b/packages/opencode/src/control-plane/sse.ts @@ -0,0 +1,66 @@ +export async function parseSSE( + body: ReadableStream, + signal: AbortSignal, + onEvent: (event: unknown) => void, +) { + const reader = body.getReader() + const decoder = new TextDecoder() + let buf = "" + let last = "" + let retry = 1000 + + const abort = () => { + void reader.cancel().catch(() => undefined) + } + + signal.addEventListener("abort", abort) + + try { + while (!signal.aborted) { + const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined })) + if (chunk.done) break + + buf += decoder.decode(chunk.value, { stream: true }) + buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + const chunks = buf.split("\n\n") + buf = chunks.pop() ?? "" + + chunks.forEach((chunk) => { + const data: string[] = [] + chunk.split("\n").forEach((line) => { + if (line.startsWith("data:")) { + data.push(line.replace(/^data:\s*/, "")) + return + } + if (line.startsWith("id:")) { + last = line.replace(/^id:\s*/, "") + return + } + if (line.startsWith("retry:")) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (!Number.isNaN(parsed)) retry = parsed + } + }) + + if (!data.length) return + const raw = data.join("\n") + try { + onEvent(JSON.parse(raw)) + } catch { + onEvent({ + type: "sse.message", + properties: { + data: raw, + id: last || undefined, + retry, + }, + }) + } + }) + } + } finally { + signal.removeEventListener("abort", abort) + reader.releaseLock() + } +} diff --git a/packages/opencode/src/control-plane/workspace-server/routes.ts b/packages/opencode/src/control-plane/workspace-server/routes.ts new file mode 100644 index 000000000..353e5d50a --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-server/routes.ts @@ -0,0 +1,33 @@ +import { GlobalBus } from "../../bus/global" +import { Hono } from "hono" +import { streamSSE } from "hono/streaming" + +export function WorkspaceServerRoutes() { + return new Hono().get("/event", async (c) => { + c.header("X-Accel-Buffering", "no") + c.header("X-Content-Type-Options", "nosniff") + return streamSSE(c, async (stream) => { + const send = async (event: unknown) => { + await stream.writeSSE({ + data: JSON.stringify(event), + }) + } + const handler = async (event: { directory?: string; payload: unknown }) => { + await send(event.payload) + } + GlobalBus.on("event", handler) + await send({ type: "server.connected", properties: {} }) + const heartbeat = setInterval(() => { + void send({ type: "server.heartbeat", properties: {} }) + }, 10_000) + + await new Promise((resolve) => { + stream.onAbort(() => { + clearInterval(heartbeat) + GlobalBus.off("event", handler) + resolve() + }) + }) + }) + }) +} diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts new file mode 100644 index 000000000..716989942 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-server/server.ts @@ -0,0 +1,24 @@ +import { Hono } from "hono" +import { SessionRoutes } from "../../server/routes/session" +import { WorkspaceServerRoutes } from "./routes" + +export namespace WorkspaceServer { + export function App() { + const session = new Hono() + .use("*", async (c, next) => { + if (c.req.method === "GET") return c.notFound() + await next() + }) + .route("/", SessionRoutes()) + + return new Hono().route("/session", session).route("/", WorkspaceServerRoutes()) + } + + export function Listen(opts: { hostname: string; port: number }) { + return Bun.serve({ + hostname: opts.hostname, + port: opts.port, + fetch: App().fetch, + }) + } +} diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts new file mode 100644 index 000000000..1a2011982 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -0,0 +1,12 @@ +import { sqliteTable, text } from "drizzle-orm/sqlite-core" +import { ProjectTable } from "@/project/project.sql" +import type { Config } from "./config" + +export const WorkspaceTable = sqliteTable("workspace", { + id: text().primaryKey(), + branch: text(), + project_id: text() + .notNull() + .references(() => ProjectTable.id, { onDelete: "cascade" }), + config: text({ mode: "json" }).notNull().$type(), +}) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts new file mode 100644 index 000000000..5ce373b12 --- /dev/null +++ b/packages/opencode/src/control-plane/workspace.ts @@ -0,0 +1,160 @@ +import z from "zod" +import { Identifier } from "@/id/id" +import { fn } from "@/util/fn" +import { Database, eq } from "@/storage/db" +import { Project } from "@/project/project" +import { BusEvent } from "@/bus/bus-event" +import { GlobalBus } from "@/bus/global" +import { Log } from "@/util/log" +import { WorkspaceTable } from "./workspace.sql" +import { Config } from "./config" +import { getAdaptor } from "./adaptors" +import { parseSSE } from "./sse" + +export namespace Workspace { + export const Event = { + Ready: BusEvent.define( + "workspace.ready", + z.object({ + name: z.string(), + }), + ), + Failed: BusEvent.define( + "workspace.failed", + z.object({ + message: z.string(), + }), + ), + } + + export const Info = z + .object({ + id: Identifier.schema("workspace"), + branch: z.string().nullable(), + projectID: z.string(), + config: Config, + }) + .meta({ + ref: "Workspace", + }) + export type Info = z.infer + + function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { + return { + id: row.id, + branch: row.branch, + projectID: row.project_id, + config: row.config, + } + } + + export const create = fn( + z.object({ + id: Identifier.schema("workspace").optional(), + projectID: Info.shape.projectID, + branch: Info.shape.branch, + config: Info.shape.config, + }), + async (input) => { + const id = Identifier.ascending("workspace", input.id) + + const { config, init } = await getAdaptor(input.config).create(input.config, input.branch) + + const info: Info = { + id, + projectID: input.projectID, + branch: input.branch, + config, + } + + setTimeout(async () => { + await init() + + Database.use((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + branch: info.branch, + project_id: info.projectID, + config: info.config, + }) + .run() + }) + + GlobalBus.emit("event", { + directory: id, + payload: { + type: Event.Ready.type, + properties: {}, + }, + }) + }, 0) + + return info + }, + ) + + export function list(project: Project.Info) { + const rows = Database.use((db) => + db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), + ) + return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) + } + + export const get = fn(Identifier.schema("workspace"), async (id) => { + const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (!row) return + return fromRow(row) + }) + + export const remove = fn(Identifier.schema("workspace"), async (id) => { + const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) + if (row) { + const info = fromRow(row) + await getAdaptor(info.config).remove(info.config) + Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) + return info + } + }) + const log = Log.create({ service: "workspace-sync" }) + + async function workspaceEventLoop(space: Info, stop: AbortSignal) { + while (!stop.aborted) { + const res = await getAdaptor(space.config) + .request(space.config, "GET", "/event", undefined, stop) + .catch(() => undefined) + if (!res || !res.ok || !res.body) { + await Bun.sleep(1000) + continue + } + await parseSSE(res.body, stop, (event) => { + GlobalBus.emit("event", { + directory: space.id, + payload: event, + }) + }) + // Wait 250ms and retry if SSE connection fails + await Bun.sleep(250) + } + } + + export function startSyncing(project: Project.Info) { + const stop = new AbortController() + const spaces = list(project).filter((space) => space.config.type !== "worktree") + + spaces.forEach((space) => { + void workspaceEventLoop(space, stop.signal).catch((error) => { + log.warn("workspace sync listener failed", { + workspaceID: space.id, + error, + }) + }) + }) + + return { + async stop() { + stop.abort() + }, + } + } +} diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index db2920b0a..6673297cb 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -11,6 +11,7 @@ export namespace Identifier { part: "prt", pty: "pty", tool: "tool", + workspace: "wrk", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 8d156c03d..892bca485 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -10,6 +10,7 @@ import { Session } from "../../session" import { zodToJsonSchema } from "zod-to-json-schema" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { WorkspaceRoutes } from "./workspace" export const ExperimentalRoutes = lazy(() => new Hono() @@ -112,6 +113,7 @@ export const ExperimentalRoutes = lazy(() => return c.json(worktree) }, ) + .route("/workspace", WorkspaceRoutes()) .get( "/worktree", describeRoute({ diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index 12938aeab..a39197952 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -16,11 +16,13 @@ import { Log } from "../../util/log" import { PermissionNext } from "@/permission/next" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { SessionProxyMiddleware } from "../../control-plane/session-proxy-middleware" const log = Log.create({ service: "server" }) export const SessionRoutes = lazy(() => new Hono() + .use(SessionProxyMiddleware) .get( "/", describeRoute({ diff --git a/packages/opencode/src/server/routes/workspace.ts b/packages/opencode/src/server/routes/workspace.ts new file mode 100644 index 000000000..0c64c9cd4 --- /dev/null +++ b/packages/opencode/src/server/routes/workspace.ts @@ -0,0 +1,104 @@ +import { Hono } from "hono" +import { describeRoute, resolver, validator } from "hono-openapi" +import z from "zod" +import { Workspace } from "../../control-plane/workspace" +import { Instance } from "../../project/instance" +import { errors } from "../error" +import { lazy } from "../../util/lazy" + +export const WorkspaceRoutes = lazy(() => + new Hono() + .post( + "/:id", + describeRoute({ + summary: "Create workspace", + description: "Create a workspace for the current project.", + operationId: "experimental.workspace.create", + responses: { + 200: { + description: "Workspace created", + content: { + "application/json": { + schema: resolver(Workspace.Info), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: Workspace.Info.shape.id, + }), + ), + validator( + "json", + z.object({ + branch: Workspace.Info.shape.branch, + config: Workspace.Info.shape.config, + }), + ), + async (c) => { + const { id } = c.req.valid("param") + const body = c.req.valid("json") + const workspace = await Workspace.create({ + id, + projectID: Instance.project.id, + branch: body.branch, + config: body.config, + }) + return c.json(workspace) + }, + ) + .get( + "/", + describeRoute({ + summary: "List workspaces", + description: "List all workspaces.", + operationId: "experimental.workspace.list", + responses: { + 200: { + description: "Workspaces", + content: { + "application/json": { + schema: resolver(z.array(Workspace.Info)), + }, + }, + }, + }, + }), + async (c) => { + return c.json(Workspace.list(Instance.project)) + }, + ) + .delete( + "/:id", + describeRoute({ + summary: "Remove workspace", + description: "Remove an existing workspace.", + operationId: "experimental.workspace.remove", + responses: { + 200: { + description: "Workspace removed", + content: { + "application/json": { + schema: resolver(Workspace.Info.optional()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + id: Workspace.Info.shape.id, + }), + ), + async (c) => { + const { id } = c.req.valid("param") + return c.json(await Workspace.remove(id)) + }, + ), +) diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts index 7961b0e38..4c1c2490e 100644 --- a/packages/opencode/src/storage/schema.ts +++ b/packages/opencode/src/storage/schema.ts @@ -2,3 +2,4 @@ export { ControlAccountTable } from "../control/control.sql" export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql" export { SessionShareTable } from "../share/share.sql" export { ProjectTable } from "../project/project.sql" +export { WorkspaceTable } from "../control-plane/workspace.sql" diff --git a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts new file mode 100644 index 000000000..596e4761e --- /dev/null +++ b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Identifier } from "../../src/id/id" +import { Hono } from "hono" +import { tmpdir } from "../fixture/fixture" +import { Project } from "../../src/project/project" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { Instance } from "../../src/project/instance" +import { Database } from "../../src/storage/db" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + mock.restore() + await resetDatabase() +}) + +type State = { + workspace?: "first" | "second" + calls: Array<{ method: string; url: string; body?: string }> +} + +const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert.config + +async function setup(state: State) { + mock.module("../../src/control-plane/adaptors", () => ({ + getAdaptor: () => ({ + request: async (_config: unknown, method: string, url: string, data?: BodyInit) => { + const body = data ? await new Response(data).text() : undefined + state.calls.push({ method, url, body }) + return new Response("proxied", { status: 202 }) + }, + }), + })) + + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const id1 = Identifier.descending("workspace") + const id2 = Identifier.descending("workspace") + + Database.use((db) => + db + .insert(WorkspaceTable) + .values([ + { + id: id1, + branch: "main", + project_id: project.id, + config: remote, + }, + { + id: id2, + branch: "main", + project_id: project.id, + config: { type: "worktree", directory: tmp.path }, + }, + ]) + .run(), + ) + + const { SessionProxyMiddleware } = await import("../../src/control-plane/session-proxy-middleware") + const app = new Hono().use(SessionProxyMiddleware) + + return { + id1, + id2, + app, + async request(input: RequestInfo | URL, init?: RequestInit) { + return Instance.provide({ + directory: state.workspace === "first" ? id1 : id2, + fn: async () => app.request(input, init), + }) + }, + } +} + +describe("control-plane/session-proxy-middleware", () => { + test("forwards non-GET session requests for remote workspaces", async () => { + const state: State = { + workspace: "first", + calls: [], + } + + const ctx = await setup(state) + + ctx.app.post("/session/foo", (c) => c.text("local", 200)) + const response = await ctx.request("http://workspace.test/session/foo?x=1", { + method: "POST", + body: JSON.stringify({ hello: "world" }), + headers: { + "content-type": "application/json", + }, + }) + + expect(response.status).toBe(202) + expect(await response.text()).toBe("proxied") + expect(state.calls).toEqual([ + { + method: "POST", + url: "/session/foo?x=1", + body: '{"hello":"world"}', + }, + ]) + }) + + test("does not forward GET requests", async () => { + const state: State = { + workspace: "first", + calls: [], + } + + const ctx = await setup(state) + + ctx.app.get("/session/foo", (c) => c.text("local", 200)) + const response = await ctx.request("http://workspace.test/session/foo?x=1") + + expect(response.status).toBe(200) + expect(await response.text()).toBe("local") + expect(state.calls).toEqual([]) + }) + + test("does not forward GET or POST requests for worktree workspaces", async () => { + const state: State = { + workspace: "second", + calls: [], + } + + const ctx = await setup(state) + + ctx.app.get("/session/foo", (c) => c.text("local-get", 200)) + ctx.app.post("/session/foo", (c) => c.text("local-post", 200)) + + const getResponse = await ctx.request("http://workspace.test/session/foo?x=1") + const postResponse = await ctx.request("http://workspace.test/session/foo?x=1", { + method: "POST", + body: JSON.stringify({ hello: "world" }), + headers: { + "content-type": "application/json", + }, + }) + + expect(getResponse.status).toBe(200) + expect(await getResponse.text()).toBe("local-get") + expect(postResponse.status).toBe(200) + expect(await postResponse.text()).toBe("local-post") + expect(state.calls).toEqual([]) + }) +}) diff --git a/packages/opencode/test/control-plane/sse.test.ts b/packages/opencode/test/control-plane/sse.test.ts new file mode 100644 index 000000000..78a8341c0 --- /dev/null +++ b/packages/opencode/test/control-plane/sse.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { parseSSE } from "../../src/control-plane/sse" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await resetDatabase() +}) + +function stream(chunks: string[]) { + return new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + chunks.forEach((chunk) => controller.enqueue(encoder.encode(chunk))) + controller.close() + }, + }) +} + +describe("control-plane/sse", () => { + test("parses JSON events with CRLF and multiline data blocks", async () => { + const events: unknown[] = [] + const stop = new AbortController() + + await parseSSE( + stream([ + 'data: {"type":"one","properties":{"ok":true}}\r\n\r\n', + 'data: {"type":"two",\r\ndata: "properties":{"n":2}}\r\n\r\n', + ]), + stop.signal, + (event) => events.push(event), + ) + + expect(events).toEqual([ + { type: "one", properties: { ok: true } }, + { type: "two", properties: { n: 2 } }, + ]) + }) + + test("falls back to sse.message for non-json payload", async () => { + const events: unknown[] = [] + const stop = new AbortController() + + await parseSSE(stream(["id: abc\nretry: 1500\ndata: hello world\n\n"]), stop.signal, (event) => events.push(event)) + + expect(events).toEqual([ + { + type: "sse.message", + properties: { + data: "hello world", + id: "abc", + retry: 1500, + }, + }, + ]) + }) +}) diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts new file mode 100644 index 000000000..91504af0f --- /dev/null +++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Log } from "../../src/util/log" +import { WorkspaceServer } from "../../src/control-plane/workspace-server/server" +import { parseSSE } from "../../src/control-plane/sse" +import { GlobalBus } from "../../src/bus/global" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + await resetDatabase() +}) + +Log.init({ print: false }) + +describe("control-plane/workspace-server SSE", () => { + test("streams GlobalBus events and parseSSE reads them", async () => { + const app = WorkspaceServer.App() + const stop = new AbortController() + const seen: unknown[] = [] + + try { + const response = await app.request("/event", { + signal: stop.signal, + }) + + expect(response.status).toBe(200) + expect(response.body).toBeDefined() + + const done = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("timed out waiting for workspace.test event")) + }, 3000) + + void parseSSE(response.body!, stop.signal, (event) => { + seen.push(event) + const next = event as { type?: string } + if (next.type === "server.connected") { + GlobalBus.emit("event", { + payload: { + type: "workspace.test", + properties: { ok: true }, + }, + }) + return + } + if (next.type !== "workspace.test") return + clearTimeout(timeout) + resolve() + }).catch((error) => { + clearTimeout(timeout) + reject(error) + }) + }) + + await done + + expect(seen.some((event) => (event as { type?: string }).type === "server.connected")).toBe(true) + expect(seen).toContainEqual({ + type: "workspace.test", + properties: { ok: true }, + }) + } finally { + stop.abort() + } + }) +}) diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts new file mode 100644 index 000000000..2769c8a3b --- /dev/null +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import { Identifier } from "../../src/id/id" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" +import { Project } from "../../src/project/project" +import { Database } from "../../src/storage/db" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { GlobalBus } from "../../src/bus/global" +import { resetDatabase } from "../fixture/db" + +afterEach(async () => { + mock.restore() + await resetDatabase() +}) + +Log.init({ print: false }) + +const seen: string[] = [] +const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert.config + +mock.module("../../src/control-plane/adaptors", () => ({ + getAdaptor: (config: { type: string }) => { + seen.push(config.type) + return { + async create() { + throw new Error("not used") + }, + async remove() {}, + async request() { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode('data: {"type":"remote.ready","properties":{}}\n\n')) + controller.close() + }, + }) + return new Response(body, { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }) + }, + } + }, +})) + +describe("control-plane/workspace.startSyncing", () => { + test("syncs only remote workspaces and emits remote SSE events", async () => { + const { Workspace } = await import("../../src/control-plane/workspace") + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const id1 = Identifier.descending("workspace") + const id2 = Identifier.descending("workspace") + + Database.use((db) => + db + .insert(WorkspaceTable) + .values([ + { + id: id1, + branch: "main", + project_id: project.id, + config: remote, + }, + { + id: id2, + branch: "main", + project_id: project.id, + config: { type: "worktree", directory: tmp.path }, + }, + ]) + .run(), + ) + + const done = new Promise((resolve) => { + const listener = (event: { directory?: string; payload: { type: string } }) => { + if (event.directory !== id1) return + if (event.payload.type !== "remote.ready") return + GlobalBus.off("event", listener) + resolve() + } + GlobalBus.on("event", listener) + }) + + const sync = Workspace.startSyncing(project) + await Promise.race([ + done, + new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for sync event")), 2000)), + ]) + + await sync.stop() + expect(seen).toContain("testing") + expect(seen).not.toContain("worktree") + }) +}) diff --git a/packages/opencode/test/fixture/db.ts b/packages/opencode/test/fixture/db.ts new file mode 100644 index 000000000..f11f0b903 --- /dev/null +++ b/packages/opencode/test/fixture/db.ts @@ -0,0 +1,11 @@ +import { rm } from "fs/promises" +import { Instance } from "../../src/project/instance" +import { Database } from "../../src/storage/db" + +export async function resetDatabase() { + await Instance.disposeAll().catch(() => undefined) + Database.close() + await rm(Database.Path, { force: true }).catch(() => undefined) + await rm(`${Database.Path}-wal`, { force: true }).catch(() => undefined) + await rm(`${Database.Path}-shm`, { force: true }).catch(() => undefined) +} diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6165c0f7b..ec8ee4685 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -26,6 +26,11 @@ import type { EventTuiToastShow, ExperimentalResourceListResponses, ExperimentalSessionListResponses, + ExperimentalWorkspaceCreateErrors, + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceListResponses, + ExperimentalWorkspaceRemoveErrors, + ExperimentalWorkspaceRemoveResponses, FileListResponses, FilePartInput, FilePartSource, @@ -901,6 +906,107 @@ export class Worktree extends HeyApiClient { } } +export class Workspace extends HeyApiClient { + /** + * Remove workspace + * + * Remove an existing workspace. + */ + public remove( + parameters: { + id: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", + ...options, + ...params, + }) + } + + /** + * Create workspace + * + * Create a workspace for the current project. + */ + public create( + parameters: { + id: string + directory?: string + branch?: string | null + config?: { + directory: string + type: "worktree" + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "body", key: "branch" }, + { in: "body", key: "config" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * List workspaces + * + * List all workspaces. + */ + public list( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace", + ...options, + ...params, + }) + } +} + export class Session extends HeyApiClient { /** * List sessions @@ -965,6 +1071,11 @@ export class Resource extends HeyApiClient { } export class Experimental extends HeyApiClient { + private _workspace?: Workspace + get workspace(): Workspace { + return (this._workspace ??= new Workspace({ client: this.client })) + } + private _session?: Session get session(): Session { return (this._session ??= new Session({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index be6c00cf4..385de2cc8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -887,6 +887,35 @@ export type EventVcsBranchUpdated = { } } +export type EventWorktreeReady = { + type: "worktree.ready" + properties: { + name: string + branch: string + } +} + +export type EventWorktreeFailed = { + type: "worktree.failed" + properties: { + message: string + } +} + +export type EventWorkspaceReady = { + type: "workspace.ready" + properties: { + name: string + } +} + +export type EventWorkspaceFailed = { + type: "workspace.failed" + properties: { + message: string + } +} + export type Pty = { id: string title: string @@ -926,21 +955,6 @@ export type EventPtyDeleted = { } } -export type EventWorktreeReady = { - type: "worktree.ready" - properties: { - name: string - branch: string - } -} - -export type EventWorktreeFailed = { - type: "worktree.failed" - properties: { - message: string - } -} - export type Event = | EventInstallationUpdated | EventInstallationUpdateAvailable @@ -979,12 +993,14 @@ export type Event = | EventSessionDiff | EventSessionError | EventVcsBranchUpdated + | EventWorktreeReady + | EventWorktreeFailed + | EventWorkspaceReady + | EventWorkspaceFailed | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted - | EventWorktreeReady - | EventWorktreeFailed export type GlobalEvent = { directory: string @@ -1627,6 +1643,16 @@ export type WorktreeCreateInput = { startCommand?: string } +export type Workspace = { + id: string + branch: string | null + projectID: string + config: { + directory: string + type: "worktree" + } +} + export type WorktreeRemoveInput = { directory: string } @@ -2473,6 +2499,93 @@ export type WorktreeCreateResponses = { export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] +export type ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceRemoveError = + ExperimentalWorkspaceRemoveErrors[keyof ExperimentalWorkspaceRemoveErrors] + +export type ExperimentalWorkspaceRemoveResponses = { + /** + * Workspace removed + */ + 200: Workspace +} + +export type ExperimentalWorkspaceRemoveResponse = + ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + branch: string | null + config: { + directory: string + type: "worktree" + } + } + path: { + id: string + } + query?: { + directory?: string + } + url: "/experimental/workspace/{id}" +} + +export type ExperimentalWorkspaceCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalWorkspaceCreateError = + ExperimentalWorkspaceCreateErrors[keyof ExperimentalWorkspaceCreateErrors] + +export type ExperimentalWorkspaceCreateResponses = { + /** + * Workspace created + */ + 200: Workspace +} + +export type ExperimentalWorkspaceCreateResponse = + ExperimentalWorkspaceCreateResponses[keyof ExperimentalWorkspaceCreateResponses] + +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + export type WorktreeResetData = { body?: WorktreeResetInput path?: never