feat(session): support directory moves from slash commands · argszero/opencode@c1d2d7a · GitHub
Skip to content

Commit c1d2d7a

Browse files
committed
feat(session): support directory moves from slash commands
1 parent e8964ce commit c1d2d7a

23 files changed

Lines changed: 521 additions & 206 deletions

File tree

packages/client/src/effect/api/api.ts

Lines changed: 2 additions & 2 deletions

packages/client/src/effect/generated/client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,13 @@ const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Inp
159159
type Endpoint5_9Request = Parameters<RawClient["server.session"]["session.move"]>[0]
160160
type Endpoint5_9Input = {
161161
readonly sessionID: Endpoint5_9Request["params"]["sessionID"]
162-
readonly destination: Endpoint5_9Request["payload"]["destination"]
163-
readonly moveChanges?: Endpoint5_9Request["payload"]["moveChanges"]
162+
readonly directory: Endpoint5_9Request["payload"]["directory"]
163+
readonly workspaceID?: Endpoint5_9Request["payload"]["workspaceID"]
164164
}
165165
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
166166
raw["session.move"]({
167167
params: { sessionID: input["sessionID"] },
168-
payload: { destination: input["destination"], moveChanges: input["moveChanges"] },
168+
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
169169
}).pipe(Effect.mapError(mapClientError))
170170

171171
type Endpoint5_10Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]

