Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/client/src/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,9 @@ export type SessionsHistoryOutput = {
readonly timestamp: number
readonly sessionID: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly source?: { readonly directory: string; readonly workspaceID?: string }
readonly subdirectory?: string
readonly transferHash?: string
}
}
| {
Expand Down Expand Up @@ -1178,7 +1180,9 @@ export type SessionsEventsOutput =
readonly timestamp: number
readonly sessionID: string
readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly source?: { readonly directory: string; readonly workspaceID?: string }
readonly subdirectory?: string
readonly transferHash?: string
}
}
| {
Expand Down
141 changes: 125 additions & 16 deletions packages/core/src/control-plane/move-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,27 @@ import { Location } from "../location"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import { SessionEvent } from "../session/event"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath, RelativePath } from "../schema"
import { Database } from "../database/database"
import { WorkspaceTable } from "./workspace.sql"
import { AbsolutePath, RelativePath, optional } from "../schema"
import { WorkspaceV2 } from "../workspace"
import { eq } from "drizzle-orm"
import path from "path"

export const Destination = Schema.Struct({
directory: AbsolutePath,
workspaceID: optional(WorkspaceV2.ID),
}).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),
transferHash: Schema.optional(Schema.String),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type

Expand All @@ -33,6 +40,14 @@ export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<Des
},
) {}

export class DestinationDirectoryMismatchError extends Schema.TaggedErrorClass<DestinationDirectoryMismatchError>()(
"MoveSession.DestinationDirectoryMismatchError",
{
expected: AbsolutePath,
actual: AbsolutePath,
},
) {}

export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
Expand All @@ -53,15 +68,34 @@ export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSource
},
) {}

export class DestinationWorkspaceNotFoundError extends Schema.TaggedErrorClass<DestinationWorkspaceNotFoundError>()(
"MoveSession.DestinationWorkspaceNotFoundError",
{
workspaceID: WorkspaceV2.ID,
},
) {}

export class WorkspaceChangeTransferUnsupportedError extends Schema.TaggedErrorClass<WorkspaceChangeTransferUnsupportedError>()(
"MoveSession.WorkspaceChangeTransferUnsupportedError",
{
source: Schema.NullOr(WorkspaceV2.ID),
destination: Schema.NullOr(WorkspaceV2.ID),
},
) {}

export type Error =
| SessionV2.NotFoundError
| DestinationProjectMismatchError
| DestinationDirectoryMismatchError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
| DestinationWorkspaceNotFoundError
| WorkspaceChangeTransferUnsupportedError

export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
readonly atDestination: (input: Input) => Effect.Effect<boolean>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
Expand All @@ -73,20 +107,57 @@ const layer = Layer.effect(
const events = yield* EventV2.Service
const project = yield* ProjectV2.Service
const sessions = yield* SessionStore.Service
const { db } = yield* Database.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)
if (current.location.directory === directory) return
if (current.location.directory === directory && current.location.workspaceID === input.destination.workspaceID)
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 destination = input.destination.workspaceID
? yield* db
.select({
id: WorkspaceTable.id,
projectID: WorkspaceTable.project_id,
directory: WorkspaceTable.directory,
})
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, input.destination.workspaceID))
.get()
.pipe(Effect.orDie)
: yield* project
.resolve(directory)
.pipe(Effect.map((resolved) => ({ id: undefined, projectID: resolved.id, directory: resolved.directory })))
if (!destination && input.destination.workspaceID)
return yield* new DestinationWorkspaceNotFoundError({ workspaceID: input.destination.workspaceID })
if (!destination) return yield* new ApplyChangesError({ message: "Destination workspace not found" })
if (current.projectID !== destination.projectID)
return yield* new DestinationProjectMismatchError({
expected: current.projectID,
actual: destination.projectID,
})
if (
input.destination.workspaceID &&
destination.directory &&
directory !== AbsolutePath.make(destination.directory)
)
return yield* new DestinationDirectoryMismatchError({
expected: AbsolutePath.make(destination.directory),
actual: directory,
})

const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceWorkspaceID = current.location.workspaceID
const destinationWorkspaceID = input.destination.workspaceID
if (input.moveChanges && (sourceWorkspaceID || destinationWorkspaceID))
return yield* new WorkspaceChangeTransferUnsupportedError({
source: sourceWorkspaceID ?? null,
destination: destinationWorkspaceID ?? null,
})

const source = input.moveChanges ? yield* project.resolve(current.location.directory) : undefined
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" })
Expand All @@ -103,12 +174,42 @@ const layer = Layer.effect(
.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,
})
const destinationLocation = Location.Ref.make({ directory, workspaceID: input.destination.workspaceID })
yield* events.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
source: current.location,
location: destinationLocation,
subdirectory: RelativePath.make(
path.relative(destination.directory ?? directory, directory).replaceAll("\\", "/"),
),
transferHash: input.transferHash,
timestamp: yield* DateTime.now,
},
// Route the event to the destination instance so directory-scoped
// consumers (e.g. workspace plugins reacting to a session leaving)
// receive it; core publishes with no ambient Location.Service.
{ location: destinationLocation },
)

