feat(core): moving sessions (#30640)
This commit is contained in:
parent
e45e0e1125
commit
ba1b660d57
36 changed files with 2180 additions and 476 deletions
128
packages/core/src/control-plane/move-session.ts
Normal file
128
packages/core/src/control-plane/move-session.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
export * as MoveSession from "./move-session"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { EventV2 } from "../event"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { AbsolutePath, RelativePath } 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: ProjectV2.ID,
|
||||
actual: ProjectV2.ID,
|
||||
},
|
||||
) {}
|
||||
|
||||
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 =
|
||||
| SessionV2.NotFoundError
|
||||
| DestinationProjectMismatchError
|
||||
| CaptureChangesError
|
||||
| ApplyChangesError
|
||||
| ResetSourceChangesError
|
||||
|
||||
export interface Interface {
|
||||
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2.Service
|
||||
const project = yield* ProjectV2.Service
|
||||
const session = yield* SessionV2.Service
|
||||
|
||||
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
|
||||
const current = yield* session.get(input.sessionID)
|
||||
const directory = AbsolutePath.make(input.destination.directory)
|
||||
if (current.location.directory === directory) 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 patch =
|
||||
input.moveChanges && source.directory !== destination.directory
|
||||
? yield* git
|
||||
.patch(current.location.directory)
|
||||
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
|
||||
: ""
|
||||
if (patch) {
|
||||
yield* git
|
||||
.applyPatch({ directory, patch })
|
||||
.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,
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
yield* git.softResetChanges(current.location.directory).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ResetSourceChangesError({
|
||||
directory: current.location.directory,
|
||||
message: error.message,
|
||||
cause: error.cause,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of({ moveSession })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionV2.defaultLayer),
|
||||
)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
export * as Git from "./git"
|
||||
|
||||
import path from "path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
|
|
@ -33,6 +33,13 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
|
|||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export class PatchError extends Schema.TaggedErrorClass<PatchError>()("Git.PatchError", {
|
||||
operation: Schema.Literals(["capture", "apply", "reset"]),
|
||||
directory: AbsolutePath,
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly find: (input: AbsolutePath) => Effect.Effect<Repo | undefined>
|
||||
readonly remote: (repo: Repo, name?: string) => Effect.Effect<string | undefined>
|
||||
|
|
@ -52,6 +59,10 @@ export interface Interface {
|
|||
readonly fetchBranch: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly checkout: (directory: string, branch: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly reset: (directory: string, target: string) => Effect.Effect<Result, AppProcess.AppProcessError>
|
||||
readonly patch: (directory: AbsolutePath) => Effect.Effect<string, PatchError>
|
||||
readonly applyPatch: (input: { directory: AbsolutePath; patch: string }) => Effect.Effect<void, PatchError>
|
||||
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
|
|
@ -159,6 +170,156 @@ export const layer = Layer.effect(
|
|||
execute(directory, proc)(["reset", "--hard", target]),
|
||||
)
|
||||
|
||||
const patch = Effect.fn("Git.patch")(function* (directory: AbsolutePath) {
|
||||
const root = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["rev-parse", "--show-toplevel"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (root.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
message: root.stderr.trim() || root.text.trim() || "Failed to locate repository root",
|
||||
})
|
||||
}
|
||||
const repo = AbsolutePath.make(resolvePath(directory, root.text))
|
||||
const scope = path.relative(repo, directory).replaceAll("\\", "/") || "."
|
||||
const tracked = yield* execute(
|
||||
repo,
|
||||
proc,
|
||||
)(["diff", "--binary", "HEAD", "--", scope]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (tracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
message: tracked.stderr.trim() || tracked.text.trim() || "Failed to capture tracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = yield* execute(
|
||||
repo,
|
||||
proc,
|
||||
)(["ls-files", "--others", "--exclude-standard", "-z", "--", scope]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (untracked.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
message: untracked.stderr.trim() || untracked.text.trim() || "Failed to list untracked changes",
|
||||
})
|
||||
}
|
||||
|
||||
const created = yield* Effect.forEach(untracked.text.split("\0").filter(Boolean), (file) =>
|
||||
execute(
|
||||
repo,
|
||||
proc,
|
||||
)(["diff", "--binary", "--no-index", "--", "/dev/null", file]).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new PatchError({ operation: "capture", directory, message: cause.message, cause }),
|
||||
),
|
||||
Effect.flatMap((result) =>
|
||||
// git diff --no-index returns 1 when differences were found.
|
||||
result.exitCode === 0 || result.exitCode === 1
|
||||
? Effect.succeed(result.text)
|
||||
: Effect.fail(
|
||||
new PatchError({
|
||||
operation: "capture",
|
||||
directory,
|
||||
message:
|
||||
result.stderr.trim() || result.text.trim() || `Failed to capture untracked change: ${file}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [tracked.text, ...created].filter(Boolean).join("\n")
|
||||
})
|
||||
|
||||
const applyPatch = Effect.fn("Git.applyPatch")(function* (input: { directory: AbsolutePath; patch: string }) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
ChildProcess.make("git", ["apply", "-"], {
|
||||
cwd: input.directory,
|
||||
extendEnv: true,
|
||||
stdin: Stream.make(new TextEncoder().encode(input.patch)),
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new PatchError({ operation: "apply", directory: input.directory, message: cause.message, cause }),
|
||||
),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "apply",
|
||||
directory: input.directory,
|
||||
message:
|
||||
result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Failed to apply changes",
|
||||
})
|
||||
})
|
||||
|
||||
const resetChanges = Effect.fn("Git.resetChanges")(function* (directory: AbsolutePath) {
|
||||
const reset = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["reset", "--hard", "HEAD"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (reset.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: reset.stderr.trim() || reset.text.trim() || "Failed to reset tracked changes",
|
||||
})
|
||||
}
|
||||
const clean = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["clean", "-fd"]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const softResetChanges = Effect.fn("Git.softResetChanges")(function* (directory: AbsolutePath) {
|
||||
const checkout = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["checkout", "--", "."]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (checkout.exitCode !== 0) {
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: checkout.stderr.trim() || checkout.text.trim() || "Failed to restore tracked changes",
|
||||
})
|
||||
}
|
||||
const clean = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(["clean", "-fd", "--", "."]).pipe(
|
||||
Effect.mapError((cause) => new PatchError({ operation: "reset", directory, message: cause.message, cause })),
|
||||
)
|
||||
if (clean.exitCode === 0) return
|
||||
return yield* new PatchError({
|
||||
operation: "reset",
|
||||
directory,
|
||||
message: clean.stderr.trim() || clean.text.trim() || "Failed to clean untracked changes",
|
||||
})
|
||||
})
|
||||
|
||||
const worktree = Effect.fnUntraced(function* (
|
||||
operation: "create" | "remove" | "list",
|
||||
repo: Repo,
|
||||
|
|
@ -216,6 +377,10 @@ export const layer = Layer.effect(
|
|||
fetchBranch,
|
||||
checkout,
|
||||
reset,
|
||||
patch,
|
||||
applyPatch,
|
||||
resetChanges,
|
||||
softResetChanges,
|
||||
worktreeCreate,
|
||||
worktreeRemove,
|
||||
worktreeList,
|
||||
|
|
|
|||
|
|
@ -25,11 +25,17 @@ export function makeStrategies(input: {
|
|||
yield* input.git.worktreeRemove({ repo: found, directory })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const entries = yield* input.git.worktreeList(repo(directory))
|
||||
const found = yield* input.git.find(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
|
||||
const entries = yield* input.git.worktreeList(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
entry === directory
|
||||
entry === core
|
||||
? Effect.succeed(undefined)
|
||||
: input.canonical(entry).pipe(Effect.map((directory) => ({ directory }))),
|
||||
: input.canonical(entry).pipe(
|
||||
Effect.map((directory) => ({ directory })),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Copy => item !== undefined)))
|
||||
}),
|
||||
detect: Effect.fn("ProjectCopy.GitWorktree.detect")(function* (inputDirectory) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export * as ProjectCopy from "./copy"
|
|||
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
|
|
@ -10,6 +11,7 @@ import { EventV2 } from "../event"
|
|||
import { Project } from "../project"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
import { makeStrategies } from "./copy-strategies"
|
||||
import { Slug } from "../util/slug"
|
||||
|
||||
export const StrategyID = Schema.Literal("git_worktree")
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
|
@ -24,6 +26,8 @@ export const CreateInput = Schema.Struct({
|
|||
strategy: StrategyID,
|
||||
sourceDirectory: AbsolutePath,
|
||||
directory: AbsolutePath,
|
||||
name: Schema.optional(Schema.String),
|
||||
context: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
|
|
@ -183,10 +187,18 @@ export const layer = Layer.effect(
|
|||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
if (yield* fs.existsSafe(input.directory))
|
||||
return yield* new DestinationExistsError({ directory: input.directory })
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(copyDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
|
||||
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* strategy(input.strategy).create({
|
||||
directory: input.directory,
|
||||
directory: copyDirectory,
|
||||
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
|
||||
})
|
||||
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
|
||||
|
|
|
|||
|
|
@ -77,11 +77,6 @@ type CreateInput = {
|
|||
location: Location.Ref
|
||||
}
|
||||
|
||||
type MoveInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
location: Location.Ref
|
||||
}
|
||||
|
||||
type CompactInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
prompt?: Prompt
|
||||
|
|
@ -110,7 +105,6 @@ export type Error = NotFoundError | MessageDecodeError | OperationUnavailableErr
|
|||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
|
||||
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -417,9 +411,6 @@ export const layer = Layer.effect(
|
|||
yield* result.get(sessionID)
|
||||
yield* execution.resume(sessionID)
|
||||
}),
|
||||
move: Effect.fn("V2Session.move")(function* () {
|
||||
return yield* new OperationUnavailableError({ operation: "move" })
|
||||
}),
|
||||
})
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import { ToolOutput } from "../tool-output"
|
|||
import { V2Schema } from "../v2-schema"
|
||||
import { FileAttachment, Prompt } from "./prompt"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { Location } from "../location"
|
||||
import { RelativePath } from "../schema"
|
||||
|
||||
export { FileAttachment }
|
||||
|
||||
|
|
@ -65,6 +67,17 @@ export const ModelSwitched = EventV2.define({
|
|||
})
|
||||
export type ModelSwitched = typeof ModelSwitched.Type
|
||||
|
||||
export const Moved = EventV2.define({
|
||||
type: "session.next.moved",
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
location: Location.Ref,
|
||||
subdirectory: RelativePath.pipe(Schema.optional),
|
||||
},
|
||||
})
|
||||
export type Moved = typeof Moved.Type
|
||||
|
||||
export const Prompted = EventV2.define({
|
||||
type: "session.next.prompted",
|
||||
...options,
|
||||
|
|
@ -387,6 +400,7 @@ export namespace Compaction {
|
|||
const DurableDefinitions = [
|
||||
AgentSwitched,
|
||||
ModelSwitched,
|
||||
Moved,
|
||||
Prompted,
|
||||
Synthetic,
|
||||
Shell.Started,
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
|
|||
}),
|
||||
)
|
||||
},
|
||||
"session.next.moved": () => Effect.void,
|
||||
"session.next.prompted": (event) => {
|
||||
return adapter.appendMessage(
|
||||
new SessionMessage.User({
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { WorkspaceTable } from "../control-plane/workspace.sql"
|
|||
import { SessionMessage } from "./message"
|
||||
import { SessionMessageUpdater } from "./message-updater"
|
||||
import { SessionInput } from "./input"
|
||||
import { WorkspaceV2 } from "../workspace"
|
||||
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
|
||||
import type { DeepMutable } from "../schema"
|
||||
|
||||
|
|
@ -245,6 +246,19 @@ export const layer = Layer.effectDiscard(
|
|||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionEvent.Moved, (event) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subdirectory,
|
||||
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
||||
time_updated: DateTime.toEpochMillis(event.data.timestamp),
|
||||
})
|
||||
.where(eq(SessionTable.id, event.data.sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
yield* events.project(SessionV1.Event.Deleted, (event) =>
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue