session.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. export * as SessionV2 from "./session"
  2. export * from "./session/schema"
  3. import { DateTime, Effect, Layer, Schema, Context } from "effect"
  4. import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
  5. import { ProjectV2 } from "./project"
  6. import { WorkspaceV2 } from "./workspace"
  7. import { ModelV2 } from "./model"
  8. import { Location } from "./location"
  9. import { SessionMessage } from "./session/message"
  10. import type { Prompt } from "./session/prompt"
  11. import { EventV2 } from "./event"
  12. import { ProviderV2 } from "./provider"
  13. import { Database } from "./database/database"
  14. import { SessionProjector } from "./session/projector"
  15. import { SessionMessageTable, SessionTable } from "./session/sql"
  16. import { SessionSchema } from "./session/schema"
  17. import { AbsolutePath, RelativePath } from "./schema"
  18. // get project -> project.locations
  19. //
  20. // get all sessions
  21. //
  22. // - by project
  23. // - by subpath
  24. // - by workspace (home is special)
  25. export const ListCursor = Schema.Struct({
  26. id: SessionSchema.ID,
  27. time: Schema.Finite,
  28. direction: Schema.Literals(["previous", "next"]),
  29. })
  30. export type ListCursor = typeof ListCursor.Type
  31. const ListInputBase = {
  32. workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
  33. search: Schema.String.pipe(Schema.optional),
  34. limit: Schema.Int.pipe(Schema.optional),
  35. order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
  36. cursor: ListCursor.pipe(Schema.optional),
  37. }
  38. export const ListInput = Schema.Union([
  39. Schema.Struct({
  40. ...ListInputBase,
  41. }),
  42. Schema.Struct({
  43. ...ListInputBase,
  44. directory: AbsolutePath,
  45. }),
  46. Schema.Struct({
  47. ...ListInputBase,
  48. project: ProjectV2.ID,
  49. subpath: RelativePath.pipe(Schema.optional),
  50. }),
  51. ])
  52. export type ListInput = typeof ListInput.Type
  53. type CreateInput = {
  54. id?: SessionSchema.ID
  55. agent?: string
  56. model?: ModelV2.Ref
  57. location: Location.Ref
  58. }
  59. type MoveInput = {
  60. sessionID: SessionSchema.ID
  61. location: Location.Ref
  62. }
  63. type CompactInput = {
  64. sessionID: SessionSchema.ID
  65. prompt?: Prompt
  66. }
  67. export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
  68. sessionID: SessionSchema.ID,
  69. }) {}
  70. export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
  71. "Session.OperationUnavailableError",
  72. {
  73. operation: Schema.Literals(["prompt", "compact", "wait"]),
  74. },
  75. ) {}
  76. export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
  77. sessionID: SessionSchema.ID,
  78. messageID: SessionMessage.ID,
  79. }) {}
  80. export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError
  81. export interface Interface {
  82. readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
  83. readonly create: (input?: CreateInput) => Effect.Effect<SessionSchema.Info>
  84. readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
  85. readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
  86. readonly messages: (input: {
  87. sessionID: SessionSchema.ID
  88. limit?: number
  89. order?: "asc" | "desc"
  90. cursor?: {
  91. id: SessionMessage.ID
  92. time: number
  93. direction: "previous" | "next"
  94. }
  95. }) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
  96. readonly context: (
  97. sessionID: SessionSchema.ID,
  98. ) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
  99. readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, never>
  100. readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
  101. readonly prompt: (input: {
  102. id?: EventV2.ID
  103. sessionID: SessionSchema.ID
  104. prompt: Prompt
  105. delivery?: SessionSchema.Delivery
  106. resume?: boolean
  107. }) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
  108. readonly shell: (input: {
  109. id?: EventV2.ID
  110. sessionID: SessionSchema.ID
  111. command: string
  112. delivery?: SessionSchema.Delivery
  113. resume?: boolean
  114. }) => Effect.Effect<void, never>
  115. readonly skill: (input: {
  116. id?: EventV2.ID
  117. sessionID: SessionSchema.ID
  118. skill: string
  119. delivery?: SessionSchema.Delivery
  120. resume?: boolean
  121. }) => Effect.Effect<void, never>
  122. readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
  123. readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
  124. readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void>
  125. }
  126. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
  127. function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
  128. return new SessionSchema.Info({
  129. id: SessionSchema.ID.make(row.id),
  130. projectID: ProjectV2.ID.make(row.project_id),
  131. workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
  132. title: row.title,
  133. parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
  134. path: row.path ?? "",
  135. agent: row.agent ?? undefined,
  136. model: row.model
  137. ? {
  138. id: ModelV2.ID.make(row.model.id),
  139. providerID: ProviderV2.ID.make(row.model.providerID),
  140. variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
  141. }
  142. : undefined,
  143. cost: row.cost,
  144. tokens: {
  145. input: row.tokens_input,
  146. output: row.tokens_output,
  147. reasoning: row.tokens_reasoning,
  148. cache: {
  149. read: row.tokens_cache_read,
  150. write: row.tokens_cache_write,
  151. },
  152. },
  153. time: {
  154. created: DateTime.makeUnsafe(row.time_created),
  155. updated: DateTime.makeUnsafe(row.time_updated),
  156. archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
  157. },
  158. })
  159. }
  160. export const layer = Layer.effect(
  161. Service,
  162. Effect.gen(function* () {
  163. const db = (yield* Database.Service).db
  164. const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
  165. const decode = (row: typeof SessionMessageTable.$inferSelect) =>
  166. decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
  167. Effect.mapError(
  168. () =>
  169. new MessageDecodeError({
  170. sessionID: SessionSchema.ID.make(row.session_id),
  171. messageID: SessionMessage.ID.make(row.id),
  172. }),
  173. ),
  174. )
  175. const result = Service.of({
  176. create: Effect.fn("V2Session.create")(function* () {
  177. return {} as SessionSchema.Info
  178. }),
  179. get: Effect.fn("V2Session.get")(function* (sessionID) {
  180. const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
  181. if (!row) return yield* new NotFoundError({ sessionID })
  182. return fromRow(row)
  183. }),
  184. list: Effect.fn("V2Session.list")(function* (input = {}) {
  185. const direction = input.cursor?.direction ?? "next"
  186. const requestedOrder = input.order ?? "desc"
  187. const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
  188. const sortColumn = SessionTable.time_updated
  189. const conditions: SQL[] = []
  190. if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
  191. if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
  192. if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
  193. if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
  194. if (input.cursor) {
  195. conditions.push(
  196. order === "asc"
  197. ? or(
  198. gt(sortColumn, input.cursor.time),
  199. and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
  200. )!
  201. : or(
  202. lt(sortColumn, input.cursor.time),
  203. and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
  204. )!,
  205. )
  206. }
  207. const query = db
  208. .select()
  209. .from(SessionTable)
  210. .where(conditions.length > 0 ? and(...conditions) : undefined)
  211. .orderBy(
  212. order === "asc" ? asc(sortColumn) : desc(sortColumn),
  213. order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
  214. )
  215. const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
  216. Effect.orDie,
  217. )
  218. return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
  219. }),
  220. messages: Effect.fn("V2Session.messages")(function* (input) {
  221. yield* result.get(input.sessionID)
  222. const direction = input.cursor?.direction ?? "next"
  223. const requestedOrder = input.order ?? "desc"
  224. const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
  225. const boundary = input.cursor
  226. ? order === "asc"
  227. ? or(
  228. gt(SessionMessageTable.time_created, input.cursor.time),
  229. and(
  230. eq(SessionMessageTable.time_created, input.cursor.time),
  231. gt(SessionMessageTable.id, input.cursor.id),
  232. ),
  233. )
  234. : or(
  235. lt(SessionMessageTable.time_created, input.cursor.time),
  236. and(
  237. eq(SessionMessageTable.time_created, input.cursor.time),
  238. lt(SessionMessageTable.id, input.cursor.id),
  239. ),
  240. )
  241. : undefined
  242. const where = boundary
  243. ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
  244. : eq(SessionMessageTable.session_id, input.sessionID)
  245. const query = db
  246. .select()
  247. .from(SessionMessageTable)
  248. .where(where)
  249. .orderBy(
  250. order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
  251. order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
  252. )
  253. const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
  254. Effect.orDie,
  255. )
  256. return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
  257. }),
  258. context: Effect.fn("V2Session.context")(function* (sessionID) {
  259. yield* result.get(sessionID)
  260. const compaction = yield* db
  261. .select()
  262. .from(SessionMessageTable)
  263. .where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
  264. .orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
  265. .limit(1)
  266. .get()
  267. .pipe(Effect.orDie)
  268. const rows = yield* db
  269. .select()
  270. .from(SessionMessageTable)
  271. .where(
  272. and(
  273. eq(SessionMessageTable.session_id, sessionID),
  274. compaction
  275. ? or(
  276. gt(SessionMessageTable.time_created, compaction.time_created),
  277. and(
  278. eq(SessionMessageTable.time_created, compaction.time_created),
  279. gte(SessionMessageTable.id, compaction.id),
  280. ),
  281. )
  282. : undefined,
  283. ),
  284. )
  285. .orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
  286. .all()
  287. .pipe(Effect.orDie)
  288. return yield* Effect.forEach(rows, decode)
  289. }),
  290. prompt: Effect.fn("V2Session.prompt")(function* (input) {
  291. yield* result.get(input.sessionID)
  292. return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" }))
  293. }),
  294. shell: Effect.fn("V2Session.shell")(function* () {}),
  295. skill: Effect.fn("V2Session.skill")(function* () {}),
  296. switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}),
  297. switchModel: Effect.fn("V2Session.switchModel")(function* () {}),
  298. compact: Effect.fn("V2Session.compact")(function* (input) {
  299. yield* result.get(input.sessionID)
  300. return yield* new OperationUnavailableError({ operation: "compact" })
  301. }),
  302. wait: Effect.fn("V2Session.wait")(function* (sessionID) {
  303. yield* result.get(sessionID)
  304. return yield* new OperationUnavailableError({ operation: "wait" })
  305. }),
  306. resume: Effect.fn("V2Session.resume")(function* () {}),
  307. move: Effect.fn("V2Session.move")(function* () {}),
  308. })
  309. return result
  310. }),
  311. )
  312. export const defaultLayer = layer.pipe(
  313. Layer.provide(SessionProjector.defaultLayer),
  314. Layer.provide(Database.defaultLayer),
  315. Layer.orDie,
  316. )