session.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import { SessionV2 } from "@kirincode-ai/core/session"
  2. import { DateTime, Effect, Stream } from "effect"
  3. import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
  4. import { Api } from "../api"
  5. import { SessionsCursor } from "@kirincode-ai/protocol/groups/session"
  6. import {
  7. ConflictError,
  8. InvalidCursorError,
  9. MessageNotFoundError,
  10. ServiceUnavailableError,
  11. SessionNotFoundError,
  12. UnknownError,
  13. } from "@kirincode-ai/protocol/errors"
  14. import { AbsolutePath } from "@kirincode-ai/core/schema"
  15. const DefaultSessionsLimit = 50
  16. const DefaultSessionHistoryLimit = 50
  17. export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
  18. Effect.gen(function* () {
  19. const session = yield* SessionV2.Service
  20. return handlers
  21. .handle(
  22. "session.list",
  23. Effect.fn(function* (ctx) {
  24. const query =
  25. ctx.query.cursor !== undefined
  26. ? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
  27. Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
  28. )
  29. : ctx.query
  30. const sessions = yield* session.list({
  31. ...query,
  32. workspaceID: query.workspace,
  33. limit: ctx.query.limit ?? DefaultSessionsLimit,
  34. })
  35. const first = sessions[0]
  36. const last = sessions.at(-1)
  37. return {
  38. data: sessions,
  39. cursor: {
  40. previous: first
  41. ? SessionsCursor.make({
  42. ...query,
  43. anchor: {
  44. id: first.id,
  45. time: DateTime.toEpochMillis(first.time.created),
  46. direction: "previous",
  47. },
  48. })
  49. : undefined,
  50. next: last
  51. ? SessionsCursor.make({
  52. ...query,
  53. anchor: {
  54. id: last.id,
  55. time: DateTime.toEpochMillis(last.time.created),
  56. direction: "next",
  57. },
  58. })
  59. : undefined,
  60. },
  61. }
  62. }),
  63. )
  64. .handle(
  65. "session.create",
  66. Effect.fn(function* (ctx) {
  67. return {
  68. data: yield* session.create({
  69. id: ctx.payload.id,
  70. agent: ctx.payload.agent,
  71. model: ctx.payload.model,
  72. location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
  73. }),
  74. }
  75. }),
  76. )
  77. .handle(
  78. "session.active",
  79. Effect.fn(function* () {
  80. return {
  81. data: Object.fromEntries(
  82. Array.from(yield* session.active, (sessionID) => [sessionID, { type: "running" as const }]),
  83. ),
  84. }
  85. }),
  86. )
  87. .handle(
  88. "session.get",
  89. Effect.fn(function* (ctx) {
  90. return {
  91. data: yield* session.get(ctx.params.sessionID).pipe(
  92. Effect.catchTag(
  93. "Session.NotFoundError",
  94. (error) =>
  95. new SessionNotFoundError({
  96. sessionID: error.sessionID,
  97. message: `Session not found: ${error.sessionID}`,
  98. }),
  99. ),
  100. ),
  101. }
  102. }),
  103. )
  104. .handle(
  105. "session.switchAgent",
  106. Effect.fn(function* (ctx) {
  107. yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe(
  108. Effect.catchTag("Session.NotFoundError", (error) =>
  109. Effect.fail(
  110. new SessionNotFoundError({
  111. sessionID: error.sessionID,
  112. message: `Session not found: ${error.sessionID}`,
  113. }),
  114. ),
  115. ),
  116. )
  117. return HttpApiSchema.NoContent.make()
  118. }),
  119. )
  120. .handle(
  121. "session.switchModel",
  122. Effect.fn(function* (ctx) {
  123. yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe(
  124. Effect.catchTag("Session.NotFoundError", (error) =>
  125. Effect.fail(
  126. new SessionNotFoundError({
  127. sessionID: error.sessionID,
  128. message: `Session not found: ${error.sessionID}`,
  129. }),
  130. ),
  131. ),
  132. )
  133. return HttpApiSchema.NoContent.make()
  134. }),
  135. )
  136. .handle(
  137. "session.prompt",
  138. Effect.fn(function* (ctx) {
  139. return {
  140. data: yield* session
  141. .prompt({
  142. sessionID: ctx.params.sessionID,
  143. id: ctx.payload.id,
  144. prompt: ctx.payload.prompt,
  145. delivery: ctx.payload.delivery,
  146. resume: ctx.payload.resume,
  147. })
  148. .pipe(
  149. Effect.catchTag("Session.NotFoundError", (error) =>
  150. Effect.fail(
  151. new SessionNotFoundError({
  152. sessionID: error.sessionID,
  153. message: `Session not found: ${error.sessionID}`,
  154. }),
  155. ),
  156. ),
  157. Effect.catchTag("Session.PromptConflictError", (error) =>
  158. Effect.fail(
  159. new ConflictError({
  160. message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`,
  161. resource: error.messageID,
  162. }),
  163. ),
  164. ),
  165. ),
  166. }
  167. }),
  168. )
  169. .handle(
  170. "session.compact",
  171. Effect.fn(function* (ctx) {
  172. yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
  173. Effect.catchTag("Session.NotFoundError", (error) =>
  174. Effect.fail(
  175. new SessionNotFoundError({
  176. sessionID: error.sessionID,
  177. message: `Session not found: ${error.sessionID}`,
  178. }),
  179. ),
  180. ),
  181. Effect.catchTag("Session.OperationUnavailableError", (error) =>
  182. Effect.fail(
  183. new ServiceUnavailableError({
  184. message: `Session ${error.operation} is not available yet`,
  185. service: `session.${error.operation}`,
  186. }),
  187. ),
  188. ),
  189. )
  190. return HttpApiSchema.NoContent.make()
  191. }),
  192. )
  193. .handle(
  194. "session.wait",
  195. Effect.fn(function* (ctx) {
  196. yield* session.wait(ctx.params.sessionID).pipe(
  197. Effect.catchTag("Session.NotFoundError", (error) =>
  198. Effect.fail(
  199. new SessionNotFoundError({
  200. sessionID: error.sessionID,
  201. message: `Session not found: ${error.sessionID}`,
  202. }),
  203. ),
  204. ),
  205. Effect.catchTag("Session.OperationUnavailableError", (error) =>
  206. Effect.fail(
  207. new ServiceUnavailableError({
  208. message: `Session ${error.operation} is not available yet`,
  209. service: `session.${error.operation}`,
  210. }),
  211. ),
  212. ),
  213. )
  214. return HttpApiSchema.NoContent.make()
  215. }),
  216. )
  217. .handle(
  218. "session.revert.stage",
  219. Effect.fn(function* (ctx) {
  220. return {
  221. data: yield* session.revert.stage({ ...ctx.params, ...ctx.payload }).pipe(
  222. Effect.catchTag(
  223. "Session.NotFoundError",
  224. (error) =>
  225. new SessionNotFoundError({
  226. sessionID: error.sessionID,
  227. message: `Session not found: ${error.sessionID}`,
  228. }),
  229. ),
  230. Effect.catchTag(
  231. "Session.MessageNotFoundError",
  232. (error) =>
  233. new MessageNotFoundError({
  234. sessionID: error.sessionID,
  235. messageID: error.messageID,
  236. message: `Message not found: ${error.messageID}`,
  237. }),
  238. ),
  239. Effect.catchTag("Snapshot.Error", (error) => {
  240. const ref = `err_${crypto.randomUUID().slice(0, 8)}`
  241. return Effect.logError("failed to stage session revert", { cause: error }).pipe(
  242. Effect.andThen(
  243. Effect.fail(
  244. new UnknownError({
  245. message: "Unexpected server error. Check server logs for details.",
  246. ref,
  247. }),
  248. ),
  249. ),
  250. )
  251. }),
  252. ),
  253. }
  254. }),
  255. )
  256. .handle(
  257. "session.revert.clear",
  258. Effect.fn(function* (ctx) {
  259. yield* session.revert.clear(ctx.params.sessionID).pipe(
  260. Effect.catchTag(
  261. "Session.NotFoundError",
  262. (error) =>
  263. new SessionNotFoundError({
  264. sessionID: error.sessionID,
  265. message: `Session not found: ${error.sessionID}`,
  266. }),
  267. ),
  268. Effect.catchTag("Snapshot.Error", (error) => {
  269. const ref = `err_${crypto.randomUUID().slice(0, 8)}`
  270. return Effect.logError("failed to clear session revert", { cause: error }).pipe(
  271. Effect.andThen(
  272. Effect.fail(
  273. new UnknownError({
  274. message: "Unexpected server error. Check server logs for details.",
  275. ref,
  276. }),
  277. ),
  278. ),
  279. )
  280. }),
  281. )
  282. return HttpApiSchema.NoContent.make()
  283. }),
  284. )
  285. .handle(
  286. "session.revert.commit",
  287. Effect.fn(function* (ctx) {
  288. yield* session.revert.commit(ctx.params.sessionID).pipe(
  289. Effect.catchTag(
  290. "Session.NotFoundError",
  291. (error) =>
  292. new SessionNotFoundError({
  293. sessionID: error.sessionID,
  294. message: `Session not found: ${error.sessionID}`,
  295. }),
  296. ),
  297. )
  298. return HttpApiSchema.NoContent.make()
  299. }),
  300. )
  301. .handle(
  302. "session.context",
  303. Effect.fn(function* (ctx) {
  304. return {
  305. data: yield* session.context(ctx.params.sessionID).pipe(
  306. Effect.catchTag("Session.NotFoundError", (error) =>
  307. Effect.fail(
  308. new SessionNotFoundError({
  309. sessionID: error.sessionID,
  310. message: `Session not found: ${error.sessionID}`,
  311. }),
  312. ),
  313. ),
  314. Effect.catchTag("Session.MessageDecodeError", (error) => {
  315. const ref = `err_${crypto.randomUUID().slice(0, 8)}`
  316. return Effect.logError("failed to decode session message").pipe(
  317. Effect.annotateLogs({ ref, sessionID: error.sessionID, messageID: error.messageID }),
  318. Effect.andThen(
  319. Effect.fail(
  320. new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref }),
  321. ),
  322. ),
  323. )
  324. }),
  325. ),
  326. }
  327. }),
  328. )
  329. .handle(
  330. "session.history",
  331. Effect.fn(function* (ctx) {
  332. return yield* session
  333. .history({
  334. sessionID: ctx.params.sessionID,
  335. after: ctx.query.after,
  336. limit: ctx.query.limit ?? DefaultSessionHistoryLimit,
  337. })
  338. .pipe(
  339. Effect.map((page) => ({
  340. data: page.events,
  341. hasMore: page.hasMore,
  342. })),
  343. Effect.catchTag(
  344. "Session.NotFoundError",
  345. (error) =>
  346. new SessionNotFoundError({
  347. sessionID: error.sessionID,
  348. message: `Session not found: ${error.sessionID}`,
  349. }),
  350. ),
  351. )
  352. }),
  353. )
  354. .handle(
  355. "session.events",
  356. Effect.fn((ctx) =>
  357. Effect.succeed(
  358. session.events({ sessionID: ctx.params.sessionID, after: ctx.query.after }).pipe(Stream.orDie),
  359. ),
  360. ),
  361. )
  362. .handle(
  363. "session.interrupt",
  364. Effect.fn(function* (ctx) {
  365. yield* session.interrupt(ctx.params.sessionID)
  366. return HttpApiSchema.NoContent.make()
  367. }),
  368. )
  369. .handle(
  370. "session.message",
  371. Effect.fn(function* (ctx) {
  372. const message = yield* session.message(ctx.params)
  373. if (message) return { data: message }
  374. return yield* new MessageNotFoundError({
  375. sessionID: ctx.params.sessionID,
  376. messageID: ctx.params.messageID,
  377. message: `Message not found: ${ctx.params.messageID}`,
  378. })
  379. }),
  380. )
  381. }),
  382. )