Merge branch 'worktree/process-plugin-streaming-72f9b3' into worktree/job-kill-delivery

# Conflicts:
#	apps/web/tests/background-job-list.e2e.ts
#	apps/web/tests/live-job-stream.e2e.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	packages/client/ui-activity/README.md
#	packages/client/ui-activity/README.zh.md
#	packages/client/ui-activity/src/client/index.ts
#	packages/client/ui-jobs/README.i18n.yaml
#	packages/client/ui-jobs/src/client/JobListAction.tsx
#	packages/client/ui-jobs/tests/browser-plugin.client.spec.ts
#	packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
#	packages/jobs/jobs-local/src/index.ts
#	packages/jobs/jobs/README.i18n.yaml
#	packages/jobs/jobs/README.md
#	packages/jobs/jobs/README.zh.md
#	packages/jobs/jobs/src/index.ts
#	packages/shell/tool-bash/src/index.ts
#	packages/shell/tool-bash/tests/observe.spec.ts
#	packages/shell/tool-pwsh/src/index.ts
#	packages/shell/tool-pwsh/tests/observe.spec.ts
#	packages/workflow/tool-workflow/tests/activity-mirror.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang committed 2026-09-01 19:35:23 +08:00
commit a2fb67d06c
187 files changed
+3599 -7085

No files matched your search

