release: opensource snapshot 2026-02-27 19:25:00

This commit is contained in:
saturn
2026-02-27 19:25:00 +08:00
commit 5de9622c8b
1055 changed files with 164772 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { formatExternalId, parseExternalId } from '@/lib/async-poll'
describe('async poll externalId contract', () => {
it('parses standard FAL externalId with endpoint', () => {
const parsed = parseExternalId('FAL:VIDEO:fal-ai/wan/v2.6/image-to-video:req_123')
expect(parsed.provider).toBe('FAL')
expect(parsed.type).toBe('VIDEO')
expect(parsed.endpoint).toBe('fal-ai/wan/v2.6/image-to-video')
expect(parsed.requestId).toBe('req_123')
})
it('rejects legacy non-standard externalId formats', () => {
expect(() => parseExternalId('FAL:fal-ai/wan/v2.6/image-to-video:req_123')).toThrow(/无效 FAL externalId/)
expect(() => parseExternalId('batches/legacy')).toThrow(/无法识别的 externalId 格式/)
})
it('requires endpoint when formatting FAL externalId', () => {
expect(() => formatExternalId('FAL', 'VIDEO', 'req_123')).toThrow(/requires endpoint/)
})
})

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { resolveTaskErrorMessage, resolveTaskErrorSummary } from '@/lib/task/error-message'
describe('task error message normalization', () => {
it('maps TASK_CANCELLED to unified cancelled message', () => {
const summary = resolveTaskErrorSummary({
errorCode: 'TASK_CANCELLED',
errorMessage: 'whatever',
})
expect(summary.cancelled).toBe(true)
expect(summary.code).toBe('CONFLICT')
expect(summary.message).toBe('Task cancelled by user')
})
it('keeps cancelled semantics from normalized task error details', () => {
const summary = resolveTaskErrorSummary({
error: {
code: 'CONFLICT',
message: 'Task cancelled by user',
details: { cancelled: true, originalCode: 'TASK_CANCELLED' },
},
})
expect(summary.cancelled).toBe(true)
expect(summary.code).toBe('CONFLICT')
expect(summary.message).toBe('Task cancelled by user')
})
it('extracts nested error message from payload', () => {
const message = resolveTaskErrorMessage({
error: {
details: {
message: 'provider failed',
},
},
}, 'fallback')
expect(message).toBe('provider failed')
})
it('supports flat error/details string payload', () => {
expect(resolveTaskErrorMessage({
error: 'provider failed',
}, 'fallback')).toBe('provider failed')
expect(resolveTaskErrorMessage({
details: 'provider failed',
}, 'fallback')).toBe('provider failed')
})
it('uses fallback when payload has no structured error', () => {
expect(resolveTaskErrorMessage({}, 'fallback')).toBe('fallback')
})
it('recognizes cancelled semantics from message-only payload', () => {
const summary = resolveTaskErrorSummary({
message: 'Task cancelled by user',
})
expect(summary.cancelled).toBe(true)
expect(summary.message).toBe('Task cancelled by user')
})
})

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { TASK_TYPE } from '@/lib/task/types'
import { resolveTaskIntent } from '@/lib/task/intent'
describe('resolveTaskIntent', () => {
it('maps generate task types', () => {
expect(resolveTaskIntent(TASK_TYPE.IMAGE_CHARACTER)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.IMAGE_LOCATION)).toBe('generate')
expect(resolveTaskIntent(TASK_TYPE.VIDEO_PANEL)).toBe('generate')
})
it('maps regenerate and modify task types', () => {
expect(resolveTaskIntent(TASK_TYPE.REGENERATE_GROUP)).toBe('regenerate')
expect(resolveTaskIntent(TASK_TYPE.PANEL_VARIANT)).toBe('regenerate')
expect(resolveTaskIntent(TASK_TYPE.MODIFY_ASSET_IMAGE)).toBe('modify')
})
it('falls back to process for unknown types', () => {
expect(resolveTaskIntent('unknown_type')).toBe('process')
expect(resolveTaskIntent(null)).toBe('process')
expect(resolveTaskIntent(undefined)).toBe('process')
})
})

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { getTaskFlowMeta, getTaskPipeline } from '@/lib/llm-observe/stage-pipeline'
import { getLLMTaskPolicy } from '@/lib/llm-observe/task-policy'
import { TASK_TYPE } from '@/lib/task/types'
describe('llm observe task contract', () => {
it('maps AI_CREATE tasks to standard llm policy', () => {
const characterPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_CHARACTER)
const locationPolicy = getLLMTaskPolicy(TASK_TYPE.AI_CREATE_LOCATION)
expect(characterPolicy.consoleEnabled).toBe(true)
expect(characterPolicy.displayMode).toBe('loading')
expect(characterPolicy.captureReasoning).toBe(true)
expect(locationPolicy.consoleEnabled).toBe(true)
expect(locationPolicy.displayMode).toBe('loading')
expect(locationPolicy.captureReasoning).toBe(true)
})
it('maps story/script run tasks to long-flow stage metadata', () => {
const storyMeta = getTaskFlowMeta(TASK_TYPE.STORY_TO_SCRIPT_RUN)
const scriptMeta = getTaskFlowMeta(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
expect(storyMeta.flowId).toBe('novel_promotion_generation')
expect(storyMeta.flowStageIndex).toBe(1)
expect(storyMeta.flowStageTotal).toBe(2)
expect(scriptMeta.flowId).toBe('novel_promotion_generation')
expect(scriptMeta.flowStageIndex).toBe(2)
expect(scriptMeta.flowStageTotal).toBe(2)
})
it('maps AI_CREATE tasks to dedicated single-stage flows', () => {
const characterMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_CHARACTER)
const locationMeta = getTaskFlowMeta(TASK_TYPE.AI_CREATE_LOCATION)
expect(characterMeta.flowId).toBe('novel_promotion_ai_create_character')
expect(characterMeta.flowStageIndex).toBe(1)
expect(characterMeta.flowStageTotal).toBe(1)
expect(locationMeta.flowId).toBe('novel_promotion_ai_create_location')
expect(locationMeta.flowStageIndex).toBe(1)
expect(locationMeta.flowStageTotal).toBe(1)
})
it('returns a stable two-stage pipeline for story/script flow', () => {
const pipeline = getTaskPipeline(TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN)
const stageTaskTypes = pipeline.stages.map((stage) => stage.taskType)
expect(stageTaskTypes).toEqual([
TASK_TYPE.STORY_TO_SCRIPT_RUN,
TASK_TYPE.SCRIPT_TO_STORYBOARD_RUN,
])
})
it('falls back to single-stage metadata for unknown task type', () => {
const meta = getTaskFlowMeta('unknown_task_type')
const pipeline = getTaskPipeline('unknown_task_type')
expect(meta.flowId).toBe('single:unknown_task_type')
expect(meta.flowStageIndex).toBe(1)
expect(meta.flowStageTotal).toBe(1)
expect(pipeline.stages).toHaveLength(1)
expect(pipeline.stages[0]?.taskType).toBe('unknown_task_type')
})
})

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { normalizeAnyError } from '@/lib/errors/normalize'
describe('normalizeAnyError network termination mapping', () => {
it('maps undici terminated TypeError to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new TypeError('terminated'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps socket hang up TypeError to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new TypeError('socket hang up'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
it('maps wrapped terminated message to NETWORK_ERROR', () => {
const normalized = normalizeAnyError(new Error('exception TypeError: terminated'))
expect(normalized.code).toBe('NETWORK_ERROR')
expect(normalized.retryable).toBe(true)
})
})

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
describe('resolveTaskPresentationState', () => {
it('uses overlay mode when running and has output', () => {
const state = resolveTaskPresentationState({
phase: 'processing',
intent: 'regenerate',
resource: 'image',
hasOutput: true,
})
expect(state.isRunning).toBe(true)
expect(state.mode).toBe('overlay')
expect(state.labelKey).toBe('taskStatus.intent.regenerate.running.image')
})
it('uses placeholder mode when running and no output', () => {
const state = resolveTaskPresentationState({
phase: 'queued',
intent: 'generate',
resource: 'image',
hasOutput: false,
})
expect(state.mode).toBe('placeholder')
expect(state.labelKey).toBe('taskStatus.intent.generate.running.image')
})
it('maps failed state to failed label', () => {
const state = resolveTaskPresentationState({
phase: 'failed',
intent: 'modify',
resource: 'video',
hasOutput: true,
})
expect(state.isError).toBe(true)
expect(state.labelKey).toBe('taskStatus.failed.video')
})
})

View File

@@ -0,0 +1,207 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const taskEventFindManyMock = vi.hoisted(() => vi.fn(async () => []))
const taskEventCreateMock = vi.hoisted(() => vi.fn(async () => null))
const taskFindManyMock = vi.hoisted(() => vi.fn(async () => []))
const redisPublishMock = vi.hoisted(() => vi.fn(async () => 1))
vi.mock('@/lib/prisma', () => ({
prisma: {
taskEvent: {
findMany: taskEventFindManyMock,
create: taskEventCreateMock,
},
task: {
findMany: taskFindManyMock,
},
},
}))
vi.mock('@/lib/redis', () => ({
redis: {
publish: redisPublishMock,
},
}))
import { listEventsAfter, listTaskLifecycleEvents, publishTaskStreamEvent } from '@/lib/task/publisher'
describe('task publisher replay', () => {
beforeEach(() => {
taskEventFindManyMock.mockReset()
taskEventCreateMock.mockReset()
taskFindManyMock.mockReset()
redisPublishMock.mockReset()
})
it('replays persisted lifecycle + stream rows in chronological order', async () => {
taskEventFindManyMock.mockResolvedValueOnce([
{
id: 12,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 2,
lane: 'main',
delta: 'world',
},
},
createdAt: new Date('2026-02-27T00:00:02.000Z'),
},
{
id: 11,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.processing',
payload: {
lifecycleType: 'task.processing',
stepId: 'step-1',
stepTitle: '阶段1',
},
createdAt: new Date('2026-02-27T00:00:01.000Z'),
},
{
id: 10,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.ignored',
payload: {},
createdAt: new Date('2026-02-27T00:00:00.000Z'),
},
])
taskFindManyMock.mockResolvedValueOnce([
{
id: 'task-1',
type: 'script_to_storyboard_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
},
])
const events = await listTaskLifecycleEvents('task-1', 50)
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
where: { taskId: 'task-1' },
orderBy: { id: 'desc' },
take: 50,
}))
expect(events).toHaveLength(2)
expect(events.map((event) => event.id)).toEqual(['11', '12'])
expect(events.map((event) => event.type)).toEqual(['task.lifecycle', 'task.stream'])
expect((events[1]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('world')
})
it('persists stream rows when persist=true', async () => {
taskEventCreateMock.mockResolvedValueOnce({
id: 99,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stream: {
kind: 'text',
seq: 1,
lane: 'main',
delta: 'hello',
},
},
createdAt: new Date('2026-02-27T00:00:03.000Z'),
})
redisPublishMock.mockResolvedValueOnce(1)
const message = await publishTaskStreamEvent({
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
taskType: 'story_to_script_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 1,
lane: 'main',
delta: 'hello',
},
},
persist: true,
})
expect(taskEventCreateMock).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({
taskId: 'task-1',
eventType: 'task.stream',
}),
}))
expect(redisPublishMock).toHaveBeenCalledTimes(1)
expect(message?.id).toBe('99')
expect(message?.type).toBe('task.stream')
})
it('replays lifecycle + stream rows in listEventsAfter', async () => {
taskEventFindManyMock.mockResolvedValueOnce([
{
id: 101,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.stream',
payload: {
stepId: 'step-1',
stream: {
kind: 'text',
seq: 3,
lane: 'main',
delta: 'chunk',
},
},
createdAt: new Date('2026-02-27T00:00:03.000Z'),
},
{
id: 102,
taskId: 'task-1',
projectId: 'project-1',
userId: 'user-1',
eventType: 'task.processing',
payload: {
lifecycleType: 'task.processing',
stepId: 'step-1',
},
createdAt: new Date('2026-02-27T00:00:04.000Z'),
},
])
taskFindManyMock.mockResolvedValueOnce([
{
id: 'task-1',
type: 'story_to_script_run',
targetType: 'episode',
targetId: 'episode-1',
episodeId: 'episode-1',
},
])
const events = await listEventsAfter('project-1', 100, 20)
expect(taskEventFindManyMock).toHaveBeenCalledWith(expect.objectContaining({
where: {
projectId: 'project-1',
id: { gt: 100 },
},
orderBy: { id: 'asc' },
}))
expect(events).toHaveLength(2)
expect(events.map((event) => event.id)).toEqual(['101', '102'])
expect(events.map((event) => event.type)).toEqual(['task.stream', 'task.lifecycle'])
expect((events[0]?.payload as { stream?: { delta?: string } }).stream?.delta).toBe('chunk')
})
})