refactor(jobs): type the producer face by the record declaration, tidy review residue

- `JobStart` is a union discriminated by `record`: `RecordingJobStart.run`
  receives `RecordingJob` (with `append`), `PlainJobStart.run` receives the
  plain `RunningJob`, and the registry builds the plain face without the
  method, so the former warn-and-drop path is unrepresentable.
- Remove the duplicated JSDoc opener in the registry seam and correct the
  job-list slot comment (order 20 sits before the subagent catalog's 30).
- Restore the `cordis-tool-round` session fixture whose only change was a
  re-recorded `createdAt`.
- Update the 2026-08-08 job-display note's stale service-disposal and slot
  ordering facts; archive the superseded 2026-08-25 unified-task-list note
  and point its remaining inbound links at the archived snapshot.
This commit is contained in:
Yichen Jiang committed 2026-09-03 13:15:23 +08:00
1 parent 6a4d7ee242
commit b48c05d117
35 files changed
+326 -212

No files matched your search

@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobOutcome, RecordingJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
@@ -38,13 +38,13 @@ function registerAgent(ctx: Context, rawId: string): Agent {
/** Start one record job whose done settles on demand; expose the producer face. */
function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent; record?: boolean } = {}) {
let settle!: (outcome: JobOutcome) => void
let face!: RunningJob
let face!: RecordingJob
const id = ctx.jobs.start({
kind: 'bash',
label: options.label ?? 'echo',
...options.owner !== undefined ? { owner: options.owner } : {},
...options.record === false ? {} : { record: true },
run: (job) => {
run: (job: RecordingJob) => {
face = job
return {
cancel() {},
@@ -54,7 +54,7 @@ function startRecordJob(ctx: Context, options: { label?: string; owner?: Agent;
})
return {
id,
append: (text: string, opts?: Parameters<RunningJob['append']>[1]) => { face.append(text, opts) },
append: (text: string, opts?: Parameters<RecordingJob['append']>[1]) => { face.append(text, opts) },
updateDetail: (detail: string) => { face.updateDetail(detail) },
settle: async (outcome: JobOutcome) => {
settle(outcome)
+2 -1
View File
@@ -38,7 +38,8 @@ export function apply(ctx: ClientContext): void {
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'job-list',
// After the subagent catalog: session lineage reads before running work.
// Between the preset label and the subagent catalog (order 30): running
// work reads before the session lineage.
order: 20,
locale: NS,
inject: (): JobListInjected => ({
@@ -1095,7 +1095,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract onJobsChanged(listener: JobsChangedListener): () => void',
description: '/** Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what 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.\n\nDelivery is owner-relative on the same terms as 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.\n\nThis is not a superset of 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.',
description: 'Register an effect-scoped observer of visible-set changes. It fires after every commit that changes what 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.\n\nDelivery is owner-relative on the same terms as 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.\n\nThis is not a superset of 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.',
parameters: [{ name: 'listener', description: '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.',
},
@@ -4239,7 +4239,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobStart',
declaration: 'export interface JobStart {\n kind: JobKind;\n label: string;\n outputLimitBytes?: number;\n owner?: Agent;\n record?: true;\n run(job: RunningJob): JobHooks;\n}',
declaration: 'export type JobStart = PlainJobStart | RecordingJobStart;',
},
{
name: 'JobStatus',
@@ -4537,6 +4537,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'PermissionSelect',
declaration: 'export interface PermissionSelect {\n options: PresetOption[];\n currentValue: string;\n}',
},
{
name: 'PlainJobStart',
declaration: 'export interface PlainJobStart extends JobStartBase {\n record?: undefined;\n run(job: RunningJob): JobHooks;\n}',
},
{
name: 'PostToolDecision',
declaration: 'export type PostToolDecision = {\n kind: \'accept\';\n content?: ContentBlock[];\n value?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'accept\';\n value: JsonValue;\n content?: never;\n additionalContexts?: UserMessage[];\n} | {\n kind: \'block\';\n feedback: ContentBlock[];\n additionalContexts?: UserMessage[];\n};',
@@ -4657,6 +4661,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ReasoningEffortId',
declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;',
},
{
name: 'RecordingJob',
declaration: 'export interface RecordingJob extends RunningJob {\n append(text: string, options?: JobAppendOptions): void;\n}',
},
{
name: 'RecordingJobStart',
declaration: 'export interface RecordingJobStart extends JobStartBase {\n record: true;\n run(job: RecordingJob): JobHooks;\n}',
},
{
name: 'RedactedSecret',
declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}',
@@ -4739,7 +4751,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'RunningJob',
declaration: 'export interface RunningJob {\n readonly id: JobId;\n append(text: string, options?: JobAppendOptions): void;\n updateDetail(detail: string): void;\n}',
declaration: 'export interface RunningJob {\n readonly id: JobId;\n updateDetail(detail: string): void;\n}',
},
{
name: 'SandboxEnforcement',
+20 -19
View File
@@ -19,7 +19,7 @@ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { JobRegistry, JobId } from '@deepseek-ai/dsh-jobs'
import type {
JobAppendOptions, JobChannel, JobDoneListener, JobKind, JobOutcome, JobOutputListener, JobRead,
JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RunningJob,
JobRecordChunk, JobRecordRead, JobSnapshot, JobStart, JobStatus, JobsChangedListener, RecordingJob, RunningJob,
} from '@deepseek-ai/dsh-jobs'
/** Timeout code that distinguishes a bounded wait from caller cancellation. */
@@ -236,17 +236,13 @@ export class LocalJobRegistry extends JobRegistry {
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = JobId(`${spec.kind}-${count}`)
const state: ProducerState = {
record: spec.record === true ? { chunks: [], retainedBytes: 0, total: 0, earliest: 0 } : undefined,
detail: undefined,
job: undefined,
}
const handle: RunningJob = {
const state: ProducerState = { record: undefined, detail: undefined, job: undefined }
const face: RunningJob = {
id,
append: (text, options) => { this.appendRecord(id, state, text, options) },
updateDetail: (detail) => { this.updateDetail(state, detail) },
}
const hooks = spec.run(handle)
// The declaration decides the face: a plain start never receives `append`.
const hooks = spec.record === true ? spec.run(this.recordingFace(face, state)) : spec.run(face)
let markSettled!: () => void
const settled = new Promise<void>((resolve) => { markSettled = resolve })
@@ -556,23 +552,28 @@ export class LocalJobRegistry extends JobRegistry {
}
}
/** Install the job's record and extend the producer face with its append. */
private recordingFace(face: RunningJob, state: ProducerState): RecordingJob {
const record: RecordState = { chunks: [], retainedBytes: 0, total: 0, earliest: 0 }
state.record = record
return {
...face,
append: (text, options) => { this.appendRecord(state, record, text, options) },
}
}
/**
* Append one chunk through a producer face. A chunk against a settled job or
* without a record declaration is logged and dropped; an empty chunk is
* dropped silently. A chunk staged inside the starter call is retained and
* signals no observer — the registration commit publishes it.
* Append one chunk through a recording producer face. A chunk against a
* settled job is logged and dropped; an empty chunk is dropped silently. A
* chunk staged inside the starter call is retained and signals no observer —
* the registration commit publishes it.
*/
private appendRecord(id: JobId, state: ProducerState, text: string, options?: JobAppendOptions): void {
private appendRecord(state: ProducerState, record: RecordState, text: string, options?: JobAppendOptions): void {
const job = state.job
if (job !== undefined && isTerminal(job.status)) {
this.selfCtx.logger.warn(`jobs: append to settled job ${job.id} dropped`)
return
}
const record = state.record
if (record === undefined) {
this.selfCtx.logger.warn(`jobs: append to job ${id} without a record declaration dropped`)
return
}
if (text.length === 0) return
const bytes = Buffer.byteLength(text, 'utf8')
record.chunks.push({
+10 -12
View File
@@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobHooks, JobKind, JobOutcome, JobSnapshot, JobStart, RecordingJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry, { type Config as JobsConfig } from '@deepseek-ai/dsh-jobs-local'
declare module '@deepseek-ai/dsh-jobs' {
@@ -66,14 +66,14 @@ function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
done: new Promise<JobOutcome>((res, rej) => { settle = res; reject = rej }),
...hookOverrides,
}
let started: RunningJob | undefined
let started: RecordingJob | undefined
const spec: JobStart = {
kind,
label,
...owner !== undefined ? { owner } : {},
...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
...record !== undefined ? { record } : {},
run: (job) => {
run: (job: RecordingJob) => {
started = job
return hooks
},
@@ -83,7 +83,7 @@ function producer(overrides: Partial<Omit<JobStart, 'run'> & JobHooks> = {}) {
settle,
reject,
cancels,
job(): RunningJob {
job(): RecordingJob {
if (started === undefined) throw new Error('producer not started')
return started
},
@@ -200,7 +200,7 @@ describe('LocalJobRegistry.start', () => {
for (const job of live) ctx.jobs.start(job.spec)
const blocked = producer()
const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
const run = vi.fn((job: RecordingJob) => blocked.spec.run(job))
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
.toThrow('background job limit reached for this owner (limit: 10)')
expect(run).not.toHaveBeenCalled()
@@ -213,7 +213,7 @@ describe('LocalJobRegistry.start', () => {
expect(ctx.jobs.start(first.spec)).toBe('bash-1')
const blocked = producer()
const run = vi.fn((job: RunningJob) => blocked.spec.run(job))
const run = vi.fn((job: RecordingJob) => blocked.spec.run(job))
expect(() => ctx.jobs.start({ ...blocked.spec, run }))
.toThrow('use job_kill to stop an unneeded job, wait for it to finish, then retry')
expect(run).not.toHaveBeenCalled()
@@ -658,7 +658,7 @@ describe('LocalJobRegistry owner isolation', () => {
ctx.jobs.start(current.spec) // Attach the current owner's cleanup first.
const stale = producer({ owner: staleOwner })
const staleRun = vi.fn((job: RunningJob) => stale.spec.run(job))
const staleRun = vi.fn((job: RecordingJob) => stale.spec.run(job))
expect(() => ctx.jobs.start({ ...stale.spec, run: staleRun }))
.toThrow('is not the registered agent instance')
expect(staleRun).not.toHaveBeenCalled()
@@ -1223,14 +1223,12 @@ describe('LocalJobRegistry record', () => {
expect(ctx.jobs.get(id).outputTotal).toBe(0)
})
it('logs and drops an append without a record declaration; updateDetail still works', async () => {
it('hands a start without a record declaration no append; updateDetail still works', async () => {
const ctx = await harness()
const warn = vi.fn()
ctx.logger.warn = warn as never
const p = producer()
const id = ctx.jobs.start(p.spec)
p.job().append('nowhere to land')
expect(warn).toHaveBeenCalledWith(expect.stringContaining(`append to job ${id} without a record declaration dropped`))
// The plain face is built without the method, not with a dropping stub.
expect(p.job()).not.toHaveProperty('append')
expect(() => ctx.jobs.readRecord(id, 0)).toThrow(`job ${id} declared no output record`)
const snapshot = ctx.jobs.get(id)
expect(snapshot.outputTotal).toBeUndefined()
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/jobs/jobs/README.md
README.md: 10c74af908f64c18616e3c1eb71f19a3cbbf4eb3
README.zh.md: 1fc500311c58a538ebfc7fbd89886d9c12b96acd
README.md: 5d4e10de9fa852dc4a38c521321bae53c744909b
README.zh.md: b584191a6b94e79ba8908875e7a0706a525edf4f
+2 -2
View File
@@ -31,7 +31,7 @@ Use this package when you are composing a background-job capability or writing a
A producer registers work with a kind and a one-line label; the registry returns a `<kind>-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded.
A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RunningJob` face its starter receives: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state.
A producer that declares `record: true` additionally streams raw output into the job's bounded record through the `RecordingJob` face its starter receives — a start without the declaration receives the plain `RunningJob` face, which has no `append`: observers read retained chunks at absolute byte offsets and get signaled on advancement, the settlement that ends the job also ends the stream, and `updateDetail` publishes a live progress line into every snapshot. The record is invisible to the model: reads consume nothing and never touch notice state.
### The ownership boundary
@@ -77,7 +77,7 @@ This section explains the design decisions behind the contract and points at the
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract |
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `JobSnapshot`, listener types |
| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `RunningJob`, `RecordingJob`, `JobSnapshot`, listener types |
| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency |
| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`: poll pump copying producer offset-readers into a job record |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, owner, and record-offset fields |
+2 -2
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
生产方以 kind 和一行标签注册工作;注册表返回 `<kind>-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。
声明 `record: true` 的生产方还会通过 starter 收到的 `RunningJob` 面把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
声明 `record: true` 的生产方还会通过 starter 收到的 `RecordingJob` 面(未声明的 start 收到的是没有 `append` 的普通 `RunningJob` 面)把原始输出流入该 job 的有界 record:观察者按绝对字节偏移读取保留块并在推进时收到信号,结束 job 的结算同时封流,`updateDetail` 把实时进度行发布进每个快照。record 对模型不可见:读取不消耗任何东西,也永不触碰通知状态。
### 归属边界
@@ -77,7 +77,7 @@ kind: "package-reference"
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 |
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`JobSnapshot`、监听器类型 |
| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`RunningJob`、`RecordingJob`、`JobSnapshot`、监听器类型 |
| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 |
| [`src/pump.ts`](src/pump.ts) | `pumpJobOutput`:把生产方偏移读取器复制进 job record 的轮询泵 |
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳、所有者与 record 偏移字段 |
+3 -1
View File
@@ -33,6 +33,9 @@ export type {
JobStart,
JobStatus,
JobsChangedListener,
PlainJobStart,
RecordingJob,
RecordingJobStart,
RunningJob,
} from './types.ts'
export { pumpJobOutput } from './pump.ts'
@@ -163,7 +166,6 @@ export abstract class JobRegistry extends Service {
*/
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 —
+2 -2
View File
@@ -5,7 +5,7 @@
* @module
*/
import type { JobChannel, RunningJob } from './types.ts'
import type { JobChannel, RecordingJob } from './types.ts'
/** One non-consuming offset read from a producer-owned stream. */
export interface JobPumpRead {
@@ -74,7 +74,7 @@ export interface JobPumpOptions {
* @returns resolves after the final post-settlement drain.
*/
export async function pumpJobOutput(
job: RunningJob,
job: RecordingJob,
sources: readonly JobPumpSource[],
options: JobPumpOptions,
): Promise<void> {
+53 -28
View File
@@ -56,12 +56,8 @@ export interface JobAppendOptions {
gapBefore?: true
}
/**
* 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 and lifecycle state.
*/
export interface JobStart {
/** 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). */
@@ -80,15 +76,34 @@ export interface JobStart {
* 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.
*/
export interface PlainJobStart extends JobStartBase {
record?: undefined
/**
* Declares an observable output record beside the model-facing surfaces:
* {@link RunningJob.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}.
* Without the declaration `append` logs and drops.
* 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.
*/
record?: true
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
@@ -96,34 +111,44 @@ export interface JobStart {
* started resources.
* @param job - the issued id plus the record append and live-detail writers.
*/
run(job: RunningJob): JobHooks
run(job: RecordingJob): JobHooks
}
/**
* Producer face of one registered job, handed to {@link JobStart.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 — both methods log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
* 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 {
/** 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. Without a
* {@link JobStart.record} declaration the chunk is logged and dropped.
* 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 snapshot's status detail with a live progress line (`3/10`).
* Works for every job, with or without a record.
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
}
/** Hooks through which the runtime controls and observes producer work. */
+38 -4
View File
@@ -3,8 +3,8 @@ import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { JobId, JobRegistry, pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type {
JobAppendOptions, JobDoneListener, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
JobSnapshot, JobStart, JobsChangedListener, RunningJob,
JobAppendOptions, JobDoneListener, JobHooks, JobOutputListener, JobPumpSource, JobRead, JobRecordRead,
JobSnapshot, JobStart, JobsChangedListener, PlainJobStart, RecordingJob, RecordingJobStart,
} from '@deepseek-ai/dsh-jobs'
/**
@@ -71,6 +71,40 @@ 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()
@@ -109,9 +143,9 @@ describe('JobRegistry seam', () => {
})
/** Record every append so pump behavior is observable without a registry. */
function recordingJob(): { job: RunningJob; appends: { text: string; options?: JobAppendOptions }[] } {
function recordingJob(): { job: RecordingJob; appends: { text: string; options?: JobAppendOptions }[] } {
const appends: { text: string; options?: JobAppendOptions }[] = []
const job: RunningJob = {
const job: RecordingJob = {
id: JobId('bash-1'),
append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) },
updateDetail() {},
+2 -2
View File
@@ -15,7 +15,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs'
/**
* Pump the process's non-consuming offset readers into the job record until
@@ -31,7 +31,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
*/
export function observeProcessRecord(
ctx: Context,
job: RunningJob,
job: RecordingJob,
proc: ShellProcess,
pollMs: number,
): Promise<void> {
@@ -10,7 +10,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import type { JobId, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobId, RecordingJob } from '@deepseek-ai/dsh-jobs'
import * as ToolTasks from '@deepseek-ai/dsh-tool-jobs'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
@@ -121,8 +121,8 @@ describe('background bash record', () => {
})
describe('observeProcessRecord', () => {
/** A RunningJob face recording appends for pump assertions. */
function recordingJob(): { job: RunningJob; appends: string[] } {
/** A RecordingJob face recording appends for pump assertions. */
function recordingJob(): { job: RecordingJob; appends: string[] } {
const appends: string[] = []
return {
job: {
+2 -2
View File
@@ -16,7 +16,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ShellProcess } from '@deepseek-ai/dsh-shell'
import { pumpJobOutput } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
import type { JobPumpSource, RecordingJob } from '@deepseek-ai/dsh-jobs'
/**
* Pump the process's non-consuming offset readers into the job record until
@@ -32,7 +32,7 @@ import type { JobPumpSource, RunningJob } from '@deepseek-ai/dsh-jobs'
*/
export function observeProcessRecord(
ctx: Context,
job: RunningJob,
job: RecordingJob,
proc: ShellProcess,
pollMs: number,
): Promise<void> {