Files
deepseek-harness/packages/api/session-controller/src/control.ts
T
Yichen Jiang 6e41857b2e Merge remote-tracking branch 'origin/master' into worktree/jobs-api-review-3052
# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/api/remotes/src/client/index.ts
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/README.md
#	packages/api/session-controller/README.zh.md
#	packages/api/session-controller/src/client/sessions/manager.ts
#	packages/api/session-controller/src/control.ts
#	packages/api/session-controller/src/types.ts
#	packages/api/session-controller/tests/client-apply.client.spec.ts
#	packages/api/session-controller/tests/manager.client.spec.ts
#	packages/api/session-controller/tests/projection-store.client.spec.ts
#	packages/api/session-controller/tests/queue-store.client.spec.ts
#	packages/api/session-controller/tests/sessions-service.client.spec.ts
#	packages/api/session-controller/tests/transport.client.spec.ts
#	packages/client/ui-jobs/package.json
#	packages/client/ui-primitives/src/TerminalBlock.module.css
#	packages/client/ui-primitives/src/TerminalBlock.tsx
#	packages/client/ui-primitives/tests/terminal-block.client.spec.tsx
#	packages/extensions/tool-cordis/src/api-catalog.ts
#	packages/test-support/client-runtime/src/assembly/remote-default-responses.ts
2026-09-16 16:05:59 +08:00

122 lines
3.5 KiB
TypeScript

/** Live Session projection state with reconnect baselines. */
import type { Context } from '@deepseek-ai/cordis'
import { Deque } from '@deepseek-ai/dsh-deque'
import type {
Session, SessionId,
} from '@deepseek-ai/dsh-session'
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
import type {
SessionControlBaseline,
SessionControlFrame,
SessionProjectionBaseline,
SessionProjectionValues,
} 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 and projection services. */
constructor(private readonly ctx: Context) {
ctx.sessionProjections.onChanged((session, key, value, seq) => {
this.broadcast({
type: 'projection',
sessionId: session.id,
key,
value: value as JsonValue,
seq,
})
})
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()
return {
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 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()
}
}
}