From fe0eb4ea8f18365c9d69184703b6812c79783693 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:26:21 -0400 Subject: [PATCH] refactor(core): remove orphaned move service (#39974) Co-authored-by: Kit Langton --- .../core/src/control-plane/move-session.ts | 181 ----------- packages/core/test/move-session.test.ts | 291 ------------------ 2 files changed, 472 deletions(-) delete mode 100644 packages/core/src/control-plane/move-session.ts delete mode 100644 packages/core/test/move-session.test.ts diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts deleted file mode 100644 index 220c503f61..0000000000 --- a/packages/core/src/control-plane/move-session.ts +++ /dev/null @@ -1,181 +0,0 @@ -export * as MoveSession from "./move-session" - -import { Context, DateTime, Effect, Layer, Schema } from "effect" -import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Git } from "../git" -import { Global } from "@opencode-ai/util/global" -import { Project } from "../project" -import { Session } from "../session" -import { SessionExecution } from "../session/execution" -import { SessionSchema } from "../session/schema" -import { SessionStore } from "../session/store" -import { AbsolutePath } from "../schema" -import path from "path" - -export const Destination = Schema.Struct({ - directory: AbsolutePath, -}).annotate({ identifier: "MoveSession.Destination" }) -export type Destination = typeof Destination.Type - -export const Input = Schema.Struct({ - sessionID: SessionSchema.ID, - destination: Destination, - moveChanges: Schema.optional(Schema.Boolean), -}).annotate({ identifier: "MoveSession.Input" }) -export type Input = typeof Input.Type - -export class DestinationProjectMismatchError extends Schema.TaggedErrorClass()( - "MoveSession.DestinationProjectMismatchError", - { - expected: Project.ID, - actual: Project.ID, - }, -) {} - -export class DestinationNotFoundError extends Schema.TaggedErrorClass()( - "MoveSession.DestinationNotFoundError", - { directory: AbsolutePath }, -) {} - -export class DestinationNotDirectoryError extends Schema.TaggedErrorClass()( - "MoveSession.DestinationNotDirectoryError", - { directory: AbsolutePath }, -) {} - -export class ApplyChangesError extends Schema.TaggedErrorClass()("MoveSession.ApplyChangesError", { - message: Schema.String, -}) {} - -export class CaptureChangesError extends Schema.TaggedErrorClass()( - "MoveSession.CaptureChangesError", - { - message: Schema.String, - }, -) {} - -export class ResetSourceChangesError extends Schema.TaggedErrorClass()( - "MoveSession.ResetSourceChangesError", - { - directory: AbsolutePath, - message: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) {} - -export type Error = - | Session.NotFoundError - | DestinationProjectMismatchError - | DestinationNotFoundError - | DestinationNotDirectoryError - | Session.DestinationNotFoundError - | Session.DestinationNotDirectoryError - | CaptureChangesError - | ApplyChangesError - | ResetSourceChangesError - -export interface Interface { - readonly moveSession: (input: Input) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ControlPlaneMoveSession") {} - -const layer = Layer.effect( - Service, - Effect.gen(function* () { - const git = yield* Git.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const project = yield* Project.Service - const sessions = yield* SessionStore.Service - const session = yield* Session.Service - const execution = yield* SessionExecution.Service - - const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) { - const current = yield* sessions.get(input.sessionID) - if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID }) - const value = input.destination.directory.trim() - const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value - const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded)) - const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!destinationInfo) return yield* new DestinationNotFoundError({ directory }) - if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory }) - if (current.location.directory === directory) return - - const source = yield* project.resolve(current.location.directory) - const destination = yield* project.resolve(directory) - if (input.moveChanges && current.projectID !== destination.id) { - return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id }) - } - // A move must not race active execution: a mid-drain relocation would let - // the source Location dispatch a request assembled under stale instructions - // and history. Serialize like removal does — stop the drain, then move. - yield* execution.interrupt(input.sessionID) - yield* execution.awaitIdle(input.sessionID) - - 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" }) - const patch = sourceRepository - ? yield* git.change - .capture({ repository: sourceRepository, path: current.location.directory }) - .pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message }))) - : Git.ChangeSet.make("") - if (patch) { - const repository = yield* git.repo.discover(directory) - if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" }) - yield* git.change - .apply({ repository, path: directory, changes: patch }) - .pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message }))) - } - - yield* session.move({ - sessionID: input.sessionID, - directory, - }) - - if (patch) { - const repository = yield* git.repo.discover(current.location.directory) - if (!repository) - return yield* new ResetSourceChangesError({ - directory: current.location.directory, - message: "Source is not a Git repository", - }) - yield* git.change - .discard({ - repository, - path: current.location.directory, - index: "preserve", - untracked: "remove", - }) - .pipe( - Effect.mapError( - (error) => - new ResetSourceChangesError({ - directory: current.location.directory, - message: error.message, - cause: error.cause, - }), - ), - ) - } - }) - - return Service.of({ moveSession }) - }), -) - -export const node = makeGlobalNode({ - service: Service, - layer, - deps: [ - FSUtil.node, - Git.node, - Global.node, - Project.node, - Session.node, - SessionStore.node, - SessionExecution.node, - ], -}) diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts deleted file mode 100644 index f97b0cfb86..0000000000 --- a/packages/core/test/move-session.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { describe, expect } from "bun:test" -import { $ } from "bun" -import fs from "fs/promises" -import path from "path" -import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" -import { MoveSession } from "@opencode-ai/core/control-plane/move-session" -import { Database } from "@opencode-ai/core/database/database" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Bus } from "@opencode-ai/core/bus" -import { Job } from "@opencode-ai/core/job" -import { Project } from "@opencode-ai/core/project" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { Session } from "@opencode-ai/core/session" -import { SessionExecution } from "@opencode-ai/core/session/execution" -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 { tmpdir } from "./fixture/tmpdir" -import { testEffect } from "./lib/effect" - -// Records the execution serialization a move must perform before relocating. -const executionCalls: string[] = [] -const recordingExecution = Layer.succeed( - SessionExecution.Service, - SessionExecution.Service.of({ - active: Effect.succeed(new Set()), - resume: () => Effect.void, - wake: () => Effect.void, - interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)), - awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)), - }), -) - -const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ - MoveSession.node, - Database.node, - Bus.node, - ProjectDirectories.node, - Project.node, - Session.node, - SessionProjector.node, - SessionStore.node, - ]), - [[SessionExecution.node, recordingExecution]], - ), -) - -function abs(input: string) { - return AbsolutePath.make(input) -} - -async function initRepo(directory: string) { - await $`git init`.cwd(directory).quiet() - await $`git config core.autocrlf false`.cwd(directory).quiet() - await $`git config core.fsmonitor false`.cwd(directory).quiet() - await $`git config commit.gpgsign false`.cwd(directory).quiet() - await $`git config user.email test@opencode.test`.cwd(directory).quiet() - await $`git config user.name Test`.cwd(directory).quiet() - await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n") - await $`git add tracked.txt`.cwd(directory).quiet() - await $`git commit -m root`.cwd(directory).quiet() -} - -describe("MoveSession", () => { - it.live("moves session changes to another project directory", () => - 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 destination = abs(`${root.path}-move-destination`) - yield* Effect.addFinalizer(() => - Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore), - ) - yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet()) - const moved = abs(yield* Effect.promise(() => fs.realpath(destination))) - yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) - yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) - - const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id - const sessionID = Session.ID.make("ses_move") - const { db } = yield* Database.Service - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "move", - directory: source, - title: "move", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - .pipe(Effect.orDie) - - executionCalls.length = 0 - yield* MoveSession.Service.use((service) => - service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }), - ) - - // The move stops active execution before any relocation side effect. - expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`]) - expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n") - expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false) - expect( - yield* db - .select({ directory: SessionTable.directory, path: SessionTable.path }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get(), - ).toEqual({ directory: moved, path: "" }) - }), - ) - - it.live("moves within a checkout without transferring existing 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 destination = abs(path.join(source, "packages")) - yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n")) - yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n")) - - const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id - const sessionID = Session.ID.make("ses_move_nested") - const { db } = yield* Database.Service - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "move-nested", - directory: source, - title: "move nested", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - .pipe(Effect.orDie) - - const missing = yield* Session.Service.use((service) => - service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip), - ) - expect(missing._tag).toBe("Session.DestinationNotFoundError") - yield* Effect.promise(() => fs.mkdir(destination)) - - yield* MoveSession.Service.use((service) => - service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }), - ) - - expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n") - expect( - yield* db - .select({ directory: SessionTable.directory, path: SessionTable.path }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get(), - ).toEqual({ directory: destination, path: "packages" }) - }), - ) - - it.live("moves a session to another project", () => - 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 destination = abs(`${root.path}-other-project`) - yield* Effect.acquireRelease( - Effect.promise(() => fs.mkdir(destination, { recursive: true })), - () => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })), - ) - - const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id - const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id - const sessionID = Session.ID.make("ses_move_project") - const { db } = yield* Database.Service - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "move-project", - directory: source, - title: "move project", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - .pipe(Effect.orDie) - - yield* Session.Service.use((service) => - service.move({ sessionID, directory: destination }), - ) - - expect( - yield* db - .select({ projectID: SessionTable.project_id, directory: SessionTable.directory }) - .from(SessionTable) - .where(eq(SessionTable.id, sessionID)) - .get(), - ).toEqual({ projectID: destinationProjectID, directory: destination }) - }), - ) - - it.live("moves nested session changes without cleaning unrelated files", () => - 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 sourceDirectory = abs(path.join(source, "packages")) - yield* Effect.promise(() => fs.mkdir(sourceDirectory)) - yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n")) - yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n")) - yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet()) - yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet()) - const destination = abs(`${root.path}-move-nested-destination`) - yield* Effect.addFinalizer(() => - Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore), - ) - yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet()) - const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages")) - yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n")) - yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n")) - yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet()) - yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n")) - yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n")) - yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n")) - - const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id - const sessionID = Session.ID.make("ses_move_nested_checkout") - const { db } = yield* Database.Service - yield* db - .insert(SessionTable) - .values({ - id: sessionID, - project_id: projectID, - slug: "move-nested-checkout", - directory: sourceDirectory, - title: "move nested checkout", - version: "test", - time_created: 1, - time_updated: 1, - }) - .run() - .pipe(Effect.orDie) - - yield* MoveSession.Service.use((service) => - service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }), - ) - - expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe( - "initial\n", - ) - expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false) - expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe( - "staged\n", - ) - expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe( - "M packages/staged.txt\n", - ) - expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n") - expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n") - }), - ) -})