657 lines
23 KiB
TypeScript
657 lines
23 KiB
TypeScript
import { SessionV2 } from "@opencode-ai/core/session"
|
|
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
|
|
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
|
|
import { DateTime, Effect, Stream } from "effect"
|
|
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
|
import { Api } from "../api"
|
|
import { SessionsCursor } from "@opencode-ai/protocol/groups/session"
|
|
import {
|
|
ConflictError,
|
|
CommandEvaluationError,
|
|
CommandNotFoundError,
|
|
InvalidRequestError,
|
|
InvalidCursorError,
|
|
MessageNotFoundError,
|
|
ServiceUnavailableError,
|
|
SessionBusyError,
|
|
SessionNotFoundError,
|
|
SkillNotFoundError,
|
|
UnknownError,
|
|
} from "@opencode-ai/protocol/errors"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
|
|
const DefaultSessionsLimit = 50
|
|
|
|
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
|
|
Effect.gen(function* () {
|
|
const session = yield* SessionV2.Service
|
|
const moveSession = yield* MoveSession.Service
|
|
|
|
return handlers
|
|
.handle(
|
|
"session.list",
|
|
Effect.fn(function* (ctx) {
|
|
const query =
|
|
ctx.query.cursor !== undefined
|
|
? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
|
|
Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
|
|
)
|
|
: ctx.query
|
|
const page = yield* session.list({
|
|
...query,
|
|
workspaceID: query.workspace,
|
|
limit: ctx.query.limit ?? DefaultSessionsLimit,
|
|
})
|
|
const sessions = page.data
|
|
const first = sessions[0]
|
|
const last = sessions.at(-1)
|
|
return {
|
|
data: sessions,
|
|
cursor: {
|
|
previous: first
|
|
? SessionsCursor.make({
|
|
...query,
|
|
anchor: {
|
|
id: first.id,
|
|
time: DateTime.toEpochMillis(first.time.updated),
|
|
direction: "previous",
|
|
},
|
|
})
|
|
: undefined,
|
|
next: last
|
|
? SessionsCursor.make({
|
|
...query,
|
|
anchor: {
|
|
id: last.id,
|
|
time: DateTime.toEpochMillis(last.time.updated),
|
|
direction: "next",
|
|
},
|
|
})
|
|
: undefined,
|
|
},
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.create",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session
|
|
.create({
|
|
id: ctx.payload.id,
|
|
agent: ctx.payload.agent,
|
|
model: ctx.payload.model,
|
|
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
|
|
})
|
|
.pipe(Effect.orDie),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.active",
|
|
Effect.fn(function* () {
|
|
const active = yield* session.active
|
|
return {
|
|
data: Object.fromEntries(Array.from(active, (sessionID) => [sessionID, { type: "running" as const }])),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.get",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session.get(ctx.params.sessionID).pipe(
|
|
Effect.catchTag(
|
|
"Session.NotFoundError",
|
|
(error) =>
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.remove",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.remove(ctx.params.sessionID).pipe(
|
|
Effect.catchTag(
|
|
"Session.NotFoundError",
|
|
(error) =>
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.fork",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID }).pipe(
|
|
Effect.catchTag(
|
|
"Session.NotFoundError",
|
|
(error) =>
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag(
|
|
"Session.MessageNotFoundError",
|
|
(error) =>
|
|
new MessageNotFoundError({
|
|
sessionID: error.sessionID,
|
|
messageID: error.messageID,
|
|
message: `Message not found: ${error.messageID}`,
|
|
}),
|
|
),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.switchAgent",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.switchModel",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.rename",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.rename({ sessionID: ctx.params.sessionID, title: ctx.payload.title }).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.move",
|
|
Effect.fn(function* (ctx) {
|
|
yield* moveSession.moveSession({
|
|
sessionID: ctx.params.sessionID,
|
|
destination: ctx.payload.destination,
|
|
moveChanges: ctx.payload.moveChanges,
|
|
}).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("MoveSession.DestinationProjectMismatchError", () =>
|
|
Effect.fail(new InvalidRequestError({ message: "Destination directory belongs to another project" })),
|
|
),
|
|
Effect.catchTag("MoveSession.ApplyChangesError", () =>
|
|
Effect.fail(
|
|
new InvalidRequestError({
|
|
message:
|
|
"Unable to apply your changes in the destination directory. The files may conflict with existing changes.",
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("MoveSession.CaptureChangesError", (error) =>
|
|
Effect.fail(new InvalidRequestError({ message: error.message })),
|
|
),
|
|
Effect.catchTag("MoveSession.ResetSourceChangesError", (error) =>
|
|
Effect.fail(new InvalidRequestError({ message: error.message })),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.prompt",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session
|
|
.prompt({
|
|
sessionID: ctx.params.sessionID,
|
|
id: ctx.payload.id,
|
|
prompt: ctx.payload.prompt,
|
|
delivery: ctx.payload.delivery,
|
|
resume: ctx.payload.resume,
|
|
})
|
|
.pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.PromptConflictError", (error) =>
|
|
Effect.fail(
|
|
new ConflictError({
|
|
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
|
|
resource: error.messageID,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.AttachmentError", (error) =>
|
|
Effect.fail(new InvalidRequestError({ message: error.message, field: "prompt.files" })),
|
|
),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.command",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session
|
|
.command({
|
|
sessionID: ctx.params.sessionID,
|
|
id: ctx.payload.id,
|
|
command: ctx.payload.command,
|
|
arguments: ctx.payload.arguments,
|
|
agent: ctx.payload.agent,
|
|
model: ctx.payload.model,
|
|
files: ctx.payload.files,
|
|
agents: ctx.payload.agents,
|
|
delivery: ctx.payload.delivery,
|
|
resume: ctx.payload.resume,
|
|
})
|
|
.pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Command.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new CommandNotFoundError({
|
|
command: error.command,
|
|
message: error.message,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Command.EvaluationError", (error) =>
|
|
Effect.fail(
|
|
new CommandEvaluationError({
|
|
command: error.command,
|
|
message: error.message,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.PromptConflictError", (error) =>
|
|
Effect.fail(
|
|
new ConflictError({
|
|
message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
|
|
resource: error.messageID,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.AttachmentError", (error) =>
|
|
Effect.fail(new InvalidRequestError({ message: error.message, field: "files" })),
|
|
),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.skill",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session
|
|
.skill({
|
|
sessionID: ctx.params.sessionID,
|
|
id: ctx.payload.id,
|
|
skill: ctx.payload.skill,
|
|
resume: ctx.payload.resume,
|
|
})
|
|
.pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.SkillNotFoundError", (error) =>
|
|
Effect.fail(new SkillNotFoundError({ skill: error.skill, message: `Skill not found: ${error.skill}` })),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.synthetic",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session
|
|
.synthetic({
|
|
sessionID: ctx.params.sessionID,
|
|
text: ctx.payload.text,
|
|
description: ctx.payload.description,
|
|
metadata: ctx.payload.metadata,
|
|
resume: ctx.payload.resume,
|
|
})
|
|
.pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.shell",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session
|
|
.shell({ sessionID: ctx.params.sessionID, id: ctx.payload.id, command: ctx.payload.command })
|
|
.pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.compact",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session.compact({ sessionID: ctx.params.sessionID, id: ctx.payload.id }).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.CompactionConflictError", (error) =>
|
|
Effect.fail(
|
|
new ConflictError({
|
|
message: `Compaction input ID conflicts with an existing durable record: ${error.inputID}`,
|
|
resource: error.inputID,
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.wait",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.wait(ctx.params.sessionID).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.revert.stage",
|
|
Effect.fn(function* (ctx) {
|
|
yield* Effect.log("session.revert.stage", {
|
|
sessionID: ctx.params.sessionID,
|
|
messageID: ctx.payload.messageID,
|
|
files: ctx.payload.files,
|
|
})
|
|
return {
|
|
data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
|
|
Effect.catchTag(
|
|
"Session.NotFoundError",
|
|
(error) =>
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag(
|
|
"Session.MessageNotFoundError",
|
|
(error) =>
|
|
new MessageNotFoundError({
|
|
sessionID: error.sessionID,
|
|
messageID: error.messageID,
|
|
message: `Message not found: ${error.messageID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag(
|
|
"Session.BusyError",
|
|
(error) =>
|
|
new SessionBusyError({
|
|
sessionID: error.sessionID,
|
|
message: `Session is busy: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag("Snapshot.Error", (error) => {
|
|
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
return Effect.logError("failed to stage session revert", { cause: error }).pipe(
|
|
Effect.andThen(
|
|
Effect.fail(
|
|
new UnknownError({
|
|
message: "Unexpected server error. Check server logs for details.",
|
|
ref,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.revert.clear",
|
|
Effect.fn(function* (ctx) {
|
|
yield* Effect.log("session.revert.clear", { sessionID: ctx.params.sessionID })
|
|
yield* session.revert.clear(ctx.params.sessionID).pipe(
|
|
Effect.catchTag(
|
|
"Session.NotFoundError",
|
|
(error) =>
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag(
|
|
"Session.BusyError",
|
|
(error) =>
|
|
new SessionBusyError({
|
|
sessionID: error.sessionID,
|
|
message: `Session is busy: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag("Snapshot.Error", (error) => {
|
|
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
return Effect.logError("failed to clear session revert", { cause: error }).pipe(
|
|
Effect.andThen(
|
|
Effect.fail(
|
|
new UnknownError({
|
|
message: "Unexpected server error. Check server logs for details.",
|
|
ref,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
}),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.revert.commit",
|
|
Effect.fn(function* (ctx) {
|
|
yield* Effect.log("session.revert.commit", { sessionID: ctx.params.sessionID })
|
|
yield* session.revert.commit(ctx.params.sessionID).pipe(
|
|
Effect.catchTag(
|
|
"Session.NotFoundError",
|
|
(error) =>
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
Effect.catchTag(
|
|
"Session.BusyError",
|
|
(error) =>
|
|
new SessionBusyError({
|
|
sessionID: error.sessionID,
|
|
message: `Session is busy: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.context",
|
|
Effect.fn(function* (ctx) {
|
|
return {
|
|
data: yield* session.context(ctx.params.sessionID).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
Effect.catchTag("Session.MessageDecodeError", (error) => {
|
|
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
|
|
return Effect.logError("failed to decode session message").pipe(
|
|
Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
|
|
Effect.andThen(
|
|
Effect.fail(
|
|
new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
|
|
),
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
}
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.instructions.entry.list",
|
|
Effect.fn(function* (ctx) {
|
|
const instructions = yield* InstructionEntry.Service
|
|
return { data: yield* instructions.list(ctx.params.sessionID) }
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.instructions.entry.put",
|
|
Effect.fn(function* (ctx) {
|
|
const instructions = yield* InstructionEntry.Service
|
|
yield* instructions.put({ sessionID: ctx.params.sessionID, key: ctx.params.key, value: ctx.payload.value })
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.instructions.entry.remove",
|
|
Effect.fn(function* (ctx) {
|
|
const instructions = yield* InstructionEntry.Service
|
|
yield* instructions.remove({ sessionID: ctx.params.sessionID, key: ctx.params.key })
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.log",
|
|
Effect.fn((ctx) =>
|
|
Effect.succeed(
|
|
session
|
|
.log({ sessionID: ctx.params.sessionID, after: ctx.query.after, follow: ctx.query.follow })
|
|
.pipe(Stream.orDie),
|
|
),
|
|
),
|
|
)
|
|
.handle(
|
|
"session.interrupt",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.interrupt(ctx.params.sessionID)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.background",
|
|
Effect.fn(function* (ctx) {
|
|
yield* session.background(ctx.params.sessionID).pipe(
|
|
Effect.catchTag("Session.NotFoundError", (error) =>
|
|
Effect.fail(
|
|
new SessionNotFoundError({
|
|
sessionID: error.sessionID,
|
|
message: `Session not found: ${error.sessionID}`,
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
return HttpApiSchema.NoContent.make()
|
|
}),
|
|
)
|
|
.handle(
|
|
"session.message",
|
|
Effect.fn(function* (ctx) {
|
|
const message = yield* session.message(ctx.params)
|
|
if (message) return { data: message }
|
|
return yield* new MessageNotFoundError({
|
|
sessionID: ctx.params.sessionID,
|
|
messageID: ctx.params.messageID,
|
|
message: `Message not found: ${ctx.params.messageID}`,
|
|
})
|
|
}),
|
|
)
|
|
}),
|
|
)
|