|
| 1 | +import * as InstanceState from "@/effect/instance-state" |
| 2 | +import { Instance } from "@/project/instance" |
| 3 | +import { Session } from "@/session" |
| 4 | +import { MessageV2 } from "@/session/message-v2" |
| 5 | +import { SessionStatus } from "@/session/status" |
| 6 | +import { SessionSummary } from "@/session/summary" |
| 7 | +import { Todo } from "@/session/todo" |
| 8 | +import { MessageID, SessionID } from "@/session/schema" |
| 9 | +import { Snapshot } from "@/snapshot" |
| 10 | +import { Effect, Layer, Schema, Struct } from "effect" |
| 11 | +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" |
| 12 | +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" |
| 13 | +import { Authorization } from "./auth" |
| 14 | + |
| 15 | +const root = "/session" |
| 16 | +const ListQuery = Schema.Struct({ |
| 17 | + directory: Schema.optional(Schema.String), |
| 18 | + roots: Schema.optional(Schema.Literals(["true", "false"])), |
| 19 | + start: Schema.optional(Schema.NumberFromString), |
| 20 | + search: Schema.optional(Schema.String), |
| 21 | + limit: Schema.optional(Schema.NumberFromString), |
| 22 | +}) |
| 23 | +const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"])) |
| 24 | +const MessagesQuery = Schema.Struct({ |
| 25 | + limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), |
| 26 | + before: Schema.optional(Schema.String), |
| 27 | +}) |
| 28 | +const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) |
| 29 | + |
| 30 | +export const SessionPaths = { |
| 31 | + list: root, |
| 32 | + status: `${root}/status`, |
| 33 | + get: `${root}/:sessionID`, |
| 34 | + children: `${root}/:sessionID/children`, |
| 35 | + todo: `${root}/:sessionID/todo`, |
| 36 | + diff: `${root}/:sessionID/diff`, |
| 37 | + messages: `${root}/:sessionID/message`, |
| 38 | + message: `${root}/:sessionID/message/:messageID`, |
| 39 | +} as const |
| 40 | + |
| 41 | +export const SessionApi = HttpApi.make("session") |
| 42 | + .add( |
| 43 | + HttpApiGroup.make("session") |
| 44 | + .add( |
| 45 | + HttpApiEndpoint.get("list", SessionPaths.list, { |
| 46 | + query: ListQuery, |
| 47 | + success: Schema.Array(Session.Info), |
| 48 | + }).annotateMerge( |
| 49 | + OpenApi.annotations({ |
| 50 | + identifier: "session.list", |
| 51 | + summary: "List sessions", |
| 52 | + description: "Get a list of all OpenCode sessions, sorted by most recently updated.", |
| 53 | + }), |
| 54 | + ), |
| 55 | + HttpApiEndpoint.get("status", SessionPaths.status, { |
| 56 | + success: StatusMap, |
| 57 | + }).annotateMerge( |
| 58 | + OpenApi.annotations({ |
| 59 | + identifier: "session.status", |
| 60 | + summary: "Get session status", |
| 61 | + description: "Retrieve the current status of all sessions, including active, idle, and completed states.", |
| 62 | + }), |
| 63 | + ), |
| 64 | + HttpApiEndpoint.get("get", SessionPaths.get, { |
| 65 | + params: { sessionID: SessionID }, |
| 66 | + success: Session.Info, |
| 67 | + }).annotateMerge( |
| 68 | + OpenApi.annotations({ |
| 69 | + identifier: "session.get", |
| 70 | + summary: "Get session", |
| 71 | + description: "Retrieve detailed information about a specific OpenCode session.", |
| 72 | + }), |
| 73 | + ), |
| 74 | + HttpApiEndpoint.get("children", SessionPaths.children, { |
| 75 | + params: { sessionID: SessionID }, |
| 76 | + success: Schema.Array(Session.Info), |
| 77 | + }).annotateMerge( |
| 78 | + OpenApi.annotations({ |
| 79 | + identifier: "session.children", |
| 80 | + summary: "Get session children", |
| 81 | + description: "Retrieve all child sessions that were forked from the specified parent session.", |
| 82 | + }), |
| 83 | + ), |
| 84 | + HttpApiEndpoint.get("todo", SessionPaths.todo, { |
| 85 | + params: { sessionID: SessionID }, |
| 86 | + success: Schema.Array(Todo.Info), |
| 87 | + }).annotateMerge( |
| 88 | + OpenApi.annotations({ |
| 89 | + identifier: "session.todo", |
| 90 | + summary: "Get session todos", |
| 91 | + description: "Retrieve the todo list associated with a specific session, showing tasks and action items.", |
| 92 | + }), |
| 93 | + ), |
| 94 | + HttpApiEndpoint.get("diff", SessionPaths.diff, { |
| 95 | + params: { sessionID: SessionID }, |
| 96 | + query: DiffQuery, |
| 97 | + success: Schema.Array(Snapshot.FileDiff), |
| 98 | + }).annotateMerge( |
| 99 | + OpenApi.annotations({ |
| 100 | + identifier: "session.diff", |
| 101 | + summary: "Get message diff", |
| 102 | + description: "Get the file changes (diff) that resulted from a specific user message in the session.", |
| 103 | + }), |
| 104 | + ), |
| 105 | + HttpApiEndpoint.get("messages", SessionPaths.messages, { |
| 106 | + params: { sessionID: SessionID }, |
| 107 | + query: MessagesQuery, |
| 108 | + success: Schema.Array(MessageV2.WithParts), |
| 109 | + }).annotateMerge( |
| 110 | + OpenApi.annotations({ |
| 111 | + identifier: "session.messages", |
| 112 | + summary: "Get session messages", |
| 113 | + description: "Retrieve all messages in a session, including user prompts and AI responses.", |
| 114 | + }), |
| 115 | + ), |
| 116 | + HttpApiEndpoint.get("message", SessionPaths.message, { |
| 117 | + params: { sessionID: SessionID, messageID: MessageID }, |
| 118 | + success: MessageV2.WithParts, |
| 119 | + }).annotateMerge( |
| 120 | + OpenApi.annotations({ |
| 121 | + identifier: "session.message", |
| 122 | + summary: "Get message", |
| 123 | + description: "Retrieve a specific message from a session by its message ID.", |
| 124 | + }), |
| 125 | + ), |
| 126 | + ) |
| 127 | + .annotateMerge( |
| 128 | + OpenApi.annotations({ |
| 129 | + title: "session", |
| 130 | + description: "Experimental HttpApi session routes.", |
| 131 | + }), |
| 132 | + ) |
| 133 | + .middleware(Authorization), |
| 134 | + ) |
| 135 | + .annotateMerge( |
| 136 | + OpenApi.annotations({ |
| 137 | + title: "opencode experimental HttpApi", |
| 138 | + version: "0.0.1", |
| 139 | + description: "Experimental HttpApi surface for selected instance routes.", |
| 140 | + }), |
| 141 | + ) |
| 142 | + |
| 143 | +export const sessionHandlers = Layer.unwrap( |
| 144 | + Effect.gen(function* () { |
| 145 | + const session = yield* Session.Service |
| 146 | + const statusSvc = yield* SessionStatus.Service |
| 147 | + const todoSvc = yield* Todo.Service |
| 148 | + const summary = yield* SessionSummary.Service |
| 149 | + |
| 150 | + const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) { |
| 151 | + const instance = yield* InstanceState.context |
| 152 | + return Instance.restore(instance, () => |
| 153 | + Array.from( |
| 154 | + Session.list({ |
| 155 | + directory: ctx.query.directory, |
| 156 | + roots: ctx.query.roots === "true" ? true : undefined, |
| 157 | + start: ctx.query.start, |
| 158 | + search: ctx.query.search, |
| 159 | + limit: ctx.query.limit, |
| 160 | + }), |
| 161 | + ), |
| 162 | + ) |
| 163 | + }) |
| 164 | + |
| 165 | + const status = Effect.fn("SessionHttpApi.status")(function* () { |
| 166 | + return Object.fromEntries(yield* statusSvc.list()) |
| 167 | + }) |
| 168 | + |
| 169 | + const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) { |
| 170 | + return yield* session.get(ctx.params.sessionID) |
| 171 | + }) |
| 172 | + |
| 173 | + const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) { |
| 174 | + return yield* session.children(ctx.params.sessionID) |
| 175 | + }) |
| 176 | + |
| 177 | + const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) { |
| 178 | + return yield* todoSvc.get(ctx.params.sessionID) |
| 179 | + }) |
| 180 | + |
| 181 | + const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: { |
| 182 | + params: { sessionID: SessionID } |
| 183 | + query: typeof DiffQuery.Type |
| 184 | + }) { |
| 185 | + return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) |
| 186 | + }) |
| 187 | + |
| 188 | + const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { |
| 189 | + params: { sessionID: SessionID } |
| 190 | + query: typeof MessagesQuery.Type |
| 191 | + }) { |
| 192 | + if (ctx.query.limit === undefined || ctx.query.limit === 0) { |
| 193 | + yield* session.get(ctx.params.sessionID) |
| 194 | + return yield* session.messages({ sessionID: ctx.params.sessionID }) |
| 195 | + } |
| 196 | + |
| 197 | + const page = MessageV2.page({ |
| 198 | + sessionID: ctx.params.sessionID, |
| 199 | + limit: ctx.query.limit, |
| 200 | + before: ctx.query.before, |
| 201 | + }) |
| 202 | + if (!page.cursor) return page.items |
| 203 | + |
| 204 | + const request = yield* HttpServerRequest.HttpServerRequest |
| 205 | + const url = new URL(request.url, "http://localhost") |
| 206 | + url.searchParams.set("limit", ctx.query.limit.toString()) |
| 207 | + url.searchParams.set("before", page.cursor) |
| 208 | + return HttpServerResponse.jsonUnsafe(page.items, { |
| 209 | + headers: { |
| 210 | + "Access-Control-Expose-Headers": "Link, X-Next-Cursor", |
| 211 | + Link: `<${url.toString()}>; rel="next"`, |
| 212 | + "X-Next-Cursor": page.cursor, |
| 213 | + }, |
| 214 | + }) |
| 215 | + }) |
| 216 | + |
| 217 | + const message = Effect.fn("SessionHttpApi.message")(function* (ctx: { |
| 218 | + params: { sessionID: SessionID; messageID: MessageID } |
| 219 | + }) { |
| 220 | + return yield* Effect.sync(() => |
| 221 | + MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }), |
| 222 | + ) |
| 223 | + }) |
| 224 | + |
| 225 | + return HttpApiBuilder.group(SessionApi, "session", (handlers) => |
| 226 | + handlers |
| 227 | + .handle("list", list) |
| 228 | + .handle("status", status) |
| 229 | + .handle("get", get) |
| 230 | + .handle("children", children) |
| 231 | + .handle("todo", todo) |
| 232 | + .handle("diff", diff) |
| 233 | + .handle("messages", messages) |
| 234 | + .handle("message", message), |
| 235 | + ) |
| 236 | + }), |
| 237 | +).pipe( |
| 238 | + Layer.provide(Session.defaultLayer), |
| 239 | + Layer.provide(SessionStatus.defaultLayer), |
| 240 | + Layer.provide(Todo.defaultLayer), |
| 241 | + Layer.provide(SessionSummary.defaultLayer), |
| 242 | +) |
0 commit comments