session.ts 20 KB

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