/** 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() /** @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 { 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 const jobs = Object.create(null) as Record 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> { const blocks = Object.create(null) as Record 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() 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 { 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((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 { 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 }), } }