diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql b/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql new file mode 100644 index 000000000..185de5913 --- /dev/null +++ b/packages/opencode/migration/20260303231226_add_workspace_fields/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE `workspace` ADD `type` text NOT NULL;--> statement-breakpoint +ALTER TABLE `workspace` ADD `name` text;--> statement-breakpoint +ALTER TABLE `workspace` ADD `directory` text;--> statement-breakpoint +ALTER TABLE `workspace` ADD `extra` text;--> statement-breakpoint +ALTER TABLE `workspace` DROP COLUMN `config`; \ No newline at end of file diff --git a/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json b/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json new file mode 100644 index 000000000..d47bad579 --- /dev/null +++ b/packages/opencode/migration/20260303231226_add_workspace_fields/snapshot.json @@ -0,0 +1,1063 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "4ec9de62-88a7-4bec-91cc-0a759e84db21", + "prevIds": [ + "572fb732-56f4-4b1e-b981-77152c9980dd" + ], + "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": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "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": "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": "workspace_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": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_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 8f4bb0144..ab51fe8c3 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -18,14 +18,7 @@ export const ServeCommand = cmd({ const server = Server.listen(opts) console.log(`opencode server listening on http://${server.hostname}:${server.port}`) - let workspaceSync: Array> = [] - // Only available in development right now - if (Installation.isLocal()) { - workspaceSync = Project.list().map((project) => Workspace.startSyncing(project)) - } - await new Promise(() => {}) await server.stop() - await Promise.all(workspaceSync.map((item) => item.stop())) }, }) diff --git a/packages/opencode/src/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index 77e1f53c6..a43fce248 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,10 +1,20 @@ -import { WorktreeAdaptor } from "./worktree" -import type { Config } from "../config" -import type { Adaptor } from "./types" +import { lazy } from "@/util/lazy" +import type { Adaptor } from "../types" -export function getAdaptor(config: Config): Adaptor { - switch (config.type) { - case "worktree": - return WorktreeAdaptor - } +const ADAPTORS: Record Promise> = { + worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), +} + +export function getAdaptor(type: string): Promise { + return ADAPTORS[type]() +} + +export function installAdaptor(type: string, adaptor: Adaptor) { + // This is experimental: mostly used for testing right now, but we + // will likely allow this in the future. Need to figure out the + // TypeScript story + + // @ts-expect-error we force the builtin types right now, but we + // will implement a way to extend the types for custom adaptors + ADAPTORS[type] = () => adaptor } diff --git a/packages/opencode/src/control-plane/adaptors/types.ts b/packages/opencode/src/control-plane/adaptors/types.ts deleted file mode 100644 index 47a0405a5..000000000 --- a/packages/opencode/src/control-plane/adaptors/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Config } from "../config" - -export type Adaptor = { - create(from: T, branch?: string | null): Promise<{ config: T; init: () => Promise }> - remove(from: T): Promise - request(from: T, method: string, url: string, data?: BodyInit, signal?: AbortSignal): Promise -} diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index e355bb770..f84890950 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,26 +1,46 @@ +import z from "zod" import { Worktree } from "@/worktree" -import type { Config } from "../config" -import type { Adaptor } from "./types" +import { type Adaptor, WorkspaceInfo } from "../types" -type WorktreeConfig = Extract +const Config = WorkspaceInfo.extend({ + name: WorkspaceInfo.shape.name.unwrap(), + branch: WorkspaceInfo.shape.branch.unwrap(), + directory: WorkspaceInfo.shape.directory.unwrap(), +}) -export const WorktreeAdaptor: Adaptor = { - async create(_from: WorktreeConfig, _branch: string) { - const next = await Worktree.create(undefined) +type Config = z.infer + +export const WorktreeAdaptor: Adaptor = { + async configure(info) { + const worktree = await Worktree.makeWorktreeInfo(info.name ?? 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 () => {}, + ...info, + name: worktree.name, + branch: worktree.branch, + directory: worktree.directory, } }, - async remove(config: WorktreeConfig) { + async create(info) { + const config = Config.parse(info) + const bootstrap = await Worktree.createFromInfo({ + name: config.name, + directory: config.directory, + branch: config.branch, + }) + return bootstrap() + }, + async remove(info) { + const config = Config.parse(info) 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") + async fetch(info, input: RequestInfo | URL, init?: RequestInit) { + const config = Config.parse(info) + const { WorkspaceServer } = await import("../workspace-server/server") + const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal") + const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)) + headers.set("x-opencode-directory", config.directory) + + const request = new Request(url, { ...init, headers }) + return WorkspaceServer.App().fetch(request) }, } diff --git a/packages/opencode/src/control-plane/config.ts b/packages/opencode/src/control-plane/config.ts deleted file mode 100644 index 73dbc4bdb..000000000 --- a/packages/opencode/src/control-plane/config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import z from "zod" - -export const Config = z.discriminatedUnion("type", [ - z.object({ - directory: z.string(), - type: z.literal("worktree"), - }), -]) - -export type Config = z.infer diff --git a/packages/opencode/src/control-plane/session-proxy-middleware.ts b/packages/opencode/src/control-plane/session-proxy-middleware.ts deleted file mode 100644 index df2591017..000000000 --- a/packages/opencode/src/control-plane/session-proxy-middleware.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/types.ts b/packages/opencode/src/control-plane/types.ts new file mode 100644 index 000000000..3d27757fd --- /dev/null +++ b/packages/opencode/src/control-plane/types.ts @@ -0,0 +1,20 @@ +import z from "zod" +import { Identifier } from "@/id/id" + +export const WorkspaceInfo = z.object({ + id: Identifier.schema("workspace"), + type: z.string(), + branch: z.string().nullable(), + name: z.string().nullable(), + directory: z.string().nullable(), + extra: z.unknown().nullable(), + projectID: z.string(), +}) +export type WorkspaceInfo = z.infer + +export type Adaptor = { + configure(input: WorkspaceInfo): WorkspaceInfo | Promise + create(input: WorkspaceInfo, from?: WorkspaceInfo): Promise + remove(config: WorkspaceInfo): Promise + fetch(config: WorkspaceInfo, input: RequestInfo | URL, init?: RequestInit): Promise +} diff --git a/packages/opencode/src/control-plane/workspace-router-middleware.ts b/packages/opencode/src/control-plane/workspace-router-middleware.ts new file mode 100644 index 000000000..b48f2fd2b --- /dev/null +++ b/packages/opencode/src/control-plane/workspace-router-middleware.ts @@ -0,0 +1,50 @@ +import { Instance } from "@/project/instance" +import type { MiddlewareHandler } from "hono" +import { Installation } from "../installation" +import { getAdaptor } from "./adaptors" +import { Workspace } from "./workspace" +import { WorkspaceContext } from "./workspace-context" + +// This middleware forwards all non-GET requests if the workspace is a +// remote. The remote workspace needs to handle session mutations +async function routeRequest(req: Request) { + // Right now, we need to forward all requests to the workspace + // because we don't have syncing. In the future all GET requests + // which don't mutate anything will be handled locally + // + // if (req.method === "GET") return + + if (!WorkspaceContext.workspaceID) return + + const workspace = await Workspace.get(WorkspaceContext.workspaceID) + if (!workspace) { + return new Response(`Workspace not found: ${WorkspaceContext.workspaceID}`, { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) + } + + const adaptor = await getAdaptor(workspace.type) + + return adaptor.fetch(workspace, `${new URL(req.url).pathname}${new URL(req.url).search}`, { + method: req.method, + body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(), + signal: req.signal, + headers: req.headers, + }) +} + +export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c, next) => { + // Only available in development for now + if (!Installation.isLocal()) { + return next() + } + + const response = await routeRequest(c.req.raw) + if (response) { + return response + } + return next() +} diff --git a/packages/opencode/src/control-plane/workspace-server/server.ts b/packages/opencode/src/control-plane/workspace-server/server.ts index 716989942..fd7fd9308 100644 --- a/packages/opencode/src/control-plane/workspace-server/server.ts +++ b/packages/opencode/src/control-plane/workspace-server/server.ts @@ -1,17 +1,57 @@ import { Hono } from "hono" +import { Instance } from "../../project/instance" +import { InstanceBootstrap } from "../../project/bootstrap" import { SessionRoutes } from "../../server/routes/session" import { WorkspaceServerRoutes } from "./routes" +import { WorkspaceContext } from "../workspace-context" export namespace WorkspaceServer { export function App() { const session = new Hono() - .use("*", async (c, next) => { - if (c.req.method === "GET") return c.notFound() + .use(async (c, next) => { + // Right now, we need handle all requests because we don't + // have syncing. In the future all GET requests will handled + // by the control plane + // + // if (c.req.method === "GET") return c.notFound() await next() }) .route("/", SessionRoutes()) - return new Hono().route("/session", session).route("/", WorkspaceServerRoutes()) + return new Hono() + .use(async (c, next) => { + const workspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") + const raw = c.req.query("directory") || c.req.header("x-opencode-directory") + if (workspaceID == null) { + throw new Error("workspaceID parameter is required") + } + if (raw == null) { + throw new Error("directory parameter is required") + } + + const directory = (() => { + try { + return decodeURIComponent(raw) + } catch { + return raw + } + })() + + return WorkspaceContext.provide({ + workspaceID, + async fn() { + return Instance.provide({ + directory, + init: InstanceBootstrap, + async fn() { + return next() + }, + }) + }, + }) + }) + .route("/session", session) + .route("/", WorkspaceServerRoutes()) } export function Listen(opts: { hostname: string; port: number }) { diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 1a2011982..1ba1605f8 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,12 +1,14 @@ 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(), + type: text().notNull(), branch: text(), + name: text(), + directory: text(), + extra: text({ mode: "json" }), project_id: text() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), - config: text({ mode: "json" }).notNull().$type(), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 5ce373b12..8c76fbdab 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -7,8 +7,8 @@ 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 { WorkspaceInfo } from "./types" import { parseSSE } from "./sse" export namespace Workspace { @@ -27,72 +27,64 @@ export namespace Workspace { ), } - export const Info = z - .object({ - id: Identifier.schema("workspace"), - branch: z.string().nullable(), - projectID: z.string(), - config: Config, - }) - .meta({ - ref: "Workspace", - }) + export const Info = WorkspaceInfo.meta({ + ref: "Workspace", + }) export type Info = z.infer function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { return { id: row.id, + type: row.type, branch: row.branch, + name: row.name, + directory: row.directory, + extra: row.extra, 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 CreateInput = z.object({ + id: Identifier.schema("workspace").optional(), + type: Info.shape.type, + branch: Info.shape.branch, + projectID: Info.shape.projectID, + extra: Info.shape.extra, + }) - const { config, init } = await getAdaptor(input.config).create(input.config, input.branch) + export const create = fn(CreateInput, async (input) => { + const id = Identifier.ascending("workspace", input.id) + const adaptor = await getAdaptor(input.type) - const info: Info = { - id, - projectID: input.projectID, - branch: input.branch, - config, - } + const config = await adaptor.configure({ ...input, id, name: null, directory: null }) - setTimeout(async () => { - await init() + const info: Info = { + id, + type: config.type, + branch: config.branch ?? null, + name: config.name ?? null, + directory: config.directory ?? null, + extra: config.extra ?? null, + projectID: input.projectID, + } - Database.use((db) => { - db.insert(WorkspaceTable) - .values({ - id: info.id, - branch: info.branch, - project_id: info.projectID, - config: info.config, - }) - .run() + Database.use((db) => { + db.insert(WorkspaceTable) + .values({ + id: info.id, + type: info.type, + branch: info.branch, + name: info.name, + directory: info.directory, + extra: info.extra, + project_id: info.projectID, }) + .run() + }) - GlobalBus.emit("event", { - directory: id, - payload: { - type: Event.Ready.type, - properties: {}, - }, - }) - }, 0) - - return info - }, - ) + await adaptor.create(config) + return info + }) export function list(project: Project.Info) { const rows = Database.use((db) => @@ -111,7 +103,8 @@ export namespace Workspace { 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) + const adaptor = await getAdaptor(row.type) + adaptor.remove(info) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) return info } @@ -120,9 +113,8 @@ export namespace Workspace { 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) + const adaptor = await getAdaptor(space.type) + const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined) if (!res || !res.ok || !res.body) { await Bun.sleep(1000) continue @@ -140,7 +132,7 @@ export namespace Workspace { export function startSyncing(project: Project.Info) { const stop = new AbortController() - const spaces = list(project).filter((space) => space.config.type !== "worktree") + const spaces = list(project).filter((space) => space.type !== "worktree") spaces.forEach((space) => { void workspaceEventLoop(space, stop.signal).catch((error) => { diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 892bca485..98c7ece10 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -88,6 +88,7 @@ export const ExperimentalRoutes = lazy(() => ) }, ) + .route("/workspace", WorkspaceRoutes()) .post( "/worktree", describeRoute({ @@ -113,7 +114,6 @@ 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 a39197952..12938aeab 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -16,13 +16,11 @@ 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 index 0c64c9cd4..cd2d844ae 100644 --- a/packages/opencode/src/server/routes/workspace.ts +++ b/packages/opencode/src/server/routes/workspace.ts @@ -9,7 +9,7 @@ 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.", @@ -26,27 +26,17 @@ export const WorkspaceRoutes = lazy(() => ...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, + Workspace.create.schema.omit({ + projectID: true, }), ), 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, + ...body, }) return c.json(workspace) }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 85049650c..6ea66be98 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -22,6 +22,7 @@ import { Flag } from "../flag/flag" import { Command } from "../command" import { Global } from "../global" import { WorkspaceContext } from "../control-plane/workspace-context" +import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware" import { ProjectRoutes } from "./routes/project" import { SessionRoutes } from "./routes/session" import { PtyRoutes } from "./routes/pty" @@ -218,6 +219,7 @@ export namespace Server { }, }) }) + .use(WorkspaceRouterMiddleware) .get( "/doc", openAPIRouteHandler(app, { diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index d85a0843f..226732249 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -331,7 +331,7 @@ export namespace Worktree { }, 0) } - export const create = fn(CreateInput.optional(), async (input) => { + export async function makeWorktreeInfo(name?: string): Promise { if (Instance.project.vcs !== "git") { throw new NotGitError({ message: "Worktrees are only supported for git projects" }) } @@ -339,9 +339,11 @@ export namespace Worktree { const root = path.join(Global.Path.data, "worktree", Instance.project.id) await fs.mkdir(root, { recursive: true }) - const base = input?.name ? slug(input.name) : "" - const info = await candidate(root, base || undefined) + const base = name ? slug(name) : "" + return candidate(root, base || undefined) + } + export async function createFromInfo(info: Info, startCommand?: string) { const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}` .quiet() .nothrow() @@ -353,8 +355,9 @@ export namespace Worktree { await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined) const projectID = Instance.project.id - const extra = input?.startCommand?.trim() - setTimeout(() => { + const extra = startCommand?.trim() + + return () => { const start = async () => { const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory) if (populated.exitCode !== 0) { @@ -411,8 +414,17 @@ export namespace Worktree { void start().catch((error) => { log.error("worktree start task failed", { directory: info.directory, error }) }) - }, 0) + } + } + export const create = fn(CreateInput.optional(), async (input) => { + const info = await makeWorktreeInfo(input?.name) + const bootstrap = await createFromInfo(info, input?.startCommand) + // This is needed due to how worktrees currently work in the + // desktop app + setTimeout(() => { + bootstrap() + }, 0) return info }) diff --git a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts index 596e4761e..369b9152a 100644 --- a/packages/opencode/test/control-plane/session-proxy-middleware.test.ts +++ b/packages/opencode/test/control-plane/session-proxy-middleware.test.ts @@ -5,8 +5,11 @@ 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 { WorkspaceContext } from "../../src/control-plane/workspace-context" import { Database } from "../../src/storage/db" import { resetDatabase } from "../fixture/db" +import * as adaptors from "../../src/control-plane/adaptors" +import type { Adaptor } from "../../src/control-plane/types" afterEach(async () => { mock.restore() @@ -18,18 +21,35 @@ type State = { calls: Array<{ method: string; url: string; body?: string }> } -const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert.config +const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert 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 }) - }, - }), - })) + const TestAdaptor: Adaptor = { + configure(config) { + return config + }, + async create() { + throw new Error("not used") + }, + async remove() {}, + + async fetch(_config: unknown, input: RequestInfo | URL, init?: RequestInit) { + const url = + input instanceof Request || input instanceof URL + ? input.toString() + : new URL(input, "http://workspace.test").toString() + const request = new Request(url, init) + const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text() + state.calls.push({ + method: request.method, + url: `${new URL(request.url).pathname}${new URL(request.url).search}`, + body, + }) + return new Response("proxied", { status: 202 }) + }, + } + + adaptors.installAdaptor("testing", TestAdaptor) await using tmp = await tmpdir({ git: true }) const { project } = await Project.fromDirectory(tmp.path) @@ -45,20 +65,23 @@ async function setup(state: State) { id: id1, branch: "main", project_id: project.id, - config: remote, + type: remote.type, + name: remote.name, }, { id: id2, branch: "main", project_id: project.id, - config: { type: "worktree", directory: tmp.path }, + type: "worktree", + directory: tmp.path, + name: "local", }, ]) .run(), ) - const { SessionProxyMiddleware } = await import("../../src/control-plane/session-proxy-middleware") - const app = new Hono().use(SessionProxyMiddleware) + const { WorkspaceRouterMiddleware } = await import("../../src/control-plane/workspace-router-middleware") + const app = new Hono().use(WorkspaceRouterMiddleware) return { id1, @@ -66,15 +89,19 @@ async function setup(state: State) { app, async request(input: RequestInfo | URL, init?: RequestInit) { return Instance.provide({ - directory: state.workspace === "first" ? id1 : id2, - fn: async () => app.request(input, init), + directory: tmp.path, + fn: async () => + WorkspaceContext.provide({ + workspaceID: state.workspace === "first" ? id1 : id2, + fn: () => app.request(input, init), + }), }) }, } } describe("control-plane/session-proxy-middleware", () => { - test("forwards non-GET session requests for remote workspaces", async () => { + test("forwards non-GET session requests for workspaces", async () => { const state: State = { workspace: "first", calls: [], @@ -102,46 +129,21 @@ describe("control-plane/session-proxy-middleware", () => { ]) }) - test("does not forward GET requests", async () => { - const state: State = { - workspace: "first", - calls: [], - } + // It will behave this way when we have syncing + // + // test("does not forward GET requests", async () => { + // const state: State = { + // workspace: "first", + // calls: [], + // } - const ctx = await setup(state) + // 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") + // 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([]) - }) + // expect(response.status).toBe(200) + // expect(await response.text()).toBe("local") + // expect(state.calls).toEqual([]) + // }) }) diff --git a/packages/opencode/test/control-plane/workspace-server-sse.test.ts b/packages/opencode/test/control-plane/workspace-server-sse.test.ts index 91504af0f..7e7cddb14 100644 --- a/packages/opencode/test/control-plane/workspace-server-sse.test.ts +++ b/packages/opencode/test/control-plane/workspace-server-sse.test.ts @@ -4,6 +4,7 @@ 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" +import { tmpdir } from "../fixture/fixture" afterEach(async () => { await resetDatabase() @@ -13,13 +14,17 @@ Log.init({ print: false }) describe("control-plane/workspace-server SSE", () => { test("streams GlobalBus events and parseSSE reads them", async () => { + await using tmp = await tmpdir({ git: true }) const app = WorkspaceServer.App() const stop = new AbortController() const seen: unknown[] = [] - try { const response = await app.request("/event", { signal: stop.signal, + headers: { + "x-opencode-workspace": "wrk_test_workspace", + "x-opencode-directory": tmp.path, + }, }) expect(response.status).toBe(200) diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index 2769c8a3b..899118920 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -7,6 +7,8 @@ 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" +import * as adaptors from "../../src/control-plane/adaptors" +import type { Adaptor } from "../../src/control-plane/types" afterEach(async () => { mock.restore() @@ -15,35 +17,34 @@ afterEach(async () => { Log.init({ print: false }) -const seen: string[] = [] -const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert.config +const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert -mock.module("../../src/control-plane/adaptors", () => ({ - getAdaptor: (config: { type: string }) => { - seen.push(config.type) - return { - async create() { - throw new Error("not used") - }, - async remove() {}, - async request() { - const body = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder() - controller.enqueue(encoder.encode('data: {"type":"remote.ready","properties":{}}\n\n')) - controller.close() - }, - }) - return new Response(body, { - status: 200, - headers: { - "content-type": "text/event-stream", - }, - }) - }, - } +const TestAdaptor: Adaptor = { + configure(config) { + return config }, -})) + async create() { + throw new Error("not used") + }, + async remove() {}, + async fetch(_config: unknown, _input: RequestInfo | URL, _init?: RequestInit) { + const body = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + controller.enqueue(encoder.encode('data: {"type":"remote.ready","properties":{}}\n\n')) + controller.close() + }, + }) + return new Response(body, { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }) + }, +} + +adaptors.installAdaptor("testing", TestAdaptor) describe("control-plane/workspace.startSyncing", () => { test("syncs only remote workspaces and emits remote SSE events", async () => { @@ -62,13 +63,16 @@ describe("control-plane/workspace.startSyncing", () => { id: id1, branch: "main", project_id: project.id, - config: remote, + type: remote.type, + name: remote.name, }, { id: id2, branch: "main", project_id: project.id, - config: { type: "worktree", directory: tmp.path }, + type: "worktree", + directory: tmp.path, + name: "local", }, ]) .run(), @@ -91,7 +95,5 @@ describe("control-plane/workspace.startSyncing", () => { ]) await sync.stop() - expect(seen).toContain("testing") - expect(seen).not.toContain("worktree") }) }) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 49ebc8473..feabf7199 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -862,6 +862,213 @@ export class Tool extends HeyApiClient { } } +export class Workspace extends HeyApiClient { + /** + * List workspaces + * + * List all workspaces. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/workspace", + ...options, + ...params, + }) + } + + /** + * Create workspace + * + * Create a workspace for the current project. + */ + public create( + parameters?: { + directory?: string + workspace?: string + body?: { + branch?: string | null + } & { + type: "worktree" + name: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { key: "body", map: "body" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + ExperimentalWorkspaceCreateResponses, + ExperimentalWorkspaceCreateErrors, + ThrowOnError + >({ + url: "/experimental/workspace", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove workspace + * + * Remove an existing workspace. + */ + public remove( + parameters: { + id: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "id" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + ExperimentalWorkspaceRemoveResponses, + ExperimentalWorkspaceRemoveErrors, + ThrowOnError + >({ + url: "/experimental/workspace/{id}", + ...options, + ...params, + }) + } +} + +export class Session extends HeyApiClient { + /** + * List sessions + * + * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. + */ + public list( + parameters?: { + directory?: string + workspace?: string + roots?: boolean + start?: number + cursor?: number + search?: string + limit?: number + archived?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "query", key: "roots" }, + { in: "query", key: "start" }, + { in: "query", key: "cursor" }, + { in: "query", key: "search" }, + { in: "query", key: "limit" }, + { in: "query", key: "archived" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/session", + ...options, + ...params, + }) + } +} + +export class Resource extends HeyApiClient { + /** + * Get MCP resources + * + * Get all available MCP resources from connected servers. Optionally filter by name. + */ + public list( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/experimental/resource", + ...options, + ...params, + }) + } +} + +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 })) + } + + private _resource?: Resource + get resource(): Resource { + return (this._resource ??= new Resource({ client: this.client })) + } +} + export class Worktree extends HeyApiClient { /** * Remove worktree @@ -1005,215 +1212,6 @@ export class Worktree extends HeyApiClient { } } -export class Workspace extends HeyApiClient { - /** - * Remove workspace - * - * Remove an existing workspace. - */ - public remove( - parameters: { - id: string - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).delete< - ExperimentalWorkspaceRemoveResponses, - ExperimentalWorkspaceRemoveErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}", - ...options, - ...params, - }) - } - - /** - * Create workspace - * - * Create a workspace for the current project. - */ - public create( - parameters: { - id: string - directory?: string - workspace?: string - branch?: string | null - config?: { - directory: string - type: "worktree" - } - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "path", key: "id" }, - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "branch" }, - { in: "body", key: "config" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceCreateResponses, - ExperimentalWorkspaceCreateErrors, - ThrowOnError - >({ - url: "/experimental/workspace/{id}", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - - /** - * List workspaces - * - * List all workspaces. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/workspace", - ...options, - ...params, - }) - } -} - -export class Session extends HeyApiClient { - /** - * List sessions - * - * Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default. - */ - public list( - parameters?: { - directory?: string - workspace?: string - roots?: boolean - start?: number - cursor?: number - search?: string - limit?: number - archived?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "query", key: "roots" }, - { in: "query", key: "start" }, - { in: "query", key: "cursor" }, - { in: "query", key: "search" }, - { in: "query", key: "limit" }, - { in: "query", key: "archived" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/session", - ...options, - ...params, - }) - } -} - -export class Resource extends HeyApiClient { - /** - * Get MCP resources - * - * Get all available MCP resources from connected servers. Optionally filter by name. - */ - public list( - parameters?: { - directory?: string - workspace?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - ], - }, - ], - ) - return (options?.client ?? this.client).get({ - url: "/experimental/resource", - ...options, - ...params, - }) - } -} - -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 })) - } - - private _resource?: Resource - get resource(): Resource { - return (this._resource ??= new Resource({ client: this.client })) - } -} - export class Session2 extends HeyApiClient { /** * List sessions @@ -3898,16 +3896,16 @@ export class OpencodeClient extends HeyApiClient { return (this._tool ??= new Tool({ client: this.client })) } - private _worktree?: Worktree - get worktree(): Worktree { - return (this._worktree ??= new Worktree({ client: this.client })) - } - private _experimental?: Experimental get experimental(): Experimental { return (this._experimental ??= new Experimental({ client: this.client })) } + private _worktree?: Worktree + get worktree(): Worktree { + return (this._worktree ??= new Worktree({ client: this.client })) + } + private _session?: Session2 get session(): Session2 { return (this._session ??= new Session2({ 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 69d105610..e40eb13a3 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1631,6 +1631,18 @@ export type ToolListItem = { export type ToolList = Array +export type Workspace = { + id: string + branch: string | null + projectID: string + config: { + type: "worktree" + directory: string + name: string + branch: string + } +} + export type Worktree = { name: string branch: string @@ -1645,16 +1657,6 @@ 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 } @@ -2444,6 +2446,93 @@ export type ToolListResponses = { export type ToolListResponse = ToolListResponses[keyof ToolListResponses] +export type ExperimentalWorkspaceListData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +export type ExperimentalWorkspaceListResponses = { + /** + * Workspaces + */ + 200: Array +} + +export type ExperimentalWorkspaceListResponse = + ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] + +export type ExperimentalWorkspaceCreateData = { + body?: { + branch?: string | null + } & { + type: "worktree" + name: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/workspace" +} + +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 ExperimentalWorkspaceRemoveData = { + body?: never + path: { + id: string + } + query?: { + directory?: string + workspace?: 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 WorktreeRemoveData = { body?: WorktreeRemoveInput path?: never @@ -2519,96 +2608,6 @@ export type WorktreeCreateResponses = { export type WorktreeCreateResponse = WorktreeCreateResponses[keyof WorktreeCreateResponses] -export type ExperimentalWorkspaceRemoveData = { - body?: never - path: { - id: string - } - query?: { - directory?: string - workspace?: 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 - workspace?: 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 - workspace?: string - } - url: "/experimental/workspace" -} - -export type ExperimentalWorkspaceListResponses = { - /** - * Workspaces - */ - 200: Array -} - -export type ExperimentalWorkspaceListResponse = - ExperimentalWorkspaceListResponses[keyof ExperimentalWorkspaceListResponses] - export type WorktreeResetData = { body?: WorktreeResetInput path?: never