Kaynağa Gözat

refactor(core): remove orphaned move service (#39974)

Co-authored-by: Kit Langton <kit.langton@gmail.com>
opencode-agent[bot] 2 hafta önce
ebeveyn
işleme
fe0eb4ea8f

+ 0 - 181
packages/core/src/control-plane/move-session.ts

@@ -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<DestinationProjectMismatchError>()(
-  "MoveSession.DestinationProjectMismatchError",
-  {
-    expected: Project.ID,
-    actual: Project.ID,
-  },
-) {}
-
-export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
-  "MoveSession.DestinationNotFoundError",
-  { directory: AbsolutePath },
-) {}
-
-export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
-  "MoveSession.DestinationNotDirectoryError",
-  { directory: AbsolutePath },
-) {}
-
-export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
-  message: Schema.String,
-}) {}
-
-export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
-  "MoveSession.CaptureChangesError",
-  {
-    message: Schema.String,
-  },
-) {}
-
-export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
-  "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<void, Error>
-}
-
-export class Service extends Context.Service<Service, Interface>()("@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,
-  ],
-})

+ 0 - 291
packages/core/test/move-session.test.ts

@@ -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")
-    }),
-  )
-})