control.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. /** Live Session queue, jobs, and projection state with reconnect baselines. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import { Deque } from '@deepseek-ai/dsh-deque'
  5. import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
  6. import type {
  7. Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
  8. } from '@deepseek-ai/dsh-session'
  9. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  10. import type {
  11. SessionControlBaseline,
  12. SessionControlFrame,
  13. SessionJob,
  14. SessionProjectionBaseline,
  15. SessionProjectionValues,
  16. SessionQueuedItem,
  17. } from './types.ts'
  18. /** Owns the Host-wide Session control stream. */
  19. export class SessionControlController {
  20. private readonly streams = new Set<ControlQueue>()
  21. /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
  22. constructor(private readonly ctx: Context) {
  23. ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
  24. ctx.sessionProjections.onChanged((session, key, value, seq) => {
  25. this.broadcast({
  26. type: 'projection',
  27. sessionId: session.id,
  28. key,
  29. value: value as JsonValue,
  30. seq,
  31. })
  32. })
  33. ctx.inject(['jobs'], (jobsCtx) => {
  34. jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
  35. })
  36. ctx.on('session/created', (session) => {
  37. const jobs = this.jobsFor(this.ctx.agents.get(session.id))
  38. if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
  39. })
  40. ctx.effect(() => () => {
  41. for (const stream of this.streams) stream.end()
  42. this.streams.clear()
  43. }, 'session-controller.control')
  44. }
  45. /**
  46. * Open one generation of Host-wide live control state.
  47. * @param signal - Remote stream cancellation.
  48. * @returns one complete baseline followed by live replacement frames.
  49. */
  50. async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
  51. signal.throwIfAborted()
  52. const queue = new ControlQueue()
  53. this.streams.add(queue)
  54. try {
  55. yield { type: 'baseline', value: this.baseline() }
  56. yield* queue.iterate(signal)
  57. } finally {
  58. this.streams.delete(queue)
  59. queue.end()
  60. }
  61. }
  62. private baseline(): SessionControlBaseline {
  63. const sessions = this.ctx.sessions.list()
  64. const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
  65. const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
  66. for (const session of sessions) {
  67. const agent = this.ctx.agents.get(session.id)
  68. queues[session.id] = agent?.session === session ? queueItems(agent) : []
  69. jobs[session.id] = this.jobsFor(agent)
  70. }
  71. return {
  72. queues,
  73. jobs,
  74. projections: this.projectionBaseline(sessions),
  75. }
  76. }
  77. private projectionBaseline(
  78. sessions: readonly Session[],
  79. ): Readonly<Record<SessionId, SessionProjectionBaseline>> {
  80. const blocks = Object.create(null) as Record<SessionId, SessionProjectionBaseline>
  81. for (const session of sessions) {
  82. const snapshot = this.ctx.sessionProjections.snapshot(session)
  83. blocks[session.id] = {
  84. asOfSeq: snapshot.asOfSeq,
  85. // Every projection definition validates its value before snapshot publication.
  86. values: snapshot.values as SessionProjectionValues,
  87. }
  88. }
  89. return blocks
  90. }
  91. private onSessionEvent(session: Session, event: SessionEvent): void {
  92. if (event.type !== 'agent/inbox/spliced') return
  93. const agent = this.ctx.agents.get(session.id)
  94. if (agent?.session !== session) return
  95. this.broadcast({
  96. type: 'queue',
  97. sessionId: session.id,
  98. items: queueItems(agent, event.data),
  99. })
  100. }
  101. private onJobsChanged(owner: Agent | undefined): void {
  102. if (owner !== undefined) {
  103. this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) })
  104. return
  105. }
  106. for (const session of this.ctx.sessions.list()) {
  107. this.broadcast({
  108. type: 'jobs',
  109. sessionId: session.id,
  110. jobs: this.jobsFor(this.ctx.agents.get(session.id)),
  111. })
  112. }
  113. }
  114. private jobsFor(agent: Agent | undefined): SessionJob[] {
  115. const jobs = this.ctx.get('jobs')
  116. return jobs === undefined ? [] : jobs.list(agent).map(jobView)
  117. }
  118. private broadcast(frame: SessionControlFrame): void {
  119. for (const stream of this.streams) stream.push(frame)
  120. }
  121. }
  122. class ControlQueue {
  123. private readonly buffer = new Deque<SessionControlFrame>()
  124. private wake: (() => void) | undefined
  125. private done = false
  126. push(frame: SessionControlFrame): void {
  127. if (this.done) return
  128. this.buffer.pushBack(frame)
  129. const wake = this.wake
  130. this.wake = undefined
  131. wake?.()
  132. }
  133. end(): void {
  134. if (this.done) return
  135. this.done = true
  136. const wake = this.wake
  137. this.wake = undefined
  138. wake?.()
  139. }
  140. async *iterate(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
  141. const onAbort = (): void => { this.end() }
  142. signal.addEventListener('abort', onAbort, { once: true })
  143. try {
  144. while (!this.done && !signal.aborted) {
  145. const frame = this.buffer.popFront()
  146. if (frame !== undefined) {
  147. yield frame
  148. continue
  149. }
  150. await new Promise<void>((resolve) => { this.wake = resolve })
  151. }
  152. while (this.buffer.size > 0 && !signal.aborted) yield this.buffer.popFront() as SessionControlFrame
  153. } finally {
  154. signal.removeEventListener('abort', onAbort)
  155. this.end()
  156. }
  157. }
  158. }
  159. function queueItems(
  160. agent: Agent,
  161. splice?: SessionEventMap['agent/inbox/spliced'],
  162. ): SessionQueuedItem[] {
  163. const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
  164. const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
  165. return splice?.target === target
  166. ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
  167. : messages
  168. }
  169. return [
  170. ...project('next-turn').map(message => ({
  171. id: message.id,
  172. placement: 'queued' as const,
  173. ...promptRpcId(message),
  174. message: { id: message.id, content: message.content as unknown as JsonValue[] },
  175. })),
  176. ...project('next-step').map(message => ({
  177. id: message.id,
  178. placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
  179. ...promptRpcId(message),
  180. message: { id: message.id, content: message.content as unknown as JsonValue[] },
  181. })),
  182. ]
  183. }
  184. /** Prompt-RPC identity carried by a browser-submitted message's user source. */
  185. function promptRpcId(message: UserMessage): Pick<SessionQueuedItem, 'rpcId'> {
  186. const source = message.source
  187. return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {}
  188. }
  189. function jobView(job: JobSnapshot): SessionJob {
  190. return {
  191. id: job.id,
  192. kind: job.kind,
  193. label: job.label,
  194. status: job.status,
  195. ...(job.detail === undefined ? {} : { detail: job.detail }),
  196. startedAt: job.startedAt,
  197. ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
  198. }
  199. }