mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
217 lines
7.0 KiB
TypeScript
217 lines
7.0 KiB
TypeScript
/** 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 }),
|
|
}
|
|
}
|