348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
import type { RunStreamEvent } from '@/lib/novel-promotion/run-stream/types'
|
|
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'
|
|
import { resolveTaskErrorMessage as resolveUnifiedTaskErrorMessage } from '@/lib/task/error-message'
|
|
import type { RunResult } from './types'
|
|
|
|
export function parseSSEBlock(block: string): { event: string; data: string } | null {
|
|
const lines = block.split('\n')
|
|
let event = 'message'
|
|
const dataLines: string[] = []
|
|
|
|
for (const line of lines) {
|
|
if (!line) continue
|
|
if (line.startsWith('event:')) {
|
|
event = line.slice(6).trim()
|
|
continue
|
|
}
|
|
if (line.startsWith('data:')) {
|
|
dataLines.push(line.slice(5).trim())
|
|
}
|
|
}
|
|
|
|
if (dataLines.length === 0) return null
|
|
return {
|
|
event,
|
|
data: dataLines.join('\n'),
|
|
}
|
|
}
|
|
|
|
export function toObject(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
return value as Record<string, unknown>
|
|
}
|
|
|
|
export function readTextField(payload: Record<string, unknown>, key: string): string | undefined {
|
|
const value = payload[key]
|
|
return typeof value === 'string' ? value : undefined
|
|
}
|
|
|
|
export function readStepField(payload: Record<string, unknown>, key: string): number | undefined {
|
|
const value = payload[key]
|
|
return typeof value === 'number' && Number.isFinite(value) ? Math.max(1, Math.floor(value)) : undefined
|
|
}
|
|
|
|
function readStringArrayField(payload: Record<string, unknown>, key: string): string[] {
|
|
const value = payload[key]
|
|
if (!Array.isArray(value)) return []
|
|
const rows: string[] = []
|
|
for (const item of value) {
|
|
if (typeof item !== 'string') continue
|
|
const trimmed = item.trim()
|
|
if (!trimmed) continue
|
|
rows.push(trimmed)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
function readBoolField(payload: Record<string, unknown>, key: string): boolean | undefined {
|
|
const value = payload[key]
|
|
if (value === true) return true
|
|
if (value === false) return false
|
|
return undefined
|
|
}
|
|
|
|
function normalizeLifecycleType(value: unknown): string | null {
|
|
if (typeof value !== 'string') return null
|
|
if (value === TASK_EVENT_TYPE.PROGRESS) return TASK_EVENT_TYPE.PROCESSING
|
|
if (
|
|
value === TASK_EVENT_TYPE.CREATED ||
|
|
value === TASK_EVENT_TYPE.PROCESSING ||
|
|
value === TASK_EVENT_TYPE.COMPLETED ||
|
|
value === TASK_EVENT_TYPE.FAILED
|
|
) {
|
|
return value
|
|
}
|
|
return null
|
|
}
|
|
|
|
function stageLooksCompleted(stage: string | undefined) {
|
|
if (!stage) return false
|
|
return (
|
|
stage === 'llm_completed' ||
|
|
stage === 'worker_llm_completed' ||
|
|
stage === 'worker_llm_complete' ||
|
|
stage === 'llm_proxy_persist' ||
|
|
stage === 'completed'
|
|
)
|
|
}
|
|
|
|
function stageLooksFailed(stage: string | undefined) {
|
|
if (!stage) return false
|
|
return stage === 'llm_error' || stage === 'worker_llm_error' || stage === 'error'
|
|
}
|
|
|
|
function resolveTaskErrorMessage(payload: Record<string, unknown>, fallback = 'task failed') {
|
|
return resolveUnifiedTaskErrorMessage(payload, fallback)
|
|
}
|
|
|
|
function extractTerminalPayload(payload: Record<string, unknown>) {
|
|
const result = toObject(payload.result)
|
|
if (Object.keys(result).length > 0) {
|
|
return result
|
|
}
|
|
return payload
|
|
}
|
|
|
|
export function mapTaskSSEEventToRunEvents(event: SSEEvent): RunStreamEvent[] {
|
|
const rawPayload = toObject(event.payload)
|
|
const payloadMeta = toObject(rawPayload.meta)
|
|
const runId = readTextField(rawPayload, 'runId')
|
|
|| readTextField(payloadMeta, 'runId')
|
|
|| (typeof event.taskId === 'string' ? event.taskId : '')
|
|
if (!runId) return []
|
|
const payload = rawPayload
|
|
const lifecycleType =
|
|
event.type === TASK_SSE_EVENT_TYPE.LIFECYCLE
|
|
? normalizeLifecycleType(payload.lifecycleType)
|
|
: null
|
|
const ts = typeof event.ts === 'string' ? event.ts : new Date().toISOString()
|
|
const flowStageTitle = readTextField(payload, 'flowStageTitle')
|
|
const flowStageIndex = readStepField(payload, 'flowStageIndex')
|
|
const flowStageTotal = readStepField(payload, 'flowStageTotal')
|
|
const rawStepId = readTextField(payload, 'stepId')
|
|
const rawStepTitle = readTextField(payload, 'stepTitle')
|
|
const stepId =
|
|
rawStepId ||
|
|
(
|
|
event.type === TASK_SSE_EVENT_TYPE.STREAM
|
|
? `step:${event.taskType || 'llm'}`
|
|
: undefined)
|
|
const stepAttempt = readStepField(payload, 'stepAttempt')
|
|
const stepTitle = rawStepTitle || flowStageTitle || undefined
|
|
const stepIndex = readStepField(payload, 'stepIndex') ?? flowStageIndex
|
|
const stepTotal = readStepField(payload, 'stepTotal') ?? flowStageTotal
|
|
const stage = readTextField(payload, 'stage')
|
|
const message = readTextField(payload, 'message')
|
|
const done = payload.done === true
|
|
const text = readTextField(payload, 'output') || readTextField(payload, 'text')
|
|
const reasoning = readTextField(payload, 'reasoning') || readTextField(payload, 'thinking')
|
|
const dependsOn = readStringArrayField(payload, 'dependsOn')
|
|
const blockedBy = readStringArrayField(payload, 'blockedBy')
|
|
const groupId = readTextField(payload, 'groupId')
|
|
const parallelKey = readTextField(payload, 'parallelKey')
|
|
const retryable = readBoolField(payload, 'retryable')
|
|
const stale = readBoolField(payload, 'stale')
|
|
|
|
if (event.type === TASK_SSE_EVENT_TYPE.STREAM) {
|
|
const stream = toObject(payload.stream)
|
|
const kind = stream.kind === 'reasoning' ? 'reasoning' : 'text'
|
|
const delta = typeof stream.delta === 'string' ? stream.delta : ''
|
|
if (!delta || !stepId) return []
|
|
const lane = stream.lane === 'reasoning' || kind === 'reasoning' ? 'reasoning' : 'text'
|
|
const seq =
|
|
typeof stream.seq === 'number' && Number.isFinite(stream.seq) ? Math.max(1, Math.floor(stream.seq)) : undefined
|
|
const streamStepAttempt =
|
|
typeof stream.attempt === 'number' && Number.isFinite(stream.attempt)
|
|
? Math.max(1, Math.floor(stream.attempt))
|
|
: undefined
|
|
|
|
return [{
|
|
runId,
|
|
event: 'step.chunk',
|
|
ts,
|
|
status: 'running',
|
|
stepId,
|
|
stepAttempt: stepAttempt ?? streamStepAttempt,
|
|
stepTitle,
|
|
stepIndex,
|
|
stepTotal,
|
|
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
|
|
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
|
|
groupId,
|
|
parallelKey,
|
|
retryable,
|
|
lane,
|
|
seq,
|
|
textDelta: lane === 'text' ? delta : undefined,
|
|
reasoningDelta: lane === 'reasoning' ? delta : undefined,
|
|
message,
|
|
}]
|
|
}
|
|
|
|
if (event.type !== TASK_SSE_EVENT_TYPE.LIFECYCLE) return []
|
|
|
|
const runEvents: RunStreamEvent[] = []
|
|
|
|
if (lifecycleType === TASK_EVENT_TYPE.CREATED) {
|
|
runEvents.push({
|
|
runId,
|
|
event: 'run.start',
|
|
ts,
|
|
status: 'running',
|
|
message,
|
|
payload,
|
|
})
|
|
return runEvents
|
|
}
|
|
|
|
if (lifecycleType === TASK_EVENT_TYPE.PROCESSING) {
|
|
if (stepId) {
|
|
runEvents.push({
|
|
runId,
|
|
event: 'step.start',
|
|
ts,
|
|
status: 'running',
|
|
stepId,
|
|
stepAttempt,
|
|
stepTitle,
|
|
stepIndex,
|
|
stepTotal,
|
|
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
|
|
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
|
|
groupId,
|
|
parallelKey,
|
|
retryable,
|
|
message,
|
|
})
|
|
if (done || stageLooksCompleted(stage)) {
|
|
runEvents.push({
|
|
runId,
|
|
event: 'step.complete',
|
|
ts,
|
|
status: stale ? 'stale' : 'completed',
|
|
stepId,
|
|
stepAttempt,
|
|
stepTitle,
|
|
stepIndex,
|
|
stepTotal,
|
|
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
|
|
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
|
|
groupId,
|
|
parallelKey,
|
|
retryable,
|
|
text,
|
|
reasoning,
|
|
message,
|
|
})
|
|
} else if (stageLooksFailed(stage)) {
|
|
runEvents.push({
|
|
runId,
|
|
event: 'step.error',
|
|
ts,
|
|
status: 'failed',
|
|
stepId,
|
|
stepAttempt,
|
|
stepTitle,
|
|
stepIndex,
|
|
stepTotal,
|
|
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
|
|
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
|
|
groupId,
|
|
parallelKey,
|
|
retryable,
|
|
message: resolveTaskErrorMessage(payload),
|
|
})
|
|
}
|
|
}
|
|
return runEvents
|
|
}
|
|
|
|
if (lifecycleType === TASK_EVENT_TYPE.COMPLETED) {
|
|
if (stepId) {
|
|
runEvents.push({
|
|
runId,
|
|
event: 'step.complete',
|
|
ts,
|
|
status: 'completed',
|
|
stepId,
|
|
stepAttempt,
|
|
stepTitle,
|
|
stepIndex,
|
|
stepTotal,
|
|
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
|
|
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
|
|
groupId,
|
|
parallelKey,
|
|
retryable,
|
|
text,
|
|
reasoning,
|
|
message,
|
|
})
|
|
}
|
|
runEvents.push({
|
|
runId,
|
|
event: 'run.complete',
|
|
ts,
|
|
status: 'completed',
|
|
message,
|
|
payload: extractTerminalPayload(payload),
|
|
})
|
|
return runEvents
|
|
}
|
|
|
|
if (lifecycleType === TASK_EVENT_TYPE.FAILED) {
|
|
const errorMessage = resolveTaskErrorMessage(payload)
|
|
if (stepId) {
|
|
runEvents.push({
|
|
runId,
|
|
event: 'step.error',
|
|
ts,
|
|
status: 'failed',
|
|
stepId,
|
|
stepAttempt,
|
|
stepTitle,
|
|
stepIndex,
|
|
stepTotal,
|
|
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
|
|
blockedBy: blockedBy.length > 0 ? blockedBy : undefined,
|
|
groupId,
|
|
parallelKey,
|
|
retryable,
|
|
message: errorMessage,
|
|
})
|
|
}
|
|
runEvents.push({
|
|
runId,
|
|
event: 'run.error',
|
|
ts,
|
|
status: 'failed',
|
|
message: errorMessage,
|
|
payload,
|
|
})
|
|
}
|
|
|
|
return runEvents
|
|
}
|
|
|
|
export function toTerminalRunResult(event: RunStreamEvent): RunResult | null {
|
|
if (event.event !== 'run.complete' && event.event !== 'run.error') return null
|
|
|
|
const summaryFromPayload =
|
|
event.payload &&
|
|
typeof event.payload.summary === 'object' &&
|
|
event.payload.summary
|
|
? (event.payload.summary as Record<string, unknown>)
|
|
: null
|
|
|
|
return {
|
|
runId: event.runId,
|
|
status: event.event === 'run.complete' ? 'completed' : 'failed',
|
|
summary:
|
|
event.event === 'run.complete'
|
|
? summaryFromPayload || event.payload || null
|
|
: summaryFromPayload,
|
|
payload: event.payload || null,
|
|
errorMessage: event.event === 'run.error' ? event.message || 'run failed' : '',
|
|
}
|
|
}
|