refactor(jobs): consolidate the seam into JobSpec, VisibleJobs, and one event stream
The registry seam keeps three members plus the controller handshake: `start(spec)`, `visibleTo(caller)` (one authorization, then list / get / read / readAt / kill / wait without a caller argument), and `events` (one typed stream with an explicit filter instead of three listeners). Every job owns an output ring: `JobSpec.output` names pull sources the registry pumps, `JobHandle.append` pushes, and the model's consuming `read` and observers' `readAt` read the same bytes through separate cursors. `reported`, the `record` declaration, the producer `readOutput` hook, and the `JobStart`/`RunningJob` split are gone; `JobView` is the one projection, split into `progress` and terminal `detail`. The client-safe `./view` leaf carries the projection vocabulary.
This commit is contained in:
1 parent
09bd6bcf54
commit
07941fe5e2
9 files changed
+513
-762
No files matched your search
@@ -26,6 +26,10 @@
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./view": {
|
||||
"types": "./lib/types/view.d.ts",
|
||||
"default": "./lib/types/view.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
|
||||
+53
-154
@@ -1,45 +1,43 @@
|
||||
/**
|
||||
* The background-job Service Definition (`ctx.jobs`). It owns the contract for
|
||||
* job ids, session-scoped access, lifecycle state, completion listeners,
|
||||
* owner cleanup, and the optional per-job observation record — an append-only
|
||||
* bounded output stream any number of independent observers read at absolute
|
||||
* byte offsets, invisible to the model-facing consuming cursor — while
|
||||
* producers retain their execution resources. The process-local registry
|
||||
* lives in `@deepseek-ai/dsh-jobs-local`.
|
||||
* job ids, session-scoped access, lifecycle state, the per-job output ring —
|
||||
* one bounded stream that the model consumes through a registry-kept cursor
|
||||
* and that any number of observers read at absolute byte offsets — and the
|
||||
* event stream announcing every commit, while producers retain their
|
||||
* execution resources. The process-local registry lives in
|
||||
* `@deepseek-ai/dsh-jobs-local`.
|
||||
* @module @deepseek-ai/dsh-jobs
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
JobDoneListener, JobId, JobOutputListener, JobRead, JobRecordRead, JobSnapshot, JobStart,
|
||||
JobsChangedListener,
|
||||
} from './types.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JobEvents, JobId, JobSpec, VisibleJobs } from './types.ts'
|
||||
|
||||
export { JobId } from './types.ts'
|
||||
export type {
|
||||
JobAppendOptions,
|
||||
JobChannel,
|
||||
JobDoneListener,
|
||||
JobChunk,
|
||||
JobEvent,
|
||||
JobEventFilter,
|
||||
JobEventListener,
|
||||
JobEvents,
|
||||
JobHandle,
|
||||
JobHooks,
|
||||
JobKillOptions,
|
||||
JobKind,
|
||||
JobKindMap,
|
||||
JobOutcome,
|
||||
JobOutputListener,
|
||||
JobOutputRead,
|
||||
JobOutputSource,
|
||||
JobRead,
|
||||
JobRecordChunk,
|
||||
JobRecordRead,
|
||||
JobSnapshot,
|
||||
JobStart,
|
||||
JobSettleCause,
|
||||
JobSourceRead,
|
||||
JobSpec,
|
||||
JobStatus,
|
||||
JobsChangedListener,
|
||||
PlainJobStart,
|
||||
RecordingJob,
|
||||
RecordingJobStart,
|
||||
RunningJob,
|
||||
JobView,
|
||||
VisibleJobs,
|
||||
} from './types.ts'
|
||||
export { pumpJobOutput } from './pump.ts'
|
||||
export type { JobPumpOptions, JobPumpRead, JobPumpSource } from './pump.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -48,7 +46,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract background job registry. Subclass, implement the abstract methods,
|
||||
* Abstract background job registry. Subclass, implement the abstract members,
|
||||
* and load the subclass as a plugin — it registers as `ctx.jobs` (one
|
||||
* implementation per context; loading a second throws, which is cordis'
|
||||
* standard duplicate-service behavior).
|
||||
@@ -56,32 +54,30 @@ declare module '@deepseek-ai/cordis' {
|
||||
* Implementations must honor these semantics:
|
||||
* - Registrations outlive producer and controller fibers. Owner and
|
||||
* service disposal cancel live work and await compliant producers; a
|
||||
* throwing teardown cancel force-fails only the record. Teardown
|
||||
* cancellation also marks the record reported, because a record its owner
|
||||
* is being destroyed for has no reader left.
|
||||
* throwing teardown cancel force-fails only the record. Such settlements
|
||||
* announce `cause: 'teardown'`, because a job whose owner is being destroyed
|
||||
* has no reader left.
|
||||
* - Owned-job access is fenced by the owner's session id. Ids are
|
||||
* predictable, so authorization — not secrecy — is the boundary.
|
||||
* - Settlement is first-wins: one terminal record, released waiters, and one
|
||||
* round of contained listener notification, even against a late producer
|
||||
* outcome. Completion is announced last, after the record is committed and
|
||||
* every other observer of the settlement has seen it, because a reporter
|
||||
* may open a model turn synchronously.
|
||||
* - Settlement is first-wins: one terminal record, released waiters, then one
|
||||
* round of contained event delivery, even against a late producer outcome.
|
||||
* The `settled` event follows every released waiter, so a consumer that
|
||||
* claims a settlement while waiting always claims before the event.
|
||||
* - {@link start} refuses work while no attached job controller serves the
|
||||
* spec's owner, so a producer cannot start work that owner cannot collect
|
||||
* or stop. One registry serves every composition in the process, so this
|
||||
* question — and completion-listener delivery — is owner-relative rather
|
||||
* than process-wide: registrations made from an unscoped context serve
|
||||
* every owner, and registrations made under an agent composition's scope
|
||||
* serve exactly the agents composed under it.
|
||||
* - Record observation never consumes. Any number of {@link readRecord}
|
||||
* readers hold their own absolute byte offsets; a record read changes no
|
||||
* cursor and no notice state, so the model-facing surfaces (the consuming
|
||||
* {@link read}, completion notices) are unaffected.
|
||||
* - Record retention is bounded. Appends past the live cap drop the oldest
|
||||
* question — and event delivery under `{ owners: 'scope' }` — is
|
||||
* owner-relative rather than process-wide: registrations made from an
|
||||
* unscoped context serve every owner, and registrations made under an agent
|
||||
* composition's scope serve exactly the agents composed under it.
|
||||
* - Every job owns one output ring. Pull sources named by the spec are pumped
|
||||
* by the registry and drained once more before settlement; pushed appends
|
||||
* land whole. The model's consuming cursor and observers' absolute offsets
|
||||
* read the same bytes and never disturb each other.
|
||||
* - Ring retention is bounded. Appends past the live cap drop the oldest
|
||||
* retained bytes; a reader below the retained window gets a lossy read,
|
||||
* never an error. Settlement — the producer outcome, a kill, or teardown —
|
||||
* trims retention to the settled cap and ends the stream; the record has no
|
||||
* separate lifecycle.
|
||||
* never an error. Settlement trims retention to the settled cap and ends
|
||||
* the stream; the ring has no separate lifecycle.
|
||||
*/
|
||||
export abstract class JobRegistry extends Service {
|
||||
constructor(ctx: Context) {
|
||||
@@ -94,125 +90,28 @@ export abstract class JobRegistry extends Service {
|
||||
super(ctx, 'jobs')
|
||||
}
|
||||
|
||||
/** Lifecycle and output events, filtered per subscription. */
|
||||
abstract readonly events: JobEvents
|
||||
|
||||
/**
|
||||
* Preflight access, validation, owner cleanup, and implementation-owned
|
||||
* admission before starting and atomically registering work. Any preflight
|
||||
* rejection leaves no job id or execution resource. A throwing starter
|
||||
* leaves nothing registered; after it returns, registration cannot fail.
|
||||
* Settlement records the outcome, notifies listeners, and releases waiters.
|
||||
* @param spec - job identity, owner, and synchronous starter.
|
||||
* @param spec - job identity, owner, output sources, and synchronous starter.
|
||||
* @returns the registry-issued `<kind>-N` id.
|
||||
*/
|
||||
abstract start(spec: JobStart): JobId
|
||||
abstract start(spec: JobSpec): JobId
|
||||
|
||||
/**
|
||||
* List caller-owned and unowned jobs in registration order without exposing
|
||||
* another session's labels.
|
||||
* @param caller - reading agent; a non-agent caller sees only unowned jobs.
|
||||
* @returns fresh snapshots.
|
||||
* Bind the caller's identity once and return its operations. A caller sees
|
||||
* its own jobs and every unowned job; a caller-less view sees unowned jobs
|
||||
* only. The view resolves visibility on every call, so it stays valid as
|
||||
* jobs come and go.
|
||||
* @param caller - the reading session, or undefined for an anonymous caller.
|
||||
* @returns the operations available to that caller.
|
||||
*/
|
||||
abstract list(caller?: Agent): JobSnapshot[]
|
||||
|
||||
/**
|
||||
* Return a non-consuming snapshot without changing its read cursor or notice
|
||||
* state. Throws for an unknown or foreign job.
|
||||
* @param id - job to look up.
|
||||
* @param caller - reading agent checked against the owner.
|
||||
* @returns a fresh snapshot.
|
||||
*/
|
||||
abstract get(id: JobId, caller?: Agent): JobSnapshot
|
||||
|
||||
/**
|
||||
* Read the next stream delta, or the idempotent final output after settlement.
|
||||
* A terminal read marks the job reported. Throws for an unknown or foreign
|
||||
* job.
|
||||
* @param id - job to read.
|
||||
* @param caller - reading agent checked against the owner.
|
||||
* @returns output text and the post-read snapshot.
|
||||
*/
|
||||
abstract read(id: JobId, caller?: Agent): JobRead
|
||||
|
||||
/**
|
||||
* Request cancellation, then mark the job stopping and reported. A producer
|
||||
* throw propagates without changing job state. Throws for an unknown or
|
||||
* foreign job.
|
||||
* @param id - job to cancel.
|
||||
* @param caller - killing agent checked against the owner.
|
||||
* @param reason - logged reason forwarded to the producer.
|
||||
* @returns `requested` for live work, otherwise `already-finished`.
|
||||
*/
|
||||
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
|
||||
|
||||
/**
|
||||
* Wait for settlement or timeout without cancelling the job. Caller abort
|
||||
* rejects only while the job is live; after settlement the terminal
|
||||
* snapshot wins so a notice suppressed for this waiter is still delivered.
|
||||
* Throws for invalid, unknown, or foreign input.
|
||||
* @param id - job to wait for.
|
||||
* @param timeoutMs - positive finite wait bound in milliseconds.
|
||||
* @param caller - waiting agent checked against the owner.
|
||||
* @param signal - optional cancellation of the wait itself.
|
||||
* @returns snapshot at settlement or timeout.
|
||||
*/
|
||||
abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<JobSnapshot>
|
||||
|
||||
/**
|
||||
* Register an effect-scoped completion listener. It receives the settlements
|
||||
* of the owners its registering context's scope covers; each listener is
|
||||
* contained; returned promises are observed but not awaited. No listener runs
|
||||
* after service disposal.
|
||||
* @param listener - receives each terminal snapshot and its exact owner.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onJobDone(listener: JobDoneListener): () => void
|
||||
|
||||
/**
|
||||
* Register an effect-scoped observer of visible-set changes. It fires after
|
||||
* every commit that changes what {@link list} returns for that owner —
|
||||
* registration, every stopping transition (including the one teardown
|
||||
* performs before it awaits a slow producer), settlement, owner-disposal
|
||||
* removal, and the emptying that service disposal commits — so an observer
|
||||
* re-reads rather than accumulating deltas.
|
||||
*
|
||||
* Delivery is owner-relative on the same terms as {@link onJobDone}: an
|
||||
* observer registered from an unscoped context — a host composition's own
|
||||
* carrier — sees every owner, while one registered under an agent
|
||||
* composition's scope sees exactly the agents composed under it.
|
||||
*
|
||||
* This is not a superset of {@link onJobDone}: that one delivers the terminal
|
||||
* record under first-wins semantics a job controller couples to notice
|
||||
* delivery, while this one carries no delivery meaning and marks nothing
|
||||
* reported. Listeners are contained and never awaited.
|
||||
* @param listener - receives the owner whose visible set changed, or
|
||||
* `undefined` when an unowned job changed and every caller's set did.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onJobsChanged(listener: JobsChangedListener): () => void
|
||||
|
||||
/**
|
||||
* Read retained record output from an absolute byte offset without consuming
|
||||
* it. Resume by passing a previous read's `next`; a foreign offset inside a
|
||||
* retained chunk returns that whole chunk (its `at` may precede `from`).
|
||||
* Never marks the job reported. Throws for an unknown or foreign job, a job
|
||||
* without a {@link JobStart.record} declaration, or a negative or
|
||||
* non-integer offset.
|
||||
* @param id - job to read.
|
||||
* @param from - absolute byte offset to read from (0 for the retained head).
|
||||
* @param caller - reading agent checked against the owner.
|
||||
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
|
||||
*/
|
||||
abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead
|
||||
|
||||
/**
|
||||
* Register an effect-scoped observer of record advancement — one signal per
|
||||
* committed append and one at settlement, carrying only the job id. A
|
||||
* consumer schedules a {@link readRecord} from its own cursor; the registry
|
||||
* never pushes payloads. Delivery is owner-relative on the same terms as
|
||||
* {@link onJobsChanged}. Listeners are contained and never awaited.
|
||||
* @param listener - receives the id whose record advanced.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
abstract onOutput(listener: JobOutputListener): () => void
|
||||
abstract visibleTo(caller?: SessionId): VisibleJobs
|
||||
|
||||
/**
|
||||
* Attach an effect-scoped controller that can read and stop jobs. It serves the
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/** Package-owned background-job snapshot invariants. @module @deepseek-ai/dsh-jobs/invariant */
|
||||
/** Package-owned background-job projection invariants. @module @deepseek-ai/dsh-jobs/invariant */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { JobSnapshot } from './types.ts'
|
||||
import type { JobView } from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-jobs'
|
||||
const TERMINAL_STATUSES = new Set(['completed', 'killed', 'failed'])
|
||||
@@ -13,48 +12,44 @@ export const name = 'jobs-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate the cross-field relationships in one registry snapshot. */
|
||||
function validateSnapshot(snapshot: JobSnapshot, owner: Agent | undefined, fail: InvariantFailure): void {
|
||||
const id = String(snapshot.id)
|
||||
const prefix = `${snapshot.kind}-`
|
||||
/** Validate the cross-field relationships in one registry projection. */
|
||||
function validateView(job: JobView, fail: InvariantFailure): void {
|
||||
const id = String(job.id)
|
||||
const prefix = `${job.kind}-`
|
||||
const ordinal = Number(id.slice(prefix.length))
|
||||
if (snapshot.kind.length === 0 || !id.startsWith(prefix)
|
||||
if (job.kind.length === 0 || !id.startsWith(prefix)
|
||||
|| !Number.isSafeInteger(ordinal) || ordinal < 1) {
|
||||
fail(`job snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`)
|
||||
fail(`job view id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`)
|
||||
}
|
||||
if (snapshot.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`)
|
||||
if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) {
|
||||
if (job.label.length === 0) fail(`job ${JSON.stringify(id)} label must be non-empty`)
|
||||
if (!Number.isSafeInteger(job.startedAt) || job.startedAt < 0) {
|
||||
fail(`job ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`)
|
||||
}
|
||||
|
||||
const terminal = TERMINAL_STATUSES.has(snapshot.status)
|
||||
if (terminal !== (snapshot.finishedAt !== undefined)) {
|
||||
const terminal = TERMINAL_STATUSES.has(job.status)
|
||||
if (terminal !== (job.finishedAt !== undefined)) {
|
||||
fail(`job ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`)
|
||||
}
|
||||
if (snapshot.finishedAt !== undefined
|
||||
&& (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) {
|
||||
if (job.finishedAt !== undefined
|
||||
&& (!Number.isSafeInteger(job.finishedAt) || job.finishedAt < job.startedAt)) {
|
||||
fail(`job ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`)
|
||||
}
|
||||
|
||||
const expectedOwner = owner?.id
|
||||
if (snapshot.ownerSession !== expectedOwner) {
|
||||
fail(`job ${JSON.stringify(id)} ownerSession does not match its completion owner`)
|
||||
if (terminal && job.progress !== undefined) {
|
||||
fail(`job ${JSON.stringify(id)} progress must be cleared once settled`)
|
||||
}
|
||||
|
||||
const { outputTotal, outputEarliest } = snapshot
|
||||
if ((outputTotal !== undefined) !== (outputEarliest !== undefined)) {
|
||||
fail(`job ${JSON.stringify(id)} record offsets must be present together`)
|
||||
} else if (outputTotal !== undefined && outputEarliest !== undefined
|
||||
&& (!Number.isSafeInteger(outputTotal) || !Number.isSafeInteger(outputEarliest)
|
||||
|| outputEarliest < 0 || outputEarliest > outputTotal)) {
|
||||
fail(`job ${JSON.stringify(id)} record offsets must satisfy 0 <= outputEarliest <= outputTotal`)
|
||||
const { total, earliest } = job.output
|
||||
if (!Number.isSafeInteger(total) || !Number.isSafeInteger(earliest) || earliest < 0 || earliest > total) {
|
||||
fail(`job ${JSON.stringify(id)} output offsets must satisfy 0 <= earliest <= total`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install checks over current unowned records and every terminal snapshot. */
|
||||
/** Install checks over the current unowned projections and every settlement. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const snapshot of ctx.jobs.list()) validateSnapshot(snapshot, undefined, fail)
|
||||
ctx.jobs.onJobDone((snapshot, owner) => { validateSnapshot(snapshot, owner, fail) })
|
||||
for (const job of ctx.jobs.visibleTo().list()) validateView(job, fail)
|
||||
ctx.jobs.events.subscribe({ owners: 'all' }, (event) => {
|
||||
if (event.type === 'settled') validateView(event.job, fail)
|
||||
})
|
||||
}, { inject: ['jobs'] })
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Shared poll pump for producers whose output substrate is a non-consuming
|
||||
* offset reader (the subprocess `readFrom(fromByte)` family). Pure utility —
|
||||
* no cordis, no timers retained past settlement.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { JobChannel, RecordingJob } from './types.ts'
|
||||
|
||||
/** One non-consuming offset read from a producer-owned stream. */
|
||||
export interface JobPumpRead {
|
||||
/** Text captured since the requested offset (the whole retained tail when lossy). */
|
||||
text: string
|
||||
/** Whole-stream offset to resume from on the next read. */
|
||||
nextOffset: number
|
||||
/** True when the requested offset slid out of the source's retained window. */
|
||||
lossy: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One producer-owned stream the pump copies into a job record.
|
||||
*
|
||||
* The source is a pull-by-offset snapshot reader, not a Node.js `Readable`,
|
||||
* because the substrate it reads (the subprocess `readFrom` family) is a
|
||||
* retained ring that several readers share: the model-facing consuming
|
||||
* cursor, the foreground result collector, and this record pump each hold
|
||||
* their own absolute offset and read without disturbing the others. A
|
||||
* `Readable` is single-consumer and consuming, so wrapping the ring in one
|
||||
* would need a tee per reader and could not express `lossy` — the reader's
|
||||
* offset having slid out of the retained window — which the record turns
|
||||
* into a visible `gapBefore` instead of a silent splice.
|
||||
*/
|
||||
export interface JobPumpSource {
|
||||
/** Stream label attached to every chunk this source yields. */
|
||||
channel?: JobChannel
|
||||
/**
|
||||
* Read everything captured since `fromByte` without consuming it.
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
|
||||
* @returns the delta text, the next offset, and the lossy flag.
|
||||
*/
|
||||
read(fromByte: number): JobPumpRead
|
||||
}
|
||||
|
||||
/** Cadence and settlement for one {@link pumpJobOutput} run. */
|
||||
export interface JobPumpOptions {
|
||||
/** Poll interval in milliseconds; a positive finite number. */
|
||||
pollMs: number
|
||||
/**
|
||||
* Settles when the producer's work finished and its sources hold their
|
||||
* final bytes. The pump drains once more after settlement and returns;
|
||||
* a rejection is treated as settlement.
|
||||
*/
|
||||
done: Promise<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy producer streams into a job record at a bounded cadence: drain every
|
||||
* source, sleep `pollMs` or until `done` settles, repeat, then drain one
|
||||
* final time. A lossy source read appends its surviving tail with
|
||||
* `gapBefore`, so the discontinuity stays visible to observers. Fold the
|
||||
* returned promise into the producer's `JobHooks.done` chain so the final
|
||||
* drain lands before settlement trims and closes the record.
|
||||
*
|
||||
* Each round drains the sources in array order, so bytes two sources
|
||||
* produced inside one poll window land in that order, not in the order they
|
||||
* were written; the record is a best-effort live view whose cross-source
|
||||
* reordering is bounded by `pollMs`, not a terminal transcript.
|
||||
*
|
||||
* The wait holds constant resources however long the job runs: one
|
||||
* subscription on `done` for the whole run and one pending timer at a time.
|
||||
* @param job - the running job's producer face receiving the copied chunks.
|
||||
* @param sources - producer streams, each pumped at its own offset.
|
||||
* @param options - poll cadence and the producer's settlement promise.
|
||||
* @returns resolves after the final post-settlement drain.
|
||||
*/
|
||||
export async function pumpJobOutput(
|
||||
job: RecordingJob,
|
||||
sources: readonly JobPumpSource[],
|
||||
options: JobPumpOptions,
|
||||
): Promise<void> {
|
||||
if (!Number.isFinite(options.pollMs) || options.pollMs <= 0) {
|
||||
throw new Error(`invalid pump pollMs: expected a positive finite number of milliseconds, got ${JSON.stringify(options.pollMs)}`)
|
||||
}
|
||||
const states = sources.map(source => ({ source, cursor: 0 }))
|
||||
const drain = (): void => {
|
||||
for (const state of states) {
|
||||
const { source } = state
|
||||
const read = source.read(state.cursor)
|
||||
state.cursor = read.nextOffset
|
||||
if (read.text.length === 0) continue
|
||||
if (source.channel === undefined && !read.lossy) {
|
||||
job.append(read.text)
|
||||
} else {
|
||||
job.append(read.text, {
|
||||
...source.channel !== undefined ? { channel: source.channel } : {},
|
||||
...read.lossy ? { gapBefore: true as const } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let wake: (() => void) | undefined
|
||||
const finish = (): void => {
|
||||
settled = true
|
||||
// Clear the pending poll so a finished pump holds no timer for up to one
|
||||
// interval, and release the current wait so the final drain runs at once.
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
wake?.()
|
||||
}
|
||||
void options.done.then(finish, finish)
|
||||
while (!settled) {
|
||||
drain()
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
timer = setTimeout(resolve, options.pollMs)
|
||||
})
|
||||
wake = undefined
|
||||
timer = undefined
|
||||
}
|
||||
drain()
|
||||
}
|
||||
+221
-209
@@ -1,154 +1,98 @@
|
||||
/**
|
||||
* Types shared by job producers, the registry, and controllers. The
|
||||
* service implementation lives in `./index.ts`.
|
||||
* Types shared by job producers, the registry, and its consumers. The
|
||||
* client-safe projection vocabulary lives in `./view.ts`; the service
|
||||
* implementation lives in `./index.ts`.
|
||||
* @module @deepseek-ai/dsh-jobs/types
|
||||
*/
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { JobId } from './brand.ts'
|
||||
import type { JobChannel, JobChunk, JobKind, JobView } from './view.ts'
|
||||
|
||||
export { JobId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Task lifecycle: `running`, optionally `stopping`, then exactly one terminal
|
||||
* status. Producer-specific facts belong in {@link JobSnapshot.detail}.
|
||||
*/
|
||||
export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* Producer-defined job kinds. Plugins extend this map by declaration merging;
|
||||
* the registry treats every value as an opaque id namespace.
|
||||
*/
|
||||
export interface JobKindMap {
|
||||
bash: 'bash'
|
||||
subagent: 'subagent'
|
||||
}
|
||||
|
||||
/** The merge-extensible union of registered producer kind names. */
|
||||
export type JobKind = JobKindMap[keyof JobKindMap]
|
||||
export type { JobChannel, JobChunk, JobKind, JobKindMap, JobStatus, JobView } from './view.ts'
|
||||
|
||||
/** Terminal result supplied by a producer through {@link JobHooks.done}. */
|
||||
export interface JobOutcome {
|
||||
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
|
||||
status: 'completed' | 'killed' | 'failed'
|
||||
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
|
||||
/**
|
||||
* Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
|
||||
* When the job settles `killed` after a {@link VisibleJobs.kill} with a
|
||||
* reason, the registry appends that reason.
|
||||
*/
|
||||
detail?: string
|
||||
/** Final output for jobs without `readOutput`; stream jobs leave it unset. */
|
||||
output?: string
|
||||
/**
|
||||
* Return value for jobs whose result is a value rather than a stream (a
|
||||
* workflow's rendered result, a subagent's report). The output ring carries
|
||||
* the stream; this is handed out once by the model's next {@link VisibleJobs.read}.
|
||||
*/
|
||||
result?: string
|
||||
}
|
||||
|
||||
/** One incremental read from a {@link JobOutputSource}. */
|
||||
export interface JobSourceRead {
|
||||
/** Text captured since the requested offset (the whole retained tail when lossy). */
|
||||
text: string
|
||||
/** Whole-stream offset to resume from on the next read. */
|
||||
nextOffset: number
|
||||
/** True when the requested offset slid out of the source's retained window. */
|
||||
lossy: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Record-stream label. Producers without distinct streams omit it; consumers
|
||||
* treat an unrecognized label like an absent one.
|
||||
* A pull source the registry pumps into the job's output ring — the subprocess
|
||||
* `readFrom` family. The registry owns the cadence and drains every source one
|
||||
* last time before settlement closes the ring, so a producer folds nothing
|
||||
* into its `done`.
|
||||
*/
|
||||
export type JobChannel = 'stdout' | 'stderr'
|
||||
export interface JobOutputSource {
|
||||
/** Stream label attached to every chunk this source yields. */
|
||||
channel?: JobChannel
|
||||
/**
|
||||
* Read everything captured since `fromByte` without consuming it.
|
||||
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
|
||||
* @returns the delta text, the next offset, and the lossy flag.
|
||||
*/
|
||||
read(fromByte: number): JobSourceRead
|
||||
}
|
||||
|
||||
/** Options for one {@link RunningJob.append}. */
|
||||
/** Options for one {@link JobHandle.append}. */
|
||||
export interface JobAppendOptions {
|
||||
/** Stream label for the chunk; omitted for producers without distinct streams. */
|
||||
channel?: JobChannel
|
||||
/**
|
||||
* Marks that producer-side bytes between the previous chunk and this one
|
||||
* were lost (a source window slid before the producer could copy it), so an
|
||||
* observer can render the discontinuity instead of a silent splice.
|
||||
* were lost, so an observer can render the discontinuity instead of a
|
||||
* silent splice.
|
||||
*/
|
||||
gapBefore?: true
|
||||
}
|
||||
|
||||
/** Fields shared by every producer declaration passed to {@link JobRegistry.start}. */
|
||||
interface JobStartBase {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||
kind: JobKind
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
label: string
|
||||
/**
|
||||
* Optional UTF-8 byte cap for each complete model-facing completion notice or
|
||||
* output read, including controller status metadata. Independent of record
|
||||
* retention: it bounds the consuming model surface, never
|
||||
* {@link JobRegistry.readRecord}.
|
||||
*/
|
||||
outputLimitBytes?: number
|
||||
/**
|
||||
* Owning live agent. Access is fenced by its session id, and agent disposal
|
||||
* cancels and awaits the job. The instance must be the one currently
|
||||
* registered under its agent id. Omitting the owner creates an unowned job,
|
||||
* open to any caller until service disposal.
|
||||
*/
|
||||
owner?: Agent
|
||||
}
|
||||
|
||||
/**
|
||||
* A start with the model-facing surfaces only. Its producer face carries no
|
||||
* `append`, so output the registry would have nowhere to keep is
|
||||
* unrepresentable rather than dropped.
|
||||
* Producer face of one registered job, handed to {@link JobSpec.run} and
|
||||
* valid for the job's whole life. All methods are synchronous. Writes staged
|
||||
* inside the starter call are retained and become visible with the
|
||||
* registration commit; after settlement — the producer's own outcome, a kill,
|
||||
* or a registry-forced teardown end — writes log and drop instead of
|
||||
* throwing, so a producer's trailing flush cannot break its own teardown path.
|
||||
*/
|
||||
export interface PlainJobStart extends JobStartBase {
|
||||
record?: undefined
|
||||
/**
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once with the job's producer face; a throw leaves nothing registered (the
|
||||
* spent ordinal is skipped), and the producer must clean up any partially
|
||||
* started resources.
|
||||
* @param job - the issued id plus the live-detail writer.
|
||||
*/
|
||||
run(job: RunningJob): JobHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* A start that declares an observable output record beside the model-facing
|
||||
* surfaces: {@link RecordingJob.append} retains chunks in a bounded ring that
|
||||
* any number of observers read at absolute byte offsets through
|
||||
* {@link JobRegistry.readRecord}, and snapshots carry
|
||||
* {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
|
||||
*/
|
||||
export interface RecordingJobStart extends JobStartBase {
|
||||
record: true
|
||||
/**
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once with the job's producer face; a throw leaves nothing registered (the
|
||||
* spent ordinal is skipped), and the producer must clean up any partially
|
||||
* started resources.
|
||||
* @param job - the issued id plus the record append and live-detail writers.
|
||||
*/
|
||||
run(job: RecordingJob): JobHooks
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer declaration passed to {@link JobRegistry.start}, discriminated by
|
||||
* `record`. The runtime preflights access and cleanup before invoking `run`;
|
||||
* the producer owns execution resources while the runtime owns identity and
|
||||
* lifecycle state.
|
||||
*/
|
||||
export type JobStart = PlainJobStart | RecordingJobStart
|
||||
|
||||
/**
|
||||
* Producer face of one registered job, handed to `run` and valid for the
|
||||
* job's whole life. All methods are synchronous. Writes staged inside the
|
||||
* starter call are retained and become visible with the registration commit;
|
||||
* after settlement — the producer's own outcome, a kill, or a registry-forced
|
||||
* teardown end — writes log and drop instead of throwing, so a producer's
|
||||
* trailing flush cannot break its own teardown path.
|
||||
*/
|
||||
export interface RunningJob {
|
||||
export interface JobHandle {
|
||||
/** The registry-issued id (`<kind>-N`). */
|
||||
readonly id: JobId
|
||||
/**
|
||||
* Replace the snapshot's status detail with a live progress line (`3/10`).
|
||||
* @param detail - the new detail line.
|
||||
*/
|
||||
updateDetail(detail: string): void
|
||||
}
|
||||
|
||||
/** Producer face of a {@link RecordingJobStart}: {@link RunningJob} plus the record append. */
|
||||
export interface RecordingJob extends RunningJob {
|
||||
/**
|
||||
* Append one record chunk. Offsets advance by the chunk's UTF-8 byte
|
||||
* length; an empty chunk is dropped without waking observers.
|
||||
* Append one chunk to the output ring. Offsets advance by the chunk's UTF-8
|
||||
* byte length; an empty chunk is dropped without waking observers.
|
||||
* @param text - the chunk text, exactly as produced.
|
||||
* @param options - stream label and gap marker.
|
||||
*/
|
||||
append(text: string, options?: JobAppendOptions): void
|
||||
/**
|
||||
* Replace the live progress line (`3/10`, the current phase). Settlement
|
||||
* clears it; the terminal reason travels in {@link JobOutcome.detail}.
|
||||
* @param line - the new progress line.
|
||||
*/
|
||||
updateProgress(line: string): void
|
||||
}
|
||||
|
||||
/** Hooks through which the runtime controls and observes producer work. */
|
||||
@@ -162,104 +106,82 @@ export interface JobHooks {
|
||||
* Resolves after the producer releases its resources, not merely when work
|
||||
* finishes. Must not reject; the runtime converts a rejection to `failed`.
|
||||
* If teardown cancellation throws, the runtime may force-fail only the
|
||||
* registry record without claiming that the work stopped. Settlement also
|
||||
* ends the record: fold any record-filling pump into this promise so the
|
||||
* final drain lands before the registry trims and closes the stream.
|
||||
* registry record without claiming that the work stopped.
|
||||
*/
|
||||
done: Promise<JobOutcome>
|
||||
/**
|
||||
* Consume output produced since the previous call. The producer formats
|
||||
* truncation and spill notices. Absence marks a final-output-only job; each
|
||||
* job has one consuming cursor. Independent of the record: this is the
|
||||
* model-facing projection, {@link JobRegistry.readRecord} the observer one.
|
||||
*/
|
||||
readOutput?(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only projection of one job, safe to hand to listeners and tools —
|
||||
* a fresh object per call, never live registry state.
|
||||
* Producer declaration passed to {@link JobRegistry.start}. The runtime
|
||||
* preflights access and cleanup before invoking {@link run}; the producer owns
|
||||
* execution resources while the runtime owns identity, lifecycle state, and
|
||||
* the output ring.
|
||||
*/
|
||||
export interface JobSnapshot {
|
||||
/** The registry-issued id (`<kind>-N`). */
|
||||
id: JobId
|
||||
/** The producer kind the job was registered with. */
|
||||
export interface JobSpec {
|
||||
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
|
||||
kind: JobKind
|
||||
/** The producer-supplied one-line label. */
|
||||
/** One-line model-facing label (the command; the delegation description). */
|
||||
label: string
|
||||
/** Producer-owned cap for complete model-facing notices and output reads. */
|
||||
/**
|
||||
* Owning session. Access is fenced by it, and the owner's live Agent must be
|
||||
* the one currently registered under that id: its disposal cancels and
|
||||
* awaits the job. Omitting the owner creates an unowned job, open to any
|
||||
* caller until service disposal.
|
||||
*/
|
||||
owner?: SessionId
|
||||
/**
|
||||
* Optional UTF-8 byte cap for each complete model-facing completion notice or
|
||||
* output read, including controller status metadata. Independent of ring
|
||||
* retention: it bounds the consuming model surface, never observers.
|
||||
*/
|
||||
outputLimitBytes?: number
|
||||
/**
|
||||
* Owner session id used for authorization and correlation; absent for
|
||||
* unowned jobs. Completion listeners receive the exact {@link Agent}
|
||||
* separately through {@link JobDoneListener}.
|
||||
* Pull sources the registry pumps into the ring at its own cadence.
|
||||
* Producers that narrate their own progress use {@link JobHandle.append}
|
||||
* instead; a job may use both.
|
||||
*/
|
||||
ownerSession?: SessionId
|
||||
/** Current lifecycle state. */
|
||||
status: JobStatus
|
||||
output?: readonly JobOutputSource[]
|
||||
/**
|
||||
* Kind-specific status detail: live progress while the producer updates it
|
||||
* through {@link RunningJob.updateDetail}, the terminal detail once settled.
|
||||
* Start the work after preflight and synchronously return its hooks. Called
|
||||
* once with the job's producer face; a throw leaves nothing registered (the
|
||||
* spent ordinal is skipped), and the producer must clean up any partially
|
||||
* started resources.
|
||||
* @param job - the issued id plus the ring append and progress writers.
|
||||
*/
|
||||
detail?: string
|
||||
/** Epoch ms when the job was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the job settled; absent while `running`/`stopping`. */
|
||||
finishedAt?: number
|
||||
/**
|
||||
* True when a kill, read, wait, or teardown cancel has reported or committed
|
||||
* to report the terminal state. Completion reporters suppress redundant
|
||||
* notices when set. Teardown claims it because the owner or service being
|
||||
* destroyed leaves no reader: a reporter that opens a turn on notice would
|
||||
* otherwise spend a model request per teardown layer.
|
||||
*/
|
||||
reported: boolean
|
||||
/**
|
||||
* Total UTF-8 bytes ever appended to the record — the offset the next chunk
|
||||
* starts at. Present exactly when the job declared {@link JobStart.record}.
|
||||
*/
|
||||
outputTotal?: number
|
||||
/**
|
||||
* Offset of the oldest retained record byte. Greater than zero exactly when
|
||||
* retention dropped the head; a reader starting below it gets a lossy read.
|
||||
* Present exactly when the job declared {@link JobStart.record}.
|
||||
*/
|
||||
outputEarliest?: number
|
||||
run(job: JobHandle): JobHooks
|
||||
}
|
||||
|
||||
/** Output and post-read state returned by {@link JobRegistry.read}. */
|
||||
/** Options for {@link VisibleJobs.kill}. */
|
||||
export interface JobKillOptions {
|
||||
/**
|
||||
* Cancellation reason: forwarded verbatim to the producer's cancel hook, and
|
||||
* merged into the terminal `detail` when the job settles `killed`, so both
|
||||
* the model and observers can see why the work stopped.
|
||||
*/
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** Output and post-read state returned by the consuming {@link VisibleJobs.read}. */
|
||||
export interface JobRead {
|
||||
/**
|
||||
* Stream kinds: the consuming delta since the previous read. Final-output
|
||||
* kinds: empty while live, the terminal {@link JobOutcome.output} (or
|
||||
* empty) once settled — idempotent, never consumed.
|
||||
*/
|
||||
text: string
|
||||
/** Ring chunks appended since the model cursor, in offset order; every channel included. */
|
||||
chunks: readonly JobChunk[]
|
||||
/** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */
|
||||
lossy: boolean
|
||||
/** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */
|
||||
result?: string
|
||||
/** The job's state at read time. */
|
||||
snapshot: JobSnapshot
|
||||
job: JobView
|
||||
}
|
||||
|
||||
/** One retained record chunk returned by {@link JobRegistry.readRecord}. */
|
||||
export interface JobRecordChunk {
|
||||
/** Absolute offset of the chunk's first byte. */
|
||||
at: number
|
||||
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
|
||||
text: string
|
||||
/** Stream label, when the producer supplied one. */
|
||||
channel?: JobChannel
|
||||
/** Producer-reported loss immediately before this chunk. */
|
||||
gapBefore?: true
|
||||
}
|
||||
|
||||
/** Result of one non-consuming {@link JobRegistry.readRecord}. */
|
||||
export interface JobRecordRead {
|
||||
/** Result of one non-consuming {@link VisibleJobs.readAt}. */
|
||||
export interface JobOutputRead {
|
||||
/** Retained chunks overlapping `[from, total)`, in offset order. */
|
||||
chunks: readonly JobRecordChunk[]
|
||||
chunks: readonly JobChunk[]
|
||||
/**
|
||||
* Offset to resume from — the record's current `outputTotal`. Always a
|
||||
* chunk boundary: appends land whole and trimming only advances chunk
|
||||
* starts, and consumers concatenate `chunks` under that assumption, so a
|
||||
* provider serving partial chunks would silently duplicate text.
|
||||
* Offset to resume from — the ring's current `total`. Always a chunk
|
||||
* boundary: appends land whole and trimming only advances chunk starts, and
|
||||
* consumers concatenate `chunks` under that assumption, so a provider
|
||||
* serving partial chunks would silently duplicate text.
|
||||
*/
|
||||
next: number
|
||||
/** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
|
||||
@@ -267,28 +189,118 @@ export interface JobRecordRead {
|
||||
}
|
||||
|
||||
/**
|
||||
* Completion callback with the exact owner supplied at start, or `undefined`
|
||||
* for an unowned job. Returned promises are observed but not awaited.
|
||||
* The operations one caller may perform, bound by {@link JobRegistry.visibleTo}.
|
||||
* A caller sees its own jobs and every unowned job; every method throws for
|
||||
* an id outside that set, without distinguishing unknown from foreign.
|
||||
*/
|
||||
export type JobDoneListener = (
|
||||
snapshot: JobSnapshot,
|
||||
owner: Agent | undefined,
|
||||
) => void | PromiseLike<void>
|
||||
export interface VisibleJobs {
|
||||
/**
|
||||
* List the visible jobs in registration order.
|
||||
* @returns fresh projections.
|
||||
*/
|
||||
list(): JobView[]
|
||||
/**
|
||||
* Project one job without touching its cursor.
|
||||
* @param id - job to look up.
|
||||
* @returns a fresh projection.
|
||||
*/
|
||||
get(id: JobId): JobView
|
||||
/**
|
||||
* Consume the ring from the model cursor and advance it to the current
|
||||
* total. After settlement the first read also carries the producer's
|
||||
* result; later reads return only what was appended since.
|
||||
* @param id - job to read.
|
||||
* @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection.
|
||||
*/
|
||||
read(id: JobId): JobRead
|
||||
/**
|
||||
* Read retained ring output from an absolute byte offset without consuming
|
||||
* it. Resume by passing a previous read's `next`; an offset inside a
|
||||
* retained chunk returns that whole chunk (its `at` may precede `from`).
|
||||
* Never moves the model cursor. Throws for a negative or non-integer offset.
|
||||
* @param id - job to read.
|
||||
* @param from - absolute byte offset to read from (0 for the retained head).
|
||||
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
|
||||
*/
|
||||
readAt(id: JobId, from: number): JobOutputRead
|
||||
/**
|
||||
* Request cancellation, then mark the job stopping. A recorded
|
||||
* {@link JobKillOptions.reason} merges into the terminal `detail` when the
|
||||
* job settles `killed`. A producer throw propagates without changing job
|
||||
* state.
|
||||
* @param id - job to cancel.
|
||||
* @param options - cancellation reason.
|
||||
* @returns `requested` for live work, otherwise `already-finished`.
|
||||
*/
|
||||
kill(id: JobId, options?: JobKillOptions): 'requested' | 'already-finished'
|
||||
/**
|
||||
* Wait for settlement or timeout without cancelling the job. Caller abort
|
||||
* rejects only while the job is live; after settlement the terminal
|
||||
* projection wins. Throws for an invalid timeout.
|
||||
* @param id - job to wait for.
|
||||
* @param timeoutMs - positive finite wait bound in milliseconds.
|
||||
* @param signal - optional cancellation of the wait itself.
|
||||
* @returns projection at settlement or timeout.
|
||||
*/
|
||||
wait(id: JobId, timeoutMs: number, signal?: AbortSignal): Promise<JobView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Observation callback for a change to what one owner's {@link JobRegistry.list}
|
||||
* would return. It is owner-granular rather than job-granular because the
|
||||
* change may be a removal, which no per-job record can express, and because
|
||||
* its consumers re-read the whole visible set anyway.
|
||||
*
|
||||
* An `undefined` owner means an unowned job changed, so every caller's visible
|
||||
* set changed with it.
|
||||
* Why a job settled: its producer finished (`producer`), a
|
||||
* {@link VisibleJobs.kill} ran first (`kill`), or owner or service teardown
|
||||
* cancelled it (`teardown`) — a settlement whose owner has no reader left.
|
||||
*/
|
||||
export type JobsChangedListener = (owner: Agent | undefined) => void
|
||||
export type JobSettleCause = 'producer' | 'kill' | 'teardown'
|
||||
|
||||
/**
|
||||
* Observer of record advancement for one job: new appended output, or the
|
||||
* settlement that ends the stream. Carries no payload — a consumer schedules a
|
||||
* {@link JobRegistry.readRecord} from its own cursor.
|
||||
* One lifecycle or output event. Lifecycle events carry the job's projection
|
||||
* after the commit they announce; `output` carries only the id and the new
|
||||
* total, so an observer schedules a {@link VisibleJobs.readAt} from its own
|
||||
* cursor and the registry never pushes payloads.
|
||||
*/
|
||||
export type JobOutputListener = (id: JobId) => void
|
||||
export type JobEvent =
|
||||
| {
|
||||
/** Registration commit, progress line change, stopping transition, or removal from the visible set. */
|
||||
readonly type: 'registered' | 'progress' | 'stopping' | 'removed'
|
||||
readonly job: JobView
|
||||
}
|
||||
| {
|
||||
readonly type: 'settled'
|
||||
readonly job: JobView
|
||||
readonly cause: JobSettleCause
|
||||
}
|
||||
| {
|
||||
readonly type: 'output'
|
||||
readonly id: JobId
|
||||
/** Owning session, absent for an unowned job. */
|
||||
readonly owner?: SessionId
|
||||
/** The ring's total after the append (or at settlement, which ends the stream). */
|
||||
readonly total: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a subscription hears about. `{ owner }` delivers that session's jobs
|
||||
* plus every unowned job (the set that session can see). `{ owners: 'scope' }`
|
||||
* delivers the owners composed under the subscribing context — one registry
|
||||
* serves every composition in the process, and a mount under one preset must
|
||||
* not hear another preset's agents. `{ owners: 'all' }` delivers everything.
|
||||
*/
|
||||
export type JobEventFilter =
|
||||
| { readonly owner: SessionId }
|
||||
| { readonly owners: 'all' | 'scope' }
|
||||
|
||||
/** Event callback; contained by the registry, never awaited. */
|
||||
export type JobEventListener = (event: JobEvent) => void
|
||||
|
||||
/** The registry's event stream. */
|
||||
export interface JobEvents {
|
||||
/**
|
||||
* Register an effect-scoped listener. Events are dispatched synchronously
|
||||
* after the commit they announce; a `settled` event follows the release of
|
||||
* every waiter. No listener runs after service disposal.
|
||||
* @param filter - which owners' events to deliver.
|
||||
* @param listener - receives each matching event.
|
||||
* @returns disposer that unregisters the listener.
|
||||
*/
|
||||
subscribe(filter: JobEventFilter, listener: JobEventListener): () => void
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Client-safe job vocabulary: the read-only projection of one job and the
|
||||
* chunks its output ring hands out. This leaf reaches no Host package, so
|
||||
* browser programs and Remote wire types import it without pulling the
|
||||
* registry's Host declaration merges.
|
||||
* @module @deepseek-ai/dsh-jobs/view
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { JobId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* Job lifecycle: `running`, optionally `stopping`, then exactly one terminal
|
||||
* status. Producer-specific facts belong in {@link JobView.progress} and
|
||||
* {@link JobView.detail}.
|
||||
*/
|
||||
export type JobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* Producer-defined job kinds. Plugins extend this map by declaration merging;
|
||||
* the registry treats every value as an opaque id namespace.
|
||||
*/
|
||||
export interface JobKindMap {
|
||||
bash: 'bash'
|
||||
subagent: 'subagent'
|
||||
}
|
||||
|
||||
/** The merge-extensible union of registered producer kind names. */
|
||||
export type JobKind = JobKindMap[keyof JobKindMap]
|
||||
|
||||
/**
|
||||
* Stream label of one output chunk. `stdout` and `stderr` reach the model's
|
||||
* consuming read; `log` marks producer narration (a workflow's phase lines)
|
||||
* that only observers see. Consumers treat an unrecognized label like an
|
||||
* absent one.
|
||||
*/
|
||||
export type JobChannel = 'stdout' | 'stderr' | 'log'
|
||||
|
||||
/** One chunk of a job's output ring: absolute offset, text, and its provenance. */
|
||||
export interface JobChunk {
|
||||
/** Absolute offset of the chunk's first byte; offsets never move once assigned. */
|
||||
readonly at: number
|
||||
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
|
||||
readonly text: string
|
||||
/** Stream label, when the producer supplied one. */
|
||||
readonly channel?: JobChannel
|
||||
/** Bytes immediately before this chunk were lost, at the producer or to retention. */
|
||||
readonly gapBefore?: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only projection of one job — a fresh object per call, never live
|
||||
* registry state. The model tools, the browser roster, and the observation
|
||||
* stream all consume this one shape.
|
||||
*/
|
||||
export interface JobView {
|
||||
/** The registry-issued id (`<kind>-N`). */
|
||||
readonly id: JobId
|
||||
/** The producer kind the job was registered with. */
|
||||
readonly kind: JobKind
|
||||
/** The producer-supplied one-line label. */
|
||||
readonly label: string
|
||||
/** Owning session; absent for an unowned job, which every caller can see. */
|
||||
readonly owner?: SessionId
|
||||
/** Current lifecycle state. */
|
||||
readonly status: JobStatus
|
||||
/** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */
|
||||
readonly progress?: string
|
||||
/** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */
|
||||
readonly detail?: string
|
||||
/** Epoch ms when the job was registered. */
|
||||
readonly startedAt: number
|
||||
/** Epoch ms when the job settled; absent while live. */
|
||||
readonly finishedAt?: number
|
||||
/**
|
||||
* The output ring's absolute coordinates. `total` is the offset the next
|
||||
* chunk starts at (0 while nothing was written); `earliest` is the oldest
|
||||
* retained byte, greater than zero exactly when retention dropped the head.
|
||||
*/
|
||||
readonly output: { readonly total: number; readonly earliest: number }
|
||||
}
|
||||
@@ -1,48 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import JobRegistry, { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobDoneListener, JobSnapshot } from '@deepseek-ai/dsh-jobs'
|
||||
import type { JobEventListener, JobView } from '@deepseek-ai/dsh-jobs'
|
||||
import * as JobsInvariant from '@deepseek-ai/dsh-jobs/invariant'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const BASE: JobSnapshot = {
|
||||
const BASE: JobView = {
|
||||
id: JobId('bash-1'),
|
||||
kind: 'bash',
|
||||
label: 'compile',
|
||||
status: 'completed',
|
||||
startedAt: 10,
|
||||
finishedAt: 20,
|
||||
reported: false,
|
||||
output: { total: 0, earliest: 0 },
|
||||
}
|
||||
|
||||
const RUNNING: JobSnapshot = {
|
||||
const RUNNING: JobView = {
|
||||
id: JobId('bash-1'),
|
||||
kind: 'bash',
|
||||
label: 'compile',
|
||||
status: 'running',
|
||||
startedAt: 10,
|
||||
reported: false,
|
||||
output: { total: 0, earliest: 0 },
|
||||
}
|
||||
|
||||
const TERMINAL_WITHOUT_FINISH: JobSnapshot = {
|
||||
const TERMINAL_WITHOUT_FINISH: JobView = {
|
||||
id: JobId('bash-1'),
|
||||
kind: 'bash',
|
||||
label: 'compile',
|
||||
status: 'completed',
|
||||
startedAt: 10,
|
||||
reported: false,
|
||||
output: { total: 0, earliest: 0 },
|
||||
}
|
||||
|
||||
async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owner?: Agent) => void> {
|
||||
async function setup(seed: JobView[] = []): Promise<(job: unknown) => void> {
|
||||
const ctx = new Context()
|
||||
let listener: JobDoneListener | undefined
|
||||
let listener: JobEventListener | undefined
|
||||
const probe = {
|
||||
list: () => seed,
|
||||
onJobDone(value: JobDoneListener) {
|
||||
listener = value
|
||||
return () => { listener = undefined }
|
||||
visibleTo: () => ({ list: () => seed }),
|
||||
events: {
|
||||
subscribe(_filter: unknown, value: JobEventListener) {
|
||||
listener = value
|
||||
return () => { listener = undefined }
|
||||
},
|
||||
},
|
||||
} as unknown as JobRegistry
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
@@ -51,44 +52,54 @@ async function setup(seed: JobSnapshot[] = []): Promise<(snapshot: unknown, owne
|
||||
apply(child: Context) { child.provide('jobs', probe) },
|
||||
})
|
||||
await ctx.plugin(JobsInvariant)
|
||||
if (listener === undefined) throw new Error('job invariant did not subscribe to terminal snapshots')
|
||||
return (snapshot, owner) => { listener!(snapshot as JobSnapshot, owner) }
|
||||
if (listener === undefined) throw new Error('job invariant did not subscribe to settlements')
|
||||
return (job) => { listener!({ type: 'settled', job: job as JobView, cause: 'producer' }) }
|
||||
}
|
||||
|
||||
describe('job-registry invariants', () => {
|
||||
it('accepts coherent current and terminal snapshots', async () => {
|
||||
it('accepts coherent current and terminal projections', async () => {
|
||||
const notify = await setup([RUNNING])
|
||||
expect(() => { notify(BASE) }).not.toThrow()
|
||||
const owner = { id: SessionId('owner') } as Agent
|
||||
expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', ownerSession: owner.id }, owner) })
|
||||
expect(() => { notify({ ...BASE, id: JobId('subagent-2'), kind: 'subagent', owner: SessionId('owner') }) })
|
||||
.not.toThrow()
|
||||
expect(() => { notify({ ...BASE, outputTotal: 8, outputEarliest: 4 }) }).not.toThrow()
|
||||
expect(() => { notify({ ...BASE, output: { total: 8, earliest: 4 } }) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('ignores non-settlement events', async () => {
|
||||
const ctx = new Context()
|
||||
let listener: JobEventListener | undefined
|
||||
const probe = {
|
||||
visibleTo: () => ({ list: () => [] }),
|
||||
events: { subscribe(_filter: unknown, value: JobEventListener) { listener = value; return () => {} } },
|
||||
} as unknown as JobRegistry
|
||||
await ctx.plugin(InvariantRegistry)
|
||||
await ctx.plugin({ name: 'job-invariant-probe', apply(child: Context) { child.provide('jobs', probe) } })
|
||||
await ctx.plugin(JobsInvariant)
|
||||
expect(() => { listener!({ type: 'registered', job: { ...BASE, label: '' } }) }).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...BASE, id: JobId('-1'), kind: '' }, undefined, /positive ordinal/],
|
||||
[{ ...BASE, id: JobId('other-1') }, undefined, /must be "bash-" followed by a positive ordinal/],
|
||||
[{ ...BASE, id: JobId('bash-x') }, undefined, /positive ordinal/],
|
||||
[{ ...BASE, id: JobId('bash-0') }, undefined, /positive ordinal/],
|
||||
[{ ...BASE, startedAt: -1 }, undefined, /startedAt must be a non-negative epoch integer/],
|
||||
[{ ...BASE, startedAt: 0.5 }, undefined, /startedAt must be a non-negative epoch integer/],
|
||||
[{ ...BASE, status: 'running' }, undefined, /finishedAt must be present exactly for a terminal status/],
|
||||
[TERMINAL_WITHOUT_FINISH, undefined, /finishedAt must be present exactly for a terminal status/],
|
||||
[{ ...BASE, finishedAt: 9 }, undefined, /no earlier than startedAt/],
|
||||
[{ ...BASE, finishedAt: 20.5 }, undefined, /no earlier than startedAt/],
|
||||
[{ ...BASE, ownerSession: SessionId('recorded') }, { id: SessionId('actual') } as Agent, /does not match its completion owner/],
|
||||
[{ ...BASE, outputTotal: 8 }, undefined, /record offsets must be present together/],
|
||||
[{ ...BASE, outputEarliest: 0 }, undefined, /record offsets must be present together/],
|
||||
[{ ...BASE, outputTotal: 8, outputEarliest: 9 }, undefined, /0 <= outputEarliest <= outputTotal/],
|
||||
[{ ...BASE, outputTotal: 8.5, outputEarliest: 0 }, undefined, /0 <= outputEarliest <= outputTotal/],
|
||||
[{ ...BASE, outputTotal: 8, outputEarliest: 0.5 }, undefined, /0 <= outputEarliest <= outputTotal/],
|
||||
[{ ...BASE, outputTotal: 8, outputEarliest: -1 }, undefined, /0 <= outputEarliest <= outputTotal/],
|
||||
] as const)('rejects an incoherent registry snapshot', async (snapshot, owner, message) => {
|
||||
[{ ...BASE, id: JobId('-1'), kind: '' }, /positive ordinal/],
|
||||
[{ ...BASE, id: JobId('other-1') }, /must be "bash-" followed by a positive ordinal/],
|
||||
[{ ...BASE, id: JobId('bash-x') }, /positive ordinal/],
|
||||
[{ ...BASE, id: JobId('bash-0') }, /positive ordinal/],
|
||||
[{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/],
|
||||
[{ ...BASE, startedAt: 0.5 }, /startedAt must be a non-negative epoch integer/],
|
||||
[{ ...BASE, status: 'running' }, /finishedAt must be present exactly for a terminal status/],
|
||||
[TERMINAL_WITHOUT_FINISH, /finishedAt must be present exactly for a terminal status/],
|
||||
[{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/],
|
||||
[{ ...BASE, finishedAt: 20.5 }, /no earlier than startedAt/],
|
||||
[{ ...BASE, progress: '3/10' }, /progress must be cleared once settled/],
|
||||
[{ ...BASE, output: { total: 8, earliest: 9 } }, /0 <= earliest <= total/],
|
||||
[{ ...BASE, output: { total: 8.5, earliest: 0 } }, /0 <= earliest <= total/],
|
||||
[{ ...BASE, output: { total: 8, earliest: 0.5 } }, /0 <= earliest <= total/],
|
||||
[{ ...BASE, output: { total: 8, earliest: -1 } }, /0 <= earliest <= total/],
|
||||
] as const)('rejects an incoherent registry projection', async (job, message) => {
|
||||
const notify = await setup()
|
||||
expect(() => { notify(snapshot, owner) }).toThrow(message)
|
||||
expect(() => { notify(job) }).toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects an incoherent record already present at installation', async () => {
|
||||
it('rejects an incoherent projection already present at installation', async () => {
|
||||
await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/)
|
||||
})
|
||||
})
|
||||
@@ -1,69 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs'
|
||||
import type {
|
||||
JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
|
||||
JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart,
|
||||
JobEvent, JobEventFilter, JobEventListener, JobHooks, JobOutputSource, JobRead, JobSpec, JobView, VisibleJobs,
|
||||
} from '@deepseek-ai/dsh-jobs'
|
||||
|
||||
/** Compile-time probe: does `Shape` carry a `Key` member? */
|
||||
type HasKey<Shape, Key extends string> = Key extends keyof Shape ? true : false
|
||||
|
||||
/**
|
||||
* Minimal concrete registry: one canned record. The Service Definition owns the contract
|
||||
* only (ids, snapshots, authorization-shaped signatures); the registry
|
||||
* Minimal concrete registry: one canned row. The Service Definition owns the
|
||||
* contract only (ids, the caller-bound view, the event stream); the registry
|
||||
* behavior suite lives with `@deepseek-ai/dsh-jobs-local`.
|
||||
*/
|
||||
class StubJobRegistry extends JobRegistry {
|
||||
snapshotOf(id: JobId): JobSnapshot {
|
||||
readonly subscriptions: { filter: JobEventFilter; listener: JobEventListener }[] = []
|
||||
readonly started: JobSpec[] = []
|
||||
|
||||
readonly events = {
|
||||
subscribe: (filter: JobEventFilter, listener: JobEventListener): (() => void) => {
|
||||
const entry = { filter, listener }
|
||||
this.subscriptions.push(entry)
|
||||
return () => { this.subscriptions.splice(this.subscriptions.indexOf(entry), 1) }
|
||||
},
|
||||
}
|
||||
|
||||
private view(id: JobId, owner: SessionId | undefined): JobView {
|
||||
return {
|
||||
id,
|
||||
kind: 'bash',
|
||||
label: 'sleep 60',
|
||||
...owner !== undefined ? { owner } : {},
|
||||
status: 'running',
|
||||
startedAt: 0,
|
||||
reported: false,
|
||||
output: { total: 0, earliest: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
start(spec: JobStart): JobId {
|
||||
start(spec: JobSpec): JobId {
|
||||
this.started.push(spec)
|
||||
const id = JobId(`${spec.kind}-1`)
|
||||
spec.run({ id, append() {}, updateDetail() {} })
|
||||
spec.run({ id, append() {}, updateProgress() {} })
|
||||
return id
|
||||
}
|
||||
|
||||
list(): JobSnapshot[] {
|
||||
return [this.snapshotOf(JobId('bash-1'))]
|
||||
}
|
||||
|
||||
get(id: JobId): JobSnapshot {
|
||||
return this.snapshotOf(id)
|
||||
}
|
||||
|
||||
read(id: JobId): JobRead {
|
||||
return { text: '', snapshot: this.snapshotOf(id) }
|
||||
}
|
||||
|
||||
kill(): 'requested' | 'already-finished' {
|
||||
return 'requested'
|
||||
}
|
||||
|
||||
wait(id: JobId, _timeoutMs: number, _caller?: Agent, _signal?: AbortSignal): Promise<JobSnapshot> {
|
||||
return Promise.resolve(this.snapshotOf(id))
|
||||
}
|
||||
|
||||
onJobDone(_listener: JobDoneListener): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
onJobsChanged(_listener: JobsChangedListener): () => void {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
readRecord(_id: JobId, from: number): JobRecordRead {
|
||||
return { chunks: [], next: from, lossy: false }
|
||||
}
|
||||
|
||||
onOutput(_listener: JobOutputListener): () => void {
|
||||
return () => {}
|
||||
visibleTo(caller?: SessionId): VisibleJobs {
|
||||
const view = (id: JobId): JobView => this.view(id, caller)
|
||||
return {
|
||||
list: () => [view(JobId('bash-1'))],
|
||||
get: id => view(id),
|
||||
read: (id): JobRead => ({ chunks: [], lossy: false, job: view(id) }),
|
||||
readAt: (_id, from) => ({ chunks: [], next: from, lossy: false }),
|
||||
kill: () => 'requested',
|
||||
wait: id => Promise.resolve(view(id)),
|
||||
}
|
||||
}
|
||||
|
||||
attachController(_name: string): () => void {
|
||||
@@ -71,63 +62,58 @@ class StubJobRegistry extends JobRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile-time probe: does the producer face `Face` carry `append`? */
|
||||
type HasAppend<Face> = 'append' extends keyof Face ? true : false
|
||||
|
||||
describe('JobStart producer faces', () => {
|
||||
it('hands append only to a start that declares a record', () => {
|
||||
const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) }
|
||||
// The literal's `record` discriminant selects the face `run` receives.
|
||||
const plain: JobStart = {
|
||||
kind: 'bash',
|
||||
label: 'no record',
|
||||
run: (job) => {
|
||||
job.updateDetail('works for every job')
|
||||
return hooks
|
||||
},
|
||||
}
|
||||
const recording: JobStart = {
|
||||
kind: 'bash',
|
||||
label: 'record',
|
||||
record: true,
|
||||
run: (job) => {
|
||||
job.append('lands in the record')
|
||||
return hooks
|
||||
},
|
||||
}
|
||||
// Negative proof: a plain start's face has no `append` at the type level.
|
||||
const plainFace: HasAppend<Parameters<PlainJobStart['run']>[0]> = false
|
||||
const recordingFace: HasAppend<Parameters<RecordingJobStart['run']>[0]> = true
|
||||
expect(plainFace).toBe(false)
|
||||
expect(recordingFace).toBe(true)
|
||||
expect(plain.record).toBeUndefined()
|
||||
expect(recording.record).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('JobRegistry seam', () => {
|
||||
it('a concrete subclass registers as ctx.jobs and serves the abstract API', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubJobRegistry)
|
||||
const caller = SessionId('session-1')
|
||||
const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) }
|
||||
|
||||
const detachController = ctx.jobs.attachController('seam-test')
|
||||
const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', run: () => ({ cancel() {}, done: new Promise(() => {}) }) })
|
||||
const id = ctx.jobs.start({ kind: 'bash', label: 'sleep 60', owner: caller, run: () => hooks })
|
||||
expect(id).toBe('bash-1')
|
||||
expect(ctx.jobs.list()).toHaveLength(1)
|
||||
expect(ctx.jobs.get(id).status).toBe('running')
|
||||
expect(ctx.jobs.read(id).text).toBe('')
|
||||
expect(ctx.jobs.kill(id)).toBe('requested')
|
||||
await expect(ctx.jobs.wait(id, 5)).resolves.toMatchObject({ id })
|
||||
expect(ctx.jobs.readRecord(id, 0)).toEqual({ chunks: [], next: 0, lossy: false })
|
||||
const detachListener = ctx.jobs.onJobDone(() => {})
|
||||
detachListener()
|
||||
const detachChanges = ctx.jobs.onJobsChanged(() => {})
|
||||
detachChanges()
|
||||
const detachOutput = ctx.jobs.onOutput(() => {})
|
||||
detachOutput()
|
||||
|
||||
const jobs = ctx.jobs.visibleTo(caller)
|
||||
expect(jobs.list()).toHaveLength(1)
|
||||
expect(jobs.get(id).status).toBe('running')
|
||||
expect(jobs.get(id).owner).toBe(caller)
|
||||
expect(jobs.read(id)).toEqual({ chunks: [], lossy: false, job: expect.objectContaining({ id }) })
|
||||
expect(jobs.readAt(id, 7)).toEqual({ chunks: [], next: 7, lossy: false })
|
||||
expect(jobs.kill(id, { reason: 'seam test' })).toBe('requested')
|
||||
await expect(jobs.wait(id, 5)).resolves.toMatchObject({ id })
|
||||
// A caller-less view is the unowned-only view.
|
||||
expect(ctx.jobs.visibleTo().get(id).owner).toBeUndefined()
|
||||
|
||||
const events: JobEvent[] = []
|
||||
const unsubscribe = ctx.jobs.events.subscribe({ owners: 'all' }, (event) => { events.push(event) })
|
||||
unsubscribe()
|
||||
detachController()
|
||||
})
|
||||
|
||||
it('types every start the same way: one handle, pull sources optional', () => {
|
||||
const hooks: JobHooks = { cancel() {}, done: new Promise(() => {}) }
|
||||
const source: JobOutputSource = {
|
||||
channel: 'stdout',
|
||||
read: fromByte => ({ text: '', nextOffset: fromByte, lossy: false }),
|
||||
}
|
||||
const spec: JobSpec = {
|
||||
kind: 'bash',
|
||||
label: 'x',
|
||||
output: [source],
|
||||
run: (job) => {
|
||||
job.append('lands in the ring', { channel: 'log' })
|
||||
job.updateProgress('1/2')
|
||||
return hooks
|
||||
},
|
||||
}
|
||||
expect(spec.output).toHaveLength(1)
|
||||
// Bookkeeping never reaches the public view, and the record declaration is gone.
|
||||
const viewHasReported: HasKey<JobView, 'reported'> = false
|
||||
const specHasRecord: HasKey<JobSpec, 'record'> = false
|
||||
const hooksHaveReadOutput: HasKey<JobHooks, 'readOutput'> = false
|
||||
expect([viewHasReported, specHasRecord, hooksHaveReadOutput]).toEqual([false, false, false])
|
||||
})
|
||||
|
||||
it('loading a second implementation throws (one jobs service per context — cordis standard)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(StubJobRegistry)
|
||||
@@ -141,119 +127,3 @@ describe('JobRegistry seam', () => {
|
||||
.rejects.toThrow(/abstract job registry seam; load an implementation such as @deepseek-ai\/dsh-jobs-local/)
|
||||
})
|
||||
})
|
||||
|
||||
/** Record every append so pump behavior is observable without a registry. */
|
||||
function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } {
|
||||
const appends: { text: string; options?: JobAppendOptions }[] = []
|
||||
const job: RecordingJob = {
|
||||
id: JobId('bash-1'),
|
||||
append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) },
|
||||
updateDetail() {},
|
||||
}
|
||||
return { job, appends }
|
||||
}
|
||||
|
||||
/** A scripted source: each read() shifts the next scripted result. */
|
||||
function scriptedSource(
|
||||
reads: { text: string; lossy?: boolean }[],
|
||||
channel?: 'stdout' | 'stderr',
|
||||
): { source: JobPumpSource; offsets: number[] } {
|
||||
const offsets: number[] = []
|
||||
let offset = 0
|
||||
const source: JobPumpSource = {
|
||||
...channel !== undefined ? { channel } : {},
|
||||
read(fromByte) {
|
||||
offsets.push(fromByte)
|
||||
const next = reads.shift() ?? { text: '' }
|
||||
offset += Buffer.byteLength(next.text, 'utf8')
|
||||
return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false }
|
||||
},
|
||||
}
|
||||
return { source, offsets }
|
||||
}
|
||||
|
||||
describe('pumpJobOutput', () => {
|
||||
it('rejects a non-positive poll interval before touching any source', async () => {
|
||||
const { job } = recordingJob()
|
||||
await expect(pumpJobOutput(job, [], { pollMs: 0, done: Promise.resolve() }))
|
||||
.rejects.toThrow(/invalid pump pollMs/)
|
||||
})
|
||||
|
||||
it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { job, appends } = recordingJob()
|
||||
let settle!: () => void
|
||||
const done = new Promise<void>((resolve) => { settle = resolve })
|
||||
const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout')
|
||||
const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr')
|
||||
const pump = pumpJobOutput(job, [out.source, err.source], { pollMs: 50, done })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
settle()
|
||||
await pump
|
||||
expect(appends).toEqual([
|
||||
{ text: 'a', options: { channel: 'stdout' } },
|
||||
{ text: 'bc', options: { channel: 'stdout' } },
|
||||
{ text: 'E', options: { channel: 'stderr' } },
|
||||
])
|
||||
// Every read resumed from the previous nextOffset, never from a shared cursor.
|
||||
expect(out.offsets).toEqual([0, 1, 3])
|
||||
expect(err.offsets).toEqual([0, 0, 1])
|
||||
// Settlement cleared the raced poll timer instead of leaving it pending.
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('subscribes to done once and holds one poll timer however long the job runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { job } = recordingJob()
|
||||
// A thenable stands in for the producer's done promise, and whatever the
|
||||
// pump derives from it counts its own reactions too: a pending promise
|
||||
// retains every reaction until it settles, so a day-long job must not
|
||||
// gain one per poll round.
|
||||
let subscriptions = 0
|
||||
let settle!: () => void
|
||||
const settled = new Promise<void>((resolve) => { settle = resolve })
|
||||
const counting = (promise: Promise<unknown>): Promise<unknown> => ({
|
||||
then(onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) {
|
||||
subscriptions += 1
|
||||
return counting(promise.then(onFulfilled, onRejected))
|
||||
},
|
||||
}) as unknown as Promise<unknown>
|
||||
const done = counting(settled)
|
||||
const { source, offsets } = scriptedSource([])
|
||||
const pump = pumpJobOutput(job, [source], { pollMs: 50, done })
|
||||
|
||||
const rounds = 10_000
|
||||
await vi.advanceTimersByTimeAsync(50 * rounds)
|
||||
expect(offsets).toHaveLength(rounds + 1)
|
||||
expect(subscriptions).toBeLessThanOrEqual(2)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
settle()
|
||||
await pump
|
||||
expect(offsets).toHaveLength(rounds + 2)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('marks a lossy source read as a gap so observers see the discontinuity', async () => {
|
||||
const { job, appends } = recordingJob()
|
||||
const { source } = scriptedSource([{ text: 'tail', lossy: true }])
|
||||
await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.resolve() })
|
||||
expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }])
|
||||
})
|
||||
|
||||
it('treats a rejected done as settlement and still drains the final bytes', async () => {
|
||||
const { job, appends } = recordingJob()
|
||||
const { source } = scriptedSource([{ text: 'last' }])
|
||||
await pumpJobOutput(job, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) })
|
||||
expect(appends).toEqual([{ text: 'last' }])
|
||||
})
|
||||
})
|
||||
@@ -102,6 +102,7 @@
|
||||
"@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"],
|
||||
"@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"],
|
||||
"@deepseek-ai/dsh-jobs/brand": ["./packages/jobs/jobs/src/brand.ts"],
|
||||
"@deepseek-ai/dsh-jobs/view": ["./packages/jobs/jobs/src/view.ts"],
|
||||
"@deepseek-ai/dsh-credentials/types": ["./packages/credentials/credentials/src/types.ts"],
|
||||
"@deepseek-ai/dsh-authorization/types": ["./packages/credentials/authorization/src/types.ts"],
|
||||
"@deepseek-ai/dsh-settings/types": ["./packages/settings/settings/src/types.ts"],
|
||||
|
||||
Reference in new issue
Block a user