packages/client/src/promise/generated/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ export function make(options: ClientOptions) {
522522
{
523523
method: "POST",
524524
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
525-
body: { destination: input["destination"], moveChanges: input["moveChanges"] },
525+
body: { directory: input["directory"], workspaceID: input["workspaceID"] },
526526
successStatus: 204,
527527
declaredStatuses: [404, 400, 401],
528528
empty: true,

packages/client/src/promise/generated/types.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -567,7 +567,7 @@ export type SessionMoved = {
567567
type: "session.moved"
568568
durable: { aggregateID: string; seq: number; version: 1 }
569569
location?: LocationRef
570-
data: { sessionID: string; location: LocationRef; subpath?: string }
570+
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
571571
}
572572

573573
export type SessionRenamed = {
@@ -2715,14 +2715,8 @@ export type SessionRenameOutput = void
27152715

27162716
export type SessionMoveInput = {
27172717
readonly sessionID: { readonly sessionID: string }["sessionID"]
2718-
readonly destination: {
2719-
readonly destination: { readonly directory: string }
2720-
readonly moveChanges?: boolean | undefined
2721-
}["destination"]
2722-
readonly moveChanges?: {
2723-
readonly destination: { readonly directory: string }
2724-
readonly moveChanges?: boolean | undefined
2725-
}["moveChanges"]
2718+
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
2719+
readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
27262720
}
27272721

27282722
export type SessionMoveOutput = void

packages/core/src/control-plane/move-session.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,15 @@ export * as MoveSession from "./move-session"
22

33
import { Context, DateTime, Effect, Layer, Schema } from "effect"
44
import { makeGlobalNode } from "../effect/app-node"
5-
import { EventV2 } from "../event"
5+
import { FSUtil } from "../fs-util"
66
import { Git } from "../git"
7-
import { Location } from "../location"
7+
import { Global } from "../global"
88
import { ProjectV2 } from "../project"
99
import { SessionV2 } from "../session"
10-
import { SessionEvent } from "../session/event"
1110
import { SessionExecution } from "../session/execution"
1211
import { SessionSchema } from "../session/schema"
1312
import { SessionStore } from "../session/store"
14-
import { AbsolutePath, RelativePath } from "../schema"
13+
import { AbsolutePath } from "../schema"
1514
import path from "path"
1615

1716
export const Destination = Schema.Struct({
@@ -34,6 +33,16 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<Des
3433
},
3534
) {}
3635

36+
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
37+
"MoveSession.DestinationNotFoundError",
38+
{ directory: AbsolutePath },
39+
) {}
40+
41+
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
42+
"MoveSession.DestinationNotDirectoryError",
43+
{ directory: AbsolutePath },
44+
) {}
45+
3746
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
3847
message: Schema.String,
3948
}) {}
@@ -57,6 +66,10 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
5766
export type Error =
5867
| SessionV2.NotFoundError
5968
| DestinationProjectMismatchError
69+
| DestinationNotFoundError
70+
| DestinationNotDirectoryError
71+
| SessionV2.DestinationNotFoundError
72+
| SessionV2.DestinationNotDirectoryError
6073
| CaptureChangesError
6174
| ApplyChangesError
6275
| ResetSourceChangesError
@@ -71,23 +84,29 @@ const layer = Layer.effect(
7184
Service,
7285
Effect.gen(function* () {
7386
const git = yield* Git.Service
74-
const events = yield* EventV2.Service
87+
const fs = yield* FSUtil.Service
88+
const global = yield* Global.Service
7589
const project = yield* ProjectV2.Service
7690
const sessions = yield* SessionStore.Service
91+
const session = yield* SessionV2.Service
7792
const execution = yield* SessionExecution.Service
7893

7994
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
8095
const current = yield* sessions.get(input.sessionID)
8196
if (!current) return yield* new SessionV2.NotFoundError({ sessionID: input.sessionID })
82-
const directory = AbsolutePath.make(input.destination.directory)
97+
const value = input.destination.directory.trim()
98+
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
99+
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
100+
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
101+
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
102+
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
83103
if (current.location.directory === directory) return
84104

85105
const source = yield* project.resolve(current.location.directory)
86106
const destination = yield* project.resolve(directory)
87-
if (current.projectID !== destination.id) {
107+
if (input.moveChanges && current.projectID !== destination.id) {
88108
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
89109
}
90-
91110
// A move must not race active execution: a mid-drain relocation would let
92111
// the source Location dispatch a request assembled under stale instructions
93112
// and history. Serialize like removal does — stop the drain, then move.
@@ -111,10 +130,9 @@ const layer = Layer.effect(
111130
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
112131
}
113132

114-
yield* events.publish(SessionEvent.Moved, {
133+
yield* session.move({
115134
sessionID: input.sessionID,
116-
location: Location.Ref.make({ directory }),
117-
subpath: RelativePath.make(path.relative(destination.directory, directory).replaceAll("\\", "/")),
135+
directory,
118136
})
119137

120138
if (patch) {
@@ -151,5 +169,13 @@ const layer = Layer.effect(
151169
export const node = makeGlobalNode({
152170
service: Service,
153171
layer,
154-
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, SessionExecution.node],
172+
deps: [
173+
FSUtil.node,
174+
Git.node,
175+
Global.node,
176+
ProjectV2.node,
177+
SessionV2.node,
178+
SessionStore.node,
179+
SessionExecution.node,
180+
],
155181
})

packages/core/src/session.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { SkillV2 } from "./skill"
4343
import { Job } from "./job"
4444
import { CommandV2 } from "./command"
4545
import { Shell } from "./shell"
46+
import { Global } from "./global"
4647
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
4748
import { KeyedMutex } from "./effect/keyed-mutex"
4849
import { fileURLToPath } from "url"
@@ -146,6 +147,16 @@ export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.Bus
146147
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
147148
skill: SkillV2.ID,
148149
}) {}
150+
151+
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
152+
"Session.DestinationNotFoundError",
153+
{ directory: AbsolutePath },
154+
) {}
155+
156+
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
157+
"Session.DestinationNotDirectoryError",
158+
{ directory: AbsolutePath },
159+
) {}
149160
export const MessageNotFoundError = SessionRevert.MessageNotFoundError
150161
export type MessageNotFoundError = SessionRevert.MessageNotFoundError
151162

@@ -159,6 +170,8 @@ export type Error =
159170
| CompactionConflictError
160171
| BusyError
161172
| SkillNotFoundError
173+
| DestinationNotFoundError
174+
| DestinationNotDirectoryError
162175
| CommandV2.NotFoundError
163176
| CommandV2.EvaluationError
164177
| MessageNotFoundError
@@ -215,6 +228,11 @@ export interface Interface {
215228
model: ModelV2.Ref
216229
}) => Effect.Effect<void, NotFoundError>
217230
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
231+
readonly move: (input: {
232+
sessionID: SessionSchema.ID
233+
directory: AbsolutePath
234+
workspaceID?: Location.Ref["workspaceID"]
235+
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
218236
readonly prompt: (input: {
219237
id?: SessionMessage.ID
220238
sessionID: SessionSchema.ID
@@ -288,6 +306,7 @@ const layer = Layer.effect(
288306
const db = database.db
289307
const events = yield* EventV2.Service
290308
const projects = yield* ProjectV2.Service
309+
const global = yield* Global.Service
291310
const execution = yield* SessionExecution.Service
292311
const store = yield* SessionStore.Service
293312
const locations = yield* LocationServiceMap.Service
@@ -673,6 +692,38 @@ const layer = Layer.effect(
673692
title: input.title,
674693
})
675694
}),
695+
move: Effect.fn("V2Session.move")(function* (input) {
696+
const current = yield* result.get(input.sessionID)
697+
const value = input.directory.trim()
698+
const expanded =
699+
value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
700+
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
701+
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
702+
if (!info) return yield* new DestinationNotFoundError({ directory })
703+
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
704+
if (
705+
current.location.directory === directory &&
706+
current.location.workspaceID === input.workspaceID
707+
)
708+
return
709+
const project = yield* projects.resolve(directory)
710+
yield* db
711+
.insert(ProjectTable)
712+
.values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
713+
.onConflictDoNothing()
714+
.run()
715+
.pipe(Effect.orDie)
716+
if ((yield* execution.active).has(input.sessionID)) {
717+
yield* execution.interrupt(input.sessionID)
718+
yield* execution.awaitIdle(input.sessionID)
719+
}
720+
yield* events.publish(SessionEvent.Moved, {
721+
sessionID: input.sessionID,
722+
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
723+
projectID: project.id,
724+
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
725+
})
726+
}),
676727
compact: Effect.fn("V2Session.compact")(function* (input) {
677728
yield* result.get(input.sessionID)
678729
const inputID = input.id ?? SessionMessage.ID.create()
@@ -949,5 +1000,6 @@ export const node = makeGlobalNode({
9491000
LocationServiceMap.node,
9501001
SessionProjector.node,
9511002
FSUtil.node,
1003+
Global.node,
9521004
],
9531005
})

packages/core/src/session/projector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ const layer = Layer.effectDiscard(
497497
.set({
498498
directory: event.data.location.directory,
499499
path: event.data.subpath,
500+
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
500501
workspace_id: event.data.location.workspaceID ? WorkspaceV2.ID.make(event.data.location.workspaceID) : null,
501502
time_updated: DateTime.toEpochMillis(event.created),
502503
})

packages/core/test/move-session.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const it = testEffect(
4343
EventV2.node,
4444
ProjectDirectories.node,
4545
Project.node,
46+
SessionV2.node,
4647
SessionProjector.node,
4748
SessionStore.node,
4849
]),
@@ -137,7 +138,6 @@ describe("MoveSession", () => {
137138
yield* Effect.promise(() => initRepo(root.path))
138139
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
139140
const destination = abs(path.join(source, "packages"))
140-
yield* Effect.promise(() => fs.mkdir(destination))
141141
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
142142
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
143143

@@ -164,8 +164,14 @@ describe("MoveSession", () => {
164164
.run()
165165
.pipe(Effect.orDie)
166166

167+
const missing = yield* SessionV2.Service.use((service) =>
168+
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
169+
)
170+
expect(missing._tag).toBe("Session.DestinationNotFoundError")
171+
yield* Effect.promise(() => fs.mkdir(destination))
172+
167173
yield* MoveSession.Service.use((service) =>
168-
service.moveSession({ sessionID, destination: { directory: destination }, moveChanges: true }),
174+
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
169175
)
170176

171177
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
@@ -180,6 +186,58 @@ describe("MoveSession", () => {
180186
}),
181187
)
182188

189+
it.live("moves a session to another project", () =>
190+
Effect.gen(function* () {
191+
const root = yield* Effect.acquireRelease(
192+
Effect.promise(() => tmpdir()),
193+
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
194+
)
195+
yield* Effect.promise(() => initRepo(root.path))
196+
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
197+
const destination = abs(`${root.path}-other-project`)
198+
yield* Effect.acquireRelease(
199+
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
200+
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
201+
)
202+
203+
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
204+
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
205+
const sessionID = SessionV2.ID.make("ses_move_project")
206+
const { db } = yield* Database.Service
207+
yield* db
208+
.insert(ProjectTable)
209+
.values({ id: projectID, worktree: source, sandboxes: [], time_created: 1, time_updated: 1 })
210+
.run()
211+
.pipe(Effect.orDie)
212+
yield* db
213+
.insert(SessionTable)
214+
.values({
215+
id: sessionID,
216+
project_id: projectID,
217+
slug: "move-project",
218+
directory: source,
219+
title: "move project",
220+
version: "test",
221+
time_created: 1,
222+
time_updated: 1,
223+
})
224+
.run()
225+
.pipe(Effect.orDie)
226+
227+
yield* SessionV2.Service.use((service) =>
228+
service.move({ sessionID, directory: destination }),
229+
)
230+
231+
expect(
232+
yield* db
233+
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
234+
.from(SessionTable)
235+
.where(eq(SessionTable.id, sessionID))
236+
.get(),
237+
).toEqual({ projectID: destinationProjectID, directory: destination })
238+
}),
239+
)
240+
183241
it.live("moves nested session changes without cleaning unrelated files", () =>
184242
Effect.gen(function* () {
185243
const root = yield* Effect.acquireRelease(

packages/plugin/src/v2/tui/context.ts

Lines changed: 4 additions & 2 deletions

0 commit comments

Comments
 (0)