diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c8742a..bb7b80806f53 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -720,7 +720,9 @@ export type SessionsHistoryOutput = { readonly timestamp: number readonly sessionID: string readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly source?: { readonly directory: string; readonly workspaceID?: string } readonly subdirectory?: string + readonly transferHash?: string } } | { @@ -1178,7 +1180,9 @@ export type SessionsEventsOutput = readonly timestamp: number readonly sessionID: string readonly location: { readonly directory: string; readonly workspaceID?: string } + readonly source?: { readonly directory: string; readonly workspaceID?: string } readonly subdirectory?: string + readonly transferHash?: string } } | { diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index e227f88df539..d15bd04bea0a 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -8,13 +8,19 @@ import { Location } from "../location" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" import { SessionEvent } from "../session/event" +import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { SessionStore } from "../session/store" -import { AbsolutePath, RelativePath } from "../schema" +import { Database } from "../database/database" +import { WorkspaceTable } from "./workspace.sql" +import { AbsolutePath, RelativePath, optional } from "../schema" +import { WorkspaceV2 } from "../workspace" +import { eq } from "drizzle-orm" import path from "path" export const Destination = Schema.Struct({ directory: AbsolutePath, + workspaceID: optional(WorkspaceV2.ID), }).annotate({ identifier: "MoveSession.Destination" }) export type Destination = typeof Destination.Type @@ -22,6 +28,7 @@ export const Input = Schema.Struct({ sessionID: SessionSchema.ID, destination: Destination, moveChanges: Schema.optional(Schema.Boolean), + transferHash: Schema.optional(Schema.String), }).annotate({ identifier: "MoveSession.Input" }) export type Input = typeof Input.Type @@ -33,6 +40,14 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationDirectoryMismatchError", + { + expected: AbsolutePath, + actual: AbsolutePath, + }, +) {} + export class ApplyChangesError extends Schema.TaggedErrorClass()("MoveSession.ApplyChangesError", { message: Schema.String, }) {} @@ -53,15 +68,34 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass()( + "MoveSession.DestinationWorkspaceNotFoundError", + { + workspaceID: WorkspaceV2.ID, + }, +) {} + +export class WorkspaceChangeTransferUnsupportedError extends Schema.TaggedErrorClass()( + "MoveSession.WorkspaceChangeTransferUnsupportedError", + { + source: Schema.NullOr(WorkspaceV2.ID), + destination: Schema.NullOr(WorkspaceV2.ID), + }, +) {} + export type Error = | SessionV2.NotFoundError | DestinationProjectMismatchError + | DestinationDirectoryMismatchError | CaptureChangesError | ApplyChangesError | ResetSourceChangesError + | DestinationWorkspaceNotFoundError + | WorkspaceChangeTransferUnsupportedError export interface Interface { readonly moveSession: (input: Input) => Effect.Effect + readonly atDestination: (input: Input) => Effect.Effect } export class Service extends Context.Service()("@opencode/ControlPlaneMoveSession") {} @@ -73,20 +107,57 @@ const layer = Layer.effect( const events = yield* EventV2.Service const project = yield* ProjectV2.Service const sessions = yield* SessionStore.Service + const { db } = yield* Database.Service const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) { const current = yield* sessions.get(input.sessionID) if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID }) const directory = AbsolutePath.make(input.destination.directory) - if (current.location.directory === directory) return + if (current.location.directory === directory && current.location.workspaceID === input.destination.workspaceID) + return - const source = yield* project.resolve(current.location.directory) - const destination = yield* project.resolve(directory) - if (current.projectID !== destination.id) { - return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) - } + const destination = input.destination.workspaceID + ? yield* db + .select({ + id: WorkspaceTable.id, + projectID: WorkspaceTable.project_id, + directory: WorkspaceTable.directory, + }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, input.destination.workspaceID)) + .get() + .pipe(Effect.orDie) + : yield* project + .resolve(directory) + .pipe(Effect.map((resolved) => ({ id: undefined, projectID: resolved.id, directory: resolved.directory }))) + if (!destination && input.destination.workspaceID) + return yield* new DestinationWorkspaceNotFoundError({ workspaceID: input.destination.workspaceID }) + if (!destination) return yield* new ApplyChangesError({ message: "Destination workspace not found" }) + if (current.projectID !== destination.projectID) + return yield* new DestinationProjectMismatchError({ + expected: current.projectID, + actual: destination.projectID, + }) + if ( + input.destination.workspaceID && + destination.directory && + directory !== AbsolutePath.make(destination.directory) + ) + return yield* new DestinationDirectoryMismatchError({ + expected: AbsolutePath.make(destination.directory), + actual: directory, + }) - const moveChanges = input.moveChanges && source.directory !== destination.directory + const sourceWorkspaceID = current.location.workspaceID + const destinationWorkspaceID = input.destination.workspaceID + if (input.moveChanges && (sourceWorkspaceID || destinationWorkspaceID)) + return yield* new WorkspaceChangeTransferUnsupportedError({ + source: sourceWorkspaceID ?? null, + destination: destinationWorkspaceID ?? null, + }) + + const source = input.moveChanges ? yield* project.resolve(current.location.directory) : undefined + const moveChanges = input.moveChanges && source?.directory !== destination.directory const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined if (moveChanges && !sourceRepository) return yield* new CaptureChangesError({ message: "Source is not a Git repository" }) @@ -103,12 +174,42 @@ const layer = Layer.effect( .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) } - yield* events.publish(SessionEvent.Moved, { - sessionID: input.sessionID, - location: Location.Ref.make({ directory }), - subdirectory: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")), - timestamp: yield* DateTime.now, - }) + const destinationLocation = Location.Ref.make({ directory, workspaceID: input.destination.workspaceID }) + yield* events.publish( + SessionEvent.Moved, + { + sessionID: input.sessionID, + source: current.location, + location: destinationLocation, + subdirectory: RelativePath.make( + path.relative(destination.directory ?? directory, directory).replaceAll("\\", "/"), + ), + transferHash: input.transferHash, + timestamp: yield* DateTime.now, + }, + // Route the event to the destination instance so directory-scoped + // consumers (e.g. workspace plugins reacting to a session leaving) + // receive it; core publishes with no ambient Location.Service. + { location: destinationLocation }, + ) + + // Earlier turns reference absolute paths under the old directory, and + // models keep reusing them long after the working directory changed — + // on a remote destination those paths don't even exist. A synthetic + // message travels with the session's events, so every server that + // replays the move surfaces the same notice to the model. + if (current.location.directory !== directory) { + yield* events.publish(SessionEvent.Synthetic, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + text: [ + `This session moved to a different workspace. Its working directory changed from ${current.location.directory} to ${directory}.`, + `Files referenced earlier under ${current.location.directory} now live under ${directory}; the old paths no longer exist here.`, + `Use only paths under ${directory} from now on.`, + ].join(" "), + timestamp: yield* DateTime.now, + }) + } if (patch) { const repository = yield* git.repo.discover(current.location.directory) @@ -137,12 +238,20 @@ const layer = Layer.effect( } }) - return Service.of({ moveSession }) + const atDestination = Effect.fn("MoveSession.atDestination")(function* (input: Input) { + const current = yield* sessions.get(input.sessionID) + return ( + current?.location.directory === input.destination.directory && + current.location.workspaceID === input.destination.workspaceID + ) + }) + + return Service.of({ moveSession, atDestination }) }), ) export const node = makeGlobalNode({ service: Service, layer, - deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node], + deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, Database.node], }) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 3f28632a034d..7cd366998bb7 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -1,9 +1,13 @@ export * as PermissionV2 from "./permission" import { makeLocationNode } from "./effect/app-node" +import { and, eq, like } from "drizzle-orm" import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect" import { Permission } from "@opencode-ai/schema/permission" +import { Database } from "./database/database" import { EventV2 } from "./event" +import { EventTable } from "./event/sql" +import { FSUtil } from "./fs-util" import { Location } from "./location" import { AgentV2 } from "./agent" import { SessionV2 } from "./session" @@ -61,7 +65,11 @@ export class DeclinedError extends Schema.TaggedErrorClass()("Per export class CorrectedError extends Schema.TaggedErrorClass()("PermissionV2.CorrectedError", { feedback: Schema.String, -}) {} +}) { + override get message() { + return `This tool call was not permitted. Feedback: ${this.feedback}` + } +} export class BlockedError extends Schema.TaggedErrorClass()("PermissionV2.BlockedError", { rules: Permission.Ruleset, @@ -110,12 +118,43 @@ const layer = Layer.effect( Service, EffectRuntime.gen(function* () { const events = yield* EventV2.Service + const { db } = yield* Database.Service + const fs = yield* FSUtil.Service const location = yield* Location.Service const agents = yield* AgentV2.Service const sessions = yield* SessionStore.Service const saved = yield* PermissionSaved.Service const pending = new Map() + // A session that moved between machines leaves absolute paths from its + // previous location baked into the model's context, and models keep + // reusing them despite reminders. Those paths cannot resolve here, and + // merely probing them can stall the process (macOS automounts /home, so + // sync stats on a remote sandbox's /home/... path block for seconds). + // Answer such asks with corrective feedback instead of prompting the + // user for a directory that cannot work. + const staleMovedRoot = EffectRuntime.fnUntraced(function* (input: AssertInput) { + if (input.action !== "external_directory") return undefined + const directory = input.resources[0]?.replace(/\/\*$/, "") + if (!directory) return undefined + const moved = yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, input.sessionID), like(EventTable.type, "session.next.moved%"))) + .all() + .pipe(EffectRuntime.catchCause(() => EffectRuntime.succeed([]))) + for (const event of moved) { + const root = (event.data as { source?: { directory?: string } }).source?.directory + if (!root || root === location.directory) continue + if (directory !== root && !FSUtil.contains(root, directory)) continue + // A former root that still exists here (e.g. a worktree move on the + // same machine) is a legitimate target; let the normal flow decide. + if (yield* fs.existsSafe(root)) continue + return root + } + return undefined + }) + yield* EffectRuntime.addFinalizer(() => EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), { discard: true, @@ -197,6 +236,12 @@ const layer = Layer.effect( const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) => EffectRuntime.uninterruptibleMask((restore) => EffectRuntime.gen(function* () { + const stale = yield* staleMovedRoot(input) + if (stale) { + return yield* new CorrectedError({ + feedback: `${input.resources[0]} is under ${stale}, a previous location of this session. The session has moved and that path does not exist here. The project now lives at ${location.directory} — re-resolve your paths against the current working directory.`, + }) + } const result = yield* evaluateInput(input) if (result.effect === "deny") { return yield* new BlockedError({ @@ -306,5 +351,5 @@ export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer export const node = makeLocationNode({ service: Service, layer, - deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], + deps: [Database.node, EventV2.node, FSUtil.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node], }) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 92beb1fa53a0..e90f4d6fb54f 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -17,6 +17,8 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" +import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -232,4 +234,117 @@ describe("MoveSession", () => { expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n") }), ) + + it.live("moves a session to a workspace destination without transferring changes", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const remote = abs("/remote/workspace/repo") + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const sessionID = SessionV2.ID.make("ses_move_workspace") + const workspaceID = WorkspaceV2.ID.make("wrk_move_workspace") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(WorkspaceTable) + .values({ id: workspaceID, type: "remote", name: "remote", directory: remote, project_id: projectID }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move-workspace", + directory: source, + title: "move workspace", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + const mismatch = yield* MoveSession.Service.use((service) => + service + .moveSession({ sessionID, destination: { directory: abs("/different/workspace/repo"), workspaceID } }) + .pipe(Effect.flip), + ) + expect(mismatch).toBeInstanceOf(MoveSession.DestinationDirectoryMismatchError) + + yield* MoveSession.Service.use((service) => + service.moveSession({ sessionID, destination: { directory: remote, workspaceID } }), + ) + + expect( + yield* db + .select({ + directory: SessionTable.directory, + path: SessionTable.path, + workspaceID: SessionTable.workspace_id, + }) + .from(SessionTable) + .where(eq(SessionTable.id, sessionID)) + .get(), + ).toEqual({ directory: remote, path: "", workspaceID }) + }), + ) + + it.live("rejects workspace moves that request change transfer", () => + Effect.gen(function* () { + const root = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => initRepo(root.path)) + const source = abs(yield* Effect.promise(() => fs.realpath(root.path))) + const remote = abs("/remote/workspace/repo") + + const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id + const sessionID = SessionV2.ID.make("ses_move_workspace_changes") + const workspaceID = WorkspaceV2.ID.make("wrk_move_workspace_changes") + const { db } = yield* Database.Service + yield* db + .insert(ProjectTable) + .values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(WorkspaceTable) + .values({ id: workspaceID, type: "remote", name: "remote", directory: remote, project_id: projectID }) + .run() + .pipe(Effect.orDie) + yield* db + .insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + slug: "move-workspace-changes", + directory: source, + title: "move workspace changes", + version: "test", + time_created: 1, + time_updated: 1, + }) + .run() + .pipe(Effect.orDie) + + const error = yield* MoveSession.Service.use((service) => + service + .moveSession({ sessionID, destination: { directory: remote, workspaceID }, moveChanges: true }) + .pipe(Effect.flip), + ) + + expect(error).toBeInstanceOf(MoveSession.WorkspaceChangeTransferUnsupportedError) + }), + ) }) diff --git a/packages/opencode/Dockerfile.workspace b/packages/opencode/Dockerfile.workspace new file mode 100644 index 000000000000..8a405503e86e --- /dev/null +++ b/packages/opencode/Dockerfile.workspace @@ -0,0 +1,37 @@ +FROM alpine AS base + +ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0 +ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH} + +RUN apk add --no-cache \ + bash \ + ca-certificates \ + curl \ + git \ + libgcc \ + libstdc++ \ + openssh-client \ + ripgrep + +FROM base AS build-amd64 +COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode + +FROM base AS build-arm64 +COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode + +ARG TARGETARCH +FROM build-${TARGETARCH} + +ARG VERSION +LABEL org.opencontainers.image.title="opencode remote workspace" \ + org.opencontainers.image.description="Beta opencode build for remote Linux workspaces" \ + org.opencontainers.image.source="https://github.com/OpeOginni/opencode" \ + org.opencontainers.image.version=${VERSION} + +WORKDIR /workspace +EXPOSE 4096 + +RUN opencode --version + +ENTRYPOINT ["opencode"] +CMD ["serve", "--hostname", "0.0.0.0", "--port", "4096"] diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a6d8a913c800..09941daf1fb5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -12,6 +12,8 @@ "bench:test": "bun run script/bench-test-suite.ts", "profile:test": "bun run script/profile-test-files.ts", "build": "bun run script/build.ts", + "build:workspace-image": "bun run script/workspace-image.ts", + "build:workspace-npm": "bun run script/workspace-npm.ts", "dev": "bun run --conditions=browser ./src/index.ts", "dev:temporary": "bun run --conditions=browser ./src/temporary.ts" }, diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 236838dbdee7..dc6b2d3db7d5 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -23,6 +23,9 @@ const skipInstall = process.argv.includes("--skip-install") const sourcemapsFlag = process.argv.includes("--sourcemaps") const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") +const workspaceImageFlag = process.argv.includes("--workspace-image") +const workspaceVersion = process.argv.find((arg) => arg.startsWith("--workspace-version="))?.slice(20) +const version = workspaceVersion ?? Script.version const createEmbeddedWebUIBundle = async () => { console.log(`Building Web UI to embed in the binary`) @@ -113,26 +116,33 @@ const allTargets: { }, ] -const targets = singleFlag - ? allTargets.filter((item) => { - if (item.os !== process.platform || item.arch !== process.arch) { - return false - } +const targets = workspaceImageFlag + ? allTargets.filter( + (item) => + item.os === "linux" && + item.abi === "musl" && + ((item.arch === "arm64" && item.avx2 !== false) || (item.arch === "x64" && item.avx2 === false)), + ) + : singleFlag + ? allTargets.filter((item) => { + if (item.os !== process.platform || item.arch !== process.arch) { + return false + } - // When building for the current platform, prefer a single native binary by default. - // Baseline binaries require additional Bun artifacts and can be flaky to download. - if (item.avx2 === false) { - return baselineFlag - } + // When building for the current platform, prefer a single native binary by default. + // Baseline binaries require additional Bun artifacts and can be flaky to download. + if (item.avx2 === false) { + return baselineFlag + } - // also skip abi-specific builds for the same reason - if (item.abi !== undefined) { - return false - } + // also skip abi-specific builds for the same reason + if (item.abi !== undefined) { + return false + } - return true - }) - : allTargets + return true + }) + : allTargets await $`rm -rf dist` @@ -181,14 +191,17 @@ for (const item of targets) { autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, outfile: `dist/${name}/bin/opencode`, - execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], + execArgv: [`--user-agent=opencode/${version}`, "--use-system-ca", "--"], windows: {}, }, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { FFF_LIBC: JSON.stringify(item.abi === "musl" ? "musl" : "gnu"), - OPENCODE_VERSION: `'${Script.version}'`, + OPENCODE_VERSION: `'${version}'`, + // Workspace builds exist to serve/connect remote workspaces; the sync + // loop they depend on must not require users to export the flag. + ...(workspaceVersion ? { OPENCODE_WORKSPACE_BUILD: "'true'" } : {}), OPENCODE_MODELS_DEV: generated.modelsData, OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, OPENCODE_WORKER_PATH: workerPath, @@ -216,7 +229,7 @@ for (const item of targets) { JSON.stringify( { name, - version: Script.version, + version, preferUnplugged: true, os: [item.os], cpu: [item.arch], @@ -226,7 +239,7 @@ for (const item of targets) { 2, ), ) - binaries[name] = Script.version + binaries[name] = version } if (Script.release) { diff --git a/packages/opencode/script/workspace-image.ts b/packages/opencode/script/workspace-image.ts new file mode 100644 index 000000000000..bf72a9db2641 --- /dev/null +++ b/packages/opencode/script/workspace-image.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import pkg from "../package.json" + +const value = (name: string) => { + const prefix = `--${name}=` + return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) +} + +if (process.argv.includes("--help")) { + console.log(`Usage: bun run build:workspace-image [options] + +Options: + --image= Image repository (default: opeoginni/opencode-remote-workspace) + --version= Versioned image tag (default: OPENCODE_VERSION or ${pkg.version}-workspaces.0) + --platform= Docker platforms (local architecture, or both when --push) + --push Push a multi-architecture image instead of loading locally + --skip-login Reuse existing Docker Hub authentication when pushing + --no-latest Do not also tag the image as latest + --help Show this help`) + process.exit(0) +} + +const push = process.argv.includes("--push") +const image = value("image") ?? "opeoginni/opencode-remote-workspace" +const version = value("version") ?? process.env.OPENCODE_VERSION ?? `${pkg.version}-workspaces.0` +const platform = + value("platform") ?? (push ? "linux/amd64,linux/arm64" : `linux/${process.arch === "arm64" ? "arm64" : "amd64"}`) +const refs = [ + `${image}:${version}`, + ...(process.argv.includes("--no-latest") || version === "latest" ? [] : [`${image}:latest`]), +] + +console.log(`Building opencode binaries for ${refs.join(", ")}`) +await $`OPENCODE_VERSION=${version} bun run script/build.ts --workspace-image --workspace-version=${version}` + +if (push && !process.argv.includes("--skip-login")) { + console.log("Sign in to Docker Hub in your browser to continue pushing.") + await $`docker login` +} + +console.log(`${push ? "Pushing" : "Loading"} ${refs.join(", ")} for ${platform}`) +const output = push ? "--push" : "--load" +const tags = refs.flatMap((ref) => ["-t", ref]) +await $`docker buildx build --platform ${platform} --build-arg VERSION=${version} -f Dockerfile.workspace ${tags} ${output} .` + +console.log(`${push ? "Pushed" : "Built"} ${refs.join(", ")}`) + +// OPENCODE_VERSION=1.17.20-workspaces.5 bun run build:workspace-image --push diff --git a/packages/opencode/script/workspace-npm.ts b/packages/opencode/script/workspace-npm.ts new file mode 100644 index 000000000000..650a50353c02 --- /dev/null +++ b/packages/opencode/script/workspace-npm.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import fs from "fs" +import path from "path" +import pkg from "../package.json" + +const value = (name: string) => { + const prefix = `--${name}=` + return process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) +} + +if (process.argv.includes("--help")) { + console.log(`Usage: bun run build:workspace-npm [options] + +Options: + --name= Package name (default: @gitterm/opencode-workspace) + --version= Package version (default: OPENCODE_VERSION or ${pkg.version}-workspaces.0) + --tag= npm dist-tag used with --publish (default: latest) + --skip-build Reuse existing platform binaries in dist + --skip-login Reuse existing npm authentication instead of browser login + --publish Publish the package to npm + --help Show this help`) + process.exit(0) +} + +const name = value("name") ?? "@gitterm/opencode-workspace" +const version = value("version") ?? process.env.OPENCODE_VERSION ?? `${pkg.version}-workspaces.0` +const tag = value("tag") ?? "latest" +const publish = process.argv.includes("--publish") +const output = path.resolve("dist/opencode-remote-workspace") + +if (!process.argv.includes("--skip-build")) { + await $`OPENCODE_VERSION=${version} bun run script/build.ts --workspace-version=${version}` +} + +const binaries = [ + { platform: "darwin", arch: "arm64", source: "dist/opencode-darwin-arm64/bin/opencode" }, + { platform: "darwin", arch: "x64", source: "dist/opencode-darwin-x64-baseline/bin/opencode" }, + { platform: "linux", arch: "arm64", source: "dist/opencode-linux-arm64/bin/opencode" }, + { platform: "linux", arch: "x64", source: "dist/opencode-linux-x64-baseline/bin/opencode" }, + { platform: "win32", arch: "arm64", source: "dist/opencode-windows-arm64/bin/opencode.exe" }, + { platform: "win32", arch: "x64", source: "dist/opencode-windows-x64-baseline/bin/opencode.exe" }, +] +const missing = binaries.filter((binary) => !fs.existsSync(binary.source)) +if (missing.length > 0) throw new Error(`Missing binaries: ${missing.map((binary) => binary.source).join(", ")}`) +const packages = binaries.map((binary) => ({ + ...binary, + name: `${name}-${binary.platform}-${binary.arch}`, + output: `${output}-${binary.platform}-${binary.arch}`, +})) + +await $`rm -rf ${output}` +await Promise.all(packages.map((pkg) => $`rm -rf ${pkg.output}`)) +await $`mkdir -p ${output}/bin` +await $`cp ../../LICENSE ${output}/LICENSE` +await Promise.all( + packages.map(async (pkg) => { + const binary = `opencode${pkg.platform === "win32" ? ".exe" : ""}` + await $`mkdir -p ${pkg.output}/bin` + await $`cp ${pkg.source} ${pkg.output}/bin/${binary}` + await $`cp ../../LICENSE ${pkg.output}/LICENSE` + await Bun.write( + path.join(pkg.output, "package.json"), + JSON.stringify( + { + name: pkg.name, + version, + description: `opencode remote-workspace binary for ${pkg.platform}-${pkg.arch}`, + license: "MIT", + os: [pkg.platform], + cpu: [pkg.arch], + files: ["bin", "LICENSE"], + repository: { + type: "git", + url: "git+https://github.com/OpeOginni/opencode.git", + }, + }, + null, + 2, + ), + ) + }), +) + +await Bun.write( + path.join(output, "bin/opencode-remote-workspace.js"), + `#!/usr/bin/env node +import { spawnSync } from "node:child_process" +import { createRequire } from "node:module" + +const platform = process.platform === "win32" ? "win32" : process.platform === "darwin" ? "darwin" : process.platform === "linux" ? "linux" : undefined +const arch = process.arch === "arm64" || process.arch === "x64" ? process.arch : undefined +if (!platform || !arch) { + console.error(\`Unsupported platform: \${process.platform} \${process.arch}\`) + process.exit(1) +} + +const packages = ${JSON.stringify(Object.fromEntries(packages.map((pkg) => [`${pkg.platform}-${pkg.arch}`, pkg.name])))} +const packageName = packages[\`\${platform}-\${arch}\`] +if (!packageName) { + console.error(\`No binary package is available for \${platform}-\${arch}\`) + process.exit(1) +} + +const require = createRequire(import.meta.url) +const binary = require.resolve(\`\${packageName}/bin/opencode\${platform === "win32" ? ".exe" : ""}\`) +const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit" }) +if (result.error) throw result.error +if (result.signal) { + process.kill(process.pid, result.signal) +} +process.exit(result.status ?? 1) +`, +) + +await Bun.write( + path.join(output, "package.json"), + JSON.stringify( + { + name, + version, + description: "Beta remote-workspace build of opencode", + type: "module", + license: "MIT", + bin: { "opencode-workspace": "bin/opencode-remote-workspace.js" }, + files: ["bin", "LICENSE", "README.md"], + engines: { node: ">=18" }, + optionalDependencies: Object.fromEntries(packages.map((pkg) => [pkg.name, version])), + publishConfig: { access: "public" }, + repository: { + type: "git", + url: "git+https://github.com/OpeOginni/opencode.git", + }, + homepage: "https://github.com/OpeOginni/opencode", + bugs: "https://github.com/OpeOginni/opencode/issues", + }, + null, + 2, + ), +) + +await Bun.write( + path.join(output, "README.md"), + `# ${name} + +Beta build of [opencode](https://github.com/anomalyco/opencode) with remote workspace support. + +## Install + +\`\`\`bash +npm install --global ${name}@${version} +opencode-workspace serve --hostname 0.0.0.0 --port 4096 +\`\`\` + +This package ships binaries for macOS, Linux, and Windows on x64 and arm64. + +## Container image + +\`\`\`bash +docker run --rm -p 4096:4096 -v "$PWD:/workspace" opeoginni/opencode-remote-workspace:${version} +\`\`\` +`, +) + +await $`chmod 755 ${output}/bin/opencode-remote-workspace.js` +await Promise.all( + packages.filter((pkg) => pkg.platform !== "win32").map((pkg) => $`chmod 755 ${pkg.output}/bin/opencode`), +) + +if (publish) { + if (!process.argv.includes("--skip-login")) { + console.log("Sign in with npm in your browser to continue publishing.") + await $`npm login --auth-type=web` + } + const publishPackage = async (pkg: { name: string; output: string }) => { + if ((await $`npm view ${pkg.name}@${version} version`.quiet().nothrow()).exitCode === 0) { + console.log(`Already published ${pkg.name}@${version}`) + return + } + await $`npm publish --access public --tag ${tag}`.cwd(pkg.output) + } + for (const pkg of packages) { + await publishPackage(pkg) + } + await publishPackage({ name, output }) + console.log(`Published ${name}@${version} with dist-tag ${tag}`) +} else { + console.log(`Built ${name}@${version} in ${output}`) +} + +// Automated publishing (recommended): +// OPENCODE_VERSION=1.18.0-workspaces.1 bun run build:workspace-npm +// +// Manual publishing after a build: +// for package in dist/opencode-remote-workspace-{darwin-arm64,darwin-x64,linux-arm64,linux-x64,win32-arm64,win32-x64}; do +// (cd "$package" && npm publish --access public --tag latest) +// done +// (cd dist/opencode-remote-workspace && npm publish --access public --tag latest) diff --git a/packages/opencode/src/control-plane/adapters/index.ts b/packages/opencode/src/control-plane/adapters/index.ts index 0b052f5c9152..f83d1df9602c 100644 --- a/packages/opencode/src/control-plane/adapters/index.ts +++ b/packages/opencode/src/control-plane/adapters/index.ts @@ -1,8 +1,10 @@ import type { ProjectV2 } from "@opencode-ai/core/project" import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types" +import { RemoteAdapter } from "./remote" import { WorktreeAdapter } from "./worktree" const BUILTIN: Record = { + remote: RemoteAdapter, worktree: WorktreeAdapter, } @@ -23,6 +25,7 @@ export function listAdapters(projectID: ProjectV2.ID): WorkspaceAdapterEntry[] { type, name: adapter.name, description: adapter.description, + kind: adapter.kind ?? (type === "worktree" ? "local" : "remote"), })) } diff --git a/packages/opencode/src/control-plane/adapters/remote.ts b/packages/opencode/src/control-plane/adapters/remote.ts new file mode 100644 index 000000000000..0ad1956fdfcf --- /dev/null +++ b/packages/opencode/src/control-plane/adapters/remote.ts @@ -0,0 +1,45 @@ +import { Schema } from "effect" +import { type WorkspaceAdapter, WorkspaceInfo } from "../types" + +const RemoteExtra = Schema.Struct({ + url: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + directory: Schema.optional(Schema.String), +}).annotate({ identifier: "Workspace.RemoteExtra" }) + +const RemoteConfig = Schema.Struct({ + name: WorkspaceInfo.fields.name, + branch: WorkspaceInfo.fields.branch, + directory: WorkspaceInfo.fields.directory, + extra: RemoteExtra, +}).annotate({ identifier: "Workspace.RemoteConfig" }) + +const decodeRemoteConfig = Schema.decodeUnknownSync(RemoteConfig) + +export const RemoteAdapter: WorkspaceAdapter = { + kind: "remote", + name: "Remote", + description: "Connect to an existing remote opencode server", + configure(info) { + const extra = Schema.decodeUnknownSync(RemoteExtra)(info.extra) + return { + ...info, + directory: info.directory ?? extra.directory ?? null, + extra, + } + }, + async create() {}, + async remove() {}, + target(info) { + const config = decodeRemoteConfig(info) + // Instance-scoped requests (e.g. /vcs/apply) carry no directory, so the + // remote server would fall back to its own cwd. Pin the configured + // directory so transfers land in the right checkout. + const directory = config.directory ?? config.extra.directory + return { + type: "remote", + url: config.extra.url, + headers: directory ? { ...config.extra.headers, "x-opencode-directory": directory } : config.extra.headers, + } + }, +} diff --git a/packages/opencode/src/control-plane/adapters/worktree.ts b/packages/opencode/src/control-plane/adapters/worktree.ts index 87e30e1139be..0805da886490 100644 --- a/packages/opencode/src/control-plane/adapters/worktree.ts +++ b/packages/opencode/src/control-plane/adapters/worktree.ts @@ -26,6 +26,7 @@ const provideContext = (effect: Effect.Effect, context: Worksp ) export const WorktreeAdapter: WorkspaceAdapter = { + kind: "local", name: "Worktree", description: "Create a git worktree", async configure(info, context) { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index f54a878dbdae..bb0dbcae5b13 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -10,7 +10,7 @@ export const WorkspaceInfo = Schema.Struct({ name: Schema.String, branch: Schema.optional(Schema.NullOr(Schema.String)), directory: Schema.optional(Schema.NullOr(Schema.String)), - extra: Schema.optional(Schema.NullOr(Schema.Unknown)), + extra: Schema.optional(Schema.NullOr(Schema.MutableJson)), projectID: ProjectV2.ID, }).annotate({ identifier: "Workspace" }) export type WorkspaceInfo = DeepMutable> @@ -24,6 +24,7 @@ export const WorkspaceAdapterEntry = Schema.Struct({ type: Schema.String, name: Schema.String, description: Schema.String, + kind: Schema.optional(Schema.Literals(["local", "remote"])), }) export type WorkspaceAdapterEntry = Schema.Schema.Type @@ -44,6 +45,7 @@ export type WorkspaceAdapterContext = { } export type WorkspaceAdapter = { + kind?: "local" | "remote" name: string description: string configure(info: WorkspaceInfo, context?: WorkspaceAdapterContext): WorkspaceInfo | Promise @@ -52,8 +54,19 @@ export type WorkspaceAdapter = { env: Record, from?: WorkspaceInfo, context?: WorkspaceAdapterContext, - ): Promise + ): Promise list?(context?: WorkspaceAdapterContext): WorkspaceListedInfo[] | Promise remove(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise + ensureReady?(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Promise + status?( + info: WorkspaceInfo, + context?: WorkspaceAdapterContext, + ): + | "connected" + | "connecting" + | "paused" + | "disconnected" + | "error" + | Promise<"connected" | "connecting" | "paused" | "disconnected" | "error"> target(info: WorkspaceInfo, context?: WorkspaceAdapterContext): Target | Promise } diff --git a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts index 235edc9d22b2..dafe3f4d71c6 100644 --- a/packages/opencode/src/control-plane/workspace-adapter-runtime.ts +++ b/packages/opencode/src/control-plane/workspace-adapter-runtime.ts @@ -18,6 +18,8 @@ export const target = (info: WorkspaceInfo) => return yield* EffectBridge.fromPromise(() => adapter.target(info, ctx)) }) +export const kind = (info: WorkspaceInfo) => getAdapter(info.projectID, info.type).kind + export const configure = (adapter: WorkspaceAdapter, info: WorkspaceInfo) => Effect.gen(function* () { const ctx = yield* context @@ -35,12 +37,26 @@ export const create = ( return yield* EffectBridge.fromPromise(() => adapter.create(info, env, from, ctx)) }) +export const ensureReady = (info: WorkspaceInfo) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, info.type) + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.ensureReady?.(info, ctx))) + }) + export const list = (adapter: WorkspaceAdapter) => Effect.gen(function* () { const ctx = yield* context return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.list?.(ctx) ?? [])) }) +export const status = (info: WorkspaceInfo) => + Effect.gen(function* () { + const adapter = getAdapter(info.projectID, info.type) + const ctx = yield* context + return yield* EffectBridge.fromPromise(() => Promise.resolve(adapter.status?.(info, ctx))) + }) + export const remove = (info: WorkspaceInfo) => Effect.gen(function* () { const adapter = getAdapter(info.projectID, info.type) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 8f746e2568a8..4920826ac265 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,10 +1,10 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" -import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" +import { Cause, Context, Effect, FiberMap, Iterable, Layer, Schema, Stream, SynchronizedRef } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@opencode-ai/core/database/database" -import { asc } from "drizzle-orm" +import { and, asc, desc } from "drizzle-orm" import { eq } from "drizzle-orm" import { inArray } from "drizzle-orm" import { Project } from "@/project/project" @@ -18,6 +18,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProjectV2 } from "@opencode-ai/core/project" import { Slug } from "@opencode-ai/core/util/slug" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" +import { MoveSession } from "@opencode-ai/core/control-plane/move-session" +import { AbsolutePath } from "@opencode-ai/core/schema" import { getAdapter, registeredAdapters } from "./adapters" import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types" import { WorkspaceV2 } from "@opencode-ai/core/workspace" @@ -53,7 +55,7 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info { branch: row.branch, name: row.name, directory: row.directory, - extra: row.extra, + extra: jsonExtra(row.extra), projectID: row.project_id, timeUsed: row.time_used, } @@ -68,12 +70,13 @@ export const CreateInput = Schema.Struct({ }) export type CreateInput = Schema.Schema.Type -export const SessionWarpInput = Schema.Struct({ +export const MoveSessionInput = Schema.Struct({ workspaceID: Schema.NullOr(WorkspaceV2.ID), + destinationDirectory: Schema.optional(Schema.String), sessionID: SessionID, copyChanges: Schema.optional(Schema.Boolean), }) -export type SessionWarpInput = Schema.Schema.Type +export type MoveSessionInput = Schema.Schema.Type export class SyncHttpError extends Schema.TaggedErrorClass()("WorkspaceSyncHttpError", { message: Schema.String, @@ -89,16 +92,16 @@ export class WorkspaceNotFoundError extends Schema.TaggedErrorClass()( - "WorkspaceSessionEventsNotFoundError", +export class WorkspaceNotReadyError extends Schema.TaggedErrorClass()( + "WorkspaceNotReadyError", { message: Schema.String, - sessionID: SessionID, + workspaceID: WorkspaceV2.ID, }, ) {} -export class SessionWarpHttpError extends Schema.TaggedErrorClass()( - "WorkspaceSessionWarpHttpError", +export class MoveSessionHttpError extends Schema.TaggedErrorClass()( + "WorkspaceMoveSessionHttpError", { message: Schema.String, workspaceID: WorkspaceV2.ID, @@ -108,6 +111,13 @@ export class SessionWarpHttpError extends Schema.TaggedErrorClass()( + "WorkspaceChangeTransferError", + { + message: Schema.String, + }, +) {} + export class SyncTimeoutError extends Schema.TaggedErrorClass()("WorkspaceSyncTimeoutError", { message: Schema.String, state: Schema.Record(Schema.String, Schema.Number), @@ -119,23 +129,28 @@ export class SyncAbortedError extends Schema.TaggedErrorClass( }) {} type CreateError = Auth.AuthError -type SessionWarpError = +type MoveSessionError = | WorkspaceNotFoundError - | SessionEventsNotFoundError - | SessionWarpHttpError + | WorkspaceNotReadyError + | MoveSessionHttpError + | ChangeTransferError | Vcs.PatchApplyError + | Vcs.DiscardError | HttpClientError.HttpClientError + | MoveSession.Error type WaitForSyncError = SyncTimeoutError | SyncAbortedError type SyncLoopError = SyncHttpError | HttpClientError.HttpClientError +type EnsureReadyError = WorkspaceNotFoundError | WorkspaceNotReadyError export interface Interface { readonly create: (input: CreateInput) => Effect.Effect - readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect + readonly moveSession: (input: MoveSessionInput) => Effect.Effect readonly list: (project: Project.Info) => Effect.Effect readonly syncList: (project: Project.Info) => Effect.Effect readonly get: (id: WorkspaceV2.ID) => Effect.Effect readonly remove: (id: WorkspaceV2.ID) => Effect.Effect readonly status: () => Effect.Effect + readonly ensureReady: (workspaceID: WorkspaceV2.ID) => Effect.Effect readonly isSyncing: (workspaceID: WorkspaceV2.ID) => Effect.Effect readonly waitForSync: ( workspaceID: WorkspaceV2.ID, @@ -156,6 +171,7 @@ const layer = Layer.effect( const auth = yield* Auth.Service const session = yield* Session.Service const prompt = yield* SessionPrompt.Service + const mover = yield* MoveSession.Service const http = yield* HttpClient.HttpClient const events = yield* EventV2Bridge.Service const vcs = yield* Vcs.Service @@ -163,13 +179,35 @@ const layer = Layer.effect( const fs = yield* FSUtil.Service const { db } = yield* Database.Service const connections = new Map() + const settledConnections = new Map() + const readiness = yield* SynchronizedRef.make(new Map>()) const syncFibers = yield* FiberMap.make() + const remoteMoveRequest = ( + request: HttpClientRequest.HttpClientRequest, + meta: { workspaceID: WorkspaceV2.ID; sessionID: SessionID; step: string }, + ) => + http.execute(request).pipe( + Effect.timeout(REMOTE_MOVE_HTTP_TIMEOUT), + Effect.catchIf(Cause.isTimeoutError, () => + Effect.fail( + new MoveSessionHttpError({ + message: `Timed out during ${meta.step} for session ${meta.sessionID} in workspace ${meta.workspaceID}`, + workspaceID: meta.workspaceID, + sessionID: meta.sessionID, + status: 504, + body: "timeout", + }), + ), + ), + ) + const setStatus = (id: WorkspaceV2.ID, status: ConnectionStatus["status"]) => { const prev = connections.get(id) if (prev?.status === status) return const next = { workspaceID: id, status } connections.set(id, next) + if (status === "connected" || status === "paused" || status === "error") settledConnections.set(id, status) GlobalBus.emit("event", { directory: "global", @@ -181,6 +219,28 @@ const layer = Layer.effect( }) } + const waitForConnection = ( + workspaceID: WorkspaceV2.ID, + settled: ReadonlySet, + timeout = TIMEOUT, + ): Effect.Effect => { + const deadline = Date.now() + timeout + const loop = (): Effect.Effect => + Effect.suspend(() => { + const status = settledConnections.get(workspaceID) + if (status && settled.has(status)) return Effect.void + if (Date.now() >= deadline) + return Effect.fail( + new WorkspaceNotReadyError({ + message: `Timed out waiting for workspace ${workspaceID}`, + workspaceID, + }), + ) + return Effect.sleep("10 millis").pipe(Effect.andThen(loop())) + }) + return loop() + } + const connectSSE = Effect.fn("Workspace.connectSSE")(function* ( url: URL | string, headers: HeadersInit | undefined, @@ -252,6 +312,7 @@ const layer = Layer.effect( const runInWorkspace = (input: { workspaceID?: WorkspaceV2.ID + directory?: string local: () => Effect.Effect remote: (input: { workspace: Info @@ -261,12 +322,29 @@ const layer = Layer.effect( response?: "json" | "text" }) => Effect.gen(function* () { - if (!input.workspaceID) return yield* input.local() + if (!input.workspaceID) { + if (!input.directory) return yield* input.local() + const store = yield* InstanceStore.Service + return yield* store.provide({ directory: input.directory }, input.local()) + } const workspace = yield* get(input.workspaceID) if (!workspace) return input.fallback - const target = yield* WorkspaceAdapterRuntime.target(workspace) + // Adapter readiness/target failures (e.g. a provider reporting the + // sandbox paused) must surface as the caller's fallback, not as a + // defect that turns the whole request into an opaque 500. + const target = yield* Effect.gen(function* () { + if (!(yield* FiberMap.has(syncFibers, workspace.id))) yield* ensureReady(workspace.id) + return yield* WorkspaceAdapterRuntime.target(workspace) + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("workspace target unavailable", { workspaceID: workspace.id, cause }).pipe( + Effect.as(undefined), + ), + ), + ) + if (!target) return input.fallback if (target.type === "local") { const store = yield* InstanceStore.Service @@ -274,6 +352,7 @@ const layer = Layer.effect( } const response = yield* http.execute(input.remote({ workspace, target })).pipe( + Effect.timeout(REMOTE_MOVE_HTTP_TIMEOUT), Effect.catch((error) => Effect.logWarning("workspace target request failed", { workspaceID: workspace.id, @@ -287,7 +366,7 @@ const layer = Layer.effect( yield* Effect.logWarning("workspace target request failed", { workspaceID: workspace.id, status: response.status, - body, + body: body.slice(0, 500), }) return input.fallback } @@ -371,13 +450,29 @@ const layer = Layer.effect( let attempt = 0 while (true) { + const adapterStatus = yield* WorkspaceAdapterRuntime.status(space).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ) + if (adapterStatus === "paused") { + setStatus(space.id, "paused") + return + } setStatus(space.id, "connecting") const stream = yield* connectSSE(target.url, target.headers).pipe( Effect.tap(() => syncHistory(space, target.url, target.headers)), Effect.catch((err) => Effect.gen(function* () { - setStatus(space.id, "error") + // Don't flip a healthy remote to error just because SSE/history + // failed (common behind a proxy). Adapter status remains source of truth. + const adapterStatus = yield* WorkspaceAdapterRuntime.status(space).pipe( + Effect.catch(() => Effect.succeed(undefined)), + ) + if (adapterStatus === "connected" || adapterStatus === "connecting" || adapterStatus === "paused") { + setStatus(space.id, adapterStatus) + } else { + setStatus(space.id, "error") + } yield* Effect.logWarning("failed to connect to global sync", { workspace: space.name, error: errorData(err), @@ -431,15 +526,34 @@ const layer = Layer.effect( setStatus(space.id, "disconnected") } - // Back off reconnect attempts up to 2 minutes while the workspace - // stays unavailable. - yield* Effect.sleep(`${Math.min(120_000, 1_000 * 2 ** attempt)} millis`) + // Back off reconnect attempts while the workspace stays unavailable. + // Cap low: a freshly provisioned sandbox needs ~30s before its server + // listens, and a large cap leaves the status stuck on "connecting" + // long after the workspace came up. + yield* Effect.sleep(`${Math.min(15_000, 1_000 * 2 ** attempt)} millis`) attempt += 1 } }) const startSync = Effect.fn("Workspace.startSync")(function* (space: Info) { - if (!flags.experimentalWorkspaces) return + const adapterStatus = yield* WorkspaceAdapterRuntime.status(space).pipe( + Effect.catch((error) => + Effect.logWarning("workspace status failed", { workspaceID: space.id, error: errorData(error) }).pipe( + Effect.as(undefined), + ), + ), + ) + if (adapterStatus === "paused") { + setStatus(space.id, "paused") + return + } + // Prefer adapter runtime status for remote UX. SSE can lag/404 behind a + // proxy while the remote workspace itself is healthy and move/proxy still work. + if (adapterStatus === "connected" || adapterStatus === "connecting") { + setStatus(space.id, adapterStatus) + } + + if (!flags.experimentalWorkspaces && WorkspaceAdapterRuntime.kind(space) !== "local") return const target = yield* WorkspaceAdapterRuntime.target(space).pipe( Effect.catch((error) => @@ -463,7 +577,7 @@ const layer = Layer.effect( const exists = yield* FiberMap.has(syncFibers, space.id) if (exists && connections.get(space.id)?.status !== "error") return - setStatus(space.id, "disconnected") + if (connections.get(space.id)?.status !== "connected") setStatus(space.id, "disconnected") yield* FiberMap.run( syncFibers, @@ -473,7 +587,8 @@ const layer = Layer.effect( syncWorkspaceLoop(space).pipe( Effect.catch((error) => Effect.gen(function* () { - setStatus(space.id, "error") + // Keep adapter-reported connected state if SSE dies; HTTP move still works. + if (connections.get(space.id)?.status !== "connected") setStatus(space.id, "error") yield* Effect.logWarning("workspace listener failed", { workspaceID: space.id, error: errorData(error), @@ -487,6 +602,7 @@ const layer = Layer.effect( const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceV2.ID) { yield* FiberMap.remove(syncFibers, id) connections.delete(id) + settledConnections.delete(id) }) const create = Effect.fn("Workspace.create")(function* (input: CreateInput) { @@ -497,7 +613,7 @@ const layer = Layer.effect( id, name: Slug.create(), directory: null, - extra: input.extra ?? null, + extra: jsonExtra(input.extra), }) const info: Info = { @@ -506,26 +622,11 @@ const layer = Layer.effect( branch: config.branch ?? null, name: config.name ?? null, directory: config.directory ?? null, - extra: config.extra ?? null, + extra: jsonExtra(config.extra), projectID: input.projectID, timeUsed: Date.now(), } - yield* 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, - time_used: info.timeUsed, - }) - .run() - .pipe(Effect.orDie) - const env = { OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()), OPENCODE_WORKSPACE_ID: config.id, @@ -535,45 +636,339 @@ const layer = Layer.effect( OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES, } - yield* WorkspaceAdapterRuntime.create(adapter, config, env) - yield* Effect.all( - [ - waitEvent({ - timeout: TIMEOUT, - fn(event) { - if (event.workspace === info.id && event.payload.type === Event.Status.type) { - const { status } = event.payload.properties - return status === "error" || status === "connected" - } - return false - }, - }), - startSync(info), - ], - { concurrency: 2, discard: true }, + const created = yield* WorkspaceAdapterRuntime.create(adapter, config, env) + // Adapter create may already have provisioned remote resources. Any failure + // after this point must compensate with remove so the user is not left with + // an unusable workspace. Do not wait on remote SSE readiness here — remotes + // can lag past TIMEOUT, and ensureReady still gates first use. + const finalized: Info = created + ? { + id: info.id, + type: info.type, + branch: created.branch ?? null, + name: created.name, + directory: created.directory ?? null, + extra: jsonExtra(created.extra), + projectID: info.projectID, + timeUsed: info.timeUsed, + } + : { ...info, extra: jsonExtra(info.extra) } + return yield* Effect.gen(function* () { + yield* db + .insert(WorkspaceTable) + .values({ + id: finalized.id, + type: finalized.type, + branch: finalized.branch, + name: finalized.name, + directory: finalized.directory, + extra: finalized.extra, + project_id: finalized.projectID, + time_used: finalized.timeUsed, + }) + .run() + .pipe(Effect.orDie) + settledConnections.delete(finalized.id) + yield* startSync(finalized) + // Fail closed before the HTTP encoder so invalid adapter metadata cannot + // leave a provisioned workspace that clients cannot move to. + return yield* Schema.decodeUnknownEffect(Info)(finalized).pipe(Effect.orDie) + }).pipe( + Effect.catchCause((cause) => + Effect.all( + [ + stopSync(finalized.id), + db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, finalized.id)).run().pipe(Effect.ignore), + WorkspaceAdapterRuntime.remove(finalized).pipe(Effect.ignore), + ], + { discard: true }, + ).pipe(Effect.andThen(Effect.failCause(cause))), + ), ) + }) - return info + const activate = Effect.fnUntraced(function* (workspaceID: WorkspaceV2.ID) { + const space = yield* get(workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${workspaceID}`, + workspaceID, + }) + + const adapterStatus = yield* WorkspaceAdapterRuntime.status(space) + if (adapterStatus === undefined) { + const target = yield* WorkspaceAdapterRuntime.target(space) + if (target.type === "local") return + } + yield* WorkspaceAdapterRuntime.ensureReady(space) + yield* stopSync(workspaceID) + settledConnections.delete(workspaceID) + yield* startSync(space) + const remote = WorkspaceAdapterRuntime.kind(space) !== "local" + // Without experimental workspaces, remote startSync is intentionally a + // no-op. Do not wait for SSE status that will never settle. + if (remote && !flags.experimentalWorkspaces) return + // Settled "error" still means the readiness probe finished. Move uses + // direct HTTP against target(), so a failed SSE loop must not block the + // transfer; only a hang/timeout is fatal here. + yield* waitForConnection( + workspaceID, + new Set(["connected", "error"]), + remote ? REMOTE_READY_TIMEOUT : TIMEOUT, + ) + }) + + const ensureReady = Effect.fn("Workspace.ensureReady")(function* (workspaceID: WorkspaceV2.ID) { + const ready = yield* SynchronizedRef.modifyEffect( + readiness, + Effect.fnUntraced(function* (items) { + const current = items.get(workspaceID) + if (current) return [current, items] as const + const next = yield* Effect.cached( + activate(workspaceID).pipe( + Effect.ensuring( + SynchronizedRef.update(readiness, (state) => { + const next = new Map(state) + next.delete(workspaceID) + return next + }), + ), + ), + ) + return [next, new Map(items).set(workspaceID, next)] as const + }), + ) + return yield* ready + }) + + // Move/proxy need the adapter runtime + target URL. They do not need the + // experimental SSE event loop to report "connected" first. + const prepareMove = Effect.fnUntraced(function* (workspaceID: WorkspaceV2.ID) { + const space = yield* get(workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${workspaceID}`, + workspaceID, + }) + yield* WorkspaceAdapterRuntime.ensureReady(space) + if (flags.experimentalWorkspaces || WorkspaceAdapterRuntime.kind(space) === "local") { + if (!(yield* FiberMap.has(syncFibers, workspaceID))) yield* startSync(space) + } + return space + }) + + // A freshly provisioned remote workspace can report ready while its + // opencode server is still booting; transfers fired into that window fail + // (a proxy in front returns 5xx). Gate on the health endpoint first. + const waitForRemoteTarget = Effect.fnUntraced(function* ( + workspaceID: WorkspaceV2.ID, + target: Extract, + ) { + const deadline = Date.now() + REMOTE_READY_TIMEOUT + while (true) { + const status = yield* http + .execute( + HttpClientRequest.get(route(target.url, "/global/health"), { headers: new Headers(target.headers) }), + ) + .pipe( + Effect.timeout("5 seconds"), + Effect.flatMap((response) => response.text.pipe(Effect.as(response.status))), + Effect.catch(() => Effect.succeed(0)), + ) + if (status >= 200 && status < 300) return + if (Date.now() >= deadline) + return yield* new WorkspaceNotReadyError({ + message: `The remote workspace is not answering (${status ? `HTTP ${status}` : "unreachable"}). Check it is running, then retry.`, + workspaceID, + }) + yield* Effect.sleep("2 seconds") + } }) - const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) { + const replayCommittedMoveToSource = Effect.fnUntraced(function* ( + workspaceID: WorkspaceV2.ID | undefined, + sessionID: SessionID, + directory: string, + ) { + if (!workspaceID) return + const space = yield* get(workspaceID) + if (!space) return + const target = yield* WorkspaceAdapterRuntime.target(space) + if (target.type === "local") return + const event = yield* db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, "session.next.moved.1"))) + .orderBy(desc(EventTable.seq)) + .get() + .pipe(Effect.orDie) + if (!event) return + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ directory, events: [event] }), + }), + ) + if (response.status >= 200 && response.status < 300) return + const body = yield* response.text + return yield* new MoveSessionHttpError({ + message: `Failed to finalize session ${sessionID} at source workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID, + status: response.status, + body, + }) + }) + + const moveSession = Effect.fn("Workspace.moveSession")(function* (input: MoveSessionInput) { return yield* Effect.gen(function* () { const current = yield* db - .select({ workspaceID: SessionTable.workspace_id }) + .select({ workspaceID: SessionTable.workspace_id, directory: SessionTable.directory }) .from(SessionTable) .where(eq(SessionTable.id, input.sessionID)) .get() .pipe(Effect.orDie) + if ( + current && + current.workspaceID === (input.workspaceID ?? null) && + current.directory === input.destinationDirectory + ) { + const moved = yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, input.sessionID), eq(EventTable.type, "session.next.moved.1"))) + .orderBy(desc(EventTable.seq)) + .get() + .pipe(Effect.orDie) + const metadata = moveMetadata(moved?.data) + if (!metadata?.source || !metadata.transferHash) return + + yield* replayCommittedMoveToSource( + metadata.source.workspaceID ? WorkspaceV2.ID.make(metadata.source.workspaceID) : undefined, + input.sessionID, + current.directory, + ) + + if (input.workspaceID) { + const space = yield* get(input.workspaceID) + if (!space) + return yield* new WorkspaceNotFoundError({ + message: `Workspace not found: ${input.workspaceID}`, + workspaceID: input.workspaceID, + }) + const target = yield* WorkspaceAdapterRuntime.target(space) + if (target.type === "remote") { + const rows = yield* db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, + }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + yield* Effect.forEach( + Iterable.chunksOf(rows, 10), + (batch) => + http + .execute( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ directory: current.directory, events: batch }), + }), + ) + .pipe( + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? Effect.void + : Effect.fail( + new ChangeTransferError({ + message: "The destination did not finalize the committed move", + }), + ), + ), + ), + { discard: true }, + ) + } + yield* events.claim(input.sessionID, input.workspaceID) + } + + const patch = yield* runInWorkspace({ + workspaceID: metadata.source.workspaceID ? WorkspaceV2.ID.make(metadata.source.workspaceID) : undefined, + directory: metadata.source.directory, + local: () => vcs.diffRaw(), + remote: ({ target }) => + HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { headers: new Headers(target.headers) }), + fallback: "", + response: "text", + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + if (!patch) return + if (new Bun.CryptoHasher("sha256").update(patch).digest("hex") !== metadata.transferHash) { + return yield* new ChangeTransferError({ + message: "The session moved, but its source now has different changes and requires manual cleanup.", + }) + } + const discarded = yield* runInWorkspace({ + workspaceID: metadata.source.workspaceID ? WorkspaceV2.ID.make(metadata.source.workspaceID) : undefined, + directory: metadata.source.directory, + local: () => vcs.discard({ patch }), + remote: ({ target }) => + HttpClientRequest.post(route(target.url, "/vcs/discard"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ patch }), + }), + fallback: { applied: false }, + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + if (!discarded.applied) + return yield* new ChangeTransferError({ message: "The committed move still requires source cleanup" }) + return + } + + // Once the move commits, the source is abandoned. A remote source may + // be paused, unreachable, or torn down by its adapter the moment the + // move lands (e.g. a workspace adapter reclaiming its backend), so + // post-commit source cleanup against it is best-effort — it must not + // fail a move whose destination already has the session and changes. + let sourceRemote = false + if (current?.workspaceID) { const previous = yield* get(current.workspaceID) if (previous) { + yield* prepareMove(previous.id) const target = yield* WorkspaceAdapterRuntime.target(previous) + sourceRemote = target.type === "remote" if (target.type === "remote") { + const response = yield* http.execute( + HttpClientRequest.post(route(target.url, `/session/${input.sessionID}/abort`), { + headers: new Headers(target.headers), + }), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new MoveSessionHttpError({ + message: `Failed to stop session ${input.sessionID} before moving: HTTP ${response.status} ${body}`, + workspaceID: previous.id, + sessionID: input.sessionID, + status: response.status, + body, + }) + } yield* syncHistory(previous, target.url, target.headers).pipe( Effect.catch((error) => - Effect.logWarning("session warp final source sync failed", { + Effect.logWarning("session move final source sync failed", { workspaceID: previous.id, sessionID: input.sessionID, error: errorData(error), @@ -581,67 +976,233 @@ const layer = Layer.effect( ), ) } else { - yield* prompt.cancel(input.sessionID) + yield* Effect.gen(function* () { + const store = yield* InstanceStore.Service + yield* store.provide({ directory: target.directory }, prompt.cancel(input.sessionID)) + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) } - - // "claim" this session so any future events coming from - // the old workspace are ignored - yield* events.claim(input.sessionID, input.workspaceID ?? previous.projectID) } + } else if (current?.directory) { + yield* Effect.gen(function* () { + const store = yield* InstanceStore.Service + yield* store.provide({ directory: current.directory }, prompt.cancel(input.sessionID)) + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + } + + // Resolve the destination before transferring anything. Adapter + // ensureReady + target URL are enough for move; do not block on + // experimental SSE connectivity — /sync/replay is plain HTTP. + // Destination readiness and source change capture touch different + // ends of the move, so they run concurrently. + const [prepared, captured] = yield* Effect.all( + [ + Effect.gen(function* () { + const destination = input.workspaceID ? yield* prepareMove(input.workspaceID) : undefined + const destinationTarget = destination + ? yield* WorkspaceAdapterRuntime.target(destination) + : undefined + if (destination && destinationTarget?.type === "remote") + yield* waitForRemoteTarget(destination.id, destinationTarget) + return { destination, destinationTarget } + }), + input.copyChanges + ? runInWorkspace({ + workspaceID: current?.workspaceID ?? undefined, + directory: current?.directory, + local: () => vcs.diffRaw(), + remote: ({ target }) => + HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { + headers: new Headers(target.headers), + }), + fallback: null, + response: "text", + }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + : Effect.succeed(""), + ], + { concurrency: 2 }, + ) + const { destination, destinationTarget } = prepared + if (captured === null) { + return yield* new ChangeTransferError({ + message: + "Unable to capture source changes from the remote workspace. Check auth/proxy health, then retry the move with copy changes enabled.", + }) + } + if (input.copyChanges && !captured) { + yield* Effect.logWarning("session move copyChanges requested but source has no dirty patch", { + sessionID: input.sessionID, + workspaceID: current?.workspaceID, + }) + } + const sourcePatch = captured + const transferHash = sourcePatch ? new Bun.CryptoHasher("sha256").update(sourcePatch).digest("hex") : undefined + if (sourcePatch) { + yield* Effect.logInfo("session move transferring changes", { + sessionID: input.sessionID, + bytes: sourcePatch.length, + fromWorkspaceID: current?.workspaceID, + toWorkspaceID: input.workspaceID, + }) } - const sourcePatch = - input.copyChanges && current?.workspaceID - ? yield* runInWorkspace({ - workspaceID: current?.workspaceID ?? undefined, - local: () => vcs.diffRaw(), - remote: ({ target }) => - HttpClientRequest.get(route(target.url, "/vcs/diff/raw"), { - headers: new Headers(target.headers), + // Transfer changes before the session moves so a rejected transfer + // never leaves a moved session pointing at a destination without its + // changes. + if (sourcePatch) { + if (destination && destinationTarget?.type === "remote") { + // A remote destination may carry unrelated working-tree dirt, and a + // retried move may have already transferred this patch. The + // destination's /vcs/apply converges on already-applied patches, + // so send the transfer directly and surface the destination's + // actual git failure when it rejects. + const response = yield* remoteMoveRequest( + HttpClientRequest.post(route(destinationTarget.url, "/vcs/apply"), { + headers: new Headers(destinationTarget.headers), + body: HttpBody.jsonUnsafe({ patch: sourcePatch }), + }), + { workspaceID: destination.id, sessionID: input.sessionID, step: "transferring changes" }, + ).pipe( + Effect.catchIf(HttpClientError.isHttpClientError, () => + Effect.fail( + new ChangeTransferError({ + message: + "Unable to reach the remote workspace to transfer changes. Check the workspace is running, then retry.", }), - fallback: "", - response: "text", + ), + ), + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + yield* Effect.logWarning("session move change transfer rejected", { + workspaceID: destination.id, + sessionID: input.sessionID, + status: response.status, + body: body.slice(0, 500), + }) + return yield* new ChangeTransferError({ + message: `The destination could not apply source changes: ${remoteFailure(body) ?? `HTTP ${response.status}`}`, + }) + } + } else { + const directory = + destinationTarget?.type === "local" + ? (input.destinationDirectory ?? destinationTarget.directory) + : input.destinationDirectory + if (!directory) return yield* new ChangeTransferError({ message: "A destination directory is required" }) + const inDestination = (effect: Effect.Effect) => + Effect.gen(function* () { + const store = yield* InstanceStore.Service + return yield* store.provide({ directory }, effect) }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) - : "" + // Local destinations must stay clean so transferred changes never + // mix with unrelated work. Dirt is only acceptable when a prior + // attempt already applied this exact patch. + if (yield* inDestination(vcs.diffRaw())) { + if (!(yield* inDestination(vcs.applied({ patch: sourcePatch })))) + return yield* new ChangeTransferError({ + message: + "The destination has changes. Commit, stash, or move them before transferring source changes.", + }) + } else { + yield* inDestination(vcs.apply({ patch: sourcePatch })) + } + } + } - if (sourcePatch) { - // Attempt to apply the file changes to the new workspace. - // We intentionally do first so if it fails we don't warp - // the session. - yield* runInWorkspace({ - workspaceID: input.workspaceID ?? undefined, - local: () => vcs.apply({ patch: sourcePatch }), + const discardSource = () => { + if (!sourcePatch) return Effect.void + return runInWorkspace({ + workspaceID: current?.workspaceID ?? undefined, + directory: current?.directory, + local: () => vcs.discard({ patch: sourcePatch }), remote: ({ target }) => - HttpClientRequest.post(route(target.url, "/vcs/apply"), { + HttpClientRequest.post(route(target.url, "/vcs/discard"), { headers: new Headers(target.headers), body: HttpBody.jsonUnsafe({ patch: sourcePatch }), }), fallback: { applied: false }, - }).pipe(Effect.provide(AppNodeBuilderV1.build(InstanceStore.node))) + }).pipe( + Effect.provide(AppNodeBuilderV1.build(InstanceStore.node)), + Effect.flatMap((result) => + result.applied + ? Effect.void + : // A local source must clear its now-duplicated changes; a remote + // source is abandoned and may already be gone, so a failed + // discard there is logged, not fatal. + sourceRemote + ? Effect.logWarning("session move source discard skipped", { + sessionID: input.sessionID, + workspaceID: current?.workspaceID, + }) + : Effect.fail(new ChangeTransferError({ message: "The source did not clear transferred changes" })), + ), + ) } + // The source may already be gone once the move commits; notifying it is + // a courtesy, never a reason to fail an otherwise-complete move. + const notifySource = (workspaceID: WorkspaceV2.ID | undefined, directory: string) => + replayCommittedMoveToSource(workspaceID, input.sessionID, directory).pipe( + Effect.catch((error) => + Effect.logWarning("session move source notify failed", { + sessionID: input.sessionID, + workspaceID, + error: errorData(error), + }), + ), + ) + if (input.workspaceID === null) { - yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined }) + if (!input.destinationDirectory) + return yield* new ChangeTransferError({ message: "A destination directory is required" }) + yield* mover.moveSession({ + sessionID: input.sessionID, + destination: { directory: AbsolutePath.make(input.destinationDirectory) }, + moveChanges: false, + transferHash, + }) + yield* notifySource(current?.workspaceID ?? undefined, input.destinationDirectory) + if (current?.workspaceID) { + const previous = yield* get(current.workspaceID) + if (previous) yield* events.claim(input.sessionID, previous.projectID) + } + yield* discardSource() return } const workspaceID = input.workspaceID - const space = yield* get(workspaceID) - if (!space) + const space = destination + const target = destinationTarget + if (!space || !target) return yield* new WorkspaceNotFoundError({ message: `Workspace not found: ${workspaceID}`, workspaceID, }) - const target = yield* WorkspaceAdapterRuntime.target(space) - if (target.type === "local") { - yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) + yield* mover.moveSession({ + sessionID: input.sessionID, + destination: { + directory: AbsolutePath.make(input.destinationDirectory ?? target.directory), + workspaceID, + }, + moveChanges: false, + transferHash, + }) + yield* notifySource(current?.workspaceID ?? undefined, input.destinationDirectory ?? target.directory) + yield* events.claim(input.sessionID, workspaceID) + yield* discardSource() return } + yield* Effect.logInfo("session move replaying history", { + sessionID: input.sessionID, + workspaceID, + url: String(target.url), + }) + const rows = yield* db .select({ id: EventTable.id, @@ -655,20 +1216,38 @@ const layer = Layer.effect( .orderBy(asc(EventTable.seq)) .all() .pipe(Effect.orDie) - if (rows.length === 0) - return yield* new SessionEventsNotFoundError({ - message: `No events found for session: ${input.sessionID}`, - sessionID: input.sessionID, - }) - - const batches = Iterable.chunksOf(rows, 10) - const total = Iterable.size(batches) + // Each batch is a sequential round trip (replay order matters), so + // chunk by payload size rather than a small fixed count — most + // sessions then replay in a single request. + const batches: (typeof rows)[] = [] + { + let batch: typeof rows = [] + let bytes = 0 + for (const row of rows) { + const size = JSON.stringify(row).length + if (batch.length > 0 && (bytes + size > 512_000 || batch.length >= 100)) { + batches.push(batch) + batch = [] + bytes = 0 + } + batch.push(row) + bytes += size + } + if (batch.length > 0) batches.push(batch) + } yield* Effect.forEach( batches, (events, i) => Effect.gen(function* () { - const response = yield* http.execute( + yield* Effect.logInfo("session move replay batch", { + sessionID: input.sessionID, + workspaceID, + batch: i + 1, + batches: batches.length, + events: events.length, + }) + const response = yield* remoteMoveRequest( HttpClientRequest.post(route(target.url, "/sync/replay"), { headers: new Headers(target.headers), body: HttpBody.jsonUnsafe({ @@ -676,12 +1255,17 @@ const layer = Layer.effect( events, }), }), + { + workspaceID, + sessionID: input.sessionID, + step: `replay batch ${i + 1}/${batches.length}`, + }, ) if (response.status < 200 || response.status >= 300) { const body = yield* response.text - return yield* new SessionWarpHttpError({ - message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, + return yield* new MoveSessionHttpError({ + message: `Failed to move session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, workspaceID, sessionID: input.sessionID, status: response.status, @@ -692,27 +1276,78 @@ const layer = Layer.effect( { discard: true }, ) - const response = yield* http.execute( - HttpClientRequest.post(route(target.url, "/sync/steal"), { - headers: new Headers(target.headers), - body: HttpBody.jsonUnsafe({ sessionID: input.sessionID }), - }), - ) - if (response.status < 200 || response.status >= 300) { - const body = yield* response.text - return yield* new SessionWarpHttpError({ - message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`, - workspaceID, - sessionID: input.sessionID, - status: response.status, - body, + const destinationDirectory = input.destinationDirectory ?? space.directory + if (!destinationDirectory) + return yield* new ChangeTransferError({ message: "The destination workspace did not provide a directory" }) + yield* Effect.logInfo("session move committing destination", { + sessionID: input.sessionID, + workspaceID, + destinationDirectory, + }) + yield* mover.moveSession({ + sessionID: input.sessionID, + destination: { directory: AbsolutePath.make(destinationDirectory), workspaceID }, + moveChanges: false, + transferHash, + }) + yield* notifySource(current?.workspaceID ?? undefined, destinationDirectory) + + const committed = yield* db + .select({ + id: EventTable.id, + aggregateID: EventTable.aggregate_id, + seq: EventTable.seq, + type: EventTable.type, + data: EventTable.data, }) + .from(EventTable) + .where(eq(EventTable.aggregate_id, input.sessionID)) + .orderBy(asc(EventTable.seq)) + .all() + .pipe(Effect.orDie) + const moved = committed.slice(rows.length) + if (moved.length) { + const response = yield* remoteMoveRequest( + HttpClientRequest.post(route(target.url, "/sync/replay"), { + headers: new Headers(target.headers), + body: HttpBody.jsonUnsafe({ directory: destinationDirectory, events: moved }), + }), + { + workspaceID, + sessionID: input.sessionID, + step: "finalize moved event", + }, + ) + if (response.status < 200 || response.status >= 300) { + const body = yield* response.text + return yield* new MoveSessionHttpError({ + message: `Failed to finalize session ${input.sessionID} in workspace ${workspaceID}: HTTP ${response.status} ${body}`, + workspaceID, + sessionID: input.sessionID, + status: response.status, + body, + }) + } } - yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID }) + yield* events.claim(input.sessionID, workspaceID) + yield* discardSource() + // The destination just served the whole transfer, so it is + // demonstrably reachable. Restart its sync loop so the connection + // status settles now instead of waiting out a reconnect backoff + // started while the workspace was still booting. + yield* stopSync(workspaceID) + yield* startSync(space).pipe( + Effect.catch((error) => + Effect.logWarning("session move destination sync restart failed", { + workspaceID, + error: errorData(error), + }), + ), + ) + yield* Effect.logInfo("session move complete", { sessionID: input.sessionID, workspaceID }) }) }) - const list = Effect.fn("Workspace.list")(function* (project: Project.Info) { return (yield* db .select() @@ -783,6 +1418,22 @@ const layer = Layer.effect( }) const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) { + const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) + if (!row) return + const info = fromRow(row) + + // Adapter cleanup is best-effort with a bound: a dead or unreachable + // backing resource must not make the record undeletable. If the + // resource still exists remotely, adapter discovery re-registers it on + // the next list sync. + yield* WorkspaceAdapterRuntime.remove(info).pipe( + Effect.timeout(REMOTE_MOVE_HTTP_TIMEOUT), + Effect.catchCause((cause) => + Effect.logWarning("workspace adapter remove failed", { workspaceID: id, cause }), + ), + ) + yield* stopSync(id) + const sessions = yield* db .select({ id: SessionTable.id, parentID: SessionTable.parent_id }) .from(SessionTable) @@ -797,19 +1448,6 @@ const layer = Layer.effect( { discard: true }, ) - const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie) - if (!row) return - - yield* stopSync(id) - - const info = fromRow(row) - yield* Effect.catchCause( - Effect.gen(function* () { - yield* WorkspaceAdapterRuntime.remove(info) - }), - () => Effect.logError("adapter not available when removing workspace", { type: row.type }), - ) - yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie) return info }) @@ -872,12 +1510,13 @@ const layer = Layer.effect( return Service.of({ create, - sessionWarp, + moveSession, list, syncList, get, remove, status, + ensureReady, isSyncing, waitForSync, startWorkspaceSyncing, @@ -886,6 +1525,11 @@ const layer = Layer.effect( ) const TIMEOUT = 5000 +// Remote agent servers can take well beyond the local worktree path to expose +// /global/event after provision; move/proxy must wait longer than create. +const REMOTE_READY_TIMEOUT = 60_000 +// Bound remote move HTTP so a hung /sync/replay cannot leave the TUI spinner forever. +const REMOTE_MOVE_HTTP_TIMEOUT = "30 seconds" type HistoryEvent = { id: string @@ -945,6 +1589,46 @@ function route(url: string | URL, path: string) { return next } +function jsonExtra(value: unknown): Schema.MutableJson | null { + if (value == null) return null + try { + return JSON.parse(JSON.stringify(value)) as Schema.MutableJson + } catch { + return null + } +} + +function remoteFailure(body: string) { + const trimmed = body.trim() + // Proxies answer with HTML error pages; those carry no usable detail for + // the user, so fall back to the HTTP status instead. + if (!trimmed || /^<(?:!doctype|html)/i.test(trimmed)) return undefined + try { + const parsed = JSON.parse(trimmed) as { data?: { message?: unknown }; message?: unknown } + const message = parsed.data?.message ?? parsed.message + if (typeof message === "string" && message) return message + } catch { + // fall through to the raw body + } + return trimmed.slice(0, 300) +} + +function moveMetadata(data: unknown) { + if (typeof data !== "object" || data === null) return + const value = data as Record + const source = value.source + if (typeof source !== "object" || source === null) return + const location = source as Record + if (typeof location.directory !== "string") return + return { + source: { + directory: location.directory, + workspaceID: typeof location.workspaceID === "string" ? location.workspaceID : undefined, + }, + transferHash: typeof value.transferHash === "string" ? value.transferHash : undefined, + } +} + export const node = LayerNode.make({ service: Service, layer: layer, @@ -952,6 +1636,7 @@ export const node = LayerNode.make({ Auth.node, Session.node, SessionPrompt.node, + MoveSession.node, httpClient, EventV2Bridge.node, Vcs.node, diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 968e394a959f..284b3e378434 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -55,6 +55,12 @@ export type Patch = { export interface PatchOptions { readonly context?: number readonly maxOutputBytes?: number + readonly binary?: boolean +} + +export interface ApplyPatchOptions { + readonly reverse?: boolean + readonly check?: boolean } export interface Result { @@ -87,7 +93,7 @@ export interface Interface { readonly patchAll: (cwd: string, ref: string, options?: PatchOptions) => Effect.Effect readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly statUntracked: (cwd: string, file: string) => Effect.Effect - readonly applyPatch: (cwd: string, patch: string) => Effect.Effect + readonly applyPatch: (cwd: string, patch: string, options?: ApplyPatchOptions) => Effect.Effect } const kind = (code: string): Kind => { @@ -270,7 +276,17 @@ const layer = Layer.effect( const patchAll = Effect.fn("Git.patchAll")(function* (cwd: string, ref: string, options?: PatchOptions) { const result = yield* run( - ["diff", "--patch", "--no-ext-diff", "--no-renames", `--unified=${options?.context ?? 3}`, ref, "--", "."], + [ + "diff", + "--patch", + "--no-ext-diff", + "--no-renames", + ...(options?.binary ? ["--binary"] : []), + `--unified=${options?.context ?? 3}`, + ref, + "--", + ".", + ], { cwd, maxOutputBytes: options?.maxOutputBytes }, ) return { text: result.text(), truncated: result.truncated } satisfies Patch @@ -288,6 +304,7 @@ const layer = Layer.effect( "--patch", "--no-ext-diff", "--no-renames", + ...(options?.binary ? ["--binary"] : []), `--unified=${options?.context ?? 3}`, "--", "/dev/null", @@ -319,8 +336,15 @@ const layer = Layer.effect( } satisfies Stat }) - const applyPatch = Effect.fn("Git.applyPatch")(function* (cwd: string, patch: string) { - return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) + const applyPatch = Effect.fn("Git.applyPatch")(function* ( + cwd: string, + patch: string, + options?: ApplyPatchOptions, + ) { + return yield* run( + ["apply", ...(options?.reverse ? ["--reverse"] : []), ...(options?.check ? ["--check"] : []), "-"], + { cwd, stdin: stdin(patch) }, + ) }) return Service.of({ diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 2e27ff2424db..ee074a25b505 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -2,7 +2,12 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { InstanceState } from "@/effect/instance-state" import { Wildcard } from "@opencode-ai/core/util/wildcard" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Database } from "@opencode-ai/core/database/database" +import { EventTable } from "@opencode-ai/core/event/sql" +import { and, eq, like } from "drizzle-orm" import { Deferred, Effect, Layer, Context } from "effect" +import fs from "fs" import os from "os" import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { EventV2Bridge } from "@/event-v2-bridge" @@ -23,6 +28,7 @@ interface PendingEntry { interface State { pending: Map approved: PermissionV1.Rule[] + directory: string } export function evaluate(permission: string, pattern: string, ...rulesets: PermissionV1.Ruleset[]): PermissionV1.Rule { @@ -43,12 +49,13 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service + const { db } = yield* Database.Service const state = yield* InstanceState.make( Effect.fn("Permission.state")(function* (ctx) { - void ctx - const state = { + const state: State = { pending: new Map(), approved: [], + directory: ctx.directory, } yield* Effect.addFinalizer(() => @@ -64,9 +71,55 @@ const layer = Layer.effect( }), ) + // A session that moved between machines leaves absolute paths from its + // previous location baked into the model's context, and models keep + // reusing them despite reminders. Those paths cannot resolve here, and + // merely probing them can stall the process (macOS automounts /home, so + // stats on a remote sandbox's /home/... path can block for seconds). + // Answer such asks with corrective feedback instead of prompting the + // user for a directory that cannot work. + const staleMovedRoot = Effect.fnUntraced(function* ( + request: Omit, + directory: string, + ) { + if (request.permission !== "external_directory") return undefined + const metadata = request.metadata as { parentDir?: unknown } | undefined + const target = + typeof metadata?.parentDir === "string" ? metadata.parentDir : request.patterns[0]?.replace(/\/\*$/, "") + if (!target) return undefined + const moved = yield* db + .select({ data: EventTable.data }) + .from(EventTable) + .where(and(eq(EventTable.aggregate_id, request.sessionID), like(EventTable.type, "session.next.moved%"))) + .all() + .pipe(Effect.catchCause(() => Effect.succeed([]))) + for (const event of moved) { + const root = (event.data as { source?: { directory?: string } }).source?.directory + if (!root || root === directory) continue + if (target !== root && !FSUtil.contains(root, target)) continue + // A former root that still exists here (e.g. a worktree move on the + // same machine) is a legitimate target; let the normal flow decide. + const exists = yield* Effect.promise(() => + fs.promises.access(root).then( + () => true, + () => false, + ), + ) + if (exists) continue + return root + } + return undefined + }) + const ask = Effect.fn("Permission.ask")(function* (input: PermissionV1.AskInput) { - const { approved, pending } = yield* InstanceState.get(state) + const { approved, pending, directory } = yield* InstanceState.get(state) const { ruleset, ...request } = input + const stale = yield* staleMovedRoot(request, directory) + if (stale) { + return yield* new PermissionV1.CorrectedError({ + feedback: `${request.patterns[0] ?? stale} is under ${stale}, a previous location of this session. The session has moved and that path does not exist here. The project now lives at ${directory} — re-resolve your paths against the current working directory.`, + }) + } let needsAsk = false for (const pattern of request.patterns) { @@ -218,6 +271,6 @@ export function visibleTools(tools: Record, ruleset: PermissionV1. return Object.fromEntries(Object.entries(tools).filter(([name]) => !hidden.has(name))) } -export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) +export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] }) export * as Permission from "." diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1558c707c3f7..bb7821aa632e 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -8,6 +8,7 @@ import type { } from "@opencode-ai/plugin" import { Config } from "@/config/config" import { createOpencodeClient } from "@opencode-ai/sdk" +import { createOpencodeClient as createOpencodeClientV2 } from "@opencode-ai/sdk/v2" import { ServerAuth } from "@/server/auth" import { CodexAuthPlugin } from "./openai/codex" import { Session } from "@/session/session" @@ -139,12 +140,16 @@ const layer = Layer.effect( const { Server } = yield* Effect.promise(() => import("../server/server")) const serverUrl = Server.url - const client = createOpencodeClient({ + const clientConfig = { baseUrl: serverUrl?.toString() ?? "http://localhost:4096", directory: ctx.directory, headers: ServerAuth.headers(), - ...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }), - }) + ...(serverUrl + ? {} + : { fetch: (async (request: Request) => Server.Default().app.fetch(request)) as typeof fetch }), + } + const client = createOpencodeClient(clientConfig) + const clientV2 = createOpencodeClientV2(clientConfig) const cfg = yield* config.get() const input: PluginInput = { client, @@ -155,6 +160,16 @@ const layer = Layer.effect( register(type: string, adapter: PluginWorkspaceAdapter) { registerAdapter(ctx.project.id, type, adapter as WorkspaceAdapter) }, + // Removes the workspace record and tears down its backing + // resource through the adapter's remove(). Lets adapters that + // treat workspaces as disposable (e.g. one sandbox per session) + // clean up listings without hand-rolling HTTP against the server. + async remove(workspaceID: string) { + const result = await clientV2.experimental.workspace.remove({ id: workspaceID }) + if (result.error) { + throw new Error(`Failed to remove workspace ${workspaceID}: ${errorMessage(result.error)}`) + } + }, }, get serverUrl(): URL { return Server.url ?? new URL("http://localhost:4096") diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index eca56c0501a1..c6f0b7cc37a9 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -273,11 +273,21 @@ export const ApplyResult = Schema.Struct({ }) export type ApplyResult = Schema.Schema.Type +export const DiscardInput = Schema.Struct({ + patch: Schema.String, +}) +export type DiscardInput = Schema.Schema.Type + export class PatchApplyError extends Schema.TaggedErrorClass()("VcsPatchApplyError", { message: Schema.String, reason: Schema.Literals(["non-git", "not-clean"]), }) {} +export class DiscardError extends Schema.TaggedErrorClass()("VcsDiscardError", { + message: Schema.String, + reason: Schema.Literals(["changed", "failed"]), +}) {} + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect @@ -285,7 +295,9 @@ export interface Interface { readonly status: () => Effect.Effect readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect readonly diffRaw: () => Effect.Effect + readonly applied: (input: ApplyInput) => Effect.Effect readonly apply: (input: ApplyInput) => Effect.Effect + readonly discard: (input: DiscardInput) => Effect.Effect } interface State { @@ -335,6 +347,34 @@ const layer: Layer.Layer = }), ) + const diffRaw = Effect.fn("Vcs.diffRaw")(function* () { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return "" + const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { + concurrency: 2, + }) + // Binary hunks keep the patch applyable when transferring changes + // between workspaces; without them git emits an inert "Binary files + // differ" placeholder that git-apply rejects. + const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD", { binary: true })).text : "" + const untracked = yield* Effect.forEach( + status.filter((item) => item.code === "??"), + (item) => git.patchUntracked(ctx.directory, item.file, { binary: true }).pipe(Effect.map((patch) => patch.text)), + ) + return [tracked, ...untracked].filter(Boolean).join("\n") + }) + + // A patch that reverses cleanly is already present in the working tree. + // Both apply and discard lean on this so a retried transfer converges + // instead of failing on exact-string comparisons. + const applied = Effect.fn("Vcs.applied")(function* (input: ApplyInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return false + if (!input.patch.trim()) return true + const check = yield* git.applyPatch(ctx.directory, input.patch, { reverse: true, check: true }) + return check.exitCode === 0 + }) + return Service.of({ init: Effect.fn("Vcs.init")(function* () { yield* InstanceState.get(state).pipe(Effect.forkIn(scope)) @@ -384,19 +424,8 @@ const layer: Layer.Layer = if (!ref) return [] return yield* diffAgainstRef(git, ctx.directory, ref, options) }), - diffRaw: Effect.fn("Vcs.diffRaw")(function* () { - const ctx = yield* InstanceState.context - if (ctx.project.vcs !== "git") return "" - const [hasHead, status] = yield* Effect.all([git.hasHead(ctx.directory), git.status(ctx.directory)], { - concurrency: 2, - }) - const tracked = hasHead ? (yield* git.patchAll(ctx.directory, "HEAD")).text : "" - const untracked = yield* Effect.forEach( - status.filter((item) => item.code === "??"), - (item) => git.patchUntracked(ctx.directory, item.file).pipe(Effect.map((patch) => patch.text)), - ) - return [tracked, ...untracked].filter(Boolean).join("\n") - }), + diffRaw, + applied, apply: Effect.fn("Vcs.apply")(function* (input: ApplyInput) { const ctx = yield* InstanceState.context if (ctx.project.vcs !== "git") { @@ -405,14 +434,38 @@ const layer: Layer.Layer = reason: "non-git", }) } - const applied = yield* git.applyPatch(ctx.directory, input.patch) - if (applied.exitCode !== 0) { - return yield* new PatchApplyError({ - message: "Patch can't be applied", - reason: "not-clean", + if (!input.patch.trim()) return { applied: true } + const result = yield* git.applyPatch(ctx.directory, input.patch) + if (result.exitCode === 0) return { applied: true } + // A retried transfer may have applied this patch before failing on a + // later step; converging here keeps the whole move retry-safe. + if (yield* applied(input)) return { applied: true } + return yield* new PatchApplyError({ + message: result.stderr.toString("utf8").trim() || "Patch can't be applied", + reason: "not-clean", + }) + }), + discard: Effect.fn("Vcs.discard")(function* (input: DiscardInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") { + return yield* new DiscardError({ + message: "Changes can't be discarded because the project is not git-based", + reason: "failed", }) } - return { applied: true } + if (!input.patch.trim()) return { applied: true } + // Reverse-apply removes exactly the transferred changes (including + // files the patch created) and leaves unrelated work untouched. + const reversed = yield* git.applyPatch(ctx.directory, input.patch, { reverse: true }) + if (reversed.exitCode === 0) return { applied: true } + // Forward-applying cleanly means the changes are no longer present — + // a previous discard already cleared them. + const forward = yield* git.applyPatch(ctx.directory, input.patch, { check: true }) + if (forward.exitCode === 0) return { applied: true } + return yield* new DiscardError({ + message: "Source changes changed after they were transferred", + reason: "changed", + }) }), }) }), diff --git a/packages/opencode/src/server/proxy-util.ts b/packages/opencode/src/server/proxy-util.ts index 5107f4759a88..8dc620ca907f 100644 --- a/packages/opencode/src/server/proxy-util.ts +++ b/packages/opencode/src/server/proxy-util.ts @@ -22,6 +22,10 @@ export function headers(input: Request | HeadersInit | Record, e const raw = input instanceof Request ? input.headers : input const out = new Headers(raw instanceof Headers ? raw : Object.entries(raw as Record)) sanitize(out) + // Drop client Authorization before applying workspace target credentials. + // Otherwise the host session Authorization would overwrite remote Basic auth + // and Gitterm/OpenCode remote would return 401/CF HTML error pages. + out.delete("authorization") if (!extra) return out for (const [key, value] of new Headers(extra).entries()) { out.set(key, value) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/control-plane.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/control-plane.ts index 9c8ef188d581..81c46ad34b0d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/control-plane.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/control-plane.ts @@ -1,10 +1,11 @@ import { MoveSession } from "@opencode-ai/core/control-plane/move-session" -import { Schema } from "effect" +import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { described } from "./metadata" +import { ApiNotFoundError } from "../errors" const root = "/experimental/control-plane" -export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields }) +export const MoveSessionPayload = Schema.Struct(Struct.omit(MoveSession.Input.fields, ["transferHash"])) export class ApiMoveSessionError extends Schema.ErrorClass("MoveSessionError")( { @@ -22,7 +23,7 @@ export const ControlPlaneApi = HttpApi.make("controlPlane").add( HttpApiEndpoint.post("moveSession", `${root}/move-session`, { payload: MoveSessionPayload, success: described(HttpApiSchema.NoContent, "Session moved"), - error: ApiMoveSessionError, + error: [ApiMoveSessionError, ApiNotFoundError], }).annotateMerge( OpenApi.annotations({ identifier: "experimental.controlPlane.moveSession", diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index 6fd18c8624c7..d46fdc8f7c56 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -40,6 +40,14 @@ export class ApiVcsApplyError extends Schema.ErrorClass("VcsAp { httpApiStatus: 400 }, ) {} +export class ApiVcsDiscardError extends Schema.ErrorClass("VcsDiscardError")( + { + name: Schema.Literal("VcsDiscardError"), + data: Schema.Struct({ message: Schema.String, reason: Schema.Literals(["changed", "failed"]) }), + }, + { httpApiStatus: 409 }, +) {} + export const InstancePaths = { dispose: "/instance/dispose", path: "/path", @@ -48,6 +56,7 @@ export const InstancePaths = { vcsDiff: "/vcs/diff", vcsDiffRaw: "/vcs/diff/raw", vcsApply: "/vcs/apply", + vcsDiscard: "/vcs/discard", command: "/command", agent: "/agent", skill: "/skill", @@ -136,6 +145,18 @@ export const InstanceApi = HttpApi.make("instance") description: "Apply a raw patch to the current working tree.", }), ), + HttpApiEndpoint.post("vcsDiscard", InstancePaths.vcsDiscard, { + query: WorkspaceRoutingQuery, + payload: Vcs.DiscardInput, + success: described(Vcs.ApplyResult, "VCS changes discarded"), + error: ApiVcsDiscardError, + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.discard", + summary: "Discard transferred VCS changes", + description: "Discard source changes only when they still match a previously transferred patch.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts index ca9b1cdaf0aa..0bdcc8073151 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts @@ -1,6 +1,5 @@ import { NonNegativeInt } from "@opencode-ai/core/schema" import { EventV2 } from "@opencode-ai/core/event" -import { SessionID } from "@/session/schema" import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { Authorization } from "../middleware/authorization" @@ -23,9 +22,6 @@ export const ReplayPayload = Schema.Struct({ export const ReplayResponse = Schema.Struct({ sessionID: Schema.String, }) -export const SessionPayload = Schema.Struct({ - sessionID: SessionID, -}) export const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt) export const HistoryEvent = Schema.Struct({ id: EventV2.ID, @@ -38,7 +34,6 @@ export const HistoryEvent = Schema.Struct({ export const SyncPaths = { start: `${root}/start`, replay: `${root}/replay`, - steal: `${root}/steal`, history: `${root}/history`, } as const @@ -68,18 +63,6 @@ export const SyncApi = HttpApi.make("sync") description: "Validate and replay a complete sync event history.", }), ), - HttpApiEndpoint.post("steal", SyncPaths.steal, { - query: WorkspaceRoutingQuery, - payload: SessionPayload, - success: described(SessionPayload, "Session stolen into workspace"), - error: HttpApiError.BadRequest, - }).annotateMerge( - OpenApi.annotations({ - identifier: "sync.steal", - summary: "Steal session into workspace", - description: "Update a session to belong to the current workspace through the sync event system.", - }), - ), HttpApiEndpoint.post("history", SyncPaths.history, { query: WorkspaceRoutingQuery, payload: HistoryPayload, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts index 09a6e67b6e9b..f6c9099b3f34 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/workspace.ts @@ -2,8 +2,6 @@ import { Workspace } from "@/control-plane/workspace" import { WorkspaceAdapterEntry } from "@/control-plane/types" import { Schema, Struct } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { ApiVcsApplyError } from "./instance" -import { ApiNotFoundError } from "../errors" import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" @@ -11,22 +9,6 @@ import { described } from "./metadata" const root = "/experimental/workspace" export const CreatePayload = Schema.Struct(Struct.omit(Workspace.CreateInput.fields, ["projectID"])) -export const WarpPayload = Schema.Struct({ - id: Schema.NullOr(Workspace.Info.fields.id), - sessionID: Workspace.SessionWarpInput.fields.sessionID, - copyChanges: Workspace.SessionWarpInput.fields.copyChanges, -}) - -export class ApiWorkspaceWarpError extends Schema.ErrorClass("WorkspaceWarpError")( - { - name: Schema.Literal("WorkspaceWarpError"), - data: Schema.Struct({ - message: Schema.String, - }), - }, - { httpApiStatus: 400 }, -) {} - export class ApiWorkspaceCreateError extends Schema.ErrorClass("WorkspaceCreateError")( { name: Schema.Literal("WorkspaceCreateError"), @@ -43,7 +25,6 @@ export const WorkspacePaths = { syncList: `${root}/sync-list`, status: `${root}/status`, remove: `${root}/:id`, - warp: `${root}/warp`, } as const export const WorkspaceApi = HttpApi.make("workspace") @@ -114,18 +95,6 @@ export const WorkspaceApi = HttpApi.make("workspace") description: "Remove an existing workspace.", }), ), - HttpApiEndpoint.post("warp", WorkspacePaths.warp, { - query: WorkspaceRoutingQuery, - payload: WarpPayload, - success: described(HttpApiSchema.NoContent, "Session warped"), - error: [ApiWorkspaceWarpError, ApiVcsApplyError, ApiNotFoundError], - }).annotateMerge( - OpenApi.annotations({ - identifier: "experimental.workspace.warp", - summary: "Warp session into workspace", - description: "Move a session's sync history into the target workspace, or detach it to the local project.", - }), - ), ) .annotateMerge(OpenApi.annotations({ title: "workspace", description: "Experimental HttpApi workspace routes." })) .middleware(InstanceContextMiddleware) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts index 4c13afdb38a9..3d55a85e12bd 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/control-plane.ts @@ -2,8 +2,10 @@ import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { SessionV2 } from "@opencode-ai/core/session" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" +import { Workspace } from "@/control-plane/workspace" import { RootHttpApi } from "../api" import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane" +import { notFound } from "../errors" export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) => Effect.gen(function* () { @@ -12,15 +14,75 @@ export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPl const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: { payload: typeof MoveSessionPayload.Type }) { - yield* service.moveSession(ctx.payload).pipe( - Effect.mapError( - (error) => - new ApiMoveSessionError({ + if (!ctx.payload.destination.workspaceID) { + if (yield* service.atDestination(ctx.payload)) { + const workspace = yield* Workspace.Service + yield* workspace + .moveSession({ + sessionID: ctx.payload.sessionID, + workspaceID: null, + destinationDirectory: ctx.payload.destination.directory, + copyChanges: ctx.payload.moveChanges, + }) + .pipe( + Effect.mapError( + (error) => new ApiMoveSessionError({ name: "MoveSessionError", data: { message: error.message } }), + ), + ) + return + } + const moved = yield* service.moveSession(ctx.payload).pipe( + Effect.as(true), + Effect.catch((error) => { + if (error instanceof MoveSession.WorkspaceChangeTransferUnsupportedError) return Effect.succeed(false) + return Effect.fail( + new ApiMoveSessionError({ + name: "MoveSessionError", + data: { message: message(error) }, + }), + ) + }), + ) + if (moved) return + + const workspace = yield* Workspace.Service + yield* workspace + .moveSession({ + sessionID: ctx.payload.sessionID, + workspaceID: null, + destinationDirectory: ctx.payload.destination.directory, + copyChanges: ctx.payload.moveChanges, + }) + .pipe( + Effect.mapError( + (error) => new ApiMoveSessionError({ name: "MoveSessionError", data: { message: error.message } }), + ), + ) + return + } + + const workspace = yield* Workspace.Service + yield* workspace + .moveSession({ + sessionID: ctx.payload.sessionID, + workspaceID: ctx.payload.destination.workspaceID, + destinationDirectory: ctx.payload.destination.directory, + copyChanges: ctx.payload.moveChanges, + }) + .pipe( + Effect.mapError((error) => { + if (error instanceof Workspace.WorkspaceNotFoundError) return notFound(error.message) + if (error instanceof Workspace.WorkspaceNotReadyError) + return new ApiMoveSessionError({ + name: "MoveSessionError", + data: { message: error.message }, + }) + return new ApiMoveSessionError({ name: "MoveSessionError", - data: { message: message(error) }, - }), - ), - ) + data: { message: error.message }, + }) + }), + ) }) return handlers.handle("moveSession", moveSession) @@ -31,6 +93,10 @@ function message(error: MoveSession.Error) { if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}` if (error instanceof MoveSession.DestinationProjectMismatchError) return "Destination directory belongs to another project" + if (error instanceof MoveSession.DestinationWorkspaceNotFoundError) + return `Destination workspace not found: ${error.workspaceID}` + if (error instanceof MoveSession.WorkspaceChangeTransferUnsupportedError) + return "Moving changes between workspaces is not supported yet" if (error instanceof MoveSession.ApplyChangesError) return `Unable to apply your changes in the destination directory. The files may conflict with existing changes.` return error.message diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index f851b3a31005..cb29ac5216e8 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -9,7 +9,7 @@ import { Skill } from "@/skill" import { Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { ApiVcsApplyError } from "../groups/instance" +import { ApiVcsApplyError, ApiVcsDiscardError } from "../groups/instance" import { markInstanceForDisposal } from "../lifecycle" export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance", (handlers) => @@ -73,6 +73,18 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" ) }) + const discardVcs = Effect.fn("InstanceHttpApi.vcsDiscard")(function* (ctx: { payload: Vcs.DiscardInput }) { + return yield* vcs.discard(ctx.payload).pipe( + Effect.mapError( + (error) => + new ApiVcsDiscardError({ + name: "VcsDiscardError", + data: { message: error.message, reason: error.reason }, + }), + ), + ) + }) + const getCommand = Effect.fn("InstanceHttpApi.command")(function* () { return yield* command.list() }) @@ -101,6 +113,7 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("vcsDiff", getVcsDiff) .handle("vcsDiffRaw", getVcsDiffRaw) .handle("vcsApply", applyVcs) + .handle("vcsDiscard", discardVcs) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts index 28fd245a6350..d72ae3ef3530 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts @@ -1,6 +1,5 @@ import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" -import { Session } from "@/session/session" import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { EventV2Bridge } from "@/event-v2-bridge" @@ -14,12 +13,11 @@ import { or } from "drizzle-orm" import { Effect, Scope } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { HistoryPayload, ReplayPayload, SessionPayload } from "../groups/sync" +import { HistoryPayload, ReplayPayload } from "../groups/sync" export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) => Effect.gen(function* () { const workspace = yield* Workspace.Service - const session = yield* Session.Service const scope = yield* Scope.Scope const events = yield* EventV2Bridge.Service const { db } = yield* Database.Service @@ -58,17 +56,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl return { sessionID: source } }) - const steal = Effect.fn("SyncHttpApi.steal")(function* (ctx: { payload: typeof SessionPayload.Type }) { - const workspaceID = yield* InstanceState.workspaceID - if (!workspaceID) return yield* new HttpApiError.BadRequest({}) - - yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID }) - - yield* Effect.logInfo("sync session stolen", { sessionID: ctx.payload.sessionID, workspaceID }) - - return { sessionID: ctx.payload.sessionID } - }) - const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) { const exclude = Object.entries(ctx.payload) return yield* db @@ -84,6 +71,6 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl .pipe(Effect.orDie) }) - return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history) + return handlers.handle("start", start).handle("replay", replay).handle("history", history) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts index 7f5c437d9d58..2c84e97854a0 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/workspace.ts @@ -1,13 +1,10 @@ import { listAdapters } from "@/control-plane/adapters" import { Workspace } from "@/control-plane/workspace" import * as InstanceState from "@/effect/instance-state" -import { Vcs } from "@/project/vcs" import { Cause, Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" -import { notFound } from "../errors" -import { ApiVcsApplyError } from "../groups/instance" -import { ApiWorkspaceCreateError, ApiWorkspaceWarpError, CreatePayload, WarpPayload } from "../groups/workspace" +import { ApiWorkspaceCreateError, CreatePayload } from "../groups/workspace" export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspace", (handlers) => Effect.gen(function* () { @@ -61,35 +58,6 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac return yield* workspace.remove(ctx.params.id) }) - const warp = Effect.fn("WorkspaceHttpApi.warp")(function* (ctx: { payload: typeof WarpPayload.Type }) { - yield* workspace - .sessionWarp({ - workspaceID: ctx.payload.id, - sessionID: ctx.payload.sessionID, - copyChanges: ctx.payload.copyChanges, - }) - .pipe( - Effect.mapError((error) => { - if (error instanceof Workspace.WorkspaceNotFoundError) return notFound(error.message) - if (error instanceof Vcs.PatchApplyError) { - return new ApiVcsApplyError({ - name: "VcsApplyError", - data: { - message: error.message, - reason: error.reason, - }, - }) - } - return new ApiWorkspaceWarpError({ - name: "WorkspaceWarpError", - data: { - message: error.message, - }, - }) - }), - ) - }) - return handlers .handle("adapters", adapters) .handle("list", list) @@ -97,6 +65,5 @@ export const workspaceHandlers = HttpApiBuilder.group(InstanceHttpApi, "workspac .handle("syncList", syncList) .handle("status", status) .handle("remove", remove) - .handle("warp", warp) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e5362f8cbe13..b9b2ac9d327e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -14,13 +14,29 @@ function requestBody(request: HttpServerRequest.HttpServerRequest) { export function websocket( request: HttpServerRequest.HttpServerRequest, target: string | URL, + extra?: HeadersInit, ): Effect.Effect { return Effect.scoped( Effect.gen(function* () { const inbound = yield* Effect.orDie(request.upgrade) - const outbound = yield* Socket.makeWebSocket(ProxyUtil.websocketTargetURL(target), { - protocols: ProxyUtil.websocketProtocols(request.headers), - }) + const protocols = ProxyUtil.websocketProtocols(request.headers) + const outbound = extra + ? yield* Socket.fromWebSocket( + Effect.acquireRelease( + Effect.sync(() => { + const WebSocket = globalThis.WebSocket as unknown as new ( + url: string, + init: { headers: HeadersInit; protocols?: string[] }, + ) => globalThis.WebSocket + return new WebSocket(ProxyUtil.websocketTargetURL(target), { + headers: ProxyUtil.headers({}, extra), + ...(protocols.length ? { protocols } : {}), + }) + }), + (socket) => Effect.sync(() => socket.close(1000)), + ), + ) + : yield* Socket.makeWebSocket(ProxyUtil.websocketTargetURL(target), { protocols }) const writeInbound = yield* inbound.writer const writeOutbound = yield* outbound.writer const closeSocket = (socket: Socket.Socket, write: (event: Socket.CloseEvent) => Effect.Effect) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index 873abd834938..e8cf8d515ea2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -9,6 +9,8 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" import { Context, Data, Effect, Layer, Option, Schema } from "effect" +import fs from "fs" +import path from "path" import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -31,6 +33,7 @@ type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ InvalidWorkspace: {} MissingWorkspace: { readonly workspaceID: WorkspaceV2.ID } + UnavailableWorkspace: { readonly workspaceID: WorkspaceV2.ID } Local: { readonly directory: string; readonly workspaceID?: WorkspaceV2.ID } Remote: { readonly request: HttpServerRequest.HttpServerRequest @@ -83,8 +86,31 @@ function selectedV2WorkspaceID( return workspaceID.value } -function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { - return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd() +// A locally-planned request can carry a directory that only exists on a +// remote workspace host (a session's pre-move directory, or path/project +// data synced back from a remote instance and echoed by the client). +// Booting a local instance for it fails every time, so decoded directory +// sources are only used when they exist here. The x-opencode-directory +// header is exempt: it arrives URI-encoded and is decoded downstream. +// Synchronous on purpose: this runs for WebSocket upgrades too, where an +// async hop in request planning stalls the upgrade window. +function firstExistingDirectory(candidates: (string | null | undefined)[]): string | undefined { + for (const candidate of candidates) { + if (!candidate) continue + // Only vet absolute paths; anything else is an opaque value that + // downstream middleware decodes and resolves with its own fallbacks. + if (!path.isAbsolute(candidate)) return candidate + if (fs.existsSync(candidate)) return candidate + } + return undefined +} + +function defaultDirectory( + request: HttpServerRequest.HttpServerRequest, + url: URL, + checked?: string, +): string { + return checked || request.headers["x-opencode-directory"] || process.cwd() } function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest, url: URL): boolean { @@ -120,14 +146,15 @@ function proxyRemote( return Effect.gen(function* () { const syncing = yield* Workspace.Service.use((svc) => svc.isSyncing(workspace.id)) if (!syncing) { - return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id}`, { + return HttpServerResponse.text(`broken sync connection for workspace: ${workspace.id} (the sync loop is not running or failed; check workspace connectivity and that experimental workspaces are enabled)`, { status: 503, contentType: "text/plain; charset=utf-8", }) } const proxyURL = workspaceProxyURL(target.url, url) const headers = request.headers as Record - if (headers["upgrade"]?.toLowerCase() === "websocket") return yield* HttpApiProxy.websocket(request, proxyURL) + if (headers["upgrade"]?.toLowerCase() === "websocket") + return yield* HttpApiProxy.websocket(request, proxyURL, target.headers) const response = yield* HttpApiProxy.http(client, proxyURL, target.headers, request) const sync = Fence.parse(new Headers(response.headers)) if (sync) { @@ -151,6 +178,14 @@ function planWorkspaceRequest( workspace: Workspace.Info, ): Effect.Effect { return Effect.gen(function* () { + const service = yield* Workspace.Service + if (WorkspaceAdapterRuntime.kind(workspace) !== "local" && !(yield* service.isSyncing(workspace.id))) { + const ready = yield* service.ensureReady(workspace.id).pipe( + Effect.as(true), + Effect.catch(() => Effect.succeed(false)), + ) + if (!ready) return RequestPlan.UnavailableWorkspace({ workspaceID: workspace.id }) + } const target = yield* resolveTarget(workspace) if (target.type === "remote") return RequestPlan.Remote({ request, workspace, target, url }) return RequestPlan.Local({ directory: target.directory, workspaceID: workspace.id }) @@ -171,6 +206,14 @@ function planRequest( const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID) if (workspaceID && workspace === undefined && !envWorkspaceID) { + // A session can reference a workspace this server does not track: a + // server addressed as a plain remote workspace stores the control + // plane's workspace id when the session's moved event replays. Serve + // the session from its own directory instead of failing the request. + if (session?.workspaceID === workspaceID && session.directory) { + const checked = firstExistingDirectory([session.directory, url.searchParams.get("directory")]) + return RequestPlan.Local({ directory: defaultDirectory(request, url, checked) }) + } return RequestPlan.MissingWorkspace({ workspaceID }) } @@ -178,8 +221,14 @@ function planRequest( return yield* planWorkspaceRequest(request, url, workspace) } + // A session in a remote workspace has a directory that only exists on the + // remote host; serving a control-plane-local route from it would boot a + // local instance for a nonexistent path. Use the request's own directory. + const sessionDirectory = + workspace !== undefined && WorkspaceAdapterRuntime.kind(workspace) !== "local" ? undefined : session?.directory + const checked = firstExistingDirectory([sessionDirectory, url.searchParams.get("directory")]) return RequestPlan.Local({ - directory: session?.directory || defaultDirectory(request, url), + directory: defaultDirectory(request, url, checked), workspaceID: envWorkspaceID ?? workspaceID, }) }) @@ -203,6 +252,13 @@ function routeWorkspace( ), ), MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), + UnavailableWorkspace: ({ workspaceID }) => + Effect.succeed( + HttpServerResponse.text(`Workspace unavailable: ${workspaceID}`, { + status: 503, + contentType: "text/plain; charset=utf-8", + }), + ), Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url), Local: ({ directory, workspaceID }) => effect.pipe(Effect.provideService(WorkspaceRouteContext, WorkspaceRouteContext.of({ directory, workspaceID }))), diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 2a7266c5118c..28b3be3da602 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -127,16 +127,6 @@ function matchLegacyOpenApi(input: Record) { if (properties?.branch) properties.branch = { anyOf: [properties.branch, { type: "null" }] } if (properties?.extra) properties.extra = { anyOf: [properties.extra, { type: "null" }] } } - if (path === "/experimental/workspace/warp" && method === "post") { - const ref = operation.requestBody.content?.["application/json"]?.schema?.$ref?.replace( - "#/components/schemas/", - "", - ) - const properties = ref - ? spec.components?.schemas?.[ref]?.properties - : operation.requestBody.content?.["application/json"]?.schema?.properties - if (properties?.id) properties.id = { anyOf: [properties.id, { type: "null" }] } - } } for (const response of Object.values(operation.responses ?? {})) { for (const content of Object.values(response.content ?? {})) { diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts index 0edfb2b30542..c724f8c28ae7 100644 --- a/packages/opencode/src/server/shared/workspace-routing.ts +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -34,5 +34,10 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) { proxyURL.search = requestURL.search proxyURL.hash = requestURL.hash proxyURL.searchParams.delete("workspace") + // The directory param names a path on this host; forwarding it makes the + // remote resolve a directory that does not exist there and serve an empty + // instance. Drop it like the x-opencode-directory header so the remote + // falls back to its own default instance. + proxyURL.searchParams.delete("directory") return proxyURL } diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index a0d3aadbef93..3b2a9f77fa6f 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -22,6 +22,7 @@ import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstan import { testEffect } from "../lib/effect" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" +import { InstanceRef } from "../../src/effect/instance-ref" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control-plane/types" import * as Workspace from "../../src/control-plane/workspace" @@ -168,7 +169,11 @@ function eventuallyEffect(effect: Effect.Effect, timeout = 1500) { function recordedAdapter(input: { target: (info: WorkspaceInfo) => Target | Promise configure?: (info: WorkspaceInfo) => WorkspaceInfo | Promise - create?: (info: WorkspaceInfo, env: Record, from?: WorkspaceInfo) => Promise + create?: ( + info: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + ) => Promise list?: () => Omit[] | Promise[]> remove?: (info: WorkspaceInfo) => Promise }): RecordedAdapter { @@ -195,7 +200,7 @@ function recordedAdapter(input: { env: { ...env }, from: from ? structuredClone(from) : undefined, }) - await input.create?.(info, env, from) + return await input.create?.(info, env, from) }, ...(input.list ? { @@ -492,12 +497,143 @@ describe("workspace CRUD", () => { expect(recorded.calls.create[0].env.OTEL_RESOURCE_ATTRIBUTES).toBe("service.name=opencode-test") expect((yield* workspace.status()).find((item) => item.workspaceID === workspaceID)?.status).toBe("connected") + yield* workspace.ensureReady(workspaceID) + yield* workspace.ensureReady(workspaceID) + expect((yield* workspace.status()).find((item) => item.workspaceID === workspaceID)?.status).toBe("connected") + yield* workspace.remove(workspaceID) expect((yield* workspace.status()).find((item) => item.workspaceID === workspaceID)?.status).toBeUndefined() }), { git: true }, ) + it.instance( + "create persists finalized adapter metadata", + () => + Effect.gen(function* () { + const instance = yield* requireInstance + const workspace = yield* Workspace.Service + const workspaceID = WorkspaceV2.ID.ascending("wrk_create_finalized") + const type = unique("create-finalized") + const targetDir = path.join(instance.directory, "created-finalized") + registerAdapter( + instance.project.id, + type, + recordedAdapter({ + configure(info) { + return { ...info, name: "Provisioning" } + }, + async create(info) { + await fs.mkdir(targetDir, { recursive: true }) + return { + ...info, + id: WorkspaceV2.ID.ascending("wrk_adapter_must_not_replace_id"), + type: "adapter-must-not-replace-type", + projectID: ProjectV2.ID.make("adapter-must-not-replace-project"), + name: "Provisioned", + directory: targetDir, + extra: { externalID: "sandbox-123" }, + } + }, + target() { + return { type: "local", directory: targetDir } + }, + }).adapter, + ) + + const info = yield* workspace.create({ + id: workspaceID, + type, + branch: "main", + projectID: instance.project.id, + }) + + expect(info).toMatchObject({ + id: workspaceID, + type, + projectID: instance.project.id, + name: "Provisioned", + directory: targetDir, + extra: { externalID: "sandbox-123" }, + }) + expect(yield* workspace.get(workspaceID)).toEqual(info) + }), + { git: true }, + ) + + it.instance( + "create strips non-JSON extra values and keeps the workspace", + () => + Effect.gen(function* () { + const instance = yield* requireInstance + const workspace = yield* Workspace.Service + const type = unique("create-json-extra") + const targetDir = path.join(instance.directory, "created-json-extra") + const recorded = recordedAdapter({ + async create(info) { + await fs.mkdir(targetDir, { recursive: true }) + return { + ...info, + name: "Provisioned", + directory: targetDir, + extra: { + workspaceID: "sandbox-123", + checkoutRef: undefined, + } as unknown as WorkspaceInfo["extra"], + } + }, + target() { + return { type: "local", directory: targetDir } + }, + }) + registerAdapter(instance.project.id, type, recorded.adapter) + + const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null }) + + expect(info.extra).toEqual({ workspaceID: "sandbox-123" }) + expect(yield* workspace.get(info.id)).toEqual(info) + expect(recorded.calls.remove).toHaveLength(0) + yield* workspace.remove(info.id) + }), + { git: true }, + ) + + it.instance( + "create removes provisioned resources when the response cannot be encoded", + () => + Effect.gen(function* () { + const instance = yield* requireInstance + const workspace = yield* Workspace.Service + const type = unique("create-encode-failure") + const targetDir = path.join(instance.directory, "created-encode-failure") + const recorded = recordedAdapter({ + async create(info) { + await fs.mkdir(targetDir, { recursive: true }) + return { + ...info, + // Force a post-create encode failure after adapter provision. + name: 123 as unknown as string, + directory: targetDir, + extra: { workspaceID: "sandbox-encode" }, + } + }, + target() { + return { type: "local", directory: targetDir } + }, + }) + registerAdapter(instance.project.id, type, recorded.adapter) + + expectExitContains( + yield* Effect.exit(workspace.create({ type, branch: null, projectID: instance.project.id, extra: null })), + "Expected string", + ) + expect(yield* workspace.list(instance.project)).toEqual([]) + expect(recorded.calls.remove).toHaveLength(1) + expect(recorded.calls.remove[0]?.extra).toEqual({ workspaceID: "sandbox-encode" }) + }), + { git: true }, + ) + it.instance( "create propagates configure failures and does not insert a workspace", () => @@ -528,7 +664,7 @@ describe("workspace CRUD", () => { ) it.instance( - "create leaves the inserted row when adapter create fails", + "create does not persist a workspace when adapter create fails", () => Effect.gen(function* () { const instance = yield* requireInstance @@ -551,11 +687,8 @@ describe("workspace CRUD", () => { "create exploded", ) - const rows = yield* workspace.list(instance.project) - expect(rows).toHaveLength(1) - expect(rows[0]).toMatchObject({ type, branch: "branch", extra: { x: 1 } }) + expect(yield* workspace.list(instance.project)).toEqual([]) expect(recorded.calls.target).toHaveLength(0) - yield* workspace.remove(rows[0].id) }), { git: true }, ) @@ -737,11 +870,17 @@ describe("workspace CRUD", () => { const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null }) - expect( - calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`), - ).toEqual(["GET /base/global/event", "POST /base/sync/history"]) + yield* eventuallyEffect( + Effect.sync(() => { + expect( + calls.map((call) => `${call.method} ${call.url.pathname}${call.url.search}${call.url.hash}`), + ).toEqual(["GET /base/global/event", "POST /base/sync/history"]) + }), + ) expect(calls[1].json).toEqual({}) - expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("connected") + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe( + "disconnected", + ) expect(yield* workspace.isSyncing(info.id)).toBe(true) yield* workspace.remove(info.id) @@ -753,6 +892,55 @@ describe("workspace CRUD", () => { }) }) + it.live("remote create does not terminate when the remote is not ready yet", () => { + const calls: FetchCall[] = [] + return Effect.gen(function* () { + yield* HttpServer.serveEffect()( + Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + const bodyText = yield* req.text + const call = { + url: new URL(req.url, "http://localhost"), + method: req.method, + headers: new Headers(req.headers), + bodyText, + json: bodyText ? JSON.parse(bodyText) : undefined, + } + calls.push(call) + // Fail SSE so the remote never settles during create. + if (call.url.pathname === "/base/global/event") return HttpServerResponse.text("down", { status: 503 }) + if (call.url.pathname === "/base/sync/history") return yield* HttpServerResponse.json([]) + return HttpServerResponse.text("unexpected", { status: 500 }) + }), + ) + const url = yield* serverUrl() + yield* provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const workspace = yield* Workspace.Service + const instance = yield* requireInstance + const type = unique("remote-create-unready") + const recorded = remoteAdapter(`${url}/base`, { directory: dir }) + registerAdapter(instance.project.id, type, recorded.adapter) + + const info = yield* workspace.create({ type, branch: null, projectID: instance.project.id, extra: null }) + + expect(yield* workspace.get(info.id)).toEqual(info) + expect(recorded.calls.remove).toHaveLength(0) + yield* eventuallyEffect( + Effect.gen(function* () { + expect((yield* workspace.status()).find((item) => item.workspaceID === info.id)?.status).toBe("error") + }), + ) + + yield* workspace.remove(info.id) + expect(recorded.calls.remove).toHaveLength(1) + }), + { git: true }, + ) + }) + }) + it.instance( "remove returns undefined for a missing workspace", () => @@ -801,9 +989,11 @@ describe("workspace CRUD", () => { ) it.instance( - "remove still deletes the row when the adapter cannot remove resources", + "remove deletes the row even when the adapter cannot remove resources", () => Effect.gen(function* () { + // Dead sandboxes make adapter remove fail forever; the record must + // still be deletable. Live resources are re-registered by list sync. const instance = yield* requireInstance const workspace = yield* Workspace.Service const type = unique("remove-throws") @@ -822,32 +1012,34 @@ describe("workspace CRUD", () => { ) yield* insertWorkspace(info) - expect(yield* workspace.remove(info.id)).toEqual(info) + const removed = yield* workspace.remove(info.id) + + expect(removed).toEqual({ ...info, timeUsed: expect.any(Number) }) expect(yield* workspace.get(info.id)).toBeUndefined() }), { git: true }, ) it.instance( - "sessionWarp moves a session into a local workspace and claims ownership", + "moveSession moves a session into a local workspace and claims ownership", () => { return Effect.gen(function* () { const { directory: dir } = yield* TestInstance const instance = yield* requireInstance const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service - const previousType = unique("warp-prev-local") - const targetType = unique("warp-target-local") + const previousType = unique("move-prev-local") + const targetType = unique("move-target-local") const previous = workspaceInfo(instance.project.id, previousType) const target = workspaceInfo(instance.project.id, targetType) yield* insertWorkspace(previous) yield* insertWorkspace(target) - registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-prev-local")).adapter) - registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "warp-target-local")).adapter) + registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "move-prev-local")).adapter) + registerAdapter(instance.project.id, targetType, localAdapter(path.join(dir, "move-target-local")).adapter) const session = yield* sessionSvc.create({}) yield* attachSessionToWorkspace(session.id, previous.id) - yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id }) + yield* workspace.moveSession({ workspaceID: target.id, sessionID: session.id }) const { db } = yield* Database.Service expect( @@ -865,17 +1057,17 @@ describe("workspace CRUD", () => { ) it.instance( - "sessionWarp applies source workspace patch to local target workspace", + "moveSession applies source workspace patch to local target workspace", () => { return Effect.gen(function* () { const { directory: dir } = yield* TestInstance const instance = yield* requireInstance const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service - const previousType = unique("warp-patch-prev-local") - const targetType = unique("warp-patch-target-local") - const previousDir = path.join(dir, "warp-patch-prev-local") - const targetDir = path.join(dir, "warp-patch-target-local") + const previousType = unique("move-patch-prev-local") + const targetType = unique("move-patch-target-local") + const previousDir = path.join(dir, "move-patch-prev-local") + const targetDir = path.join(dir, "move-patch-target-local") yield* Effect.promise(() => initGitRepo(previousDir)) yield* Effect.promise(() => initGitRepo(targetDir)) yield* Effect.promise(() => fs.writeFile(path.join(previousDir, "tracked.txt"), "changed\n")) @@ -890,31 +1082,101 @@ describe("workspace CRUD", () => { const session = yield* sessionSvc.create({}) yield* attachSessionToWorkspace(session.id, previous.id) - yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + yield* workspace.moveSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "tracked.txt"), "utf8"))).toBe("changed\n") expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "new.txt"), "utf8"))).toBe("new\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(previousDir, "tracked.txt"), "utf8"))).toBe("base\n") + expect( + yield* Effect.promise(() => + fs.stat(path.join(previousDir, "new.txt")).then( + () => true, + () => false, + ), + ), + ).toBe(false) + + yield* workspace.moveSession({ workspaceID: previous.id, sessionID: session.id, copyChanges: true }) + + expect(yield* Effect.promise(() => fs.readFile(path.join(previousDir, "tracked.txt"), "utf8"))).toBe( + "changed\n", + ) + expect(yield* Effect.promise(() => fs.readFile(path.join(previousDir, "new.txt"), "utf8"))).toBe("new\n") + expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "tracked.txt"), "utf8"))).toBe("base\n") + expect( + yield* Effect.promise(() => + fs.stat(path.join(targetDir, "new.txt")).then( + () => true, + () => false, + ), + ), + ).toBe(false) }) }, { git: true }, ) it.instance( - "sessionWarp detaches a session to the local project and claims project ownership", + "moveSession captures a local source patch without an ambient InstanceRef", + () => + Effect.gen(function* () { + const { directory: dir } = yield* TestInstance + const instance = yield* requireInstance + const workspace = yield* Workspace.Service + const sessionSvc = yield* SessionNs.Service + const targetType = unique("move-local-source-target") + const targetDir = path.join(dir, "move-local-source-target") + yield* Effect.promise(() => initGitRepo(targetDir)) + yield* Effect.promise(() => fs.writeFile(path.join(dir, "move-source-new.txt"), "new\n")) + + const target = workspaceInfo(instance.project.id, targetType, { directory: targetDir }) + yield* insertWorkspace(target) + registerAdapter(instance.project.id, targetType, localAdapter(targetDir, { createDir: false }).adapter) + const session = yield* sessionSvc.create({}) + + yield* workspace + .moveSession({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + .pipe(Effect.provideService(InstanceRef, undefined)) + + expect(yield* Effect.promise(() => fs.readFile(path.join(targetDir, "move-source-new.txt"), "utf8"))).toBe( + "new\n", + ) + }), + { git: true }, + ) + + it.instance( + "moveSession detaches a session to the local project and claims project ownership", () => { return Effect.gen(function* () { const { directory: dir } = yield* TestInstance const instance = yield* requireInstance const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service - const previousType = unique("warp-detach-local") + const previousType = unique("move-detach-local") + const previousDirectory = path.join(dir, "move-detach-local") const previous = workspaceInfo(instance.project.id, previousType) yield* insertWorkspace(previous) - registerAdapter(instance.project.id, previousType, localAdapter(path.join(dir, "warp-detach-local")).adapter) + yield* Effect.promise(() => initGitRepo(previousDirectory)) + yield* Effect.promise(() => fs.writeFile(path.join(previousDirectory, "move-back.txt"), "moved\n")) + registerAdapter( + instance.project.id, + previousType, + localAdapter(previousDirectory, { createDir: false }).adapter, + ) const session = yield* sessionSvc.create({}) yield* attachSessionToWorkspace(session.id, previous.id) - yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id }) + yield* workspace + .moveSession({ + workspaceID: null, + destinationDirectory: dir, + sessionID: session.id, + copyChanges: true, + }) + .pipe(Effect.provideService(InstanceRef, undefined)) + + expect(yield* Effect.promise(() => fs.readFile(path.join(dir, "move-back.txt"), "utf8"))).toBe("moved\n") const { db } = yield* Database.Service expect( @@ -933,14 +1195,14 @@ describe("workspace CRUD", () => { const itCrossInstance = process.platform === "win32" ? it.instance.skip : it.instance itCrossInstance( - "sessionWarp detaches to the source project when invoked from a workspace instance", + "moveSession detaches to the source project when invoked from a workspace instance", () => Effect.gen(function* () { const instance = yield* requireInstance const projectID = instance.project.id const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service - const previousType = unique("warp-detach-workspace-instance") + const previousType = unique("move-detach-workspace-instance") const previous = workspaceInfo(projectID, previousType) yield* insertWorkspace(previous) const session = yield* sessionSvc.create({}) @@ -952,7 +1214,11 @@ describe("workspace CRUD", () => { registerAdapter(projectID, previousType, localAdapter(workspaceDir, { createDir: false }).adapter) const workspaceCtx = yield* requireInstance expect(workspaceCtx.project.id).not.toBe(projectID) - yield* workspace.sessionWarp({ workspaceID: null, sessionID: session.id }) + yield* workspace.moveSession({ + workspaceID: null, + destinationDirectory: instance.directory, + sessionID: session.id, + }) return workspaceCtx.project.id }), { git: true }, @@ -973,11 +1239,13 @@ describe("workspace CRUD", () => { { git: true }, ) - it.live("sessionWarp syncs previous remote history, replays it, steals, and claims the sequence", () => { + it.live("moveSession replays remote history and the canonical moved event before claiming the sequence", () => { const calls: FetchCall[] = [] let historySessionID: SessionID | undefined let historySession: SessionNs.Info | undefined let historyNextSeq = 0 + let sourceDirty = true + let failFinalReplay = true return Effect.gen(function* () { yield* HttpServer.serveEffect()( Effect.gen(function* () { @@ -991,10 +1259,10 @@ describe("workspace CRUD", () => { json: bodyText ? JSON.parse(bodyText) : undefined, } calls.push(call) - if (call.url.pathname === "/warp-source/sync/history") { + if (call.url.pathname === "/move-source/sync/history") { return yield* HttpServerResponse.json([ { - id: `evt_${unique("warp-source-history")}`, + id: `evt_${unique("move-source-history")}`, aggregate_id: historySessionID!, seq: historyNextSeq, type: "session.updated.1", @@ -1002,12 +1270,30 @@ describe("workspace CRUD", () => { }, ]) } - if (call.url.pathname === "/warp-source/vcs/diff/raw") return HttpServerResponse.text("remote patch") - if (call.url.pathname === "/warp-target/sync/replay") + if (call.url.pathname === "/move-source/sync/replay") return yield* HttpServerResponse.json({ sessionID: "ok" }) - if (call.url.pathname === "/warp-target/sync/steal") + if (call.url.pathname === `/move-source/session/${historySessionID}/abort`) return HttpServerResponse.empty() + if (call.url.pathname === "/move-source/vcs/diff/raw") + return HttpServerResponse.text(sourceDirty ? "remote patch" : "") + if (call.url.pathname === "/move-source/vcs/discard") { + sourceDirty = false + return yield* HttpServerResponse.json({ applied: true }) + } + if ( + call.url.pathname === "/move-target/sync/replay" && + (call.json as { events?: { type?: string }[] } | undefined)?.events?.some( + (event) => event.type === "session.next.moved.1", + ) && + failFinalReplay + ) { + failFinalReplay = false + return HttpServerResponse.text("retry final replay", { status: 503 }) + } + if (call.url.pathname === "/move-target/sync/replay") return yield* HttpServerResponse.json({ sessionID: "ok" }) - if (call.url.pathname === "/warp-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true }) + if (call.url.pathname === "/move-target/global/health") + return yield* HttpServerResponse.json({ healthy: true }) + if (call.url.pathname === "/move-target/vcs/apply") return yield* HttpServerResponse.json({ applied: true }) return HttpServerResponse.text("unexpected", { status: 500 }) }), ) @@ -1018,33 +1304,67 @@ describe("workspace CRUD", () => { const workspace = yield* Workspace.Service const sessionSvc = yield* SessionNs.Service const instance = yield* requireInstance - const previousType = unique("warp-remote-source") - const targetType = unique("warp-remote-target") + const previousType = unique("move-remote-source") + const targetType = unique("move-remote-target") const previous = workspaceInfo(instance.project.id, previousType) - const target = workspaceInfo(instance.project.id, targetType, { directory: "remote-target-dir" }) + const target = workspaceInfo(instance.project.id, targetType, { directory: "/remote-target-dir" }) yield* insertWorkspace(previous) yield* insertWorkspace(target) - registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/warp-source`).adapter) - registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/warp-target`).adapter) + registerAdapter(instance.project.id, previousType, remoteAdapter(`${url}/move-source`).adapter) + registerAdapter(instance.project.id, targetType, remoteAdapter(`${url}/move-target`).adapter) const session = yield* sessionSvc.create({}) yield* attachSessionToWorkspace(session.id, previous.id) historySessionID = session.id historySession = { ...session, workspaceID: previous.id, title: "from source history" } historyNextSeq = ((yield* sessionSequence(session.id)) ?? -1) + 1 - yield* workspace.sessionWarp({ workspaceID: target.id, sessionID: session.id, copyChanges: true }) + expectExitContains( + yield* Effect.exit( + workspace.moveSession({ + workspaceID: target.id, + destinationDirectory: "/remote-target-dir", + sessionID: session.id, + copyChanges: true, + }), + ), + "retry final replay", + ) + // The committed move appends two events: the canonical moved + // event plus the synthetic directory-change notice for the model. + const committedSequence = yield* sessionSequence(session.id) + expect(committedSequence).toBe(historyNextSeq + 2) + expect((yield* sessionSvc.get(session.id)).workspaceID).toBe(target.id) + expect(sourceDirty).toBe(true) + + yield* workspace.moveSession({ + workspaceID: target.id, + destinationDirectory: "/remote-target-dir", + sessionID: session.id, + copyChanges: true, + }) - expect(calls.map((call) => `${call.method} ${call.url.pathname}`)).toEqual([ - "POST /warp-source/sync/history", - "GET /warp-source/vcs/diff/raw", - "POST /warp-target/vcs/apply", - "POST /warp-target/sync/replay", - "POST /warp-target/sync/steal", - ]) - expect(calls[0].json).toEqual({ [session.id]: historyNextSeq - 1 }) - expect(calls[2].json).toEqual({ patch: "remote patch" }) - expect(calls[3].json).toMatchObject({ - directory: "remote-target-dir", + const requests = calls.map((call) => `${call.method} ${call.url.pathname}`) + for (const request of [ + "POST /move-source/sync/history", + "POST /move-source/sync/replay", + `POST /move-source/session/${session.id}/abort`, + "GET /move-source/global/event", + "GET /move-source/vcs/diff/raw", + "GET /move-target/global/event", + "POST /move-target/vcs/apply", + "POST /move-target/sync/replay", + "POST /move-source/vcs/discard", + ]) { + expect(requests).toContain(request) + } + expect(calls.find((call) => call.url.pathname === "/move-source/sync/history")?.json).toEqual({ + [session.id]: historyNextSeq - 1, + }) + expect(calls.find((call) => call.url.pathname === "/move-target/vcs/apply")?.json).toEqual({ + patch: "remote patch", + }) + expect(calls.find((call) => call.url.pathname === "/move-target/sync/replay")?.json).toMatchObject({ + directory: "/remote-target-dir", events: [ { aggregateID: session.id, @@ -1058,9 +1378,18 @@ describe("workspace CRUD", () => { }, ], }) - expect(calls[4].json).toEqual({ sessionID: session.id }) + const replay = calls.filter((call) => call.url.pathname === "/move-target/sync/replay") + expect( + replay.some((call) => + (call.json as { events?: { type?: string }[] } | undefined)?.events?.some( + (event) => event.type === "session.next.moved.1", + ), + ), + ).toBe(true) expect((yield* sessionSvc.get(session.id)).title).toBe("from source history") expect(yield* sessionSequenceOwner(session.id)).toBe(target.id) + expect(yield* sessionSequence(session.id)).toBe(committedSequence) + expect(sourceDirty).toBe(false) }), { git: true }, ) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 2f2ce98efb34..c1cc3f696a65 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,7 +1,9 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { test, expect } from "bun:test" import os from "os" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionEvent } from "@opencode-ai/core/session/event" import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Permission } from "../../src/permission" @@ -1172,3 +1174,132 @@ it.instance( }), { git: true }, ) + +// After a cross-machine move, models keep reusing absolute paths from the +// session's previous location. external_directory asks for a former root +// that no longer exists must be answered with corrective feedback instead +// of prompting the user (see staleMovedRoot in src/permission/index.ts). + +const publishMoved = (sessionID: SessionID, source: string, destination: string) => + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service + yield* events.publish(SessionEvent.Moved, { + sessionID, + source: { directory: AbsolutePath.make(source) }, + location: { directory: AbsolutePath.make(destination) }, + timestamp: yield* DateTime.now, + }) + }) + +it.instance( + "ask - corrects external_directory asks for a vanished former session root", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.make("session_moved_stale") + const staleRoot = "/gitterm-e2e-nonexistent/tui-invaders" + yield* publishMoved(sessionID, staleRoot, test.directory) + + const err = yield* fail( + ask({ + sessionID, + permission: "external_directory", + patterns: [`${staleRoot}/*`], + metadata: { filepath: `${staleRoot}/README.md`, parentDir: staleRoot }, + always: [`${staleRoot}/*`], + ruleset: [], + }), + ) + expect(err).toBeInstanceOf(PermissionV1.CorrectedError) + expect((err as PermissionV1.CorrectedError).feedback).toContain(staleRoot) + expect((err as PermissionV1.CorrectedError).feedback).toContain(test.directory) + expect(yield* list()).toHaveLength(0) + }), + { git: true }, +) + +it.instance( + "ask - corrects paths nested below a vanished former session root", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.make("session_moved_nested") + const staleRoot = "/gitterm-e2e-nonexistent/tui-invaders" + yield* publishMoved(sessionID, staleRoot, test.directory) + + const err = yield* fail( + ask({ + sessionID, + permission: "external_directory", + patterns: [`${staleRoot}/src/game/*`], + metadata: { filepath: `${staleRoot}/src/game/main.ts`, parentDir: `${staleRoot}/src/game` }, + always: [`${staleRoot}/src/game/*`], + ruleset: [], + }), + ) + expect(err).toBeInstanceOf(PermissionV1.CorrectedError) + }), + { git: true }, +) + +it.instance( + "ask - still prompts for a former session root that exists on this machine", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.make("session_moved_local") + const existingRoot = yield* tmpdirScoped() + yield* publishMoved(sessionID, existingRoot, test.directory) + + const fiber = yield* ask({ + sessionID, + permission: "external_directory", + patterns: [`${existingRoot}/*`], + metadata: { filepath: `${existingRoot}/README.md`, parentDir: existingRoot }, + always: [`${existingRoot}/*`], + ruleset: [], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + { git: true }, +) + +it.instance( + "ask - ignores move history of other permissions and sessions", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.make("session_moved_other") + const staleRoot = "/gitterm-e2e-nonexistent/tui-invaders" + yield* publishMoved(SessionID.make("session_someone_else"), staleRoot, test.directory) + + // Another session's move history must not correct this session's ask. + const fiber = yield* ask({ + sessionID, + permission: "external_directory", + patterns: [`${staleRoot}/*`], + metadata: { filepath: `${staleRoot}/README.md`, parentDir: staleRoot }, + always: [`${staleRoot}/*`], + ruleset: [], + }).pipe(Effect.forkScoped) + + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + + // Non-external permissions never consult move history. + const bash = yield* ask({ + sessionID, + permission: "bash", + patterns: [`cat ${staleRoot}/README.md`], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], + }) + expect(bash).toBeUndefined() + }), + { git: true }, +) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index c13b108bc6a9..6530bd511d23 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -307,6 +307,142 @@ describe("Vcs diff", () => { 20_000, ) + it.instance( + "apply() converges when the patch was already applied", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "file.txt") + yield* write(file, "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(file, "changed\n") + + const vcs = yield* init() + const patch = yield* vcs.diffRaw() + yield* git(test.directory, ["checkout", "--", "."]) + + expect(yield* vcs.apply({ patch })).toEqual({ applied: true }) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("changed\n") + expect(yield* vcs.apply({ patch })).toEqual({ applied: true }) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("changed\n") + }), + { git: true }, + ) + + it.instance( + "apply() fails with the git error when the patch conflicts", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "file.txt") + yield* write(file, "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(file, "changed\n") + + const vcs = yield* init() + const patch = yield* vcs.diffRaw() + yield* write(file, "conflicting\n") + + const exit = yield* Effect.exit(vcs.apply({ patch })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("patch does not apply") + }), + { git: true }, + ) + + it.instance( + "applied() reports whether the working tree contains the patch", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "file.txt") + yield* write(file, "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(file, "changed\n") + + const vcs = yield* init() + const patch = yield* vcs.diffRaw() + + expect(yield* vcs.applied({ patch })).toBe(true) + yield* git(test.directory, ["checkout", "--", "."]) + expect(yield* vcs.applied({ patch })).toBe(false) + }), + { git: true }, + ) + + it.instance( + "discard() reverses only the transferred changes", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const tracked = path.join(test.directory, "tracked.txt") + const created = path.join(test.directory, "created.txt") + const unrelated = path.join(test.directory, "unrelated.txt") + yield* write(tracked, "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(tracked, "changed\n") + yield* write(created, "new\n") + + const vcs = yield* init() + const patch = yield* vcs.diffRaw() + yield* write(unrelated, "keep me\n") + + expect(yield* vcs.discard({ patch })).toEqual({ applied: true }) + expect(yield* Effect.promise(() => fs.readFile(tracked, "utf8"))).toBe("original\n") + expect(yield* Effect.promise(() => fs.readFile(unrelated, "utf8"))).toBe("keep me\n") + expect(yield* Effect.promise(() => fs.stat(created).then(() => true, () => false))).toBe(false) + }), + { git: true }, + ) + + it.instance( + "discard() converges when the changes were already discarded", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "file.txt") + yield* write(file, "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(file, "changed\n") + + const vcs = yield* init() + const patch = yield* vcs.diffRaw() + yield* git(test.directory, ["checkout", "--", "."]) + + expect(yield* vcs.discard({ patch })).toEqual({ applied: true }) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("original\n") + }), + { git: true }, + ) + + it.instance( + "discard() fails when transferred changes were edited afterwards", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "file.txt") + yield* write(file, "original\n") + yield* git(test.directory, ["add", "."]) + yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"]) + yield* write(file, "changed\n") + + const vcs = yield* init() + const patch = yield* vcs.diffRaw() + yield* write(file, "changed\nedited\n") + + const exit = yield* Effect.exit(vcs.discard({ patch })) + expect(exit._tag).toBe("Failure") + expect(String(exit)).toContain("Source changes changed") + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("changed\nedited\n") + }), + { git: true }, + ) + it.instance( "diff('branch') returns changes against default branch", () => diff --git a/packages/opencode/test/server/httpapi-control-plane.test.ts b/packages/opencode/test/server/httpapi-control-plane.test.ts index b837bf7556da..320026305558 100644 --- a/packages/opencode/test/server/httpapi-control-plane.test.ts +++ b/packages/opencode/test/server/httpapi-control-plane.test.ts @@ -41,6 +41,7 @@ const apiLayer = HttpRouter.serve( Layer.provide(Layer.mock(Installation.Service)({})), Layer.provide( Layer.mock(MoveSession.Service)({ + atDestination: () => Effect.succeed(false), moveSession: (value) => Ref.set(called, value), }), ), diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index c8e11ea8451b..e8f165f9fa56 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -508,14 +508,6 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .status(200), - http.protected - .post("/experimental/workspace/warp", "experimental.workspace.warp") - .at((ctx) => ({ - path: "/experimental/workspace/warp", - headers: ctx.headers(), - body: {}, - })) - .status(400), http.protected .post("/experimental/control-plane/move-session", "experimental.controlPlane.moveSession") .global() @@ -602,10 +594,6 @@ const scenarios: Scenario[] = [ .post("/sync/replay", "sync.replay") .at((ctx) => ({ path: "/sync/replay", headers: ctx.headers(), body: { directory: ctx.directory, events: [] } })) .status(400), - http.protected - .post("/sync/steal", "sync.steal.invalid") - .at((ctx) => ({ path: "/sync/steal", headers: ctx.headers(), body: {} })) - .status(400, undefined, "status"), http.protected .post("/sync/start", "sync.start") .mutating() diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 2f6195a87ebc..29446545be65 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -209,9 +209,13 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) => const write = yield* socket.writer yield* socket .runRaw((message) => write(`echo:${String(message)}`), { - onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe( - Effect.catch(() => Effect.void), - ), + onOpen: Effect.all( + [ + write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`), + write(`authorization:${request.headers.authorization ?? "none"}`), + ], + { concurrency: 1, discard: true }, + ).pipe(Effect.catch(() => Effect.void)), }) .pipe(Effect.catch(() => Effect.void)) return HttpServerResponse.empty() @@ -228,6 +232,11 @@ const ProbeApi = HttpApi.make("workspace-routing-probe").add( HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }), HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }), HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }), + HttpApiEndpoint.get("sessionScoped", "/session/:id/probe", { + params: { id: Schema.String }, + query: WorkspaceRoutingQuery, + success: ProbeResult, + }), HttpApiEndpoint.get("workspace", WorkspacePaths.list, { query: WorkspaceRoutingQuery, success: ProbeResult, @@ -246,6 +255,7 @@ const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) => .handle("get", () => routeContextResponse) .handle("patch", () => Effect.succeed(false)) .handle("session", () => routeContextResponse) + .handle("sessionScoped", () => routeContextResponse) .handle("workspace", () => routeContextResponse), ) @@ -258,6 +268,87 @@ const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe( ) describe("HttpApi workspace routing middleware", () => { + it.live("activates and reconnects a paused remote workspace before proxying", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + let status = "paused" as "paused" | "connected" + let activations = 0 + + const remoteUrl = yield* startRemoteWorkspaceHttpServer(() => HttpServerResponse.json({ resumed: true })) + const type = "remote-paused-target" + registerAdapter(project.project.id, type, { + name: "Paused Remote Test", + description: "Resume a paused remote workspace", + configure: (info) => ({ ...info, name: "paused-remote", directory: "/workspace/repo" }), + async create() {}, + async remove() {}, + status: () => status, + async ensureReady() { + activations += 1 + await Bun.sleep(20) + status = "connected" + }, + target: () => ({ type: "remote", url: `${remoteUrl}/base` }), + }) + const workspace = yield* Workspace.Service.use((workspace) => + workspace.create({ type, branch: "main", projectID: project.project.id }), + ) + yield* Effect.addFinalizer(() => Workspace.use.remove(workspace.id).pipe(Effect.ignore)) + + expect((yield* Workspace.use.status()).find((item) => item.workspaceID === workspace.id)?.status).toBe("paused") + + yield* serveProbe + const responses = yield* Effect.all( + [HttpClient.get(`/probe?workspace=${workspace.id}`), HttpClient.get(`/probe?workspace=${workspace.id}`)], + { concurrency: "unbounded" }, + ).pipe(Effect.timeout("2 seconds")) + + expect(responses.map((response) => response.status)).toEqual([200, 200]) + expect(yield* responses[0].json).toEqual({ resumed: true }) + expect(activations).toBe(1) + }), + ) + + it.live("creates and routes a built-in remote workspace from generic target metadata", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const project = yield* Project.use.fromDirectory(dir) + let forwarded: ProxiedRequest | undefined + + const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => { + forwarded = request + return HttpServerResponse.json({ proxied: true }) + }) + const workspace = yield* Workspace.Service.use((workspace) => + workspace.create({ + type: "remote", + branch: "main", + projectID: project.project.id, + extra: { + url: `${remoteUrl}/base`, + headers: { "x-target-auth": "remote-token" }, + directory: "/workspace/repo", + }, + }), + ) + yield* Effect.addFinalizer(() => Workspace.use.remove(workspace.id).pipe(Effect.ignore)) + + expect(workspace.type).toBe("remote") + expect(workspace.branch).toBe("main") + expect(workspace.directory).toBe("/workspace/repo") + + yield* serveProbe + + const response = yield* HttpClient.get(`/probe?workspace=${workspace.id}`).pipe(Effect.timeout("2 seconds")) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ proxied: true }) + expect(forwarded ? requestURL(forwarded).pathname : undefined).toBe("/base/probe") + expect(forwarded?.headers["x-target-auth"]).toBe("remote-token") + }), + ) + it.live("proxies remote workspace HTTP requests through the selected workspace target", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) @@ -296,7 +387,9 @@ describe("HttpApi workspace routing middleware", () => { yield* serveProbe const body = '{"title":"Remote workspace request"}' - const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe( + const response = yield* HttpClientRequest.patch( + `/probe?workspace=${workspace.id}&keep=yes&directory=${encodeURIComponent("/secret/path")}`, + ).pipe( HttpClientRequest.setHeaders({ "x-opencode-directory": "/secret/path", "x-opencode-workspace": "internal", @@ -314,10 +407,12 @@ describe("HttpApi workspace routing middleware", () => { expect(yield* response.json).toEqual({ proxied: true, path: "/base/probe", keep: "yes", workspace: null }) const forwardedURL = forwarded ? requestURL(forwarded) : undefined // These assertions are the routing contract: append the original path to - // the remote base URL, preserve normal query params, and remove workspace. + // the remote base URL, preserve normal query params, and remove workspace + // and directory — both name control-plane state the remote cannot resolve. expect(forwardedURL?.pathname).toBe("/base/probe") expect(forwardedURL?.searchParams.get("keep")).toBe("yes") expect(forwardedURL?.searchParams.get("workspace")).toBeNull() + expect(forwardedURL?.searchParams.get("directory")).toBeNull() expect(forwarded?.method).toBe("PATCH") expect(forwarded?.body).toBe(body) expect(forwarded?.headers["content-type"]).toBe("application/json") @@ -347,7 +442,7 @@ describe("HttpApi workspace routing middleware", () => { const workspace = Workspace.Service.of({ create: () => Effect.die("unused"), - sessionWarp: () => Effect.die("unused"), + moveSession: () => Effect.die("unused"), list: () => Effect.die("unused"), syncList: () => Effect.die("unused"), get: (id) => @@ -367,6 +462,7 @@ describe("HttpApi workspace routing middleware", () => { ), remove: () => Effect.die("unused"), status: () => Effect.die("unused"), + ensureReady: () => Effect.die("unused"), isSyncing: () => Effect.succeed(true), waitForSync: (id, state) => Ref.set(waited, { workspaceID: id, state }), startWorkspaceSyncing: () => Effect.die("unused"), @@ -405,7 +501,7 @@ describe("HttpApi workspace routing middleware", () => { const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`) expect(response.status).toBe(503) - expect(yield* response.text).toBe(`broken sync connection for workspace: ${workspaceID}`) + expect(yield* response.text).toContain(`broken sync connection for workspace: ${workspaceID}`) }), ) @@ -419,6 +515,7 @@ describe("HttpApi workspace routing middleware", () => { projectID: project.project.id, type: "remote-websocket-target", url: `${remoteUrl}/base`, + headers: { authorization: "Basic remote-secret" }, }) // The client connects to the local test server. The middleware should @@ -437,6 +534,7 @@ describe("HttpApi workspace routing middleware", () => { const write = yield* socket.writer expect(yield* Queue.take(messages)).toBe("protocol:chat") + expect(yield* Queue.take(messages)).toBe("authorization:Basic remote-secret") yield* write("hello") expect(yield* Queue.take(messages)).toBe("echo:hello") }), @@ -456,6 +554,34 @@ describe("HttpApi workspace routing middleware", () => { }), ) + it.live("serves sessions referencing an untracked workspace from their own directory", () => + Effect.gen(function* () { + // A plain remote server stores the control plane's workspace id when a + // moved session replays; it has no workspace row for it. Session-scoped + // requests must fall back to the session directory instead of 500ing. + const dir = yield* tmpdirScoped({ git: true }) + const workspaceID = WorkspaceV2.ID.ascending("wrk_untracked") + const sessionID = "ses_untracked_workspace" + + yield* HttpApiBuilder.layer(ProbeApi).pipe( + Layer.provide(probeHandlers), + Layer.provide(workspaceRoutingTestLayer), + Layer.provide( + Layer.mock(Session.Service)({ + get: () => Effect.succeed({ id: sessionID, directory: dir, workspaceID } as Session.Info), + }), + ), + HttpRouter.serve, + Layer.build, + ) + + const response = yield* HttpClient.get(`/session/${sessionID}/probe`) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: dir, workspaceID: null }) + }), + ) + it.live("keeps control-plane routes local even when workspace is selected", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) @@ -507,6 +633,8 @@ describe("HttpApi workspace routing middleware", () => { const dir = yield* tmpdirScoped() const queryDir = path.join(dir, "query-target") const headerDir = path.join(dir, "header-target") + yield* Effect.promise(() => mkdir(queryDir, { recursive: true })) + yield* Effect.promise(() => mkdir(headerDir, { recursive: true })) yield* serveProbe // Without a selected workspace, the middleware falls back to request @@ -524,6 +652,22 @@ describe("HttpApi workspace routing middleware", () => { }), ) + it.live("falls back to the server directory when the requested directory does not exist locally", () => + Effect.gen(function* () { + yield* serveProbe + + // A directory hint that only exists on a remote workspace host (e.g. a + // sandbox path echoed back by the client after a move) cannot boot a + // local instance; serve from the server's own directory instead. + const response = yield* HttpClient.get( + `/probe?directory=${encodeURIComponent("/gitterm-e2e-nonexistent/tui-invaders")}`, + ) + + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ directory: process.cwd(), workspaceID: null }) + }), + ) + it.live("routes local workspace requests through WorkspaceRouteContext", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 4cb9f55c847e..b79bcdc27f92 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -190,6 +190,7 @@ describe("workspace HttpApi", () => { type: "worktree", name: "Worktree", description: "Create a git worktree", + kind: "local", }) expect(workspaces.status).toBe(200) @@ -217,12 +218,15 @@ describe("workspace HttpApi", () => { expect(workspace).toMatchObject({ type: "local-test", name: "local-test" }) const session = yield* Session.use.create({}).pipe(provideInstance(dir)) - const warped = yield* request(WorkspacePaths.warp, dir, { + const moved = yield* request("/experimental/control-plane/move-session", dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: workspace.id, sessionID: session.id }), + body: JSON.stringify({ + sessionID: session.id, + destination: { directory: workspace.directory, workspaceID: workspace.id }, + }), }) - expect(warped.status).toBe(204) + expect(moved.status).toBe(204) const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" }) expect(removed.status).toBe(200) @@ -258,16 +262,19 @@ describe("workspace HttpApi", () => { }), ) - it.live("returns a declared not found error when warping into a missing workspace", () => + it.live("returns a declared not found error when moving into a missing workspace", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const session = yield* Session.use.create({}).pipe(provideInstance(dir)) - const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp") + const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_move") - const response = yield* request(WorkspacePaths.warp, dir, { + const response = yield* request("/experimental/control-plane/move-session", dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: workspaceID, sessionID: session.id }), + body: JSON.stringify({ + sessionID: session.id, + destination: { directory: dir, workspaceID }, + }), }) expect(response.status).toBe(404) @@ -464,12 +471,15 @@ describe("workspace HttpApi", () => { const workspace = (yield* created.json) as Workspace.Info const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" }) const session = (yield* sessionResponse.json) as Session.Info - const warped = yield* requestDefault(WorkspacePaths.warp, dir, { + const moved = yield* requestDefault("/experimental/control-plane/move-session", dir, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ id: workspace.id, sessionID: session.id }), + body: JSON.stringify({ + sessionID: session.id, + destination: { directory: workspace.directory, workspaceID: workspace.id }, + }), }) - expect(warped.status).toBe(204) + expect(moved.status).toBe(204) try { const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, { diff --git a/packages/opencode/test/server/proxy-util.test.ts b/packages/opencode/test/server/proxy-util.test.ts index d13a06bb31a8..e295679a93b2 100644 --- a/packages/opencode/test/server/proxy-util.test.ts +++ b/packages/opencode/test/server/proxy-util.test.ts @@ -90,6 +90,20 @@ describe("ProxyUtil", () => { expect(result.get("content-type")).toBe("text/plain") }) + test("drops client authorization so workspace target credentials win", () => { + const req = new Request("http://localhost", { + headers: { + authorization: "Bearer host-token", + "content-type": "application/json", + }, + }) + const result = ProxyUtil.headers(req, { + Authorization: "Basic cmVtb3RlOnBhc3M=", + }) + expect(result.get("authorization")).toBe("Basic cmVtb3RlOnBhc3M=") + expect(result.get("content-type")).toBe("application/json") + }) + test("returns original headers when no extra", () => { const req = new Request("http://localhost", { headers: { "content-type": "application/json", "x-foo": "bar" }, diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 9ae0f3e632c4..d564dcfd8ed9 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -80,6 +80,13 @@ describe("workspaceProxyURL", () => { expect(result.searchParams.get("keep")).toBe("yes") }) + test("removes directory so the remote resolves its own instance", () => { + const url = new URL("http://localhost/vcs/diff/raw?workspace=ws_123&directory=%2FUsers%2Fme%2Frepo&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("directory")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + test("preserves hash from request", () => { const url = new URL("http://localhost/page#section") const result = workspaceProxyURL("http://remote:8080", url) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index edfa0139dfca..200a2f989a1c 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -45,11 +45,26 @@ export type WorkspaceTarget = } export type WorkspaceAdapter = { + kind?: "local" | "remote" name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise + create( + config: WorkspaceInfo, + env: Record, + from?: WorkspaceInfo, + ): Promise remove(config: WorkspaceInfo): Promise + ensureReady?(config: WorkspaceInfo): Promise + status?( + config: WorkspaceInfo, + ): + | "connected" + | "connecting" + | "paused" + | "disconnected" + | "error" + | Promise<"connected" | "connecting" | "paused" | "disconnected" | "error"> target(config: WorkspaceInfo): WorkspaceTarget | Promise } @@ -60,6 +75,7 @@ export type PluginInput = { worktree: string experimental_workspace: { register(type: string, adapter: WorkspaceAdapter): void + remove?(workspaceID: string): Promise } serverUrl: URL $: BunShell diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 3a559c3e38a4..fb22cad7ccae 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -79,7 +79,9 @@ export const Moved = Event.define({ schema: { ...Base, location: Location.Ref, + source: Location.Ref.pipe(optional), subdirectory: RelativePath.pipe(optional), + transferHash: Schema.String.pipe(optional), }, }) export type Moved = typeof Moved.Type diff --git a/packages/schema/src/workspace-event.ts b/packages/schema/src/workspace-event.ts index 82e15e077190..2d8f089f34c2 100644 --- a/packages/schema/src/workspace-event.ts +++ b/packages/schema/src/workspace-event.ts @@ -6,7 +6,7 @@ import { WorkspaceID } from "./workspace-id" export const ConnectionStatus = Schema.Struct({ workspaceID: WorkspaceID, - status: Schema.Literals(["connected", "connecting", "disconnected", "error"]), + status: Schema.Literals(["connected", "connecting", "paused", "disconnected", "error"]), }).annotate({ identifier: "WorkspaceEvent.ConnectionStatus" }) export interface ConnectionStatus extends Schema.Schema.Type {} diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index c1956cffe037..38d9ba91ba70 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -83,7 +83,7 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp ) client.interceptors.response.use((response) => { const contentType = response.headers.get("content-type") - if (contentType === "text/html") + if (contentType?.includes("text/html")) throw new Error("Request is not supported by this version of OpenCode Server (Server responded with text/html)") return response diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..803f5a78c45f 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -58,8 +58,6 @@ import type { ExperimentalWorkspaceStatusResponses, ExperimentalWorkspaceSyncListErrors, ExperimentalWorkspaceSyncListResponses, - ExperimentalWorkspaceWarpErrors, - ExperimentalWorkspaceWarpResponses, FileListErrors, FileListResponses, FilePartInput, @@ -230,8 +228,6 @@ import type { SyncReplayResponses, SyncStartErrors, SyncStartResponses, - SyncStealErrors, - SyncStealResponses, TextPartInput, ToolIdsErrors, ToolIdsResponses, @@ -391,6 +387,8 @@ import type { VcsDiffRawErrors, VcsDiffRawResponses, VcsDiffResponses, + VcsDiscardErrors, + VcsDiscardResponses, VcsGetErrors, VcsGetResponses, VcsStatusErrors, @@ -1189,51 +1187,6 @@ export class Workspace extends HeyApiClient { }) } - /** - * Warp session into workspace - * - * Move a session's sync history into the target workspace, or detach it to the local project. - */ - public warp( - parameters?: { - directory?: string - workspace?: string - id?: string | null - sessionID?: string - copyChanges?: boolean - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "id" }, - { in: "body", key: "sessionID" }, - { in: "body", key: "copyChanges" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post< - ExperimentalWorkspaceWarpResponses, - ExperimentalWorkspaceWarpErrors, - ThrowOnError - >({ - url: "/experimental/workspace/warp", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - private _adapter?: Adapter get adapter(): Adapter { return (this._adapter ??= new Adapter({ client: this.client })) @@ -2150,6 +2103,43 @@ export class Vcs extends HeyApiClient { }) } + /** + * Discard transferred VCS changes + * + * Discard source changes only when they still match a previously transferred patch. + */ + public discard( + parameters?: { + directory?: string + workspace?: string + patch?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "patch" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/vcs/discard", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + private _diff?: Diff get diff2(): Diff { return (this._diff ??= new Diff({ client: this.client })) @@ -4531,43 +4521,6 @@ export class Sync extends HeyApiClient { }) } - /** - * Steal session into workspace - * - * Update a session to belong to the current workspace through the sync event system. - */ - public steal( - parameters?: { - directory?: string - workspace?: string - sessionID?: string - }, - options?: Options, - ) { - const params = buildClientParams( - [parameters], - [ - { - args: [ - { in: "query", key: "directory" }, - { in: "query", key: "workspace" }, - { in: "body", key: "sessionID" }, - ], - }, - ], - ) - return (options?.client ?? this.client).post({ - url: "/sync/steal", - ...options, - ...params, - headers: { - "Content-Type": "application/json", - ...options?.headers, - ...params.headers, - }, - }) - } - private _history?: History get history(): History { return (this._history ??= new History({ 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 5e067f3afb23..2c215cfa9b6d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -149,6 +149,13 @@ export type MoveSessionError = { } } +export type NotFoundError = { + name: "NotFoundError" + data: { + message: string + } +} + export type SnapshotFileDiff = { file?: string patch?: string @@ -845,7 +852,9 @@ export type GlobalEvent = { timestamp: number sessionID: string location: LocationRef + source?: LocationRef subdirectory?: string + transferHash?: string } } | { @@ -1568,7 +1577,7 @@ export type GlobalEvent = { type: "workspace.status" properties: { workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" + status: "connected" | "connecting" | "paused" | "disconnected" | "error" } } | { @@ -2331,6 +2340,14 @@ export type VcsApplyError = { } } +export type VcsDiscardError = { + name: "VcsDiscardError" + data: { + message: string + reason: "changed" | "failed" + } +} + export type Command = { name: string description?: string @@ -2535,13 +2552,6 @@ export type ProviderAuthError1 = { } } -export type NotFoundError = { - name: "NotFoundError" - data: { - message: string - } -} - export type TextPartInput = { id?: string type: "text" @@ -2665,13 +2675,6 @@ export type WorkspaceCreateError = { } } -export type WorkspaceWarpError = { - name: "WorkspaceWarpError" - data: { - message: string - } -} - export type UnauthorizedError = { _tag: "UnauthorizedError" message: string @@ -3028,6 +3031,7 @@ export type SkillV2Source = SkillV2DirectorySource | SkillV2UrlSource | SkillV2E export type MoveSessionDestination = { directory: string + workspaceID?: string } export type ModelRef = { @@ -3339,7 +3343,9 @@ export type SyncEventSessionNextMoved = { timestamp: number sessionID: string location: LocationRef + source?: LocationRef subdirectory?: string + transferHash?: string } } } @@ -3851,7 +3857,7 @@ export type PtyTicketConnectToken = { export type WorkspaceEventConnectionStatus = { workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" + status: "connected" | "connecting" | "paused" | "disconnected" | "error" } export type LocationInfo = { @@ -4213,7 +4219,9 @@ export type SessionNextMoved = { timestamp: number sessionID: string location: LocationRef + source?: LocationRef subdirectory?: string + transferHash?: string } } @@ -6027,7 +6035,7 @@ export type WorkspaceStatus = { location?: LocationRef data: { workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" + status: "connected" | "connecting" | "paused" | "disconnected" | "error" } } @@ -6272,7 +6280,9 @@ export type EventSessionNextMoved = { timestamp: number sessionID: string location: LocationRef + source?: LocationRef subdirectory?: string + transferHash?: string } } @@ -7010,7 +7020,7 @@ export type EventWorkspaceStatus = { type: "workspace.status" properties: { workspaceID: string - status: "connected" | "connecting" | "disconnected" | "error" + status: "connected" | "connecting" | "paused" | "disconnected" | "error" } } @@ -7203,6 +7213,10 @@ export type ExperimentalControlPlaneMoveSessionErrors = { * MoveSessionError | InvalidRequestError */ 400: MoveSessionError | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError } export type ExperimentalControlPlaneMoveSessionError = @@ -8278,6 +8292,42 @@ export type VcsApplyResponses = { export type VcsApplyResponse = VcsApplyResponses[keyof VcsApplyResponses] +export type VcsDiscardData = { + body?: { + patch: string + } + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/vcs/discard" +} + +export type VcsDiscardErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * VcsDiscardError + */ + 409: VcsDiscardError +} + +export type VcsDiscardError2 = VcsDiscardErrors[keyof VcsDiscardErrors] + +export type VcsDiscardResponses = { + /** + * VCS changes discarded + */ + 200: { + applied: boolean + } +} + +export type VcsDiscardResponse = VcsDiscardResponses[keyof VcsDiscardResponses] + export type CommandListData = { body?: never path?: never @@ -10540,38 +10590,6 @@ export type SyncReplayResponses = { export type SyncReplayResponse = SyncReplayResponses[keyof SyncReplayResponses] -export type SyncStealData = { - body?: { - sessionID: string - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/sync/steal" -} - -export type SyncStealErrors = { - /** - * BadRequest | InvalidRequestError - */ - 400: EffectHttpApiErrorBadRequest | InvalidRequestError -} - -export type SyncStealError = SyncStealErrors[keyof SyncStealErrors] - -export type SyncStealResponses = { - /** - * Session stolen into workspace - */ - 200: { - sessionID: string - } -} - -export type SyncStealResponse = SyncStealResponses[keyof SyncStealResponses] - export type SyncHistoryListData = { body?: { [key: string]: number @@ -11023,6 +11041,7 @@ export type ExperimentalWorkspaceAdapterListResponses = { type: string name: string description: string + kind?: "local" | "remote" }> } @@ -11185,43 +11204,6 @@ export type ExperimentalWorkspaceRemoveResponses = { export type ExperimentalWorkspaceRemoveResponse = ExperimentalWorkspaceRemoveResponses[keyof ExperimentalWorkspaceRemoveResponses] -export type ExperimentalWorkspaceWarpData = { - body?: { - id: string | null - sessionID: string - copyChanges?: boolean - } - path?: never - query?: { - directory?: string - workspace?: string - } - url: "/experimental/workspace/warp" -} - -export type ExperimentalWorkspaceWarpErrors = { - /** - * WorkspaceWarpError | VcsApplyError | InvalidRequestError - */ - 400: WorkspaceWarpError | VcsApplyError | InvalidRequestError - /** - * NotFoundError - */ - 404: NotFoundError -} - -export type ExperimentalWorkspaceWarpError = ExperimentalWorkspaceWarpErrors[keyof ExperimentalWorkspaceWarpErrors] - -export type ExperimentalWorkspaceWarpResponses = { - /** - * Session warped - */ - 204: void -} - -export type ExperimentalWorkspaceWarpResponse = - ExperimentalWorkspaceWarpResponses[keyof ExperimentalWorkspaceWarpResponses] - export type V2HealthGetData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index d97885b55390..fd28e24dec03 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -237,6 +237,16 @@ } } } + }, + "404": { + "description": "NotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } } }, "description": "Move a session to another project directory, optionally transferring local changes.", @@ -2659,6 +2669,94 @@ ] } }, + "/vcs/discard": { + "post": { + "tags": ["instance"], + "operationId": "vcs.discard", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "VCS changes discarded", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "applied": { + "type": "boolean" + } + }, + "required": ["applied"], + "additionalProperties": false, + "description": "VCS changes discarded" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "409": { + "description": "VcsDiscardError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VcsDiscardError" + } + } + } + } + }, + "description": "Discard source changes only when they still match a previously transferred patch.", + "summary": "Discard transferred VCS changes", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "patch": { + "type": "string" + } + }, + "required": ["patch"], + "additionalProperties": false + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.vcs.discard({\n ...\n})" + } + ] + } + }, "/command": { "get": { "tags": ["instance"], @@ -8222,93 +8320,6 @@ ] } }, - "/sync/steal": { - "post": { - "tags": ["sync"], - "operationId": "sync.steal", - "parameters": [ - { - "name": "directory", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - }, - { - "name": "workspace", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - } - ], - "responses": { - "200": { - "description": "Session stolen into workspace", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false, - "description": "Session stolen into workspace" - } - } - } - }, - "400": { - "description": "BadRequest | InvalidRequestError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/effect_HttpApiError_BadRequest" - }, - { - "$ref": "#/components/schemas/InvalidRequestError" - } - ] - } - } - } - } - }, - "description": "Update a session to belong to the current workspace through the sync event system.", - "summary": "Steal session into workspace", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses" - } - }, - "required": ["sessionID"], - "additionalProperties": false - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.sync.steal({\n ...\n})" - } - ] - } - }, "/sync/history": { "post": { "tags": ["sync"], @@ -9315,6 +9326,10 @@ }, "description": { "type": "string" + }, + "kind": { + "type": "string", + "enum": ["local", "remote"] } }, "required": ["type", "name", "description"], @@ -9679,104 +9694,6 @@ ] } }, - "/experimental/workspace/warp": { - "post": { - "tags": ["workspace"], - "operationId": "experimental.workspace.warp", - "parameters": [ - { - "name": "directory", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - }, - { - "name": "workspace", - "in": "query", - "schema": { - "type": "string" - }, - "required": false - } - ], - "responses": { - "204": { - "description": "Session warped" - }, - "400": { - "description": "WorkspaceWarpError | VcsApplyError | InvalidRequestError", - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/WorkspaceWarpError" - }, - { - "$ref": "#/components/schemas/VcsApplyError" - }, - { - "$ref": "#/components/schemas/InvalidRequestError" - } - ] - } - } - } - }, - "404": { - "description": "NotFoundError", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFoundError" - } - } - } - } - }, - "description": "Move a session's sync history into the target workspace, or detach it to the local project.", - "summary": "Warp session into workspace", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "anyOf": [ - { - "type": "string", - "pattern": "^wrk" - }, - { - "type": "null" - } - ] - }, - "sessionID": { - "type": "string", - "pattern": "^ses" - }, - "copyChanges": { - "type": "boolean" - } - }, - "required": ["id", "sessionID"], - "additionalProperties": false - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.experimental.workspace.warp({\n ...\n})" - } - ] - } - }, "/api/health": { "get": { "tags": ["opencode HttpApi"], @@ -15693,6 +15610,25 @@ "required": ["name", "data"], "additionalProperties": false }, + "NotFoundError": { + "type": "object", + "required": ["name", "data"], + "properties": { + "name": { + "type": "string", + "enum": ["NotFoundError"] + }, + "data": { + "type": "object", + "required": ["message"], + "properties": { + "message": { + "type": "string" + } + } + } + } + }, "SnapshotFileDiff": { "type": "object", "properties": { @@ -17803,8 +17739,14 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "source": { + "$ref": "#/components/schemas/LocationRef" + }, "subdirectory": { "type": "string" + }, + "transferHash": { + "type": "string" } }, "required": ["timestamp", "sessionID", "location"], @@ -20314,7 +20256,7 @@ }, "status": { "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] + "enum": ["connected", "connecting", "paused", "disconnected", "error"] } }, "required": ["workspaceID", "status"], @@ -22448,6 +22390,31 @@ "required": ["name", "data"], "additionalProperties": false }, + "VcsDiscardError": { + "type": "object", + "properties": { + "name": { + "type": "string", + "enum": ["VcsDiscardError"] + }, + "data": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["changed", "failed"] + } + }, + "required": ["message", "reason"], + "additionalProperties": false + } + }, + "required": ["name", "data"], + "additionalProperties": false + }, "Command": { "type": "object", "properties": { @@ -23039,25 +23006,6 @@ "required": ["name", "data"], "additionalProperties": false }, - "NotFoundError": { - "type": "object", - "required": ["name", "data"], - "properties": { - "name": { - "type": "string", - "enum": ["NotFoundError"] - }, - "data": { - "type": "object", - "required": ["message"], - "properties": { - "message": { - "type": "string" - } - } - } - } - }, "TextPartInput": { "type": "object", "properties": { @@ -23434,27 +23382,6 @@ "required": ["name", "data"], "additionalProperties": false }, - "WorkspaceWarpError": { - "type": "object", - "properties": { - "name": { - "type": "string", - "enum": ["WorkspaceWarpError"] - }, - "data": { - "type": "object", - "properties": { - "message": { - "type": "string" - } - }, - "required": ["message"], - "additionalProperties": false - } - }, - "required": ["name", "data"], - "additionalProperties": false - }, "UnauthorizedError": { "type": "object", "properties": { @@ -24498,6 +24425,10 @@ "properties": { "directory": { "type": "string" + }, + "workspaceID": { + "type": "string", + "pattern": "^wrk" } }, "required": ["directory"], @@ -25399,8 +25330,14 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "source": { + "$ref": "#/components/schemas/LocationRef" + }, "subdirectory": { "type": "string" + }, + "transferHash": { + "type": "string" } }, "required": ["timestamp", "sessionID", "location"], @@ -27084,7 +27021,7 @@ }, "status": { "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] + "enum": ["connected", "connecting", "paused", "disconnected", "error"] } }, "required": ["workspaceID", "status"], @@ -28112,8 +28049,14 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "source": { + "$ref": "#/components/schemas/LocationRef" + }, "subdirectory": { "type": "string" + }, + "transferHash": { + "type": "string" } }, "required": ["timestamp", "sessionID", "location"], @@ -33487,7 +33430,7 @@ }, "status": { "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] + "enum": ["connected", "connecting", "paused", "disconnected", "error"] } }, "required": ["workspaceID", "status"], @@ -34190,8 +34133,14 @@ "location": { "$ref": "#/components/schemas/LocationRef" }, + "source": { + "$ref": "#/components/schemas/LocationRef" + }, "subdirectory": { "type": "string" + }, + "transferHash": { + "type": "string" } }, "required": ["timestamp", "sessionID", "location"], @@ -36555,7 +36504,7 @@ }, "status": { "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] + "enum": ["connected", "connecting", "paused", "disconnected", "error"] } }, "required": ["workspaceID", "status"], diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index e0d5508736b9..252d0a572d07 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -16,16 +16,25 @@ import { useCommandShortcut } from "../keymap" import { useProject } from "../context/project" import { Spinner } from "./spinner" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" -import type { ProjectDirectories } from "@opencode-ai/sdk/v2" +import { remoteWorkspaceAdapters, workspaceProvenance } from "./dialog-workspace-create" +import type { ExperimentalWorkspaceAdapterListResponse, ProjectDirectories } from "@opencode-ai/sdk/v2" import { useRoute } from "../context/route" -export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } +export type MoveSessionSelection = + | { type: "directory"; directory: string; subdirectory: boolean } + | { type: "new" } + | { type: "workspace"; workspaceID: string; directory: string } + | { type: "workspace-new"; workspaceType: string; workspaceName: string } + | { type: "remote-list" } type ProjectDirectory = ProjectDirectories[number] +type WorkspaceAdapter = ExperimentalWorkspaceAdapterListResponse[number] type DialogMoveSessionProps = { projectID: string current?: MoveSessionSelection onSelect: (selection: MoveSessionSelection) => void + workspaceEnabled?: boolean + currentWorkspaceID?: string onCurrentChange?: (selection: MoveSessionSelection) => void initialDirectories?: ProjectDirectory[] initialRemoving?: string @@ -46,6 +55,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { const [removing, setRemoving] = createSignal(props.initialRemoving) const [replacementCurrent, setReplacementCurrent] = createSignal() const [loadError, setLoadError] = createSignal() + const [createStep, setCreateStep] = createSignal<"root" | "remote">("root") const deleteHint = useCommandShortcut("dialog.move_session.delete") onMount(() => dialog.setSize("xlarge")) @@ -91,6 +101,18 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { }, ) const directoryData = createMemo(() => directories() ?? props.initialDirectories) + const [adapters] = createResource( + () => (props.workspaceEnabled ? props.projectID : undefined), + async (): Promise => { + const response = await sdk.client.experimental.workspace.adapter.list({ directory: sdk.directory }) + if (response.error) throw response.error + return response.data.toSorted((a, b) => { + if (a.type === "worktree") return -1 + if (b.type === "worktree") return 1 + return a.name.localeCompare(b.name) + }) + }, + ) // Show the locked error view only when we have nothing to display. A refresh // that fails after the list rendered keeps the list and its actions. const showError = createMemo(() => Boolean(loadError()) && !directoryData()) @@ -148,39 +170,124 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { if (b.location === b.root.directory) return 1 return a.location.localeCompare(b.location) }) - const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 12) + // The dialog is xlarge (116 columns, terminal-capped); right-aligned + // details padded against the raw terminal width land past the dialog's + // clip edge and vanish on wide terminals. + const titleWidth = Math.max(1, Math.min(116, dimensions().width - 2) - 14) + + const workspaces = projectContext.workspace + .list() + .filter((workspace) => workspace.id !== props.currentWorkspaceID) + .filter((workspace) => ["connected", "paused"].includes(projectContext.workspace.status(workspace.id) ?? "")) + .filter((workspace): workspace is typeof workspace & { directory: string } => Boolean(workspace.directory)) + const workspaceDirectories = new Set(workspaces.map((workspace) => workspace.directory)) + // The current workspace is excluded from the selectable workspace rows, + // so its directory (e.g. a remote sandbox path) surfaces as a plain + // directory row — label it with the adapter's provenance instead of + // letting it read as a local path. + const workspaceByDirectory = new Map( + projectContext.workspace + .list() + .filter((workspace): workspace is typeof workspace & { directory: string } => Boolean(workspace.directory)) + .map((workspace) => [workspace.directory, workspace]), + ) + const local = list + .filter((item) => !workspaceDirectories.has(item.location)) + .map((item) => { + const title = abbreviateHome(item.location, paths.home) + const suffix = + item.location === item.root.directory + ? undefined + : path.sep + path.relative(item.root.directory, item.location) + const visible = Locale.truncateLeft(title, titleWidth) + const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length + const deleting = toDelete() === item.location + const isRemoving = removing() === item.location + const rowWorkspace = workspaceByDirectory.get(item.location) + return { + title, + detail: rowWorkspace + ? workspaceProvenance(adapters(), rowWorkspace) + : item.location === item.root.directory + ? "local · main" + : "local", + titleView: isRemoving ? ( + Deleting {item.location} + ) : deleting ? ( + Press {deleteHint()} again to confirm + ) : suffix ? ( + <> + {visible.slice(0, split)} + {visible.slice(split)} + + ) : undefined, + bg: deleting ? theme.error : undefined, + value: { + type: "directory", + directory: item.location, + subdirectory: item.location !== item.root.directory, + } as const, + category: item.root.directory === current ? "Current" : "Other", + titleWidth, + truncateTitle: "left" as const, + } + }) + if (!props.workspaceEnabled) return local - return list.map((item) => { - const title = abbreviateHome(item.location, paths.home) - const suffix = - item.location === item.root.directory ? undefined : path.sep + path.relative(item.root.directory, item.location) - const visible = Locale.truncateLeft(title, titleWidth) - const split = suffix ? Math.max(0, visible.length - suffix.length) : visible.length - const deleting = toDelete() === item.location - const isRemoving = removing() === item.location - return { - title, - titleView: isRemoving ? ( - Deleting {item.location} - ) : deleting ? ( - Press {deleteHint()} again to confirm - ) : suffix ? ( - <> - {visible.slice(0, split)} - {visible.slice(split)} - - ) : undefined, - bg: deleting ? theme.error : undefined, + const existing = workspaces.map((workspace) => ({ + title: workspace.name, + detail: `${workspaceProvenance(adapters(), workspace)} ${workspace.directory}`, + titleWidth, + category: "Workspaces", + value: { type: "workspace", workspaceID: workspace.id, directory: workspace.directory } as const, + })) + const remoteAdapters = remoteWorkspaceAdapters(adapters() ?? []) + if (createStep() === "remote") { + if (adapters.loading && !adapters()) return [{ title: "Loading remote adapters...", value: undefined }] + if (remoteAdapters.length === 0) { + return [{ title: "No remote adapters available", value: undefined }] + } + return remoteAdapters.map((adapter) => ({ + title: adapter.name, + description: adapter.description, + category: "Remote adapters", value: { - type: "directory", - directory: item.location, - subdirectory: item.location !== item.root.directory, + type: "workspace-new", + workspaceType: adapter.type, + workspaceName: adapter.name, } as const, - category: item.root.directory === current ? "Current" : "Other", - titleWidth, - truncateTitle: "left" as const, - } - }) + })) + } + const create = [ + ...(adapters() ?? []) + .filter((adapter) => adapter.kind === "local" || adapter.type === "worktree") + .map((adapter) => ({ + title: adapter.name, + description: adapter.description, + category: "Create workspace", + value: { + type: "workspace-new", + workspaceType: adapter.type, + workspaceName: adapter.name, + } as const, + })), + ...(remoteAdapters.length + ? [ + { + title: "Remote", + description: "Choose a remote workspace adapter", + category: "Create workspace", + value: { type: "remote-list" } as const, + }, + ] + : []), + ] + return [ + ...local.filter((item) => item.category === "Current"), + ...existing, + ...local.filter((item) => item.category === "Other"), + ...create, + ] }) const current = createMemo(() => { @@ -208,7 +315,35 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { } async function remove(option: DialogSelectOption) { - if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return + if (!option.value || removing()) return + if (option.value.type === "workspace") { + if (toDelete() !== option.value.directory) { + setToDelete(option.value.directory) + return + } + setToDelete(undefined) + setRemoving(option.value.directory) + setWorking(true) + const result = await sdk.client.experimental.workspace + .remove({ id: option.value.workspaceID }) + .catch((error) => ({ error })) + if (result.error) { + setRemoving(undefined) + setWorking(false) + toast.show({ + variant: "error", + title: "Failed to delete workspace", + message: errorMessage(result.error), + }) + return + } + await Promise.all([projectContext.workspace.sync(), refetch()]) + setRemoving(undefined) + setWorking(false) + reopen() + return + } + if (option.value.type !== "directory" || option.value.subdirectory) return const data = directoryData() const selected = option.value const root = data?.find((item) => item.directory === selected.directory) @@ -286,13 +421,13 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { return ( - Move session + {createStep() === "remote" ? "Choose remote adapter" : "Move session"} - + @@ -312,33 +447,45 @@ export function DialogMoveSession(props: DialogMoveSessionProps) { locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} current={current()} onSelect={(option) => { - if (option.value) props.onSelect(option.value) + if (!option.value) return + if (option.value.type === "remote-list") { + setCreateStep("remote") + return + } + props.onSelect(option.value) }} onMove={() => setToDelete(undefined)} actions={ showError() ? [] : [ - { - command: "dialog.move_session.new", - title: "new", - onTrigger: () => props.onSelect({ type: "new" }), - }, - { - command: "dialog.move_session.delete", - title: "delete", - disabled: (option) => { - const value = option?.value - if (!value || value.type !== "directory" || value.subdirectory) return true - return !directoryData()?.find((item) => item.directory === value.directory)?.strategy - }, - onTrigger: remove, - }, - { - command: "dialog.move_session.refresh", - title: "refresh", - onTrigger: () => void refetch(), - }, + ...(createStep() === "remote" + ? [ + { + command: "dialog.move_session.refresh", + title: "back", + onTrigger: () => setCreateStep("root"), + }, + ] + : [ + { + command: "dialog.move_session.delete", + title: "delete", + disabled: (option: DialogSelectOption | undefined) => { + const value = option?.value + if (!value) return true + if (value.type === "workspace") return false + if (value.type !== "directory" || value.subdirectory) return true + return !directoryData()?.find((item) => item.directory === value.directory)?.strategy + }, + onTrigger: remove, + }, + { + command: "dialog.move_session.refresh", + title: "refresh", + onTrigger: () => void refetch(), + }, + ]), ] } /> diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index c508fc6b7b01..3724b8c07cf2 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -12,7 +12,12 @@ import { useLocal } from "../context/local" import { DialogSessionRename } from "./dialog-session-rename" import { createDebouncedSignal } from "../util/signal" import { useToast } from "../ui/toast" -import { openWorkspaceSelect, type WorkspaceSelection, warpWorkspaceSession } from "./dialog-workspace-create" +import { + confirmWorkspaceFileChanges, + moveWorkspaceSession, + openWorkspaceSelect, + type WorkspaceSelection, +} from "./dialog-workspace-create" import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" @@ -101,7 +106,13 @@ export function DialogSessionList() { function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) const list = () => dialog.replace(() => ) - const warp = async (selection: WorkspaceSelection) => { + const move = async (selection: WorkspaceSelection) => { + const copyChanges = await confirmWorkspaceFileChanges({ + dialog, + sdk, + sourceWorkspaceID: session.workspaceID, + }) + if (copyChanges === undefined) return const workspaceID = await (async () => { if (selection.type === "none") return null if (selection.type === "existing") return selection.workspaceID @@ -129,7 +140,7 @@ export function DialogSessionList() { return workspace.id })() if (workspaceID === undefined) return - await warpWorkspaceSession({ + await moveWorkspaceSession({ dialog, sdk, sync, @@ -138,7 +149,7 @@ export function DialogSessionList() { sourceWorkspaceID: session.workspaceID, workspaceID, sessionID: session.id, - copyChanges: false, + copyChanges, done: list, }) } @@ -176,7 +187,7 @@ export function DialogSessionList() { project, toast, onSelect: (selection) => { - void warp(selection) + void move(selection) }, }) return false diff --git a/packages/tui/src/component/dialog-workspace-create.tsx b/packages/tui/src/component/dialog-workspace-create.tsx index 98c71bb00023..d2d9e179cfd1 100644 --- a/packages/tui/src/component/dialog-workspace-create.tsx +++ b/packages/tui/src/component/dialog-workspace-create.tsx @@ -8,7 +8,6 @@ import { createMemo, createSignal, onMount } from "solid-js" import { errorMessage } from "../util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" -import { DialogAlert } from "../ui/dialog-alert" import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes" type Adapter = ExperimentalWorkspaceAdapterListResponse[number] @@ -29,7 +28,11 @@ export type WorkspaceSelection = workspaceName: string } -type WorkspaceSelectValue = WorkspaceSelection | { type: "existing-list" } +type WorkspaceSelectValue = + | WorkspaceSelection + | { type: "existing-list" } + | { type: "remote-list" } + | { type: "remote-empty" } type ExistingWorkspaceSelectValue = { workspace: Workspace } export function recentConnectedWorkspaces(input: { @@ -38,13 +41,40 @@ export function recentConnectedWorkspaces input.status(workspace.id) === "connected") + const allWorkspaces = input.workspaces.filter((workspace) => + ["connected", "paused"].includes(input.status(workspace.id) ?? ""), + ) const workspaces = allWorkspaces.toSorted((a, b) => Number(b.timeUsed) - Number(a.timeUsed)) const recent = workspaces.slice(0, input.limit ?? 3) return { recent, hasMore: recent.length < workspaces.length } } +// Human-readable provenance for a workspace row: the registering adapter's +// name plus whether it runs locally or remotely (e.g. "Gitterm · remote", +// "Worktree · local"). Falls back to the raw workspace type when the adapter +// is not currently registered. +export function workspaceProvenance( + adapters: readonly { type: string; kind?: string | null; name: string }[] | undefined, + workspace: { type: string }, +) { + const adapter = adapters?.find((item) => item.type === workspace.type) + const kind = adapter?.kind ?? (workspace.type === "worktree" ? "local" : "remote") + const name = adapter?.name ?? workspace.type + return `${name} · ${kind}` +} + +// Remote workspace adapters are those a plugin registered. The built-in +// "remote" (connect to an existing opencode server) and "worktree" adapters +// are never offered as remote workspaces. +export function remoteWorkspaceAdapters( + adapters: readonly T[], +): T[] { + return adapters + .filter((adapter) => adapter.kind === "remote" && adapter.type !== "remote" && adapter.type !== "worktree") + .toSorted((a, b) => a.name.localeCompare(b.name)) +} + export function warpReminderText(dir: string) { return `The user has changed the current working directory to "${dir}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.` } @@ -54,7 +84,10 @@ async function loadWorkspaceAdapters(input: { sync: ReturnType toast: ReturnType }) { - const dir = input.sync.path.directory || input.sdk.directory + // sync.path can hold a REMOTE workspace's directory after a move; adapter + // listing is a control-plane call and must use a directory that exists on + // this machine. + const dir = input.sdk.directory || input.sync.path.directory try { const response = await input.sdk.client.experimental.workspace.adapter.list({ directory: dir }) if (response.error) throw response.error @@ -85,7 +118,7 @@ export async function openWorkspaceSelect(input: { input.dialog.replace(() => ) } -export async function warpWorkspaceSession(input: { +export async function moveWorkspaceSession(input: { dialog: ReturnType sdk: ReturnType sync: ReturnType @@ -99,31 +132,26 @@ export async function warpWorkspaceSession(input: { }): Promise { let result try { - result = await input.sdk.client.experimental.workspace.warp({ - id: input.workspaceID, + const directory = input.workspaceID + ? input.project.workspace.get(input.workspaceID)?.directory + : input.project.instance.directory() || input.sync.path.directory + if (!directory) throw new Error("Workspace did not return a project directory") + result = await input.sdk.client.experimental.controlPlane.moveSession({ sessionID: input.sessionID, - copyChanges: input.copyChanges, + destination: { directory, ...(input.workspaceID ? { workspaceID: input.workspaceID } : {}) }, + moveChanges: input.copyChanges, }) } catch (err) { input.toast.show({ - title: "Failed to warp session", + title: "Failed to move session", message: errorMessage(err), variant: "error", }) return false } if (!result?.data) { - if (result?.error && "name" in result.error && result.error.name === "VcsApplyError") { - await DialogAlert.show( - input.dialog, - "Unable to Warp Session", - "Unable to apply file changes to this workspace. It has existing changes that conflict or is based off a different branch. Session has not been warped.", - ) - return false - } - input.toast.show({ - title: "Failed to warp session", + title: "Failed to move session", message: errorMessage(result?.error ?? "no response"), variant: "error", }) @@ -168,9 +196,11 @@ export async function confirmWorkspaceFileChanges(input: { sourceWorkspaceID?: string }) { const status = await input.sdk.client.vcs.status({ workspace: input.sourceWorkspaceID }).catch(() => undefined) - const fileChangeChoice = status?.data?.length - ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) - : "no" + // A source whose changes cannot be inspected must not silently drop them: + // default to copying — an empty patch is a no-op, and an unreachable source + // fails the move with a visible error instead. + if (!status?.data) return true + const fileChangeChoice = status.data.length ? await DialogWorkspaceFileChanges.show(input.dialog, status.data) : "no" if (!fileChangeChoice) return return fileChangeChoice === "yes" } @@ -186,6 +216,7 @@ export function DialogWorkspaceSelect(props: { const sdk = useSDK() const toast = useToast() const [adapters, setAdapters] = createSignal(props.adapters) + const [createStep, setCreateStep] = createSignal<"root" | "remote">("root") const omittedWorkspaceID = createMemo(() => (route.data.type === "session" ? project.workspace.current() : undefined)) onMount(() => { @@ -201,27 +232,60 @@ export function DialogWorkspaceSelect(props: { const options = createMemo[]>(() => { const list = adapters() if (!list) return [] + const remote = remoteWorkspaceAdapters(list) + if (createStep() === "remote") { + if (remote.length === 0) { + return [ + { + title: "No remote adapters", + value: { type: "remote-empty" as const }, + description: "You have no registered remote workspace adapters", + category: "Remote adapters", + disabled: true, + }, + ] + } + return remote.map((adapter) => ({ + title: adapter.name, + value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, + description: adapter.description, + category: "Remote adapters", + })) + } + const local = list + .filter((adapter) => adapter.kind === "local" || adapter.type === "worktree") + .toSorted((a, b) => { + if (a.type === "worktree") return -1 + if (b.type === "worktree") return 1 + return a.name.localeCompare(b.name) + }) const { recent, hasMore } = recentConnectedWorkspaces({ workspaces: project.workspace.list(), status: project.workspace.status, omitWorkspaceID: omittedWorkspaceID(), }) return [ - ...list.map((adapter) => ({ + ...local.map((adapter) => ({ title: adapter.name, value: { type: "new" as const, workspaceType: adapter.type, workspaceName: adapter.name }, description: adapter.description, category: "New workspace", })), { - title: "None", + title: "Remote", + value: { type: "remote-list" as const }, + description: "Choose a remote workspace adapter", + category: "New workspace", + }, + { + title: "Local (main)", value: { type: "none" as const }, - description: "Use the local project", + description: "Your main local project", category: "Choose workspace", }, ...recent.map((workspace: Workspace) => ({ title: workspace.name, - description: `(${workspace.type})`, + description: `${workspaceProvenance(list, workspace)} · ${project.workspace.status(workspace.id)}`, value: { type: "existing" as const, workspaceID: workspace.id, @@ -246,12 +310,17 @@ export function DialogWorkspaceSelect(props: { if (!adapters()) return null return ( - title="Warp" + title={createStep() === "remote" ? "Choose remote adapter" : "Move session"} skipFilter={true} renderFilter={false} options={options()} onSelect={(option) => { if (!option.value) return + if (option.value.type === "remote-empty") return + if (option.value.type === "remote-list") { + setCreateStep("remote") + return + } if (option.value.type === "none") { void props.onSelect(option.value) return @@ -266,7 +335,11 @@ export function DialogWorkspaceSelect(props: { } dialog.replace(() => ( - + )) }} /> @@ -274,6 +347,7 @@ export function DialogWorkspaceSelect(props: { } function DialogExistingWorkspaceSelect(props: { + adapters?: Adapter[] omitWorkspaceID?: string onSelect: (selection: WorkspaceSelection) => Promise | void }) { @@ -282,11 +356,11 @@ function DialogExistingWorkspaceSelect(props: { const options = createMemo[]>(() => project.workspace .list() - .filter((workspace) => project.workspace.status(workspace.id) === "connected") + .filter((workspace) => ["connected", "paused"].includes(project.workspace.status(workspace.id) ?? "")) .filter((workspace) => workspace.id !== props.omitWorkspaceID) .map((workspace: Workspace) => ({ title: workspace.name, - description: `(${workspace.type})`, + description: workspaceProvenance(props.adapters, workspace), value: { workspace }, })), ) diff --git a/packages/tui/src/component/dialog-workspace-list.tsx b/packages/tui/src/component/dialog-workspace-list.tsx index eab2acf7c8e0..bc17edc4d113 100644 --- a/packages/tui/src/component/dialog-workspace-list.tsx +++ b/packages/tui/src/component/dialog-workspace-list.tsx @@ -1,4 +1,4 @@ -import type { Workspace } from "@opencode-ai/sdk/v2" +import type { ExperimentalWorkspaceAdapterListResponse, Workspace } from "@opencode-ai/sdk/v2" import { useDialog } from "../ui/dialog" import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" import { useProject } from "../context/project" @@ -10,8 +10,9 @@ import { createStore } from "solid-js/store" import { errorMessage } from "../util/error" import { useSDK } from "../context/sdk" import { useToast } from "../ui/toast" +import { workspaceProvenance } from "./dialog-workspace-create" -type WorkspaceOption = { workspace: Workspace } +type WorkspaceOption = { workspace?: Workspace } export function DialogWorkspaceList() { const dialog = useDialog() @@ -24,14 +25,26 @@ export function DialogWorkspaceList() { const [deleting, setDeleting] = createSignal() const [removing, setRemoving] = createSignal() const [expanded, setExpanded] = createStore>({}) + const [adapters, setAdapters] = createSignal() const current = createMemo(() => { if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID return project.workspace.current() }) - const options = createMemo[]>(() => - project.workspace + // The "Main" row is the LOCAL project; instance/path context can point at a + // remote workspace's directory after a move, so prefer the SDK's directory. + const mainDirectory = createMemo(() => sdk.directory || project.instance.directory() || sync.path.directory) + + const options = createMemo[]>(() => [ + { + title: "Main", + value: {}, + footer: "local · main", + details: expanded["main"] && mainDirectory() ? [mainDirectory()!] : undefined, + gutter: () => , + }, + ...project.workspace .list() .toSorted((a, b) => a.name.localeCompare(b.name)) .map((workspace) => { @@ -44,15 +57,15 @@ export function DialogWorkspaceList() { ? `Delete ${workspace.name}? Press delete again` : workspace.name, value: { workspace }, - footer: workspace.type, + footer: workspaceProvenance(adapters(), workspace), details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined, gutter: () => , } }), - ) + ]) - function showDetails(workspace: Workspace) { - setExpanded(workspace.id, (open) => !open) + function showDetails(workspace?: Workspace) { + setExpanded(workspace?.id ?? "main", (open) => !open) } async function remove(workspace: Workspace) { @@ -90,6 +103,12 @@ export function DialogWorkspaceList() { dialog.setSize("large") void sdk.client.experimental.workspace.syncList().catch(() => undefined) void project.workspace.sync() + void sdk.client.experimental.workspace.adapter + .list({ directory: mainDirectory() }) + .then((response) => { + if (response.data) setAdapters(response.data) + }) + .catch(() => undefined) }) return ( @@ -104,7 +123,10 @@ export function DialogWorkspaceList() { { command: "session.delete", title: "delete", - onTrigger: (option) => void remove(option.value.workspace), + onTrigger: (option) => { + if (!option.value.workspace) return + void remove(option.value.workspace) + }, }, ]} /> diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 00efcbed2887..857daac0990c 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -531,17 +531,6 @@ export function Prompt(props: PromptProps) { )) }, }, - { - title: "Warp", - desc: "Change the workspace for the session", - name: "workspace.set", - category: "Session", - enabled: Flag.OPENCODE_EXPERIMENTAL_WORKSPACES, - slashName: "warp", - run: () => { - workspace.open() - }, - }, { title: "Move session", desc: "Move to another project dir", diff --git a/packages/tui/src/component/prompt/move.tsx b/packages/tui/src/component/prompt/move.tsx index f3f5a94bf243..4905a6de1e6f 100644 --- a/packages/tui/src/component/prompt/move.tsx +++ b/packages/tui/src/component/prompt/move.tsx @@ -87,20 +87,76 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess subdirectory: project.instance.directory() !== project.instance.path().worktree, }) } - onCurrentChange={(selection) => homeDestination?.setDestination(selection)} + onCurrentChange={(selection) => { + if (selection.type === "directory" || selection.type === "new") homeDestination?.setDestination(selection) + }} + workspaceEnabled={Boolean(sessionID)} + currentWorkspaceID={session?.workspaceID} onSelect={(selection) => { const sessionID = input.sessionID() if (!sessionID) { - homeDestination?.setDestination(selection) + if (selection.type === "directory" || selection.type === "new") homeDestination?.setDestination(selection) dialog.clear() return } + if (selection.type === "remote-list") return + if (selection.type === "workspace" || selection.type === "workspace-new") { + void moveExistingSessionToWorkspace(sessionID, selection) + return + } void moveExistingSession(sessionID, selection) }} /> )) } + async function moveExistingSessionToWorkspace( + sessionID: string, + selection: Extract, + ) { + const session = sync.session.get(sessionID) + const status = await sdk.client.vcs + .status({ directory: session?.directory, workspace: session?.workspaceID }) + .catch(() => undefined) + const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" + if (!choice) return + + setCreating(true) + setProgress(selection.type === "workspace-new" ? `Creating ${selection.workspaceName}` : "Moving session") + try { + const workspace = + selection.type === "workspace-new" + ? ( + await sdk.client.experimental.workspace.create( + { type: selection.workspaceType, branch: null }, + { throwOnError: true }, + ) + ).data + : project.workspace.get(selection.workspaceID) + if (!workspace) throw new Error("Workspace not found") + if (!workspace.directory) throw new Error("Workspace did not return a project directory") + + setProgress("Moving session") + await sdk.client.experimental.controlPlane.moveSession( + { + sessionID, + destination: { directory: workspace.directory, workspaceID: workspace.id }, + moveChanges: choice === "yes", + }, + { throwOnError: true }, + ) + project.workspace.set(workspace.id) + await Promise.all([project.workspace.sync(), sync.session.refresh()]) + dialog.clear() + } catch (error) { + toast.error(error) + dialog.clear() + } finally { + setProgress(undefined) + setCreating(false) + } + } + function sessionContext(sessionID: string) { const session = sync.session.get(sessionID) const messages = (sync.data.message[sessionID] ?? []) @@ -114,9 +170,16 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess return [session?.title, ...messages].filter(Boolean).join("\n") || undefined } - async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) { + async function moveExistingSession( + sessionID: string, + selection: Extract, + ) { const session = sync.session.get(sessionID) - const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined) + // When the session currently lives on a remote workspace, status must be + // queried with that workspace so we can offer to transfer remote dirty files. + const status = await sdk.client.vcs + .status({ directory: session?.directory, workspace: session?.workspaceID }) + .catch(() => undefined) const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no" if (!choice) return dialog.clear() diff --git a/packages/tui/src/component/prompt/workspace.tsx b/packages/tui/src/component/prompt/workspace.tsx index 57fad0ec3b8e..b61266db483f 100644 --- a/packages/tui/src/component/prompt/workspace.tsx +++ b/packages/tui/src/component/prompt/workspace.tsx @@ -8,7 +8,7 @@ import { errorMessage } from "../../util/error" import { confirmWorkspaceFileChanges, openWorkspaceSelect, - warpWorkspaceSession, + moveWorkspaceSession, type WorkspaceSelection, } from "../dialog-workspace-create" import type { WorkspaceStatus } from "../workspace-label" @@ -58,7 +58,7 @@ export function usePromptWorkspace(sessionID?: string) { return workspace } - async function warp(selection: WorkspaceSelection) { + async function move(selection: WorkspaceSelection) { if (!sessionID) { setSelection(selection) dialog.clear() @@ -79,7 +79,7 @@ export function usePromptWorkspace(sessionID?: string) { : await create(selection) if (!workspace) return - const warped = await warpWorkspaceSession({ + const moved = await moveWorkspaceSession({ dialog, sdk, sync, @@ -90,11 +90,11 @@ export function usePromptWorkspace(sessionID?: string) { sessionID, copyChanges, }) - if (warped) showNotice(workspace.name) + if (moved) showNotice(workspace.name) } function showNotice(name: string) { - setNotice(`Warped to ${name}`) + setNotice(`Moved to ${name}`) setTimeout(() => setNotice(undefined), 4000) } @@ -103,7 +103,7 @@ export function usePromptWorkspace(sessionID?: string) { } function open() { - void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: warp }) + void openWorkspaceSelect({ dialog, sdk, sync, project, toast, onSelect: move }) } createEffect(() => { @@ -133,5 +133,5 @@ export function usePromptWorkspace(sessionID?: string) { } }) - return { selection, creating, creatingDots, notice, label, open, warp, clearNotice } + return { selection, creating, creatingDots, notice, label, open, move, clearNotice } } diff --git a/packages/tui/src/component/workspace-label.tsx b/packages/tui/src/component/workspace-label.tsx index 4dd8982ae75d..b4cc33504d22 100644 --- a/packages/tui/src/component/workspace-label.tsx +++ b/packages/tui/src/component/workspace-label.tsx @@ -1,11 +1,12 @@ import { useTheme } from "../context/theme" -export type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +export type WorkspaceStatus = "connected" | "connecting" | "paused" | "disconnected" | "error" export function WorkspaceLabel(props: { type: string; name: string; status?: WorkspaceStatus; icon?: boolean }) { const { theme } = useTheme() const color = () => { if (props.status === "connected") return theme.success + if (props.status === "paused" || props.status === "connecting") return theme.warning if (props.status === "error") return theme.error return theme.textMuted } diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 5dd7e4b5aafe..6a069a8c0d54 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -208,7 +208,7 @@ export const Definitions = { "dialog.select.submit": keybind("return", "Submit selected dialog item"), "dialog.prompt.submit": keybind("return", "Submit dialog prompt"), "dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"), - "dialog.move_session.new": keybind("ctrl+m", "New project copy"), + "dialog.move_session.new": keybind("ctrl+m", "New workspace"), "dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"), "dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"), "prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"), diff --git a/packages/tui/src/context/project.tsx b/packages/tui/src/context/project.tsx index d73a17e7ecda..07d2c6cd3dee 100644 --- a/packages/tui/src/context/project.tsx +++ b/packages/tui/src/context/project.tsx @@ -4,7 +4,7 @@ import { createStore, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" -type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" +type WorkspaceStatus = "connected" | "connecting" | "paused" | "disconnected" | "error" export const { use: useProject, provider: ProjectProvider } = createSimpleContext({ name: "Project", diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d244c..4fa3d816d426 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -58,6 +58,7 @@ export interface DialogSelectOption { titleView?: JSX.Element value: T description?: string + detail?: string details?: string[] footer?: JSX.Element | string titleWidth?: number @@ -688,6 +689,7 @@ export function DialogSelect(props: DialogSelectProps) { titleWidth={option.titleWidth} truncateTitle={option.truncateTitle} description={option.description !== category ? option.description : undefined} + detail={option.detail} active={active()} current={current()} muted={actionFocused()} @@ -732,6 +734,7 @@ function Option(props: { title: string titleView?: JSX.Element description?: string + detail?: string active?: boolean current?: boolean muted?: boolean @@ -771,11 +774,28 @@ function Option(props: { paddingLeft={3} > {props.titleView ?? - (props.truncateTitle === false - ? props.title - : props.truncateTitle === "left" - ? Locale.truncateLeft(props.title, props.titleWidth ?? 61) - : Locale.truncate(props.title, props.titleWidth ?? 61))} + (() => { + const width = props.titleWidth ?? 61 + const detail = props.detail + ? Locale.truncateLeft(props.detail, Math.max(1, width - Math.min(props.title.length, 24) - 2)) + : "" + const available = Math.max(1, width - detail.length - (detail ? 2 : 0)) + const title = + props.truncateTitle === false + ? props.title + : props.truncateTitle === "left" + ? Locale.truncateLeft(props.title, available) + : Locale.truncate(props.title, available) + return ( + <> + {title} + + {" ".repeat(Math.max(1, width - title.length - detail.length))} + {detail} + + + ) + })()} {props.description} diff --git a/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts b/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts index 17e7090cf34e..29bd1e020df6 100644 --- a/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts +++ b/packages/tui/test/cli/cmd/tui/dialog-workspace-create.test.ts @@ -1,8 +1,39 @@ import { describe, expect, test } from "bun:test" -import { recentConnectedWorkspaces } from "../../../../src/component/dialog-workspace-create" +import { + recentConnectedWorkspaces, + remoteWorkspaceAdapters, +} from "../../../../src/component/dialog-workspace-create" + +describe("remoteWorkspaceAdapters", () => { + const adapters = [ + { type: "worktree", kind: "local", name: "Worktree" }, + { type: "remote", kind: "remote", name: "Remote" }, + { type: "gitterm", kind: "remote", name: "Gitterm" }, + { type: "acme", kind: "remote", name: "Acme Cloud" }, + ] + + test("keeps only plugin-registered remote adapters, sorted by name", () => { + expect(remoteWorkspaceAdapters(adapters).map((adapter) => adapter.type)).toEqual(["acme", "gitterm"]) + }) + + test("excludes the built-in remote and worktree adapters", () => { + const types = remoteWorkspaceAdapters(adapters).map((adapter) => adapter.type) + expect(types).not.toContain("remote") + expect(types).not.toContain("worktree") + }) + + test("returns empty when no plugin remote adapters are registered", () => { + expect( + remoteWorkspaceAdapters([ + { type: "worktree", kind: "local", name: "Worktree" }, + { type: "remote", kind: "remote", name: "Remote" }, + ]), + ).toEqual([]) + }) +}) describe("recentConnectedWorkspaces", () => { - test("returns connected workspaces sorted by time used", () => { + test("returns connected and paused workspaces sorted by time used", () => { const workspaces = [ { id: "wrk_a", name: "alpha", timeUsed: 700 }, { id: "wrk_b", name: "beta", timeUsed: 800 }, @@ -12,7 +43,7 @@ describe("recentConnectedWorkspaces", () => { ] const status = { wrk_a: "connected", - wrk_b: "disconnected", + wrk_b: "paused", wrk_c: "error", wrk_d: "connected", wrk_e: "connected", @@ -23,6 +54,6 @@ describe("recentConnectedWorkspaces", () => { status: (workspaceID) => status[workspaceID as keyof typeof status], }) - expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_a", "wrk_d", "wrk_e"]) + expect(recent.map((workspace) => workspace.id)).toEqual(["wrk_b", "wrk_a", "wrk_d"]) }) }) diff --git a/specs/v2/api.html b/specs/v2/api.html index 147d24f58bb3..53d107ab333e 100644 --- a/specs/v2/api.html +++ b/specs/v2/api.html @@ -1044,13 +1044,16 @@

Operation Inventory

Update workspace metadata or lifecycle state. - workspace.warp + controlPlane.moveSession - { workspaceID?: WorkspaceID sessionID: SessionID copyChanges: boolean } + { sessionID: SessionID destination: { directory: string workspaceID?: WorkspaceID } moveChanges?: + boolean } server - POST /api/workspace/warp - Move a session into or out of a workspace. Needs team discussion. + POST /experimental/control-plane/move-session + Move a session between local directories and adapter-backed workspaces.