Files
anomalyco_opencode/packages/server/src/handlers/session.ts
T
2026-08-11 03:30:08 +00:00

817 lines
29 KiB
TypeScript

import { Session } from "@opencode-ai/core/session"
import { SessionTransfer } from "@opencode-ai/core/session/transfer"
import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry"
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* Session.Service
const transfer = yield* SessionTransfer.Service
const pendingMutation = (effect: ReturnType<typeof session.cancelPending>, conflict: string) =>
effect.pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
Effect.catchTag(
"Session.PendingInputConflictError",
(error) => new ConflictError({ resource: error.inputID, message: `${conflict}: ${error.inputID}` }),
),
Effect.as(HttpApiSchema.NoContent.make()),
)
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,
title: ctx.payload.title,
agent: ctx.payload.agent,
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(Effect.orDie),
}
}),
)
.handle(
"session.import",
Effect.fn(function* (ctx) {
return {
data: yield* transfer
.import({
data: { info: ctx.payload.info, messages: ctx.payload.messages },
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
})
.pipe(
Effect.catchTag(
"SessionTransfer.ImportConflictError",
(error) =>
new ConflictError({
message: `Session already exists: ${error.sessionID}`,
resource: error.sessionID,
}),
),
),
}
}),
)
.handle(
"session.export",
Effect.fn(function* (ctx) {
return {
data: yield* transfer.export({ sessionID: ctx.params.sessionID, sanitize: ctx.query.sanitize }).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
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.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, boundary: ctx.payload.boundary }).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.ForkEmptyError",
(error) => new InvalidRequestError({ message: error.message, kind: "empty_session" }),
),
),
}
}),
)
.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* session
.move({
sessionID: ctx.params.sessionID,
directory: ctx.payload.directory,
workspaceID: ctx.payload.workspaceID,
})
.pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
Effect.catchTag("Session.DestinationNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Directory does not exist: ${error.directory}` })),
),
Effect.catchTag("Session.DestinationNotDirectoryError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Not a directory: ${error.directory}` })),
),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.prompt",
Effect.fn(function* (ctx) {
return {
data: yield* session
.prompt({
sessionID: ctx.params.sessionID,
id: ctx.payload.id,
text: ctx.payload.text,
files: ctx.payload.files,
agents: ctx.payload.agents,
skills: ctx.payload.skills,
metadata: ctx.payload.metadata,
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: "files" })),
),
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
),
),
}
}),
)
.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,
files: ctx.payload.files,
agents: ctx.payload.agents,
skills: ctx.payload.skills,
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" })),
),
Effect.catchTag("Session.SkillNotFoundError", (error) =>
Effect.fail(new InvalidRequestError({ message: `Skill not found: ${error.skill}`, field: "skills" })),
),
),
}
}),
)
.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) {
const data = yield* session
.synthetic({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
text: ctx.payload.text,
description: ctx.payload.description,
metadata: ctx.payload.metadata,
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.SyntheticConflictError", (error) =>
Effect.fail(
new ConflictError({
message: `Synthetic input ID conflicts with an existing durable record: ${error.inputID}`,
resource: error.inputID,
}),
),
),
)
return { data }
}),
)
.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.pending.list",
Effect.fn(function* (ctx) {
return {
data: yield* session.pending(ctx.params.sessionID).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
),
}
}),
)
.handle(
"session.pending.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input can no longer be cancelled",
)
}),
)
.handle(
"session.pending.steer",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerPending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.pending.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queuePending({ sessionID: ctx.params.sessionID, inputID: ctx.params.inputID }),
"Pending input is no longer a steer",
)
}),
)
.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.generate",
Effect.fn(function* (ctx) {
const text = yield* session.generate({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt }).pipe(
Effect.mapError((error) =>
error._tag === "Session.NotFoundError"
? new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
})
: new ServiceUnavailableError({ message: error.message, service: "session generation" }),
),
)
return { data: { text } }
}),
)
.handle(
"session.log",
Effect.fn(function* (ctx) {
yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
return 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) {
yield* session.get(ctx.params.sessionID).pipe(
Effect.catchTag(
"Session.NotFoundError",
(error) =>
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
)
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}`,
})
}),
)
}),
)