Browse Source

feat(core): basic implementation of remote workspace support (#15120)

James Long 1 month ago
parent
commit
c12ce2ffff
26 changed files with 2153 additions and 65 deletions
  1. 7 0
      packages/opencode/migration/20260225215848_workspace/migration.sql
  2. 1009 0
      packages/opencode/migration/20260225215848_workspace/snapshot.json
  3. 11 0
      packages/opencode/src/cli/cmd/serve.ts
  4. 5 48
      packages/opencode/src/cli/cmd/workspace-serve.ts
  5. 10 0
      packages/opencode/src/control-plane/adaptors/index.ts
  6. 7 0
      packages/opencode/src/control-plane/adaptors/types.ts
  7. 26 0
      packages/opencode/src/control-plane/adaptors/worktree.ts
  8. 10 0
      packages/opencode/src/control-plane/config.ts
  9. 46 0
      packages/opencode/src/control-plane/session-proxy-middleware.ts
  10. 66 0
      packages/opencode/src/control-plane/sse.ts
  11. 33 0
      packages/opencode/src/control-plane/workspace-server/routes.ts
  12. 24 0
      packages/opencode/src/control-plane/workspace-server/server.ts
  13. 12 0
      packages/opencode/src/control-plane/workspace.sql.ts
  14. 160 0
      packages/opencode/src/control-plane/workspace.ts
  15. 1 0
      packages/opencode/src/id/id.ts
  16. 2 0
      packages/opencode/src/server/routes/experimental.ts
  17. 2 0
      packages/opencode/src/server/routes/session.ts
  18. 104 0
      packages/opencode/src/server/routes/workspace.ts
  19. 1 0
      packages/opencode/src/storage/schema.ts
  20. 147 0
      packages/opencode/test/control-plane/session-proxy-middleware.test.ts
  21. 56 0
      packages/opencode/test/control-plane/sse.test.ts
  22. 65 0
      packages/opencode/test/control-plane/workspace-server-sse.test.ts
  23. 97 0
      packages/opencode/test/control-plane/workspace-sync.test.ts
  24. 11 0
      packages/opencode/test/fixture/db.ts
  25. 111 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  26. 130 17
      packages/sdk/js/src/v2/gen/types.gen.ts

+ 7 - 0
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
+);

+ 1009 - 0
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": []
+}

+ 11 - 0
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()))
   },
 })

+ 5 - 48
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()
   },
 })

+ 10 - 0
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
+  }
+}

+ 7 - 0
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>
+}

+ 26 - 0
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")
+  },
+}

+ 10 - 0
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>

+ 46 - 0
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()
+}

+ 66 - 0
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()
+  }
+}

+ 33 - 0
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()
+        })
+      })
+    })
+  })
+}

+ 24 - 0
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,
+    })
+  }
+}

+ 12 - 0
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>(),
+})

+ 160 - 0
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()
+      },
+    }
+  }
+}

+ 1 - 0
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) {

+ 2 - 0
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({

+ 2 - 0
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({

+ 104 - 0
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))
+      },
+    ),
+)

+ 1 - 0
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"

+ 147 - 0
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([])
+  })
+})

+ 56 - 0
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,
+        },
+      },
+    ])
+  })
+})

+ 65 - 0
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()
+    }
+  })
+})

+ 97 - 0
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")
+  })
+})

+ 11 - 0
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)
+}

+ 111 - 0
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 }))

+ 130 - 17
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