323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
/** Workflow child ownership and progress over the shared sandboxed PTC executor. */
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-working-directory'
|
|
import type { PtcBindingFunction, PtcJsonValue, PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime'
|
|
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
|
import type { SubagentActivation } from '@deepseek-ai/dsh-subagent'
|
|
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
|
|
import { assertNever, snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
|
|
import type { WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowMeta, WorkflowResult, WorkflowRun, WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
|
import { WORKFLOW_GUEST_SOURCE } from './guest-source.ts'
|
|
import type { WorkflowProgress } from './guest-types.ts'
|
|
import { renderThrown } from './realm.ts'
|
|
import type { ExecutionObserver } from './runtime.ts'
|
|
import type { ChildStartRequest, WorkerInit } from './types.ts'
|
|
|
|
interface ChildRecord {
|
|
readonly callId: number
|
|
readonly run: SubagentActivation
|
|
disposal?: Promise<void>
|
|
}
|
|
|
|
const GUEST_URL = `data:text/javascript,${encodeURIComponent(WORKFLOW_GUEST_SOURCE)}`
|
|
const PROGRAM = `const { runWorkflowGuest } = await import(${JSON.stringify(GUEST_URL)}); return await runWorkflowGuest(workflowHost);`
|
|
|
|
function object(value: unknown): Record<string, unknown> {
|
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error('workflow binding requires an object')
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
function text(value: unknown, name: string): string {
|
|
if (typeof value !== 'string') throw new Error(`workflow ${name} must be a string`)
|
|
return value
|
|
}
|
|
|
|
function json(value: unknown): PtcJsonValue {
|
|
const result = snapshotJsonValue(value)
|
|
if (result === undefined) throw new Error('workflow binding value must be lossless JSON')
|
|
return result as PtcJsonValue
|
|
}
|
|
|
|
function childRequest(value: unknown): ChildStartRequest {
|
|
const request = object(value)
|
|
const prompt = text(request.prompt, 'prompt')
|
|
const provider = request.provider === undefined ? undefined : text(request.provider, 'provider')
|
|
const model = request.model === undefined ? undefined : text(request.model, 'model')
|
|
let schema: ObjectJsonSchema | undefined
|
|
if (request.schema !== undefined) {
|
|
const candidate = object(request.schema)
|
|
assertObjectJsonSchema(candidate)
|
|
schema = candidate
|
|
}
|
|
return {
|
|
prompt,
|
|
...provider === undefined ? {} : { provider },
|
|
...model === undefined ? {} : { model },
|
|
...schema === undefined ? {} : { schema },
|
|
}
|
|
}
|
|
|
|
function agentInfo(value: unknown): WorkflowAgentInfo {
|
|
const info = object(value)
|
|
if (!Number.isSafeInteger(info.seq) || (info.seq as number) < 1) throw new Error('workflow agent sequence must be a positive integer')
|
|
return {
|
|
seq: info.seq as number,
|
|
label: text(info.label, 'agent label'),
|
|
childId: SessionId(text(info.childId, 'child id')),
|
|
...info.phase === undefined ? {} : { phase: text(info.phase, 'agent phase') },
|
|
}
|
|
}
|
|
|
|
function progress(value: unknown): WorkflowProgress {
|
|
const event = object(value)
|
|
switch (event.type) {
|
|
case 'phase': return { type: 'phase', title: text(event.title, 'phase') }
|
|
case 'log': return { type: 'log', message: text(event.message, 'log') }
|
|
case 'agent-start': return { type: 'agent-start', info: agentInfo(event.info) }
|
|
case 'agent-end': {
|
|
const info = object(event.info)
|
|
if (info.outcome !== 'completed' && info.outcome !== 'failed' && info.outcome !== 'cancelled') throw new Error('invalid workflow agent outcome')
|
|
return { type: 'agent-end', info: { ...agentInfo(info), outcome: info.outcome } }
|
|
}
|
|
default: throw new Error('invalid workflow progress event')
|
|
}
|
|
}
|
|
|
|
function progressBatch(value: unknown): WorkflowProgress[] {
|
|
if (!Array.isArray(value)) throw new Error('workflow progress requires an array of events')
|
|
return value.map(progress)
|
|
}
|
|
|
|
function workflowResult(value: unknown): WorkflowResult {
|
|
const result = object(value)
|
|
if (result.stopReason !== 'completed' && result.stopReason !== 'error' && result.stopReason !== 'cancelled') throw new Error('invalid workflow stop reason')
|
|
if (!Number.isSafeInteger(result.agentsStarted) || (result.agentsStarted as number) < 0) throw new Error('invalid workflow agent count')
|
|
if (!Object.hasOwn(result, 'value')) throw new Error('workflow result is missing its value')
|
|
return {
|
|
value: result.value,
|
|
stopReason: result.stopReason,
|
|
agentsStarted: result.agentsStarted as number,
|
|
...result.error === undefined ? {} : { error: text(result.error, 'error') },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Holder-owned workflow. Cancellation stops the program immediately; settlement waits for
|
|
* its managed process and every admitted child startup/disposal. Engine unload does not
|
|
* invalidate the captured runtime or subagent handles.
|
|
*/
|
|
export class PtcWorkflowRun implements WorkflowRun {
|
|
readonly result: Promise<WorkflowResult>
|
|
private readonly controller = new AbortController()
|
|
private readonly children = new Map<number, ChildRecord>()
|
|
private readonly pending = new Set<Promise<unknown>>()
|
|
private readonly liveAgents = new Map<number, WorkflowAgentInfo>()
|
|
private started = 0
|
|
private terminal = false
|
|
private cancelReason: string | undefined
|
|
private disposed: Promise<void> | undefined
|
|
private readonly externalAbort: () => void
|
|
private readonly cwd: Promise<string>
|
|
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
private readonly subagents: SubagentRuntime,
|
|
private readonly runtime: PtcRuntime,
|
|
readonly id: WorkflowRunId,
|
|
readonly meta: WorkflowMeta,
|
|
private readonly parent: Agent,
|
|
private readonly init: WorkerInit,
|
|
private readonly provider: string,
|
|
private readonly policy: SandboxExecutionPolicy,
|
|
private readonly observer: ExecutionObserver,
|
|
private readonly signal?: AbortSignal,
|
|
) {
|
|
this.externalAbort = () => { this.cancel('workflow signal aborted') }
|
|
if (signal?.aborted) this.externalAbort()
|
|
else signal?.addEventListener('abort', this.externalAbort, { once: true })
|
|
this.cwd = ctx.workingDirectory.ensure(parent, this.controller.signal)
|
|
// Consumers attach durable run recording after start() returns.
|
|
this.result = Promise.resolve().then(() => this.drive())
|
|
}
|
|
|
|
/**
|
|
* Stop the script and abort pending and published children.
|
|
* @param reason - Human-readable cancellation cause; the first request wins.
|
|
*/
|
|
cancel(reason = 'workflow cancelled'): void {
|
|
if (this.terminal || this.cancelReason !== undefined) return
|
|
this.cancelReason = reason
|
|
this.controller.abort(reason)
|
|
for (const record of this.children.values()) void this.disposeChild(record)
|
|
}
|
|
|
|
/**
|
|
* Cancel unfinished work and await the program and child cleanup.
|
|
* @returns One shared completion promise for repeated disposal calls.
|
|
*/
|
|
dispose(): Promise<void> {
|
|
this.cancel('workflow disposed')
|
|
this.disposed ??= this.result.then(() => {})
|
|
return this.disposed
|
|
}
|
|
|
|
private requireActive(): void {
|
|
this.controller.signal.throwIfAborted()
|
|
}
|
|
|
|
private track<T>(task: Promise<T>): Promise<T> {
|
|
this.pending.add(task)
|
|
void task.then(() => { this.pending.delete(task) }, () => { this.pending.delete(task) })
|
|
return task
|
|
}
|
|
|
|
private bindings(): Record<string, PtcBindingFunction> {
|
|
return {
|
|
begin: () => { this.requireActive(); return Promise.resolve(json(this.init)) },
|
|
startChild: value => this.track(this.startChild(childRequest(value))),
|
|
childResult: value => this.track(this.childResult(this.child(value))),
|
|
disposeChild: async (value) => { await this.disposeChild(this.child(value)); return null },
|
|
progress: (value) => {
|
|
for (const event of progressBatch(value)) this.onProgress(event)
|
|
return Promise.resolve(null)
|
|
},
|
|
}
|
|
}
|
|
|
|
private child(value: unknown): ChildRecord {
|
|
this.requireActive()
|
|
const callId = object(value).callId
|
|
if (!Number.isSafeInteger(callId)) throw new Error('workflow child call id must be an integer')
|
|
const record = this.children.get(callId as number)
|
|
if (record === undefined) throw new Error('workflow child call is not active')
|
|
return record
|
|
}
|
|
|
|
private async startChild(request: ChildStartRequest): Promise<PtcJsonValue> {
|
|
this.requireActive()
|
|
const callId = ++this.started
|
|
const run = await this.subagents.startActivation({
|
|
provider: this.provider,
|
|
label: `${this.meta.name} child ${callId}`,
|
|
delivery: 'caller',
|
|
signal: this.controller.signal,
|
|
cwd: await this.cwd,
|
|
prompt: [{ type: 'text', text: request.prompt }],
|
|
parent: this.parent,
|
|
...request.schema === undefined ? {} : { outputSchema: request.schema },
|
|
...request.provider === undefined && request.model === undefined ? {} : {
|
|
agentOptions: {
|
|
...request.provider === undefined ? {} : { provider: request.provider },
|
|
...request.model === undefined ? {} : { model: request.model },
|
|
},
|
|
},
|
|
})
|
|
const record: ChildRecord = { callId, run }
|
|
this.children.set(callId, record)
|
|
// A provider can publish after the signal fired while startup was pending.
|
|
if (this.controller.signal.aborted) {
|
|
await this.disposeChild(record)
|
|
throw new Error('workflow child started after cancellation')
|
|
}
|
|
return { callId, childId: run.childId }
|
|
}
|
|
|
|
private async childResult(record: ChildRecord): Promise<PtcJsonValue> {
|
|
const signal = this.controller.signal
|
|
signal.throwIfAborted()
|
|
const aborted = Promise.withResolvers<never>()
|
|
const onAbort = (): void => { aborted.reject(signal.reason) }
|
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
try {
|
|
const result = await Promise.race([record.run.result, aborted.promise])
|
|
return json({
|
|
output: result.output,
|
|
stopReason: result.stopReason,
|
|
...result.structured === undefined ? {} : { structured: result.structured },
|
|
})
|
|
} finally {
|
|
signal.removeEventListener('abort', onAbort)
|
|
}
|
|
}
|
|
|
|
private disposeChild(record: ChildRecord): Promise<void> {
|
|
record.disposal ??= Promise.resolve().then(() => record.run.dispose()).catch((error: unknown) => {
|
|
this.ctx.logger.warn(`workflow-ptc: child dispose failed: ${renderThrown(error)}`)
|
|
}).finally(() => {
|
|
this.children.delete(record.callId)
|
|
})
|
|
return record.disposal
|
|
}
|
|
|
|
private onProgress(event: WorkflowProgress): void {
|
|
this.requireActive()
|
|
switch (event.type) {
|
|
case 'phase': this.observer.phase(event.title); break
|
|
case 'log': this.observer.log(event.message); break
|
|
case 'agent-start':
|
|
this.liveAgents.set(event.info.seq, event.info)
|
|
this.observer.agentStart(event.info)
|
|
break
|
|
case 'agent-end': this.endAgent(event.info); break
|
|
/* v8 ignore next -- progress() validates the closed message union before dispatch. */
|
|
default: assertNever(event, 'workflow progress')
|
|
}
|
|
}
|
|
|
|
private endAgent(info: WorkflowAgentEndInfo): void {
|
|
if (!this.liveAgents.delete(info.seq)) return
|
|
this.observer.agentEnd(info)
|
|
}
|
|
|
|
private cancelled(): WorkflowResult {
|
|
return { value: null, stopReason: 'cancelled', error: `workflow run cancelled: ${this.cancelReason}`, agentsStarted: this.started }
|
|
}
|
|
|
|
private async drive(): Promise<WorkflowResult> {
|
|
let result: WorkflowResult
|
|
try {
|
|
const cwd = await this.cwd
|
|
this.requireActive()
|
|
if (this.runtime.sandboxMode === undefined && this.policy.mode !== 'danger-full-access') {
|
|
throw new Error(`workflow runtime cannot enforce the ${this.policy.mode} file policy`)
|
|
}
|
|
const outcome = await this.runtime.run(this.runtime.resolve({
|
|
program: {
|
|
'function-body': PROGRAM,
|
|
repl: `await (async () => {\n${PROGRAM}\n})()`,
|
|
script: `(async () => {\n${PROGRAM}\n})()`,
|
|
}[this.runtime.programFormat],
|
|
bindings: [{ global: 'workflowHost', functions: this.bindings() }],
|
|
cwd,
|
|
...this.runtime.sandboxMode === undefined ? {} : { sandboxPolicy: this.policy },
|
|
timeoutMs: null,
|
|
signal: this.controller.signal,
|
|
}))
|
|
this.terminal = true
|
|
if (this.cancelReason !== undefined) result = this.cancelled()
|
|
else if (outcome.error !== undefined) result = { value: null, stopReason: 'error', error: `workflow execution failed (${outcome.error.kind}): ${outcome.error.message}`, agentsStarted: this.started }
|
|
else result = workflowResult(outcome.value)
|
|
} catch (error: unknown) {
|
|
this.terminal = true
|
|
result = this.cancelReason === undefined
|
|
? { value: null, stopReason: 'error', error: renderThrown(error), agentsStarted: this.started }
|
|
: this.cancelled()
|
|
} finally {
|
|
this.terminal = true
|
|
this.signal?.removeEventListener('abort', this.externalAbort)
|
|
this.controller.abort('workflow settled')
|
|
// Disposing published children releases binding waits; pending starts may publish more.
|
|
for (const record of this.children.values()) void this.disposeChild(record)
|
|
while (this.pending.size > 0) await Promise.allSettled([...this.pending])
|
|
await Promise.all([...this.children.values()].map(record => this.disposeChild(record)))
|
|
this.children.clear()
|
|
for (const info of this.liveAgents.values()) this.endAgent({ ...info, outcome: 'cancelled' })
|
|
}
|
|
return result
|
|
}
|
|
}
|