feat(core): moving sessions (#30640)

This commit is contained in:
James Long
2026-06-04 15:07:46 -04:00
committed by GitHub
parent e45e0e1125
commit ba1b660d57
36 changed files with 2189 additions and 485 deletions
@@ -5,6 +5,7 @@ import { InstanceDisposed } from "@/server/event"
import { Question } from "@/question"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { ControlPlaneApi } from "./groups/control-plane"
import { EventApi } from "./groups/event"
import { ExperimentalApi } from "./groups/experimental"
import { FileApi } from "./groups/file"
@@ -42,6 +43,7 @@ const EventSchema = Schema.Union([
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
.addHttpApi(ControlPlaneApi)
.addHttpApi(GlobalApi)
.middleware(SchemaErrorMiddleware)
.middleware(Authorization)
@@ -0,0 +1,35 @@
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const root = "/experimental/control-plane"
export const MoveSessionPayload = Schema.Struct({ ...MoveSession.Input.fields })
export class ApiMoveSessionError extends Schema.ErrorClass<ApiMoveSessionError>("MoveSessionError")(
{
name: Schema.Literal("MoveSessionError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const ControlPlaneApi = HttpApi.make("controlPlane").add(
HttpApiGroup.make("controlPlane")
.add(
HttpApiEndpoint.post("moveSession", `${root}/move-session`, {
payload: MoveSessionPayload,
success: described(HttpApiSchema.NoContent, "Session moved"),
error: ApiMoveSessionError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.controlPlane.moveSession",
summary: "Move session",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "controlPlane", description: "Control-plane orchestration routes." })),
)
@@ -1,31 +1,50 @@
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "../middleware/workspace-routing"
import { described } from "./metadata"
const root = "/experimental/project/:projectID/copy"
const CreateQuery = Schema.Struct({
workspace: WorkspaceRoutingQueryFields.workspace,
})
export const CreatePayload = Schema.Struct({
strategy: ProjectCopy.StrategyID,
directory: ProjectCopy.CreateInput.fields.directory,
name: ProjectCopy.CreateInput.fields.name,
context: ProjectCopy.CreateInput.fields.context,
})
export const RemovePayload = Schema.Struct({
directory: ProjectCopy.RemoveInput.fields.directory,
})
export class ApiProjectCopyError extends Schema.ErrorClass<ApiProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
}),
},
{ httpApiStatus: 400 },
) {}
export const ProjectCopyApi = HttpApi.make("projectCopy").add(
HttpApiGroup.make("projectCopy")
.add(
HttpApiEndpoint.post("create", root, {
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
query: CreateQuery,
payload: CreatePayload,
success: described(ProjectCopy.Copy, "Project copy created"),
error: HttpApiError.BadRequest,
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.create",
@@ -38,7 +57,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
query: WorkspaceRoutingQuery,
payload: RemovePayload,
success: described(HttpApiSchema.NoContent, "Project copy removed"),
error: HttpApiError.BadRequest,
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.remove",
@@ -51,7 +70,7 @@ export const ProjectCopyApi = HttpApi.make("projectCopy").add(
query: WorkspaceRoutingQuery,
payload: HttpApiSchema.NoContent,
success: described(HttpApiSchema.NoContent, "Project copies refreshed"),
error: HttpApiError.BadRequest,
error: ApiProjectCopyError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.projectCopy.refresh",
@@ -0,0 +1,35 @@
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { ApiMoveSessionError, MoveSessionPayload } from "../groups/control-plane"
export const controlPlaneHandlers = HttpApiBuilder.group(RootHttpApi, "controlPlane", (handlers) =>
Effect.gen(function* () {
const service = yield* MoveSession.Service
const moveSession = Effect.fn("ControlPlaneHttpApi.moveSession")(function* (ctx: {
payload: typeof MoveSessionPayload.Type
}) {
yield* service.moveSession(ctx.payload).pipe(
Effect.mapError(
(error) =>
new ApiMoveSessionError({
name: "MoveSessionError",
data: { message: message(error) },
}),
),
)
})
return handlers.handle("moveSession", moveSession)
}),
)
function message(error: MoveSession.Error) {
if (error instanceof SessionV2.NotFoundError) return `Session not found: ${error.sessionID}`
if (error instanceof MoveSession.DestinationProjectMismatchError)
return "Destination directory belongs to another project"
return error.message
}
@@ -2,26 +2,104 @@ import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { InstanceState } from "@/effect/instance-state"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { Effect, Stream } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { CreatePayload, RemovePayload } from "../groups/project-copy"
import { ApiProjectCopyError, CreatePayload, RemovePayload } from "../groups/project-copy"
import { Agent } from "@/agent/agent"
import { LLM } from "@/session/llm"
import { LLMEvent } from "@opencode-ai/llm"
import { MessageID, SessionID } from "@/session/schema"
import { Provider } from "@/provider/provider"
import { Slug } from "@opencode-ai/core/util/slug"
const FALLBACK_AGENT: Agent.Info = {
name: "title",
mode: "primary" as const,
permission: [],
options: {},
native: true,
prompt: "",
}
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
return effect.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
return effect.pipe(
Effect.mapError(
(error) =>
new ApiProjectCopyError({
name: "ProjectCopyError",
data: { message: message(error) },
}),
),
)
}
export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projectCopy", (handlers) =>
Effect.gen(function* () {
const llm = yield* LLM.Service
const agent = yield* Agent.Service
const provider = yield* Provider.Service
const service = yield* ProjectCopy.Service
const generateName = Effect.fn("ProjectCopyHttpApi.generateName")(function* (context: string | undefined) {
const text = context?.trim()
if (!text) return Slug.create()
const [titleAgent, fallback] = yield* Effect.all(
[
agent.get("title").pipe(Effect.catch(() => Effect.succeed(FALLBACK_AGENT))),
provider.defaultModel().pipe(Effect.catch(() => Effect.succeed(undefined))),
],
{ concurrency: 2 },
)
if (!fallback) return Slug.create()
const model = titleAgent.model
? yield* provider.getModel(titleAgent.model.providerID, titleAgent.model.modelID)
: ((yield* provider.getSmallModel(fallback.providerID)) ??
(yield* provider.getModel(fallback.providerID, fallback.modelID)))
const sessionID = SessionID.descending()
const result = yield* llm
.stream({
agent: titleAgent,
user: {
id: MessageID.ascending(),
sessionID,
role: "user",
time: { created: Date.now() },
agent: titleAgent.name,
model: { providerID: model.providerID, modelID: model.id },
},
system: [],
small: true,
tools: {},
model,
sessionID,
retries: 2,
messages: [
{
role: "user",
content: `Generate a short filesystem-safe name for a project working copy based on this context:\n${text}`,
},
],
})
.pipe(
Stream.filter(LLMEvent.is.textDelta),
Stream.map((event) => event.text),
Stream.mkString,
)
return slugify(result) || Slug.create()
})
const create = Effect.fn("ProjectCopyHttpApi.create")(function* (ctx: {
params: { projectID: ProjectV2.ID }
payload: typeof CreatePayload.Type
}) {
const name =
ctx.payload.name ??
(yield* generateName(ctx.payload.context).pipe(Effect.catch(() => Effect.succeed(Slug.create()))))
return yield* badRequest(
service.create({
...ctx.payload,
name,
projectID: ctx.params.projectID,
sourceDirectory: AbsolutePath.make((yield* InstanceState.context).worktree),
}),
@@ -51,3 +129,24 @@ export const projectCopyHandlers = HttpApiBuilder.group(InstanceHttpApi, "projec
return handlers.handle("create", create).handle("remove", remove).handle("refresh", refresh)
}),
)
function slugify(input: string) {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
}
function message(error: ProjectCopy.Error) {
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
return `Project copy source not found: ${error.directory}`
if (error instanceof ProjectCopy.DestinationExistsError)
return `Project copy destination already exists: ${error.directory}`
if (error instanceof ProjectCopy.DirectoryUnavailableError)
return `Project copy directory unavailable: ${error.directory}`
if (error instanceof ProjectCopy.StrategyNotFoundError)
return `Project copy strategy not found for: ${error.directory}`
return error.message
}
@@ -28,6 +28,7 @@ import { Plugin } from "@/plugin"
import { Project } from "@/project/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { ProviderAuth } from "@/provider/auth"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
@@ -35,6 +36,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { Question } from "@/question"
import { Session } from "@/session/session"
import { SessionCompaction } from "@/session/compaction"
import { LLM } from "@/session/llm"
import { SessionPrompt } from "@/session/prompt"
import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
@@ -70,6 +72,7 @@ import { PtyConnectApi } from "./groups/pty"
import { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
import { controlPlaneHandlers } from "./handlers/control-plane"
import { experimentalHandlers } from "./handlers/experimental"
import { fileHandlers } from "./handlers/file"
import { globalHandlers } from "./handlers/global"
@@ -120,7 +123,7 @@ const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provi
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
@@ -215,6 +218,7 @@ export function createRoutes(
Config.defaultLayer,
Format.defaultLayer,
LSP.defaultLayer,
LLM.defaultLayer,
Installation.defaultLayer,
MCP.defaultLayer,
ModelsDev.defaultLayer,
@@ -223,6 +227,7 @@ export function createRoutes(
Project.defaultLayer,
ProjectV2.defaultLayer,
ProjectCopy.defaultLayer,
MoveSession.defaultLayer,
ProviderAuth.defaultLayer,
Provider.defaultLayer,
PtyTicket.defaultLayer,