session.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. export * as SessionV2 from "./session"
  2. export * from "./session/schema"
  3. import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
  4. import { and, asc, desc, eq, gt, 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 { Prompt } from "./session/prompt"
  11. import { EventV2 } from "./event"
  12. import { Database } from "./database/database"
  13. import { SessionProjector } from "./session/projector"
  14. import { SessionMessageTable, SessionTable } from "./session/sql"
  15. import { SessionSchema } from "./session/schema"
  16. import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
  17. import { AgentV2 } from "./agent"
  18. import { SessionV1 } from "./v1/session"
  19. import { InstallationVersion } from "./installation/version"
  20. import { Slug } from "./util/slug"
  21. import { ProjectTable } from "./project/sql"
  22. import path from "path"
  23. import { fromRow } from "./session/info"
  24. import { SessionRunner } from "./session/runner/index"
  25. import { SessionStore } from "./session/store"
  26. import { SessionExecution } from "./session/execution"
  27. import { logFailure } from "./session/logging"
  28. import { MessageDecodeError } from "./session/error"
  29. import { SessionEvent } from "./session/event"
  30. import { SessionInput } from "./session/input"
  31. // get project -> project.locations
  32. //
  33. // get all sessions
  34. //
  35. // - by project
  36. // - by subpath
  37. // - by workspace (home is special)
  38. export const ListAnchor = Schema.Struct({
  39. id: SessionSchema.ID,
  40. time: Schema.Finite,
  41. direction: Schema.Literals(["previous", "next"]),
  42. })
  43. export type ListAnchor = typeof ListAnchor.Type
  44. const ListInputBase = {
  45. workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
  46. search: Schema.String.pipe(Schema.optional),
  47. limit: PositiveInt.pipe(Schema.optional),
  48. order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
  49. anchor: ListAnchor.pipe(Schema.optional),
  50. }
  51. const ListDirectoryInput = Schema.Struct({
  52. ...ListInputBase,
  53. directory: AbsolutePath,
  54. })
  55. const ListProjectInput = Schema.Struct({
  56. ...ListInputBase,
  57. project: ProjectV2.ID,
  58. subpath: RelativePath.pipe(Schema.optional),
  59. })
  60. const ListAllInput = Schema.Struct(ListInputBase)
  61. export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
  62. export type ListInput = typeof ListInput.Type
  63. type CreateInput = {
  64. id?: SessionSchema.ID
  65. agent?: AgentV2.ID
  66. model?: ModelV2.Ref
  67. location: Location.Ref
  68. }
  69. type CompactInput = {
  70. sessionID: SessionSchema.ID
  71. prompt?: Prompt
  72. }
  73. export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
  74. sessionID: SessionSchema.ID,
  75. }) {}
  76. export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
  77. "Session.OperationUnavailableError",
  78. {
  79. operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
  80. },
  81. ) {}
  82. export { ContextSnapshotDecodeError, MessageDecodeError } from "./session/error"
  83. export class PromptConflictError extends Schema.TaggedErrorClass<PromptConflictError>()("Session.PromptConflictError", {
  84. sessionID: SessionSchema.ID,
  85. messageID: SessionMessage.ID,
  86. }) {}
  87. export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError | PromptConflictError
  88. export interface Interface {
  89. readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
  90. readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info>
  91. readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
  92. readonly messages: (input: {
  93. sessionID: SessionSchema.ID
  94. limit?: number
  95. order?: "asc" | "desc"
  96. cursor?: {
  97. id: SessionMessage.ID
  98. direction: "previous" | "next"
  99. }
  100. }) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
  101. readonly message: (input: {
  102. sessionID: SessionSchema.ID
  103. messageID: SessionMessage.ID
  104. }) => Effect.Effect<SessionMessage.Message | undefined>
  105. readonly context: (
  106. sessionID: SessionSchema.ID,
  107. ) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
  108. readonly events: (input: {
  109. sessionID: SessionSchema.ID
  110. after?: number
  111. }) => Stream.Stream<SessionEvent.DurableEvent, NotFoundError>
  112. readonly switchAgent: (input: {
  113. sessionID: SessionSchema.ID
  114. agent: string
  115. }) => Effect.Effect<void, OperationUnavailableError>
  116. readonly switchModel: (input: {
  117. sessionID: SessionSchema.ID
  118. model: ModelV2.Ref
  119. }) => Effect.Effect<void, NotFoundError>
  120. readonly prompt: (input: {
  121. id?: SessionMessage.ID
  122. sessionID: SessionSchema.ID
  123. prompt: Prompt
  124. delivery?: SessionInput.Delivery
  125. resume?: boolean
  126. }) => Effect.Effect<SessionInput.Admitted, NotFoundError | PromptConflictError>
  127. readonly shell: (input: {
  128. id?: EventV2.ID
  129. sessionID: SessionSchema.ID
  130. command: string
  131. resume?: boolean
  132. }) => Effect.Effect<void, OperationUnavailableError>
  133. readonly skill: (input: {
  134. id?: EventV2.ID
  135. sessionID: SessionSchema.ID
  136. skill: string
  137. resume?: boolean
  138. }) => Effect.Effect<void, OperationUnavailableError>
  139. readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
  140. readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
  141. readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
  142. readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
  143. }
  144. export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
  145. export const layer = Layer.effect(
  146. Service,
  147. Effect.gen(function* () {
  148. const db = (yield* Database.Service).db
  149. const events = yield* EventV2.Service
  150. const projects = yield* ProjectV2.Service
  151. const execution = yield* SessionExecution.Service
  152. const store = yield* SessionStore.Service
  153. const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
  154. const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
  155. const scope = yield* Effect.scope
  156. const enqueueWake = (admitted: SessionInput.Admitted) =>
  157. execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
  158. Effect.tapCause((cause) =>
  159. Cause.hasInterruptsOnly(cause)
  160. ? Effect.void
  161. : logFailure("Failed to wake Session", admitted.sessionID, cause),
  162. ),
  163. Effect.ignore,
  164. Effect.forkIn(scope, { startImmediately: true }),
  165. Effect.asVoid,
  166. )
  167. const decode = (row: typeof SessionMessageTable.$inferSelect) =>
  168. decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
  169. Effect.mapError(
  170. () =>
  171. new MessageDecodeError({
  172. sessionID: SessionSchema.ID.make(row.session_id),
  173. messageID: SessionMessage.ID.make(row.id),
  174. }),
  175. ),
  176. )
  177. const result = Service.of({
  178. create: Effect.fn("V2Session.create")(function* (input) {
  179. const sessionID = input.id ?? SessionSchema.ID.create()
  180. const recorded = yield* store.get(sessionID)
  181. if (recorded) return recorded
  182. const project = yield* projects.resolve(input.location.directory)
  183. yield* db
  184. .insert(ProjectTable)
  185. .values({ id: project.id, worktree: project.directory, vcs: project.vcs?.type, sandboxes: [] })
  186. .onConflictDoNothing()
  187. .run()
  188. .pipe(Effect.orDie)
  189. const now = Date.now()
  190. const info = SessionV1.SessionInfo.make({
  191. id: sessionID,
  192. slug: Slug.create(),
  193. version: InstallationVersion,
  194. projectID: project.id,
  195. directory: input.location.directory,
  196. path: path.relative(project.directory, input.location.directory).replaceAll("\\", "/"),
  197. workspaceID: input.location.workspaceID ? WorkspaceV2.ID.make(input.location.workspaceID) : undefined,
  198. title: `New session - ${new Date(now).toISOString()}`,
  199. agent: input.agent,
  200. model: input.model
  201. ? {
  202. id: ModelV2.ID.make(input.model.id),
  203. providerID: input.model.providerID,
  204. variant: input.model.variant,
  205. }
  206. : undefined,
  207. cost: 0,
  208. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  209. time: { created: now, updated: now },
  210. })
  211. const projected = yield* events
  212. .publish(SessionV1.Event.Created, { sessionID, info }, { location: input.location })
  213. .pipe(
  214. Effect.as({ type: "created" } as const),
  215. Effect.catchDefect((defect) => {
  216. if (!(defect instanceof SessionProjector.SessionAlreadyProjected)) {
  217. return Effect.die(defect)
  218. }
  219. // Concurrent creation lost the projection race. The existing Session identity wins.
  220. return store
  221. .get(sessionID)
  222. .pipe(
  223. Effect.flatMap((session) =>
  224. session ? Effect.succeed({ type: "existing", session } as const) : Effect.die(defect),
  225. ),
  226. )
  227. }),
  228. )
  229. if (projected.type === "existing") return projected.session
  230. // TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
  231. return yield* result.get(sessionID).pipe(Effect.orDie)
  232. }),
  233. get: Effect.fn("V2Session.get")(function* (sessionID) {
  234. const session = yield* store.get(sessionID)
  235. if (!session) return yield* new NotFoundError({ sessionID })
  236. return session
  237. }),
  238. list: Effect.fn("V2Session.list")(function* (input = {}) {
  239. const direction = input.anchor?.direction ?? "next"
  240. const requestedOrder = input.order ?? "desc"
  241. const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
  242. const sortColumn = SessionTable.time_created
  243. const conditions: SQL[] = []
  244. if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
  245. if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
  246. if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
  247. if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
  248. if (input.anchor) {
  249. conditions.push(
  250. order === "asc"
  251. ? or(
  252. gt(sortColumn, input.anchor.time),
  253. and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
  254. )!
  255. : or(
  256. lt(sortColumn, input.anchor.time),
  257. and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
  258. )!,
  259. )
  260. }
  261. const query = db
  262. .select()
  263. .from(SessionTable)
  264. .where(conditions.length > 0 ? and(...conditions) : undefined)
  265. .orderBy(
  266. order === "asc" ? asc(sortColumn) : desc(sortColumn),
  267. order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
  268. )
  269. const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
  270. Effect.orDie,
  271. )
  272. return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
  273. }),
  274. messages: Effect.fn("V2Session.messages")(function* (input) {
  275. yield* result.get(input.sessionID)
  276. const direction = input.cursor?.direction ?? "next"
  277. const requestedOrder = input.order ?? "desc"
  278. const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
  279. const anchor = input.cursor
  280. ? yield* db
  281. .select({ seq: SessionMessageTable.seq })
  282. .from(SessionMessageTable)
  283. .where(
  284. and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
  285. )
  286. .get()
  287. .pipe(Effect.orDie)
  288. : undefined
  289. if (input.cursor && !anchor) return []
  290. const boundary = anchor
  291. ? order === "asc"
  292. ? gt(SessionMessageTable.seq, anchor.seq)
  293. : lt(SessionMessageTable.seq, anchor.seq)
  294. : undefined
  295. const where = boundary
  296. ? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
  297. : eq(SessionMessageTable.session_id, input.sessionID)
  298. const query = db
  299. .select()
  300. .from(SessionMessageTable)
  301. .where(where)
  302. .orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
  303. const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
  304. Effect.orDie,
  305. )
  306. return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
  307. }),
  308. message: Effect.fn("V2Session.message")(function* (input) {
  309. const stored = yield* store.message(input.messageID)
  310. return stored?.sessionID === input.sessionID ? stored.message : undefined
  311. }),
  312. context: Effect.fn("V2Session.context")(function* (sessionID) {
  313. yield* result.get(sessionID)
  314. return yield* store.context(sessionID)
  315. }),
  316. events: (input) =>
  317. Stream.unwrap(
  318. result
  319. .get(input.sessionID)
  320. .pipe(Effect.as(events.durable({ aggregateID: input.sessionID, after: input.after }))),
  321. ).pipe(Stream.filter((event): event is SessionEvent.DurableEvent => isDurableSessionEvent(event))),
  322. prompt: Effect.fn("V2Session.prompt")((input) =>
  323. Effect.uninterruptible(
  324. Effect.gen(function* () {
  325. yield* result.get(input.sessionID)
  326. const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
  327. if (input.resume !== false) yield* enqueueWake(admitted)
  328. return admitted
  329. }, Effect.uninterruptible)
  330. const messageID = input.id ?? SessionMessage.ID.create()
  331. const delivery = input.delivery ?? "steer"
  332. const expected = { sessionID: input.sessionID, messageID, prompt: input.prompt, delivery }
  333. const admitted = yield* SessionInput.admit(db, events, {
  334. id: messageID,
  335. sessionID: input.sessionID,
  336. prompt: input.prompt,
  337. delivery,
  338. }).pipe(
  339. Effect.catchDefect((defect) =>
  340. defect instanceof SessionInput.LifecycleConflict
  341. ? new PromptConflictError({ sessionID: input.sessionID, messageID })
  342. : Effect.die(defect),
  343. ),
  344. )
  345. if (!SessionInput.equivalent(admitted, expected))
  346. return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
  347. return yield* returnPrompt(admitted)
  348. }),
  349. ),
  350. ),
  351. shell: Effect.fn("V2Session.shell")(function* () {
  352. return yield* new OperationUnavailableError({ operation: "shell" })
  353. }),
  354. skill: Effect.fn("V2Session.skill")(function* () {
  355. return yield* new OperationUnavailableError({ operation: "skill" })
  356. }),
  357. switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
  358. return yield* new OperationUnavailableError({ operation: "switchAgent" })
  359. }),
  360. switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
  361. yield* result.get(input.sessionID)
  362. yield* events.publish(SessionEvent.ModelSwitched, {
  363. sessionID: input.sessionID,
  364. messageID: SessionMessage.ID.create(),
  365. timestamp: yield* DateTime.now,
  366. model: input.model,
  367. })
  368. }),
  369. compact: Effect.fn("V2Session.compact")(function* (input) {
  370. yield* result.get(input.sessionID)
  371. return yield* new OperationUnavailableError({ operation: "compact" })
  372. }),
  373. wait: Effect.fn("V2Session.wait")(function* (sessionID) {
  374. yield* result.get(sessionID)
  375. return yield* new OperationUnavailableError({ operation: "wait" })
  376. }),
  377. resume: Effect.fn("V2Session.resume")(function* (sessionID) {
  378. yield* result.get(sessionID)
  379. yield* execution.resume(sessionID)
  380. }),
  381. interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
  382. Effect.uninterruptible(
  383. Effect.gen(function* () {
  384. const session = yield* store.get(sessionID)
  385. if (!session) return yield* execution.interrupt(sessionID)
  386. const event = yield* events.publish(SessionEvent.InterruptRequested, {
  387. sessionID,
  388. timestamp: yield* DateTime.now,
  389. })
  390. if (event.durable === undefined)
  391. return yield* Effect.die("Interrupt request event is missing aggregate sequence")
  392. yield* execution.interrupt(sessionID, event.durable.seq)
  393. }),
  394. ),
  395. ),
  396. })
  397. return result
  398. }),
  399. )
  400. export const defaultLayer = layer.pipe(
  401. Layer.provide(SessionExecution.noopLayer),
  402. Layer.provide(SessionStore.defaultLayer),
  403. Layer.provide(SessionProjector.defaultLayer),
  404. Layer.provide(EventV2.defaultLayer),
  405. Layer.provide(Database.defaultLayer),
  406. Layer.provide(ProjectV2.defaultLayer),
  407. Layer.orDie,
  408. )