summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/migration/20260225215848_workspace/migration.sql7
-rw-r--r--packages/opencode/migration/20260225215848_workspace/snapshot.json1009
-rw-r--r--packages/opencode/src/cli/cmd/serve.ts11
-rw-r--r--packages/opencode/src/cli/cmd/workspace-serve.ts53
-rw-r--r--packages/opencode/src/control-plane/adaptors/index.ts10
-rw-r--r--packages/opencode/src/control-plane/adaptors/types.ts7
-rw-r--r--packages/opencode/src/control-plane/adaptors/worktree.ts26
-rw-r--r--packages/opencode/src/control-plane/config.ts10
-rw-r--r--packages/opencode/src/control-plane/session-proxy-middleware.ts46
-rw-r--r--packages/opencode/src/control-plane/sse.ts66
-rw-r--r--packages/opencode/src/control-plane/workspace-server/routes.ts33
-rw-r--r--packages/opencode/src/control-plane/workspace-server/server.ts24
-rw-r--r--packages/opencode/src/control-plane/workspace.sql.ts12
-rw-r--r--packages/opencode/src/control-plane/workspace.ts160
-rw-r--r--packages/opencode/src/id/id.ts1
-rw-r--r--packages/opencode/src/server/routes/experimental.ts2
-rw-r--r--packages/opencode/src/server/routes/session.ts2
-rw-r--r--packages/opencode/src/server/routes/workspace.ts104
-rw-r--r--packages/opencode/src/storage/schema.ts1
-rw-r--r--packages/opencode/test/control-plane/session-proxy-middleware.test.ts147
-rw-r--r--packages/opencode/test/control-plane/sse.test.ts56
-rw-r--r--packages/opencode/test/control-plane/workspace-server-sse.test.ts65
-rw-r--r--packages/opencode/test/control-plane/workspace-sync.test.ts97
-rw-r--r--packages/opencode/test/fixture/db.ts11
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts111
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts147
26 files changed, 2153 insertions, 65 deletions
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<ReturnType<typeof Workspace.startSyncing>> = []
+ // 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<T extends Config = Config> = {
+ create(from: T, branch?: string | null): Promise<{ config: T; init: () => Promise<void> }>
+ remove(from: T): Promise<void>
+ request(from: T, method: string, url: string, data?: BodyInit, signal?: AbortSignal): Promise<Response | undefined>
+}
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<Config, { type: "worktree" }>
+
+export const WorktreeAdaptor: Adaptor<WorktreeConfig> = {
+ 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<typeof Config>
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<Uint8Array>,
+ 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<void>((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<Config>(),
+})
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<typeof Info>
+
+ 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<Uint8Array>({
+ 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<void>((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<Uint8Array>({
+ 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<void>((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<ThrowOnError extends boolean = false>(
+ parameters: {
+ id: string
+ directory?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ 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<ThrowOnError extends boolean = false>(
+ parameters: {
+ id: string
+ directory?: string
+ branch?: string | null
+ config?: {
+ directory: string
+ type: "worktree"
+ }
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ 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<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get<ExperimentalWorkspaceListResponses, unknown, ThrowOnError>({
+ 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<Workspace>
+}
+
+export type ExperimentalWorkspaceListResponse =
+ ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses]
+
export type WorktreeResetData = {
body?: WorktreeResetInput
path?: never