// Earlier turns reference absolute paths under the old directory, and
// models keep reusing them long after the working directory changed —
// on a remote destination those paths don't even exist. A synthetic
// message travels with the session's events, so every server that
// replays the move surfaces the same notice to the model.
if (current.location.directory !== directory) {
yield* events.publish(SessionEvent.Synthetic, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
text: [
`This session moved to a different workspace. Its working directory changed from ${current.location.directory} to ${directory}.`,
`Files referenced earlier under ${current.location.directory} now live under ${directory}; the old paths no longer exist here.`,
`Use only paths under ${directory} from now on.`,
].join(" "),
timestamp: yield* DateTime.now,
})
}

if (patch) {
const repository = yield* git.repo.discover(current.location.directory)
Expand Down Expand Up @@ -137,12 +238,20 @@ const layer = Layer.effect(
}
})

return Service.of({ moveSession })
const atDestination = Effect.fn("MoveSession.atDestination")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
return (
current?.location.directory === input.destination.directory &&
current.location.workspaceID === input.destination.workspaceID
)
})

return Service.of({ moveSession, atDestination })
}),
)

export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node, Database.node],
})
49 changes: 47 additions & 2 deletions packages/core/src/permission.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
export * as PermissionV2 from "./permission"

import { makeLocationNode } from "./effect/app-node"
import { and, eq, like } from "drizzle-orm"
import { Context, Deferred, Effect as EffectRuntime, Layer, Schema } from "effect"
import { Permission } from "@opencode-ai/schema/permission"
import { Database } from "./database/database"
import { EventV2 } from "./event"
import { EventTable } from "./event/sql"
import { FSUtil } from "./fs-util"
import { Location } from "./location"
import { AgentV2 } from "./agent"
import { SessionV2 } from "./session"
Expand Down Expand Up @@ -61,7 +65,11 @@ export class DeclinedError extends Schema.TaggedErrorClass<DeclinedError>()("Per

export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
feedback: Schema.String,
}) {}
}) {
override get message() {
return `This tool call was not permitted. Feedback: ${this.feedback}`
}
}

export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
rules: Permission.Ruleset,
Expand Down Expand Up @@ -110,12 +118,43 @@ const layer = Layer.effect(
Service,
EffectRuntime.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const agents = yield* AgentV2.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const pending = new Map<ID, Pending>()

// A session that moved between machines leaves absolute paths from its
// previous location baked into the model's context, and models keep
// reusing them despite reminders. Those paths cannot resolve here, and
// merely probing them can stall the process (macOS automounts /home, so
// sync stats on a remote sandbox's /home/... path block for seconds).
// Answer such asks with corrective feedback instead of prompting the
// user for a directory that cannot work.
const staleMovedRoot = EffectRuntime.fnUntraced(function* (input: AssertInput) {
if (input.action !== "external_directory") return undefined
const directory = input.resources[0]?.replace(/\/\*$/, "")
if (!directory) return undefined
const moved = yield* db
.select({ data: EventTable.data })
.from(EventTable)
.where(and(eq(EventTable.aggregate_id, input.sessionID), like(EventTable.type, "session.next.moved%")))
.all()
.pipe(EffectRuntime.catchCause(() => EffectRuntime.succeed([])))
for (const event of moved) {
const root = (event.data as { source?: { directory?: string } }).source?.directory
if (!root || root === location.directory) continue
if (directory !== root && !FSUtil.contains(root, directory)) continue
// A former root that still exists here (e.g. a worktree move on the
// same machine) is a legitimate target; let the normal flow decide.
if (yield* fs.existsSafe(root)) continue
return root
}
return undefined
})

yield* EffectRuntime.addFinalizer(() =>
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
discard: true,
Expand Down Expand Up @@ -197,6 +236,12 @@ const layer = Layer.effect(
const assert = EffectRuntime.fn("PermissionV2.assert")((input: AssertInput) =>
EffectRuntime.uninterruptibleMask((restore) =>
EffectRuntime.gen(function* () {
const stale = yield* staleMovedRoot(input)
if (stale) {
return yield* new CorrectedError({
feedback: `${input.resources[0]} is under ${stale}, a previous location of this session. The session has moved and that path does not exist here. The project now lives at ${location.directory} — re-resolve your paths against the current working directory.`,
})
}
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new BlockedError({
Expand Down Expand Up @@ -306,5 +351,5 @@ export const locationLayer = layer.pipe(Layer.provideMerge(AgentV2.locationLayer
export const node = makeLocationNode({
service: Service,
layer,
deps: [EventV2.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
deps: [Database.node, EventV2.node, FSUtil.node, Location.node, AgentV2.node, SessionStore.node, PermissionSaved.node],
})
Loading