| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216 |
- /** Live Session queue, jobs, and projection state with reconnect baselines. */
- import type { Context } from '@deepseek-ai/cordis'
- import type { Agent } from '@deepseek-ai/dsh-agent'
- import { Deque } from '@deepseek-ai/dsh-deque'
- import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
- import type {
- Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
- } from '@deepseek-ai/dsh-session'
- import type { JsonValue } from '@deepseek-ai/dsh-util-values'
- import type {
- SessionControlBaseline,
- SessionControlFrame,
- SessionJob,
- SessionProjectionBaseline,
- SessionProjectionValues,
- SessionQueuedItem,
- } from './types.ts'
- /** Owns the Host-wide Session control stream. */
- export class SessionControlController {
- private readonly streams = new Set<ControlQueue>()
- /** @param ctx - Host context carrying live Agent, projection, and jobs services. */
- constructor(private readonly ctx: Context) {
- ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) })
- ctx.sessionProjections.onChanged((session, key, value, seq) => {
- this.broadcast({
- type: 'projection',
- sessionId: session.id,
- key,
- value: value as JsonValue,
- seq,
- })
- })
- ctx.inject(['jobs'], (jobsCtx) => {
- jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) })
- })
- ctx.on('session/created', (session) => {
- const jobs = this.jobsFor(this.ctx.agents.get(session.id))
- if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs })
- })
- ctx.effect(() => () => {
- for (const stream of this.streams) stream.end()
- this.streams.clear()
- }, 'session-controller.control')
- }
- /**
- * Open one generation of Host-wide live control state.
- * @param signal - Remote stream cancellation.
- * @returns one complete baseline followed by live replacement frames.
- */
- async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
- signal.throwIfAborted()
- const queue = new ControlQueue()
- this.streams.add(queue)
- try {
- yield { type: 'baseline', value: this.baseline() }
- yield* queue.iterate(signal)
- } finally {
- this.streams.delete(queue)
- queue.end()
- }
- }
- private baseline(): SessionControlBaseline {
- const sessions = this.ctx.sessions.list()
- const queues = Object.create(null) as Record<SessionId, readonly SessionQueuedItem[]>
- const jobs = Object.create(null) as Record<SessionId, readonly SessionJob[]>
- for (const session of sessions) {
- const agent = this.ctx.agents.get(session.id)
- queues[session.id] = agent?.session === session ? queueItems(agent) : []
- jobs[session.id] = this.jobsFor(agent)
- }
- return {
- queues,
- jobs,
- projections: this.projectionBaseline(sessions),
- }
- }
- private projectionBaseline(
- sessions: readonly Session[],
- ): Readonly<Record<SessionId, SessionProjectionBaseline>> {
- const blocks = Object.create(null) as Record<SessionId, SessionProjectionBaseline>
- for (const session of sessions) {
- const snapshot = this.ctx.sessionProjections.snapshot(session)
- blocks[session.id] = {
- asOfSeq: snapshot.asOfSeq,
- // Every projection definition validates its value before snapshot publication.
- values: snapshot.values as SessionProjectionValues,
- }
- }
- return blocks
- }
- private onSessionEvent(session: Session, event: SessionEvent): void {
- if (event.type !== 'agent/inbox/spliced') return
- const agent = this.ctx.agents.get(session.id)
- if (agent?.session !== session) return
- this.broadcast({
- type: 'queue',
- sessionId: session.id,
- items: queueItems(agent, event.data),
- })
- }
- private onJobsChanged(owner: Agent | undefined): void {
- if (owner !== undefined) {
- this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) })
- return
- }
- for (const session of this.ctx.sessions.list()) {
- this.broadcast({
- type: 'jobs',
- sessionId: session.id,
- jobs: this.jobsFor(this.ctx.agents.get(session.id)),
- })
- }
- }
- private jobsFor(agent: Agent | undefined): SessionJob[] {
- const jobs = this.ctx.get('jobs')
- return jobs === undefined ? [] : jobs.list(agent).map(jobView)
- }
- private broadcast(frame: SessionControlFrame): void {
- for (const stream of this.streams) stream.push(frame)
- }
- }
- class ControlQueue {
- private readonly buffer = new Deque<SessionControlFrame>()
- private wake: (() => void) | undefined
- private done = false
- push(frame: SessionControlFrame): void {
- if (this.done) return
- this.buffer.pushBack(frame)
- const wake = this.wake
- this.wake = undefined
- wake?.()
- }
- end(): void {
- if (this.done) return
- this.done = true
- const wake = this.wake
- this.wake = undefined
- wake?.()
- }
- async *iterate(signal: AbortSignal): AsyncIterable<SessionControlFrame> {
- const onAbort = (): void => { this.end() }
- signal.addEventListener('abort', onAbort, { once: true })
- try {
- while (!this.done && !signal.aborted) {
- const frame = this.buffer.popFront()
- if (frame !== undefined) {
- yield frame
- continue
- }
- await new Promise<void>((resolve) => { this.wake = resolve })
- }
- while (this.buffer.size > 0 && !signal.aborted) yield this.buffer.popFront() as SessionControlFrame
- } finally {
- signal.removeEventListener('abort', onAbort)
- this.end()
- }
- }
- }
- function queueItems(
- agent: Agent,
- splice?: SessionEventMap['agent/inbox/spliced'],
- ): SessionQueuedItem[] {
- const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
- const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
- return splice?.target === target
- ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
- : messages
- }
- return [
- ...project('next-turn').map(message => ({
- id: message.id,
- placement: 'queued' as const,
- ...promptRpcId(message),
- message: { id: message.id, content: message.content as unknown as JsonValue[] },
- })),
- ...project('next-step').map(message => ({
- id: message.id,
- placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
- ...promptRpcId(message),
- message: { id: message.id, content: message.content as unknown as JsonValue[] },
- })),
- ]
- }
- /** Prompt-RPC identity carried by a browser-submitted message's user source. */
- function promptRpcId(message: UserMessage): Pick<SessionQueuedItem, 'rpcId'> {
- const source = message.source
- return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {}
- }
- function jobView(job: JobSnapshot): SessionJob {
- return {
- id: job.id,
- kind: job.kind,
- label: job.label,
- status: job.status,
- ...(job.detail === undefined ? {} : { detail: job.detail }),
- startedAt: job.startedAt,
- ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
- }
- }
|