@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent/types'
import { createSessionControlStream } from './transport.ts'
import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
import { ClientSessions } from './sessions/service.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
import type {} from '../remote-events.ts'
@@ -23,6 +24,8 @@ export type {
} from './transport.ts'
export { createScope, scopeOf } from './scope.ts'
export type { AgentContext, AgentScopeHandle } from './scope.ts'
export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts'
export { SessionCreateError, SessionForkError } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export type {
@@ -89,6 +92,14 @@ export const inject = [
export function apply(ctx: Context): void {
const remotes = ctx.remote as unknown as SessionRemotes
const sessions = new ClientSessions(ctx, remotes)
// Resolve the session namespace to a concrete value while this plugin's
// context is current: observation (re)opens run on caller stacks (a React
// event, a carrier retry) whose dynamic context has not declared
// `remote.session`, and a property access there fails the inject check.
new ClientJobOutput(ctx, {
$stream: options => remotes.$stream(options),
session: remotes.session,
}, new ClientJobOutputModel())
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
ctx.remote.$on('api-session/status', (sessionId, running) => {
@@ -0,0 +1,348 @@
/**
* React-free client job-record observation: per-job accumulated output views
* over the `session.observeJob` stream, reference-counted per job so
* overlapping panels share one stream. The roster itself rides the session
* control stream (`jobsBySession`); this module carries only the output.
* @module @deepseek-ai/dsh-api-session-controller/client/job-output
*/
import { Service, type Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionJob, SessionObserveJobFrame } from '../types.ts'
import type { SessionRemote } from './transport.ts'
/** Bounded per-job render tail, in UTF-16 code units. */
const RENDER_TAIL_LIMIT = 128 * 1024
/** One observed job's live view state. */
export interface ObservedJob {
readonly jobId: JobId
/** Accumulated output tail, bounded to the render limit. */
readonly text: string
/** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
readonly gapBefore: boolean
/** Latest status delivered on the observation stream. */
readonly status: SessionJob['status']
readonly detail?: string
/** True while the observation stream is open and the job has not settled. */
readonly streaming: boolean
/** Terminal observation failure, when the stream ended abnormally. */
readonly error?: string
}
/** Immutable client job-output state. */
export interface JobOutputSnapshot {
/** Live observation state keyed by job id. */
readonly observed: Readonly<Record<string, ObservedJob>>
}
/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */
interface ObservedState {
view: ObservedJob
/** Resume offset for the next generation's `from`. */
cursor: number | undefined
}
/**
* Owns per-job observation state. Pure data plus subscriptions — transport
* wiring stays in the client service, UI stays in slot components.
*/
export class ClientJobOutputModel {
private readonly observedStates = new Map<string, ObservedState>()
private readonly listeners = new Set<() => void>()
private snapshotCache: JobOutputSnapshot = { observed: {} }
private snapshotDirty = false
/**
* Read the identity-stable current snapshot.
* @returns the cached snapshot, rebuilt only after a change.
*/
getSnapshot(): JobOutputSnapshot {
if (this.snapshotDirty) {
const observed: Record<string, ObservedJob> = {}
for (const [id, state] of this.observedStates) observed[id] = state.view
this.snapshotCache = { observed }
this.snapshotDirty = false
}
return this.snapshotCache
}
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* The resume offset for one job's next observation generation.
* @param id - observed job.
* @returns the last accepted `next`, or undefined for a fresh observation.
*/
cursorOf(id: JobId): number | undefined {
return this.observedStates.get(String(id))?.cursor
}
/**
* Install or reset observation state when a generation's anchor arrives.
* @param id - observed job.
* @param frame - the generation's `opened` anchor.
*/
observeOpened(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'opened' }>): void {
const existing = this.observedStates.get(String(id))
// A fresh view anchored past offset zero starts after an evicted head
// (fresh observations anchor at the registry's earliest retained byte), so
// it owes the same gap mark a live observer earned from lossy reads. A
// resume that already accumulated text keeps its recorded gap state.
const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
const view: ObservedJob = {
jobId: id,
text: existing?.view.text ?? '',
gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: true,
}
this.observedStates.set(String(id), { view, cursor: frame.from })
this.changed()
}
/**
* Append one output frame's chunks to the bounded render tail.
* @param id - observed job.
* @param frame - a coalesced `output` frame.
*/
observeOutput(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'output' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
let gapBefore = state.view.gapBefore || frame.lossy === true
|| frame.chunks.some(chunk => chunk.gapBefore === true)
if (text.length > RENDER_TAIL_LIMIT) {
let cut = text.length - RENDER_TAIL_LIMIT
// Never split a surrogate pair at the render bound.
const unit = text.charCodeAt(cut)
if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
text = text.slice(cut)
gapBefore = true
}
state.view = { ...state.view, text, gapBefore }
state.cursor = frame.next
this.changed()
}
/**
* Record the terminal status delivered at the end of the observation stream.
* @param id - observed job.
* @param frame - the terminal `status` frame.
*/
observeStatus(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'status' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
state.view = {
...state.view,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: false,
}
this.changed()
}
/**
* Record a terminal observation failure.
* @param id - observed job.
* @param error - the stream's terminal failure.
*/
observeFailed(id: JobId, error: unknown): void {
const state = this.observedStates.get(String(id))
if (state === undefined) return
state.view = { ...state.view, streaming: false, error: String(error) }
this.changed()
}
/**
* Drop observation state after the last observer stops.
* @param id - the no-longer-observed job.
*/
observeStopped(id: JobId): void {
if (!this.observedStates.delete(String(id))) return
this.changed()
}
private changed(): void {
this.snapshotDirty = true
notifySubscribers(this.listeners, 'job output')
}
}
/** Bare observable source for the client job-output snapshot. */
export interface JobOutputSource {
/** Read the identity-stable current snapshot. */
getSnapshot(): JobOutputSnapshot
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void
}
/** The job-output client service face. */
export interface IJobOutput {
/** Per-job observation state. */
readonly state: JobOutputSource
/**
* Start observing one job's live record; reference-counted, so two viewers
* of the same job share one stream.
* @param sessionId - owning session used for the fenced read; undefined for an unowned job.
* @param id - job to observe.
* @returns stop function releasing this observer's reference.
*/
observe(sessionId: SessionId | undefined, id: JobId): () => void
}
/** One reference-counted observation stream. */
interface ObservationEntry {
refs: number
stopped: boolean
dispose: () => Promise<void>
}
/** Remote face the observation runner drives. */
type JobObserveRemote = Pick<ClientRemote, '$stream'> & { readonly session: Pick<SessionRemote, 'observeJob'> }
declare module '@deepseek-ai/cordis' {
interface Context {
/** React-free client job-record observation control. */
jobOutput: IJobOutput
}
}
/** Owns the bare job-output snapshot and per-job observation streams. */
export class ClientJobOutput extends Service implements IJobOutput {
readonly state: JobOutputSource
private readonly entries = new Map<string, ObservationEntry>()
/**
* @param ctx - client root Context.
* @param remote - generated session namespace plus the Gateway stream factory.
* @param model - shared client job-output model.
*/
constructor(
ctx: Context,
private readonly remote: JobObserveRemote,
private readonly model: ClientJobOutputModel,
) {
super(ctx, 'jobOutput')
this.state = model
ctx.effect(() => () => {
const open = [...this.entries.values()]
this.entries.clear()
for (const entry of open) {
entry.stopped = true
void entry.dispose()
}
}, 'session-controller.client.observations')
}
observe(sessionId: SessionId | undefined, id: JobId): () => void {
const key = String(id)
const existing = this.entries.get(key)
if (existing !== undefined && !existing.stopped) {
existing.refs += 1
return this.releaser(key, existing)
}
const entry = this.startObservation(sessionId, id)
this.entries.set(key, entry)
return this.releaser(key, entry)
}
/**
* Release closures bind the exact entry they were minted for, never the
* map's current occupant: a later `observe()` on the same id may have
* replaced a stopped entry, and decrementing or disposing through the key
* alone would tear down that newer stream's references.
*/
private releaser(key: string, entry: ObservationEntry): () => void {
let released = false
return () => {
if (released) return
released = true
entry.refs -= 1
if (entry.refs > 0) return
if (this.entries.get(key) === entry) this.entries.delete(key)
entry.stopped = true
void entry.dispose().then(() => {
// Clear the view only while no successor observation holds the key:
// a re-expand inside the dispose round-trip already re-anchored the
// model, and a stale clear would blank its panel for good.
if (this.entries.has(key)) return
// The key is the stringified branded id this releaser was minted for.
this.model.observeStopped(key as JobId)
})
}
}
private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry {
const name = `job observation ${String(id)}`
const stream = this.remote.$stream<SessionObserveJobFrame>({
name,
open: (signal) => {
const from = this.model.cursorOf(id)
return this.remote.session.observeJob(
{
jobId: id,
...sessionId !== undefined ? { sessionId } : {},
...from !== undefined ? { from } : {},
},
signal,
)
},
// A premature end after the anchor is retryable (a Host reload closes the
// generation); resuming from the cursor loses nothing. An end before the
// anchor is terminal.
ended: accepted => accepted
? new RemoteStreamCarrierError(`${name} ended before settlement`)
: new Error(`${name} ended before its anchor`),
})
const entry: ObservationEntry = {
refs: 1,
stopped: false,
dispose: () => stream.dispose(),
}
void (async () => {
try {
for await (const item of stream) {
const frame = item.value
if (frame.type === 'opened') {
this.model.observeOpened(id, frame)
item.accept()
continue
}
if (frame.type === 'output') {
this.model.observeOutput(id, frame)
continue
}
// Terminal status: leave the loop before the generation end is
// classified, then close the stream for good.
this.model.observeStatus(id, frame)
break
}
} catch (error) {
if (!entry.stopped) this.model.observeFailed(id, error)
} finally {
entry.stopped = true
void entry.dispose()
}
})()
return entry
}
}
@@ -212,5 +212,6 @@ function jobView(job: JobSnapshot): SessionJob {
...(job.detail === undefined ? {} : { detail: job.detail }),
startedAt: job.startedAt,
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }),
}
}
@@ -2,6 +2,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-jobs'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -15,6 +16,7 @@ import {
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { observeJobRecord } from './observe-job.ts'
import { SessionFileReferences } from './file-references.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import { buildModelCatalog } from './catalog.ts'
@@ -37,6 +39,8 @@ import type {
SessionKillJobValue,
SessionListRequest,
SessionListValue,
SessionObserveJobFrame,
SessionObserveJobRequest,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
@@ -65,12 +69,22 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Default coalescing window between job-observation reads, in milliseconds. */
const DEFAULT_OBSERVE_FLUSH_MS = 100
/** Default soft byte budget per job-observation output frame. */
const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size eligible for one full projection observation. */
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
/** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
/** Host integrations replaceable by direct unit tests. */
@@ -98,6 +112,8 @@ export class SessionController extends TypertRemoteService {
static Config: z<Config> = z.object({
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
nativeOpen: z.boolean(),
observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS),
observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES),
})
private readonly agents: ApiSessionAgentController
@@ -108,6 +124,8 @@ export class SessionController extends TypertRemoteService {
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
private readonly promotions = new Set<Promise<void>>()
private readonly observeFlushMs: number
private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context containing the Session capability assembly.
@@ -132,6 +150,8 @@ export class SessionController extends TypertRemoteService {
this.openPath = internals.openPath ?? openNativePath
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS
this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES
ctx.plugin(SessionFileReferences)
ctx.plugin(SessionSkillCatalog)
@@ -399,6 +419,30 @@ export class SessionController extends TypertRemoteService {
return this.controlState.control(signal)
}
/**
* Stream one job's retained record output from an absolute byte offset,
* then its terminal status once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session resolves the fenced-read caller; the registry rejects a
* job the session does not own, a job without a record, or an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' })
observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable<SessionObserveJobFrame> {
const registry = this.ctx.get('jobs')
if (registry === undefined) {
throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)')
}
const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId)
return observeJobRecord(registry, request, {
flushMs: this.observeFlushMs,
maxFrameBytes: this.observeMaxFrameBytes,
caller,
}, signal)
}
}
export { buildModelCatalog }
@@ -0,0 +1,191 @@
/** Per-job record observation generations: anchor, coalesced output, terminal status. */
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs'
import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts'
/** Cadence and framing bounds for one observation generation. */
export interface ObserveJobOptions {
/** Coalescing window after a wake before reading, in milliseconds. */
readonly flushMs: number
/**
* Soft byte budget per `output` frame. Accumulated chunks flush once the
* budget is met; one chunk larger than the budget ships whole (the
* registry's retention cap is the hard bound on any single chunk).
*/
readonly maxFrameBytes: number
/** The live owning agent for fenced reads, resolved from the request's session. */
readonly caller: Agent | undefined
}
function isTerminal(status: JobSnapshot['status']): boolean {
return status !== 'running' && status !== 'stopping'
}
/** A record job's total offset; the anchor check proved the declaration and it never leaves. */
function recordTotal(snapshot: JobSnapshot): number {
/* v8 ignore next -- a declared record never loses its offsets. */
return snapshot.outputTotal ?? 0
}
/** Wake-flag waiter: a wake between waits is never lost. */
class OutputWaiter {
private dirty = false
private resolve: (() => void) | undefined
wake(): void {
this.dirty = true
this.resolve?.()
}
/**
* Resolve on the next wake, immediately when one already arrived, or on abort.
* @param signal - generation cancellation.
*/
wait(signal: AbortSignal): Promise<void> {
if (this.dirty || signal.aborted) {
this.dirty = false
return Promise.resolve()
}
return new Promise((resolve) => {
const finish = (): void => {
signal.removeEventListener('abort', finish)
/* v8 ignore next -- one wait owns the sole installed resolver. */
if (this.resolve === finish) this.resolve = undefined
this.dirty = false
resolve()
}
this.resolve = finish
signal.addEventListener('abort', finish, { once: true })
})
}
}
function sleep(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise((resolve) => {
const timer = setTimeout(done, ms)
function done(): void {
clearTimeout(timer)
signal.removeEventListener('abort', done)
resolve()
}
signal.addEventListener('abort', done, { once: true })
})
}
/**
* Stream one job's retained record output from an absolute offset: one
* `opened` anchor, coalesced `output` frames as the record advances, then one
* terminal `status` after the settled job is drained, after which the
* generation closes normally. Reads are non-consuming — the model-facing
* cursor and notice state never observe them; reconnecting callers resume by
* passing the last frame's `next` as `from`. Throws for a job that declared
* no record.
* @param registry - the live job registry.
* @param request - target job and optional resume offset.
* @param options - cadence, framing bounds, and the fenced-read caller.
* @param signal - generation cancellation owned by the Remote stream carrier.
* @returns the observation frame sequence for one generation.
*/
export async function* observeJobRecord(
registry: JobRegistry,
request: SessionObserveJobRequest,
options: ObserveJobOptions,
signal: AbortSignal,
): AsyncIterable<SessionObserveJobFrame> {
if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) {
throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`)
}
signal.throwIfAborted()
// The brand is nominal typing only; the wire boundary stamps it here rather
// than value-importing the optional registry package's constructor.
const id = String(request.jobId) as JobId
const waiter = new OutputWaiter()
// Subscribe before the first read so an append between the anchor read and
// the wait cannot be missed.
const unsubscribe = registry.onOutput((changed) => {
if (changed === id) waiter.wake()
})
try {
const caller = options.caller
let snapshot = registry.get(id, caller)
if (snapshot.outputTotal === undefined || snapshot.outputEarliest === undefined) {
throw new Error(`job ${id} declared no output record`)
}
let cursor = request.from ?? snapshot.outputEarliest
yield {
type: 'opened',
jobId: id,
from: cursor,
earliest: snapshot.outputEarliest,
total: snapshot.outputTotal,
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
}
while (!signal.aborted) {
const read = registry.readRecord(id, cursor, caller)
if (read.chunks.length > 0) {
yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes)
}
cursor = read.next
snapshot = registry.get(id, caller)
if (isTerminal(snapshot.status) && cursor >= recordTotal(snapshot)) {
yield {
type: 'status',
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
// Settlement always stamps finishedAt; the guard only discharges the optional field type.
/* v8 ignore next */
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
}
return
}
await waiter.wait(signal)
// Let a burst accumulate so producer chatter becomes bounded frames.
await sleep(options.flushMs, signal)
}
} finally {
unsubscribe()
}
}
/** Split one read into frames along the soft per-frame byte budget. */
function* outputFrames(
chunks: readonly SessionJobWireChunk[],
next: number,
lossy: boolean,
maxFrameBytes: number,
): Iterable<SessionObserveJobFrame> {
let batch: SessionJobWireChunk[] = []
let batchBytes = 0
let flaggedLossy = lossy
for (const chunk of chunks) {
batch.push(chunk)
batchBytes += Buffer.byteLength(chunk.text, 'utf8')
if (batchBytes >= maxFrameBytes) {
const last = batch[batch.length - 1]
/* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */
const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8')
/* v8 ignore stop */
yield {
type: 'output',
chunks: batch,
next: end,
...flaggedLossy ? { lossy: true as const } : {},
}
flaggedLossy = false
batch = []
batchBytes = 0
}
}
if (batch.length > 0 || flaggedLossy) {
yield {
type: 'output',
chunks: batch,
next,
...flaggedLossy ? { lossy: true as const } : {},
}
}
}
@@ -482,6 +482,12 @@ export interface SessionJob {
readonly detail?: string
readonly startedAt: number
readonly finishedAt?: number
/**
* Total UTF-8 bytes the job's observation record holds so far. Present
* exactly when the job declared a record — the row is observable through
* `session.observeJob`.
*/
readonly outputTotal?: number
}
/** Complete live control baseline emitted once per control stream generation. */
@@ -506,6 +512,63 @@ export type SessionControlFrame =
| { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
| ({ readonly type: 'projection' } & SessionProjectionUpdate)
/** Opens one observation stream over a job's retained record output. */
export interface SessionObserveJobRequest {
/** Owning session used for the fenced read; omitted for an unowned job. */
readonly sessionId?: SessionId
readonly jobId: JobId
/**
* Absolute byte offset to resume from — a previous frame's `next`. Omitted
* starts at the oldest retained byte.
*/
readonly from?: number
}
/** One retained record chunk on the wire. `channel` widens to string like `kind`. */
export interface SessionJobWireChunk {
/** Absolute offset of the chunk's first byte. */
readonly at: number
readonly text: string
readonly channel?: string
/** Bytes immediately before this chunk were lost at the producer or to retention. */
readonly gapBefore?: true
}
/**
* Job observation stream frames: one `opened` anchor, then coalesced `output`
* batches, then — once the job has settled and its retained record is
* drained — one terminal `status`, after which the stream closes normally.
* Status rides the same stream as output so settlement can never race a
* still-open output channel.
*/
export type SessionObserveJobFrame =
| {
readonly type: 'opened'
readonly jobId: JobId
/** Offset the first `output` frame continues from. */
readonly from: number
/** Oldest retained byte at open time; `from < earliest` means the head is gone. */
readonly earliest: number
/** Total bytes produced at open time. */
readonly total: number
readonly status: SessionJob['status']
readonly detail?: string
}
| {
readonly type: 'output'
readonly chunks: readonly SessionJobWireChunk[]
/** Offset to resume from after this frame. */
readonly next: number
/** Bytes between the requested offset and `chunks` were already evicted. */
readonly lossy?: true
}
| {
readonly type: 'status'
readonly status: SessionJob['status']
readonly detail?: string
readonly finishedAt?: number
}
declare module '@deepseek-ai/cordis' {
interface Events {
/**