feat(core): moving sessions (#30640)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user