Files
waooplus/src/lib/task/state-service.ts
saturn 9aff44e37a refactor: analysis workflow architecture
fix: NEXTAUTH_URL

fix: prevent project model edits from affecting default model
2026-03-16 21:48:57 +08:00

288 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { prisma } from '@/lib/prisma'
import { normalizeTaskError } from '@/lib/errors/normalize'
import { coerceTaskIntent, type TaskIntent } from './intent'
export type TaskTargetQuery = {
targetType: string
targetId: string
types?: string[]
}
export type TaskTargetPhase = 'idle' | 'queued' | 'processing' | 'completed' | 'failed'
export type TaskTargetState = {
targetType: string
targetId: string
phase: TaskTargetPhase
runningTaskId: string | null
runningTaskType: string | null
intent: TaskIntent
hasOutputAtStart: boolean | null
progress: number | null
stage: string | null
stageLabel: string | null
lastError: {
code: string
message: string
} | null
updatedAt: string | null
}
const ACTIVE_STATUS = new Set(['queued', 'processing'])
export function pairKey(targetType: string, targetId: string) {
return `${targetType}:${targetId}`
}
export function asObject(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
export function asNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
export function asBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
return null
}
export function toProgress(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null
const rounded = Math.floor(value)
if (rounded < 0) return 0
if (rounded > 100) return 100
return rounded
}
export function extractTaskStateFields(task: {
type: string
progress: number
payload: unknown
}) {
const payload = asObject(task.payload)
const payloadUi = asObject(payload?.ui)
return {
stage: asNonEmptyString(payload?.stage),
stageLabel: asNonEmptyString(payload?.stageLabel),
hasOutputAtStart: asBoolean(payloadUi?.hasOutputAtStart),
intent: coerceTaskIntent(payloadUi?.intent ?? payload?.intent, task.type),
progress: toProgress(task.progress),
}
}
export function normalizeFailedError(task: {
errorCode: string | null
errorMessage: string | null
}) {
const normalized = normalizeTaskError(task.errorCode, task.errorMessage)
if (!normalized) return null
return {
code: normalized.code,
message: normalized.message,
}
}
export function buildIdleState(target: TaskTargetQuery): TaskTargetState {
return {
targetType: target.targetType,
targetId: target.targetId,
phase: 'idle',
runningTaskId: null,
runningTaskType: null,
intent: 'process',
hasOutputAtStart: null,
progress: null,
stage: null,
stageLabel: null,
lastError: null,
updatedAt: null,
}
}
export function resolveTargetState(
target: TaskTargetQuery,
tasks: Array<{
id: string
type: string
status: string
progress: number
payload: unknown
errorCode: string | null
errorMessage: string | null
updatedAt: Date
}>,
): TaskTargetState {
const allowedTypes = target.types?.length ? new Set(target.types) : null
const filtered = allowedTypes
? tasks.filter((task) => allowedTypes.has(task.type))
: tasks
if (filtered.length === 0) return buildIdleState(target)
const running = filtered.find((task) => ACTIVE_STATUS.has(task.status)) || null
const terminal = filtered.find((task) =>
task.status === 'completed' || task.status === 'failed' || task.status === 'canceled'
) || null
const latest = running || terminal
if (!latest) return buildIdleState(target)
const latestFields = extractTaskStateFields(latest)
if (running) {
const runningFields = extractTaskStateFields(running)
return {
targetType: target.targetType,
targetId: target.targetId,
phase: running.status === 'processing' ? 'processing' : 'queued',
runningTaskId: running.id,
runningTaskType: running.type,
intent: runningFields.intent,
hasOutputAtStart: runningFields.hasOutputAtStart,
progress: runningFields.progress,
stage: runningFields.stage,
stageLabel: runningFields.stageLabel,
lastError: null,
updatedAt: running.updatedAt.toISOString(),
}
}
if (latest.status === 'completed') {
return {
targetType: target.targetType,
targetId: target.targetId,
phase: 'completed',
runningTaskId: null,
runningTaskType: latest.type,
intent: latestFields.intent,
hasOutputAtStart: latestFields.hasOutputAtStart,
progress: 100,
stage: latestFields.stage,
stageLabel: latestFields.stageLabel,
lastError: null,
updatedAt: latest.updatedAt.toISOString(),
}
}
return {
targetType: target.targetType,
targetId: target.targetId,
phase: 'failed',
runningTaskId: null,
runningTaskType: latest.type,
intent: latestFields.intent,
hasOutputAtStart: latestFields.hasOutputAtStart,
progress: null,
stage: latestFields.stage,
stageLabel: latestFields.stageLabel,
lastError: normalizeFailedError(latest),
updatedAt: latest.updatedAt.toISOString(),
}
}
/**
* 单次查询的 OR 条件上限。
* 过大的 OR 列表 + ORDER BY 会导致 MySQL sort buffer 溢出Error 1038
*/
const QUERY_BATCH_SIZE = 50
export async function queryTaskTargetStates(params: {
projectId: string
userId: string
targets: TaskTargetQuery[]
}): Promise<TaskTargetState[]> {
if (!params.targets.length) return []
const pairEntries = new Map<string, { targetType: string; targetId: string }>()
const typeUnion = new Set<string>()
for (const target of params.targets) {
pairEntries.set(pairKey(target.targetType, target.targetId), {
targetType: target.targetType,
targetId: target.targetId,
})
for (const type of target.types || []) {
if (type) typeUnion.add(type)
}
}
const pairs = Array.from(pairEntries.values())
if (pairs.length === 0) return params.targets.map((target) => buildIdleState(target))
const typeFilter = typeUnion.size > 0 ? { type: { in: Array.from(typeUnion) } } : {}
// 分批查询,避免 MySQL sort buffer 溢出
const allRows: Array<{
id: string
type: string
status: string
progress: number
payload: unknown
errorCode: string | null
errorMessage: string | null
targetType: string
targetId: string
updatedAt: Date
}> = []
for (let i = 0; i < pairs.length; i += QUERY_BATCH_SIZE) {
const batch = pairs.slice(i, i + QUERY_BATCH_SIZE)
const rows = await prisma.task.findMany({
where: {
projectId: params.projectId,
userId: params.userId,
OR: batch.map((item) => ({
targetType: item.targetType,
targetId: item.targetId,
})),
status: {
in: ['queued', 'processing', 'completed', 'failed', 'canceled'],
},
...typeFilter,
},
// 不在数据库层排序,改为应用层排序以避免 sort buffer 溢出
select: {
id: true,
type: true,
status: true,
progress: true,
payload: true,
errorCode: true,
errorMessage: true,
targetType: true,
targetId: true,
updatedAt: true,
},
})
allRows.push(...rows)
}
// 应用层按 updatedAt desc 排序(每个 target 组内排序即可)
const grouped = new Map<string, typeof allRows>()
for (const row of allRows) {
const key = pairKey(row.targetType, row.targetId)
const existing = grouped.get(key)
if (existing) {
existing.push(row)
} else {
grouped.set(key, [row])
}
}
// 对每组按 updatedAt desc 排序
for (const group of grouped.values()) {
group.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
}
return params.targets.map((target) =>
resolveTargetState(
target,
grouped.get(pairKey(target.targetType, target.targetId)) || [],
),
)
}