feat(session): support directory moves from slash commands
This commit is contained in:
parent
e8964ce672
commit
c1d2d7aba3
23 changed files with 521 additions and 206 deletions
|
|
@ -2,16 +2,15 @@ export * as MoveSession from "./move-session"
|
|||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
import { Global } from "../global"
|
||||
import { ProjectV2 } from "../project"
|
||||
import { SessionV2 } from "../session"
|
||||
import { SessionEvent } from "../session/event"
|
||||
import { SessionExecution } from "../session/execution"
|
||||
import { SessionSchema } from "../session/schema"
|
||||
import { SessionStore } from "../session/store"
|
||||
import { AbsolutePath, RelativePath } from "../schema"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import path from "path"
|
||||
|
||||
export const Destination = Schema.Struct({
|
||||
|
|
@ -34,6 +33,16 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<Des
|
|||
},
|
||||
) {}
|
||||
|
||||
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,
|
||||
}) {}
|
||||
|
|
@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
|
|||
export type Error =
|
||||
| SessionV2.NotFoundError
|
||||
| DestinationProjectMismatchError
|
||||
| DestinationNotFoundError
|
||||
| DestinationNotDirectoryError
|
||||
| SessionV2.DestinationNotFoundError
|
||||
| SessionV2.DestinationNotDirectoryError
|
||||
| CaptureChangesError
|
||||
| ApplyChangesError
|
||||
| ResetSourceChangesError
|
||||
|
|
@ -71,23 +84,29 @@ const layer = Layer.effect(
|
|||
Service,
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
const events = yield* EventV2.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const project = yield* ProjectV2.Service
|
||||
const sessions = yield* SessionStore.Service
|
||||
const session = yield* SessionV2.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 SessionV2.NotFoundError({ sessionID: input.sessionID })
|
||||
const directory = AbsolutePath.make(input.destination.directory)
|
||||
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 (current.projectID !== destination.id) {
|
||||
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.
|
||||
|
|
@ -111,10 +130,9 @@ const layer = Layer.effect(
|
|||
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
|
||||
}
|
||||
|
||||
yield* events.publish(SessionEvent.Moved, {
|
||||
yield* session.move({
|
||||
sessionID: input.sessionID,
|
||||
location: Location.Ref.make({ directory }),
|
||||
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
|
||||
directory,
|
||||
})
|
||||
|
||||
if (patch) {
|
||||
|
|
@ -151,5 +169,13 @@ const layer = Layer.effect(
|
|||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
|
||||
deps: [
|
||||
FSUtil.node,
|
||||
Git.node,
|
||||
Global.node,
|
||||
ProjectV2.node,
|
||||
SessionV2.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { SkillV2 } from "./skill"
|
|||
import { Job } from "./job"
|
||||
import { CommandV2 } from "./command"
|
||||
import { Shell } from "./shell"
|
||||
import { Global } from "./global"
|
||||
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { fileURLToPath } from "url"
|
||||
|
|
@ -146,6 +147,16 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
|
|||
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
|
||||
skill: SkillV2.ID,
|
||||
}) {}
|
||||
|
||||
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
|
||||
"Session.DestinationNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
|
||||
"Session.DestinationNotDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
|
||||
|
||||
|
|
@ -159,6 +170,8 @@ export type Error =
|
|||
| CompactionConflictError
|
||||
| BusyError
|
||||
| SkillNotFoundError
|
||||
| DestinationNotFoundError
|
||||
| DestinationNotDirectoryError
|
||||
| CommandV2.NotFoundError
|
||||
| CommandV2.EvaluationError
|
||||
| MessageNotFoundError
|
||||
|
|
@ -215,6 +228,11 @@ export interface Interface {
|
|||
model: ModelV2.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
directory: AbsolutePath
|
||||
workspaceID?: Location.Ref["workspaceID"]
|
||||
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
|
||||
readonly prompt: (input: {
|
||||
id?: SessionMessage.ID
|
||||
sessionID: SessionSchema.ID
|
||||
|
|
@ -288,6 +306,7 @@ const layer = Layer.effect(
|
|||
const db = database.db
|
||||
const events = yield* EventV2.Service
|
||||
const projects = yield* ProjectV2.Service
|
||||
const global = yield* Global.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
|
@ -673,6 +692,38 @@ const layer = Layer.effect(
|
|||
title: input.title,
|
||||
})
|
||||
}),
|
||||
move: Effect.fn("V2Session.move")(function* (input) {
|
||||
const current = yield* result.get(input.sessionID)
|
||||
const value = input.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 info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!info) return yield* new DestinationNotFoundError({ directory })
|
||||
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
|
||||
if (
|
||||
current.location.directory === directory &&
|
||||
current.location.workspaceID === input.workspaceID
|
||||
)
|
||||
return
|
||||
const project = yield* projects.resolve(directory)
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if ((yield* execution.active).has(input.sessionID)) {
|
||||
yield* execution.interrupt(input.sessionID)
|
||||
yield* execution.awaitIdle(input.sessionID)
|
||||
}
|
||||
yield* events.publish(SessionEvent.Moved, {
|
||||
sessionID: input.sessionID,
|
||||
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
|
||||
projectID: project.id,
|
||||
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
|
||||
})
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (input) {
|
||||
yield* result.get(input.sessionID)
|
||||
const inputID = input.id ?? SessionMessage.ID.create()
|
||||
|
|
@ -949,5 +1000,6 @@ export const node = makeGlobalNode({
|
|||
LocationServiceMap.node,
|
||||
SessionProjector.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -497,6 +497,7 @@ const layer = Layer.effectDiscard(
|
|||
.set({
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
|
||||
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
|
||||
time_updated: DateTime.toEpochMillis(event.created),
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue