diff --git a/packages/app/e2e/app/home.spec.ts b/packages/app/e2e/app/home.spec.ts index 04bf6ea5e..7e3e270e0 100644 --- a/packages/app/e2e/app/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -9,7 +9,7 @@ test("@smoke root route renders seeded home entrypoints", async ({ page }) => { await expect(page.getByRole("button", { name: /Process documents/i })).toBeVisible() await expect(page.getByRole("button", { name: /Analyze data/i })).toBeVisible() await expect(page.getByRole("button", { name: /Write faster/i })).toBeVisible() - await expect(page.getByRole("button", { name: "Status" })).toBeVisible() + await expect(page.getByRole("button", { name: "Right utility panel" })).toBeVisible() }) test("@smoke home renders the hero composer and starter cards", async ({ page, project }) => { @@ -28,7 +28,7 @@ test("@smoke home renders the hero composer and starter cards", async ({ page, p await expect(firstCard).toBeVisible() await expect(page.getByRole("button", { name: /Analyze data/i })).toBeVisible() await expect(page.getByRole("button", { name: /Write faster/i })).toBeVisible() - await expect(page.getByRole("button", { name: "Status" })).toBeVisible() + await expect(page.getByRole("button", { name: "Right utility panel" })).toBeVisible() const cardBox = await firstCard.boundingBox() const composerBox = await composer.boundingBox() @@ -55,7 +55,7 @@ test("@smoke home hero prompt starts a session", async ({ page, project, assista test("@smoke project home status panel can open the server picker dialog", async ({ page, project }) => { await project.open() - const statusButton = page.getByRole("button", { name: "Status" }).first() + const statusButton = page.getByRole("button", { name: "Right utility panel" }).first() const rightPanel = page.locator("#right-panel") await statusButton.click() diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts index 2c63130f6..6a12bcd52 100644 --- a/packages/app/e2e/app/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -15,18 +15,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => { await gotoSession() - const status = page.getByRole("button", { name: "Status" }) - await expect(status).toBeVisible() - const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" }) - - const ensurePopoverOpen = async () => { - if (await popover.isVisible()) return - await status.click() - await expect(popover).toBeVisible() + const statusPanel = page.getByRole("button", { name: "Right utility panel" }) + await expect(statusPanel).toBeVisible() + const rightPanel = page.locator("#right-panel") + + const ensureStatusPanelOpen = async () => { + if ((await rightPanel.getAttribute("aria-hidden")) === "false") return + await statusPanel.click() + await expect(rightPanel).toHaveAttribute("aria-hidden", "false") } - await ensurePopoverOpen() - await popover.getByRole("button", { name: "Manage servers" }).click() + await ensureStatusPanelOpen() + await rightPanel.getByRole("button", { name: "Manage servers" }).click() const dialog = page.getByRole("dialog") await expect(dialog).toBeVisible() @@ -50,9 +50,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => { await closeDialog(page, dialog) - await ensurePopoverOpen() + await ensureStatusPanelOpen() - const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first() + const serverRow = rightPanel.locator("button").filter({ hasText: serverNamePattern }).first() await expect(serverRow).toBeVisible() await expect(serverRow.getByText("Default", { exact: true })).toBeVisible() }) diff --git a/packages/opencode/migration/20260419092854_workspace-owner-directory/migration.sql b/packages/opencode/migration/20260419092854_workspace-owner-directory/migration.sql new file mode 100644 index 000000000..ab19312b2 --- /dev/null +++ b/packages/opencode/migration/20260419092854_workspace-owner-directory/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `owner_directory` text; \ No newline at end of file diff --git a/packages/opencode/migration/20260419092854_workspace-owner-directory/snapshot.json b/packages/opencode/migration/20260419092854_workspace-owner-directory/snapshot.json new file mode 100644 index 000000000..ab75efec6 --- /dev/null +++ b/packages/opencode/migration/20260419092854_workspace-owner-directory/snapshot.json @@ -0,0 +1,1357 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "57c01470-eb95-4288-a184-3cdb8c353c99", + "prevIds": [ + "6badfa56-8d38-45c7-ac9a-ab488d72b608" + ], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "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" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "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": "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": "owner_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": 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": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "skill", + "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" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": [ + "active_account_id" + ], + "tableTo": "account", + "columnsTo": [ + "id" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "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": [ + "aggregate_id" + ], + "tableTo": "event_sequence", + "columnsTo": [ + "aggregate_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "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": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "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": [ + "aggregate_id" + ], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_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/control-plane/adaptors/index.ts b/packages/opencode/src/control-plane/adaptors/index.ts index a43fce248..453a00611 100644 --- a/packages/opencode/src/control-plane/adaptors/index.ts +++ b/packages/opencode/src/control-plane/adaptors/index.ts @@ -1,20 +1,65 @@ import { lazy } from "@/util/lazy" +import type { ProjectID } from "@/project/schema" import type { Adaptor } from "../types" -const ADAPTORS: Record Promise> = { +const BUILTIN: Record Promise> = { worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor), } -export function getAdaptor(type: string): Promise { - return ADAPTORS[type]() +const CUSTOM = new Map>>() + +export function ownerKey(directory: string, worktree: string) { + return worktree === "/" ? directory : worktree +} + +export function getBuiltinAdaptor(type: string) { + return BUILTIN[type] +} + +export async function getAdaptor(projectID: ProjectID, type: string, owner?: string): Promise { + const project = CUSTOM.get(projectID)?.get(type) + if (project) { + if (owner) { + const exact = project.get(owner) + if (exact) return exact + throw new Error(`Unknown workspace adaptor owner: ${owner} (${type})`) + } + + const adaptors = [...project.values()] + const latest = adaptors.at(-1) + if (latest) return latest + } + + const builtin = BUILTIN[type] + if (builtin) return builtin() + + throw new Error(`Unknown workspace adaptor: ${type}`) } -export function installAdaptor(type: string, adaptor: Adaptor) { +export function installAdaptor(projectID: ProjectID, owner: string, 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 + if (type in BUILTIN) { + throw new Error(`Cannot register built-in workspace adaptor: ${type}`) + } + + const project = CUSTOM.get(projectID) ?? new Map>() + const adaptors = project.get(type) ?? new Map() + if (adaptors.has(owner)) adaptors.delete(owner) + adaptors.set(owner, adaptor) + project.set(type, adaptors) + CUSTOM.set(projectID, project) +} + +export function uninstallAdaptor(projectID: ProjectID, owner: string, type: string) { + const project = CUSTOM.get(projectID) + if (!project) return + + const adaptors = project.get(type) + if (!adaptors) return - // @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 + adaptors.delete(owner) + if (adaptors.size === 0) project.delete(type) + if (project.size === 0) CUSTOM.delete(projectID) } diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 272907da1..df3bbc8d4 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -9,6 +9,7 @@ export const WorkspaceTable = sqliteTable("workspace", { branch: text(), name: text(), directory: text(), + owner_directory: text(), extra: text({ mode: "json" }), project_id: text() .$type() diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index bbf79620c..c3fec8233 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -9,8 +9,10 @@ import { SyncEvent } from "@/sync" import { Log } from "@/util/log" import { Filesystem } from "@/util/filesystem" import { ProjectID } from "@/project/schema" +import { Instance } from "@/project/instance" +import { Plugin } from "@/plugin" import { WorkspaceTable } from "./workspace.sql" -import { getAdaptor } from "./adaptors" +import { getAdaptor, getBuiltinAdaptor, ownerKey } from "./adaptors" import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" @@ -20,6 +22,9 @@ export namespace Workspace { ref: "Workspace", }) export type Info = z.infer + type StoredInfo = Info & { + owner: string | null + } export const ConnectionStatus = z.object({ workspaceID: WorkspaceID.zod, @@ -44,18 +49,31 @@ export namespace Workspace { Status: BusEvent.define("workspace.status", ConnectionStatus), } - function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { + function fromRow(row: typeof WorkspaceTable.$inferSelect): StoredInfo { return { id: row.id, type: row.type, branch: row.branch, name: row.name, directory: row.directory, + owner: row.owner_directory, extra: row.extra, projectID: row.project_id, } } + function toInfo(row: StoredInfo): Info { + return { + id: row.id, + type: row.type, + branch: row.branch, + name: row.name, + directory: row.directory, + extra: row.extra, + projectID: row.projectID, + } + } + const CreateInput = z.object({ id: WorkspaceID.zod.optional(), type: Info.shape.type, @@ -64,18 +82,105 @@ export namespace Workspace { extra: Info.shape.extra, }) + async function bootstrapAdaptor( + input: Pick & { hint?: string | null }, + error: unknown, + ) { + const project = Project.get(input.projectID) + if (!project) throw error + const projectWorktree = project.worktree === "/" ? undefined : project.worktree + + const candidates = [ + ...new Set( + [input.hint, input.owner, projectWorktree, ...project.sandboxes].filter( + (value): value is string => Boolean(value), + ), + ), + ] + let lastError = error + const resolved: { owner: string; adaptor: Awaited> }[] = [] + + for (const directory of candidates) { + try { + const match = await Instance.provide({ + directory, + fn: async () => { + await Plugin.init() + const owner = ownerKey(Instance.directory, Instance.worktree) + return { + owner, + adaptor: await getAdaptor(input.projectID, input.type, owner), + } + }, + }) + + if (input.owner) { + if (match.owner === input.owner) return match.adaptor + continue + } + + if (!resolved.some((item) => item.owner === match.owner)) { + resolved.push(match) + } + } catch (candidateError) { + lastError = candidateError + } + } + + if (!input.owner) { + if (resolved.length === 1) return resolved[0]!.adaptor + if (resolved.length > 1) { + throw new Error(`Ambiguous workspace adaptor owner for ${input.type}`) + } + } + + throw lastError + } + + export async function resolveAdaptor(input: Pick & { hint?: string | null }) { + const hint = + input.hint ?? + (() => { + try { + return ownerKey(Instance.directory, Instance.worktree) + } catch { + return undefined + } + })() + + if (input.owner) { + try { + return await getAdaptor(input.projectID, input.type, input.owner) + } catch (error) { + return bootstrapAdaptor({ ...input, hint }, error) + } + } + + const builtin = getBuiltinAdaptor(input.type) + if (builtin) return builtin() + + const project = Project.get(input.projectID) + if (project?.worktree === "/" && !hint) { + throw new Error(`Missing workspace owner for non-git adaptor: ${input.type}`) + } + + return bootstrapAdaptor({ ...input, hint }, new Error(`Missing workspace owner for adaptor: ${input.type}`)) + } + export const create = fn(CreateInput, async (input) => { const id = WorkspaceID.ascending(input.id) - const adaptor = await getAdaptor(input.type) + const owner = ownerKey(Instance.directory, Instance.worktree) + const adaptor = await getAdaptor(input.projectID, input.type, owner) const config = await adaptor.configure({ ...input, id, name: null, directory: null }) - const info: Info = { + const info: StoredInfo = { id, type: config.type, branch: config.branch ?? null, name: config.name ?? null, directory: config.directory ?? null, + owner, extra: config.extra ?? null, projectID: input.projectID, } @@ -88,6 +193,7 @@ export namespace Workspace { branch: info.branch, name: info.name, directory: info.directory, + owner_directory: info.owner, extra: info.extra, project_id: info.projectID, }) @@ -96,9 +202,9 @@ export namespace Workspace { await adaptor.create(config) - startSync(info) + startSync({ space: info }) - return info + return toInfo(info) }) export function list(project: Project.Info) { @@ -106,33 +212,48 @@ export namespace Workspace { db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(), ) const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id)) - for (const space of spaces) startSync(space) - return spaces + for (const space of spaces) startSync({ space }) + return spaces.map(toInfo) } - export const get = fn(WorkspaceID.zod, async (id) => { + export const record = fn(WorkspaceID.zod, async (id) => { const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) if (!row) return - const space = fromRow(row) - startSync(space) - return space + return fromRow(row) + }) + + export const get = fn(WorkspaceID.zod, async (id) => { + const space = await record(id) + if (!space) return + startSync({ space }) + return toInfo(space) }) + export function ensureSync(space: Awaited> | undefined, hint?: string | null) { + if (!space) return + startSync({ space, hint }) + } + export const remove = fn(WorkspaceID.zod, async (id) => { - const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()) - if (row) { + const info = await record(id) + if (info) { stopSync(id) - const info = fromRow(row) - const adaptor = await getAdaptor(row.type) - adaptor.remove(info) + const adaptor = await resolveAdaptor(info) + await adaptor.remove(info) Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run()) - return info + return toInfo(info) } }) const connections = new Map() + type SyncRequest = { + space: StoredInfo + hint?: string | null + } + const aborts = new Map() + const pending = new Map() function setStatus(id: WorkspaceID, status: ConnectionStatus["status"], error?: string) { const prev = connections.get(id) @@ -155,17 +276,22 @@ export namespace Workspace { const log = Log.create({ service: "workspace-sync" }) - async function workspaceEventLoop(space: Info, signal: AbortSignal) { + async function workspaceEventLoop(input: SyncRequest, signal: AbortSignal) { + const space = input.space log.info("starting sync: " + space.id) while (!signal.aborted) { log.info("connecting to sync: " + space.id) setStatus(space.id, "connecting") - const adaptor = await getAdaptor(space.type) + const adaptor = await resolveAdaptor({ ...space, hint: input.hint }) const target = await adaptor.target(space) - if (target.type === "local") return + if (target.type === "local") { + const exists = await Filesystem.exists(target.directory) + setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") + return + } const res = await fetch(target.url + "/sync/event", { method: "GET", signal }).catch((err: unknown) => { setStatus(space.id, "error", String(err)) @@ -199,7 +325,8 @@ export namespace Workspace { } } - function startSync(space: Info) { + function startSync(input: SyncRequest) { + const space = input.space if (space.type === "worktree") { void Filesystem.exists(space.directory!).then((exists) => { setStatus(space.id, exists ? "connected" : "error", exists ? undefined : "directory does not exist") @@ -207,23 +334,37 @@ export namespace Workspace { return } - if (aborts.has(space.id)) return + if (aborts.has(space.id)) { + pending.set(space.id, input) + return + } const abort = new AbortController() aborts.set(space.id, abort) setStatus(space.id, "disconnected") - void workspaceEventLoop(space, abort.signal).catch((error) => { - setStatus(space.id, "error", String(error)) - log.warn("workspace sync listener failed", { - workspaceID: space.id, - error, + void workspaceEventLoop(input, abort.signal) + .catch((error) => { + setStatus(space.id, "error", String(error)) + log.warn("workspace sync listener failed", { + workspaceID: space.id, + error, + }) + }) + .finally(() => { + if (aborts.get(space.id) === abort) { + aborts.delete(space.id) + } + const next = pending.get(space.id) + if (!next) return + pending.delete(space.id) + startSync(next) }) - }) } function stopSync(id: WorkspaceID) { aborts.get(id)?.abort() aborts.delete(id) + pending.delete(id) connections.delete(id) } } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0e7fb5a37..7d3c4525b 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -1,4 +1,10 @@ -import type { Hooks, PluginInput, Plugin as PluginInstance, PluginModule } from "@opencode-ai/plugin" +import type { + Hooks, + PluginInput, + Plugin as PluginInstance, + PluginModule, + WorkspaceAdaptor as PluginWorkspaceAdaptor, +} from "@opencode-ai/plugin" import { fileURLToPath } from "url" import path from "path" import { Config } from "../config/config" @@ -21,6 +27,8 @@ import { errorMessage } from "@/util/error" import { needsConfigDependencies } from "@/config/dependency" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" +import { installAdaptor, ownerKey, uninstallAdaptor } from "@/control-plane/adaptors" +import type { Adaptor } from "@/control-plane/types" export namespace Plugin { const log = Log.create({ service: "plugin" }) @@ -122,6 +130,7 @@ export namespace Plugin { const state = yield* InstanceState.make( Effect.fn("Plugin.state")(function* (ctx) { const hooks: Hooks[] = [] + const registeredAdaptors = new Set() const { Server } = yield* Effect.promise(() => import("../server/server")) @@ -141,6 +150,12 @@ export namespace Plugin { project: ctx.project, worktree: ctx.worktree, directory: ctx.directory, + experimental_workspace: { + register(type: string, adaptor: PluginWorkspaceAdaptor) { + installAdaptor(ctx.project.id, ownerKey(ctx.directory, ctx.worktree), type, adaptor as unknown as Adaptor) + registeredAdaptors.add(type) + }, + }, get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") }, @@ -267,6 +282,14 @@ export namespace Plugin { Effect.forkScoped, ) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const type of registeredAdaptors) { + uninstallAdaptor(ctx.project.id, ownerKey(ctx.directory, ctx.worktree), type) + } + }), + ) + return { hooks } }), ) diff --git a/packages/opencode/src/server/instance/middleware.ts b/packages/opencode/src/server/instance/middleware.ts index 46a4824d7..4d0b064ec 100644 --- a/packages/opencode/src/server/instance/middleware.ts +++ b/packages/opencode/src/server/instance/middleware.ts @@ -3,7 +3,6 @@ import type { UpgradeWebSocket } from "hono/ws" import { mkdirSync } from "fs" import os from "os" import path from "path" -import { getAdaptor } from "@/control-plane/adaptors" import { WorkspaceID } from "@/control-plane/schema" import { Workspace } from "@/control-plane/workspace" import { ServerProxy } from "../proxy" @@ -85,7 +84,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const workspace = await Workspace.get(WorkspaceID.make(workspaceID)) + const workspace = await Workspace.record(WorkspaceID.make(workspaceID)) if (!workspace) { // Special-case deleting a session in case user's data in a @@ -106,7 +105,12 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware }) } - const adaptor = await getAdaptor(workspace.type) + Workspace.ensureSync(workspace, directory) + + const adaptor = await Workspace.resolveAdaptor({ + ...workspace, + hint: directory, + }) const target = await adaptor.target(workspace) if (target.type === "local") { diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 0b67588a7..33ddef5dd 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -125,6 +125,9 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { project: {} as never, directory: "", worktree: "", + experimental_workspace: { + register() {}, + }, serverUrl: new URL("https://example.com"), $: {} as never, }) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts new file mode 100644 index 000000000..c85fe2f46 --- /dev/null +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -0,0 +1,456 @@ +import { $ } from "bun" +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { mkdir } from "fs/promises" +import path from "path" +import { pathToFileURL } from "url" +import { eq } from "../../src/storage/db" +import { tmpdir } from "../fixture/fixture" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { Database } from "../../src/storage/db" + +const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Flag } = await import("../../src/flag/flag") +const { Plugin } = await import("../../src/plugin/index") +const { Workspace } = await import("../../src/control-plane/workspace") +const { Instance } = await import("../../src/project/instance") + +const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +// @ts-expect-error test-only flag override +Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + +afterEach(async () => { + await Instance.disposeAll() +}) + +afterAll(() => { + if (disableDefault === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault + // @ts-expect-error test-only flag override + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental +}) + +function wait(ms = 50) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function pluginProject() { + return tmpdir({ + git: true, + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const mark = path.join(dir, "created.json") + const space = path.join(dir, "space") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adaptor",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create(input) {", + ` await Bun.write(${JSON.stringify(mark)}, JSON.stringify(input))`, + " },", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { mark, space, type } + }, + }) +} + +describe("plugin.workspace", () => { + test("plugin can install a workspace adaptor", async () => { + await using tmp = await pluginProject() + await mkdir(tmp.extra.space, { recursive: true }) + + const info = await Instance.provide({ + directory: tmp.path, + fn: async () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.init() + return Workspace.create({ + type: tmp.extra.type, + branch: null, + extra: { key: "value" }, + projectID: Instance.project.id, + }) + }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), + }) + + expect(info.type).toBe(tmp.extra.type) + expect(info.name).toBe("plug") + expect(info.branch).toBe("plug/main") + expect(info.directory).toBe(tmp.extra.space) + expect(info.extra).toEqual({ key: "value" }) + expect(JSON.parse(await Bun.file(tmp.extra.mark).text())).toMatchObject({ + type: tmp.extra.type, + name: "plug", + branch: "plug/main", + directory: tmp.extra.space, + extra: { key: "value" }, + }) + await wait(100) + expect(Workspace.status().find((item) => item.workspaceID === info.id)?.status).not.toBe("connecting") + }) + + test("plugin workspace adaptor registration does not survive instance disposal", async () => { + await using source = await pluginProject() + + await Instance.provide({ + directory: source.path, + fn: async () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.init() + return Workspace.create({ + type: source.extra.type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), + }) + + await Instance.disposeAll() + + await expect( + Instance.provide({ + directory: source.path, + fn: async () => + Workspace.create({ + type: source.extra.type, + branch: null, + extra: null, + projectID: Instance.project.id, + }), + }), + ).rejects.toThrow(/workspace adaptor/i) + }) + + test("disposing one checkout restores the previous adaptor for the same project and type", async () => { + await using root = await tmpdir({ git: true }) + + const type = "shared" + const rootPlugin = path.join(root.path, "plugin.ts") + const rootSpace = path.join(root.path, "root-space") + await Bun.write( + rootPlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "root",', + ' description: "root adaptor",', + " configure(input) {", + ` return { ...input, name: "root", branch: "root/main", directory: ${JSON.stringify(rootSpace)} }`, + " },", + " async create() {},", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(root.path, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(rootPlugin).href], + }, + null, + 2, + ), + ) + + const worktreePath = path.join(root.path, "..", path.basename(root.path) + "-plugin-wt") + const worktreePlugin = path.join(worktreePath, "plugin.ts") + const worktreeSpace = path.join(worktreePath, "worktree-space") + + try { + await $`git worktree add ${worktreePath} -b test-plugin-${Date.now()}`.cwd(root.path).quiet() + await Bun.write( + worktreePlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "worktree",', + ' description: "worktree adaptor",', + " configure(input) {", + ` return { ...input, name: "worktree", branch: "worktree/main", directory: ${JSON.stringify(worktreeSpace)} }`, + " },", + " async create() {},", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(worktreePath, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(worktreePlugin).href], + }, + null, + 2, + ), + ) + + const fromRoot = await Instance.provide({ + directory: root.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + expect(fromRoot.directory).toBe(rootSpace) + + const fromWorktree = await Instance.provide({ + directory: worktreePath, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + expect(fromWorktree.directory).toBe(worktreeSpace) + + await Instance.provide({ + directory: worktreePath, + fn: async () => Instance.dispose(), + }) + + const restored = await Instance.provide({ + directory: root.path, + fn: async () => + Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }), + }) + expect(restored.directory).toBe(rootSpace) + } finally { + await $`git worktree remove ${worktreePath}` + .cwd(root.path) + .quiet() + .catch(() => {}) + } + }) + + test("cold-start Workspace.get retries sync after owner bootstrap for remote adaptors", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const counter = path.join(dir, "target-count.txt") + await Bun.write(counter, "0") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "remote",', + ' description: "remote adaptor",', + ' configure(input) { return { ...input, name: "remote", branch: "remote/main", directory: null } },', + " async create() {},", + " async remove() {},", + " async target() {", + ` const file = Bun.file(${JSON.stringify(counter)})`, + ' const count = Number((await file.text()) || "0") + 1', + ` await Bun.write(${JSON.stringify(counter)}, String(count))`, + ' throw new Error("target unavailable")', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { counter, type } + }, + }) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type: tmp.extra.type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + await Instance.disposeAll() + + await Workspace.get(workspace.id) + await wait(100) + const first = Number(await Bun.file(tmp.extra.counter).text()) + expect(first).toBeGreaterThan(0) + + await Workspace.get(workspace.id) + await wait(100) + const second = Number(await Bun.file(tmp.extra.counter).text()) + expect(second).toBeGreaterThan(first) + + const status = Workspace.status().find((item) => item.workspaceID === workspace.id) + expect(status?.error).toContain("target unavailable") + }) + + test("plugin cannot shadow the built-in worktree adaptor", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const file = path.join(dir, "plugin.ts") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ' experimental_workspace.register("worktree", {', + ' name: "shadow",', + ' description: "shadow builtin",', + ' configure(input) { return { ...input, name: "shadow", branch: "shadow/main", directory: input.directory } },', + " async create() {},", + " async remove() {},", + ' target(input) { return { type: "local", directory: input.directory } },', + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + }, + }) + + const info = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type: "worktree", + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + expect(info.name).not.toBe("shadow") + expect(info.branch).not.toBe("shadow/main") + + await Instance.provide({ + directory: tmp.path, + fn: async () => Workspace.remove(info.id), + }) + }) + + test("Workspace.get recovers a null-owner non-git workspace when called from its original directory", async () => { + await using tmp = await pluginProject() + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type: tmp.extra.type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + Database.use((db) => + db.update(WorkspaceTable).set({ owner_directory: null }).where(eq(WorkspaceTable.id, workspace.id)).run(), + ) + await Instance.disposeAll() + + const reloaded = await Instance.provide({ + directory: tmp.path, + fn: async () => Workspace.get(workspace.id), + }) + + expect(reloaded?.id).toBe(workspace.id) + expect( + Workspace.status() + .find((item) => item.workspaceID === workspace.id) + ?.error?.includes("Unknown workspace adaptor") ?? false, + ).toBe(false) + }) +}) diff --git a/packages/opencode/test/server/workspace-router.test.ts b/packages/opencode/test/server/workspace-router.test.ts new file mode 100644 index 000000000..b080ccf64 --- /dev/null +++ b/packages/opencode/test/server/workspace-router.test.ts @@ -0,0 +1,830 @@ +import { $ } from "bun" +import { afterAll, afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import path from "path" +import { pathToFileURL } from "url" +import { eq } from "../../src/storage/db" +import { Instance } from "../../src/project/instance" +import { Plugin } from "../../src/plugin" +import { Server } from "../../src/server/server" +import { WorkspaceID } from "../../src/control-plane/schema" +import { Workspace } from "../../src/control-plane/workspace" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { Database } from "../../src/storage/db" +import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Flag } = await import("../../src/flag/flag") +const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES +// @ts-expect-error test-only flag override +Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true + +afterEach(async () => { + await Instance.disposeAll() + await resetDatabase() +}) + +afterAll(() => { + if (disableDefault === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault + // @ts-expect-error test-only flag override + Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental +}) + +function wait(ms = 50) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function pluginProject() { + return tmpdir({ + git: true, + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + const space = path.join(dir, "space") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "plugin workspace adaptor",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create() {},", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { space, type } + }, + }) +} + +describe("workspace router", () => { + test("bootstraps the owning project before routing a persisted plugin workspace", async () => { + await using tmp = await pluginProject() + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.init() + return Workspace.create({ + type: tmp.extra.type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), + }) + + await Instance.disposeAll() + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: tmp.extra.space, + }) + }) + + test("tries project sandboxes when the plugin only exists in a secondary worktree", async () => { + await using root = await tmpdir({ git: true }) + + const worktreePath = path.join(root.path, "..", path.basename(root.path) + "-router-wt") + const type = `plug-${Math.random().toString(36).slice(2)}` + const plugin = path.join(worktreePath, "plugin.ts") + const space = path.join(worktreePath, "space") + + try { + await $`git worktree add ${worktreePath} -b test-router-${Date.now()}`.cwd(root.path).quiet() + + await Bun.write( + plugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "plug",', + ' description: "worktree-only adaptor",', + " configure(input) {", + ` return { ...input, name: "plug", branch: "plug/main", directory: ${JSON.stringify(space)} }`, + " },", + " async create() {},", + " async remove() {},", + " target(input) {", + ' return { type: "local", directory: input.directory }', + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(worktreePath, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(plugin).href], + }, + null, + 2, + ), + ) + + const workspace = await Instance.provide({ + directory: worktreePath, + fn: async () => + Effect.gen(function* () { + const plugin = yield* Plugin.Service + yield* plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }).pipe(Effect.provide(Plugin.defaultLayer), Effect.runPromise), + }) + + await Instance.disposeAll() + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": root.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: space, + }) + } finally { + await $`git worktree remove ${worktreePath}` + .cwd(root.path) + .quiet() + .catch(() => {}) + } + }) + + test("routes a persisted workspace through its original checkout after the owner instance is disposed", async () => { + await using root = await tmpdir({ git: true }) + + const type = "shared" + const rootPlugin = path.join(root.path, "plugin.ts") + const rootSpace = path.join(root.path, "root-space") + await Bun.write( + rootPlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "root",', + ' description: "root adaptor",', + " configure(input) {", + ` return { ...input, name: "root", branch: "root/main", directory: ${JSON.stringify(rootSpace)} }`, + " },", + " async create() {},", + " async remove() {},", + " target() {", + ` return { type: "local", directory: ${JSON.stringify(rootSpace)} }`, + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(root.path, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(rootPlugin).href], + }, + null, + 2, + ), + ) + + const worktreePath = path.join(root.path, "..", path.basename(root.path) + "-router-shared") + const worktreePlugin = path.join(worktreePath, "plugin.ts") + const worktreeSpace = path.join(worktreePath, "worktree-space") + + try { + await $`git worktree add ${worktreePath} -b test-shared-${Date.now()}`.cwd(root.path).quiet() + await Bun.write( + worktreePlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "worktree",', + ' description: "worktree adaptor",', + " configure(input) {", + ` return { ...input, name: "worktree", branch: "worktree/main", directory: ${JSON.stringify(worktreeSpace)} }`, + " },", + " async create() {},", + " async remove() {},", + " target() {", + ` return { type: "local", directory: ${JSON.stringify(worktreeSpace)} }`, + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(worktreePath, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(worktreePlugin).href], + }, + null, + 2, + ), + ) + + const workspace = await Instance.provide({ + directory: root.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + await Instance.provide({ + directory: worktreePath, + fn: async () => Plugin.init(), + }) + + await Instance.provide({ + directory: root.path, + fn: async () => Instance.dispose(), + }) + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": worktreePath, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: rootSpace, + }) + } finally { + await $`git worktree remove ${worktreePath}` + .cwd(root.path) + .quiet() + .catch(() => {}) + } + }) + + test("keeps non-git workspace ownership separate by directory", async () => { + await using first = await tmpdir() + await using second = await tmpdir() + + const type = "shared" + const firstPlugin = path.join(first.path, "plugin.ts") + const firstSpace = path.join(first.path, "first-space") + await Bun.write( + firstPlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "first",', + ' description: "first adaptor",', + " configure(input) {", + ` return { ...input, name: "first", branch: null, directory: ${JSON.stringify(firstSpace)} }`, + " },", + " async create() {},", + " async remove() {},", + " target() {", + ` return { type: "local", directory: ${JSON.stringify(firstSpace)} }`, + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(first.path, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(firstPlugin).href], + }, + null, + 2, + ), + ) + + const secondPlugin = path.join(second.path, "plugin.ts") + const secondSpace = path.join(second.path, "second-space") + await Bun.write( + secondPlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "second",', + ' description: "second adaptor",', + " configure(input) {", + ` return { ...input, name: "second", branch: null, directory: ${JSON.stringify(secondSpace)} }`, + " },", + " async create() {},", + " async remove() {},", + " target() {", + ` return { type: "local", directory: ${JSON.stringify(secondSpace)} }`, + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(second.path, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(secondPlugin).href], + }, + null, + 2, + ), + ) + + const workspace = await Instance.provide({ + directory: first.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + await Instance.provide({ + directory: second.path, + fn: async () => Plugin.init(), + }) + + await Instance.provide({ + directory: first.path, + fn: async () => Instance.dispose(), + }) + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": second.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: firstSpace, + }) + }) + + test("routing a persisted remote workspace restarts background sync after cold start", async () => { + let syncHits = 0 + let pathHits = 0 + + await using remote = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + if (url.pathname === "/sync/event") { + syncHits++ + return new Response( + new ReadableStream({ + start(controller) { + controller.close() + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ) + } + + pathHits++ + return Response.json({ ok: true }) + }, + }) + + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "remote",', + ' description: "remote adaptor",', + ' configure(input) { return { ...input, name: "remote", branch: "remote/main", directory: null } },', + " async create() {},", + " async remove() {},", + " target() {", + ` return { type: "remote", url: ${JSON.stringify(remote.url.origin)} }`, + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { type } + }, + }) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + const id = WorkspaceID.ascending() + Database.use((db) => + db.insert(WorkspaceTable) + .values({ + id, + type: tmp.extra.type, + branch: "remote/main", + name: "remote", + directory: null, + owner_directory: tmp.path, + extra: null, + project_id: Instance.project.id, + }) + .run(), + ) + return { id } + }, + }) + + const before = syncHits + + await Instance.disposeAll() + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + + await wait(100) + expect(pathHits).toBe(1) + expect(syncHits).toBeGreaterThan(before) + }) + + test("fails explicitly when an upgraded workspace has no owner and multiple checkouts register the same type", async () => { + await using root = await tmpdir({ git: true }) + + const type = "shared" + const rootPlugin = path.join(root.path, "plugin.ts") + await Bun.write( + rootPlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "root",', + ' description: "root adaptor",', + ' configure(input) { return { ...input, name: "root", branch: "root/main", directory: null } },', + " async create() {},", + " async remove() {},", + ' target() { return { type: "local", directory: "/tmp/root-space" } },', + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(root.path, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(rootPlugin).href], + }, + null, + 2, + ), + ) + + const worktreePath = path.join(root.path, "..", path.basename(root.path) + "-router-null-owner") + const worktreePlugin = path.join(worktreePath, "plugin.ts") + + try { + await $`git worktree add ${worktreePath} -b test-null-owner-${Date.now()}`.cwd(root.path).quiet() + await Bun.write( + worktreePlugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "worktree",', + ' description: "worktree adaptor",', + ' configure(input) { return { ...input, name: "worktree", branch: "worktree/main", directory: null } },', + " async create() {},", + " async remove() {},", + ' target() { return { type: "local", directory: "/tmp/worktree-space" } },', + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(worktreePath, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(worktreePlugin).href], + }, + null, + 2, + ), + ) + + const workspace = await Instance.provide({ + directory: root.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + Database.use((db) => + db.update(WorkspaceTable).set({ owner_directory: null }).where(eq(WorkspaceTable.id, workspace.id)).run(), + ) + + await Instance.provide({ + directory: worktreePath, + fn: async () => Plugin.init(), + }) + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": worktreePath, + }, + }) + + expect(response.status).toBe(500) + } finally { + await $`git worktree remove ${worktreePath}` + .cwd(root.path) + .quiet() + .catch(() => {}) + } + }) + + test("recovers a null-owner non-git workspace from the request directory when there is only one candidate", async () => { + await using tmp = await tmpdir() + + const type = "shared" + const plugin = path.join(tmp.path, "plugin.ts") + const space = path.join(tmp.path, "space") + await Bun.write( + plugin, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "single",', + ' description: "single adaptor",', + ` configure(input) { return { ...input, name: "single", branch: null, directory: ${JSON.stringify(space)} } },`, + " async create() {},", + " async remove() {},", + ` target() { return { type: "local", directory: ${JSON.stringify(space)} } },`, + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(tmp.path, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(plugin).href], + }, + null, + 2, + ), + ) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + return Workspace.create({ + type, + branch: null, + extra: null, + projectID: Instance.project.id, + }) + }, + }) + + Database.use((db) => + db.update(WorkspaceTable).set({ owner_directory: null }).where(eq(WorkspaceTable.id, workspace.id)).run(), + ) + await Instance.disposeAll() + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + directory: space, + }) + }) + + test("routing an ownerless non-git remote workspace restarts sync with the request directory hint", async () => { + let syncHits = 0 + let pathHits = 0 + + await using remote = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + if (url.pathname === "/sync/event") { + syncHits++ + return new Response( + new ReadableStream({ + start(controller) { + controller.close() + }, + }), + { + status: 200, + headers: { + "content-type": "text/event-stream", + }, + }, + ) + } + + pathHits++ + return Response.json({ ok: true }) + }, + }) + + await using tmp = await tmpdir({ + init: async (dir) => { + const type = `plug-${Math.random().toString(36).slice(2)}` + const file = path.join(dir, "plugin.ts") + await Bun.write( + file, + [ + "export default async ({ experimental_workspace }) => {", + ` experimental_workspace.register(${JSON.stringify(type)}, {`, + ' name: "remote",', + ' description: "remote adaptor",', + ' configure(input) { return { ...input, name: "remote", branch: null, directory: null } },', + " async create() {},", + " async remove() {},", + " target() {", + ` return { type: "remote", url: ${JSON.stringify(remote.url.origin)} }`, + " },", + " })", + " return {}", + "}", + "", + ].join("\n"), + ) + + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify( + { + $schema: "https://opencode.ai/config.json", + plugin: [pathToFileURL(file).href], + }, + null, + 2, + ), + ) + + return { type } + }, + }) + + const workspace = await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Plugin.init() + const id = WorkspaceID.ascending() + Database.use((db) => + db.insert(WorkspaceTable) + .values({ + id, + type: tmp.extra.type, + branch: null, + name: "remote", + directory: null, + owner_directory: null, + extra: null, + project_id: Instance.project.id, + }) + .run(), + ) + return { id } + }, + }) + + await Instance.disposeAll() + + const app = Server.Default().app + const response = await app.request(`/path?workspace=${workspace.id}`, { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + + await wait(100) + expect(pathHits).toBe(1) + expect(syncHits).toBeGreaterThan(0) + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 1afb55daa..10b0f8848 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -24,11 +24,44 @@ export type ProviderContext = { options: Record } +export type WorkspaceInfo = { + id: string + type: string + name: string | null + branch: string | null + directory: string | null + extra: unknown | null + projectID: string +} + +export type WorkspaceTarget = + | { + type: "local" + directory: string + } + | { + type: "remote" + url: string | URL + headers?: HeadersInit + } + +export type WorkspaceAdaptor = { + name: string + description: string + configure(config: WorkspaceInfo): WorkspaceInfo | Promise + create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + remove(config: WorkspaceInfo): Promise + target(config: WorkspaceInfo): WorkspaceTarget | Promise +} + export type PluginInput = { client: ReturnType project: Project directory: string worktree: string + experimental_workspace: { + register(type: string, adaptor: WorkspaceAdaptor): void + } serverUrl: URL $: BunShell }