feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

@@ -0,0 +1,140 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
executePipelineGraph,
GraphCancellationError,
type GraphExecutorState,
} from '@/lib/run-runtime/graph-executor'
const { createCheckpointMock, getRunByIdMock } = vi.hoisted(() => ({
createCheckpointMock: vi.fn(),
getRunByIdMock: vi.fn(),
}))
vi.mock('@/lib/run-runtime/service', () => ({
buildLeanState: vi.fn((value: unknown) => value),
createCheckpoint: createCheckpointMock,
getRunById: getRunByIdMock,
}))
describe('graph executor', () => {
beforeEach(() => {
vi.clearAllMocks()
getRunByIdMock.mockResolvedValue({
id: 'run_1',
userId: 'user_1',
status: 'running',
})
})
it('retries retryable node error and writes checkpoint once success', async () => {
const state: GraphExecutorState = {
refs: {},
meta: {},
}
const runMock = vi
.fn()
.mockRejectedValueOnce(new TypeError('fetch failed sending request'))
.mockResolvedValueOnce({
output: { ok: true },
})
await executePipelineGraph({
runId: 'run_1',
projectId: 'project_1',
userId: 'user_1',
state,
nodes: [
{
key: 'node_a',
title: 'Node A',
maxAttempts: 2,
run: runMock,
},
],
})
expect(runMock).toHaveBeenCalledTimes(2)
expect(createCheckpointMock).toHaveBeenCalledTimes(1)
expect(createCheckpointMock).toHaveBeenCalledWith(expect.objectContaining({
runId: 'run_1',
nodeKey: 'node_a',
version: 2,
}))
})
it('throws cancellation error when run status is canceling', async () => {
getRunByIdMock.mockResolvedValue({
id: 'run_1',
userId: 'user_1',
status: 'canceling',
})
await expect(
executePipelineGraph({
runId: 'run_1',
projectId: 'project_1',
userId: 'user_1',
state: {
refs: {},
meta: {},
},
nodes: [
{
key: 'node_a',
title: 'Node A',
run: async () => ({ output: { ok: true } }),
},
],
}),
).rejects.toBeInstanceOf(GraphCancellationError)
})
it('merges refs into state and persists lean checkpoint', async () => {
const state: GraphExecutorState = {
refs: {
scriptId: 'script_1',
},
meta: {
tag: 'v1',
},
}
await executePipelineGraph({
runId: 'run_1',
projectId: 'project_1',
userId: 'user_1',
state,
nodes: [
{
key: 'node_b',
title: 'Node B',
run: async () => ({
checkpointRefs: {
storyboardId: 'storyboard_1',
},
checkpointMeta: {
done: true,
},
}),
},
],
})
expect(state.refs).toEqual({
scriptId: 'script_1',
storyboardId: 'storyboard_1',
voiceLineBatchId: undefined,
versionHash: undefined,
cursor: undefined,
})
expect(createCheckpointMock).toHaveBeenCalledWith(expect.objectContaining({
state: expect.objectContaining({
refs: expect.objectContaining({
scriptId: 'script_1',
storyboardId: 'storyboard_1',
}),
}),
}))
})
})

View File

@@ -0,0 +1,136 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GraphExecutorState } from '@/lib/run-runtime/graph-executor'
const executePipelineGraphMock = vi.hoisted(() =>
vi.fn(async (input: {
runId: string
projectId: string
userId: string
state: GraphExecutorState
nodes: Array<{
key: string
run: (context: {
runId: string
projectId: string
userId: string
nodeKey: string
attempt: number
state: GraphExecutorState
}) => Promise<unknown>
}>
}) => {
for (const node of input.nodes) {
await node.run({
runId: input.runId,
projectId: input.projectId,
userId: input.userId,
nodeKey: node.key,
attempt: 1,
state: input.state,
})
}
return input.state
}),
)
vi.mock('@/lib/run-runtime/graph-executor', () => ({
executePipelineGraph: executePipelineGraphMock,
}))
import { runLangGraphPipeline } from '@/lib/run-runtime/langgraph-pipeline'
type TestState = GraphExecutorState & {
order: string[]
}
describe('langgraph pipeline adapter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('runs nodes in declared order through langgraph', async () => {
const state: TestState = {
refs: {},
meta: {},
order: [],
}
const result = await runLangGraphPipeline({
runId: 'run-1',
projectId: 'project-1',
userId: 'user-1',
state,
nodes: [
{
key: 'node_a',
title: 'Node A',
run: async (context) => {
const typedState = context.state as TestState
typedState.order.push('node_a')
return { output: { ok: true } }
},
},
{
key: 'node_b',
title: 'Node B',
run: async (context) => {
const typedState = context.state as TestState
typedState.order.push('node_b')
return { output: { ok: true } }
},
},
],
})
expect(result.order).toEqual(['node_a', 'node_b'])
expect(executePipelineGraphMock).toHaveBeenCalledTimes(2)
})
it('returns input state when graph has no nodes', async () => {
const state: TestState = {
refs: {},
meta: {},
order: [],
}
const result = await runLangGraphPipeline({
runId: 'run-1',
projectId: 'project-1',
userId: 'user-1',
state,
nodes: [],
})
expect(result).toBe(state)
expect(executePipelineGraphMock).not.toHaveBeenCalled()
})
it('fails explicitly on duplicate node keys', async () => {
const state: TestState = {
refs: {},
meta: {},
order: [],
}
await expect(
runLangGraphPipeline({
runId: 'run-1',
projectId: 'project-1',
userId: 'user-1',
state,
nodes: [
{
key: 'dup',
title: 'Dup 1',
run: async () => ({ output: { ok: true } }),
},
{
key: 'dup',
title: 'Dup 2',
run: async () => ({ output: { ok: true } }),
},
],
}),
).rejects.toThrow('LANGGRAPH_NODE_KEY_DUPLICATE: dup')
})
})

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest'
import { mapTaskSSEEventToRunEvents } from '@/lib/run-runtime/task-bridge'
import { RUN_EVENT_TYPE } from '@/lib/run-runtime/types'
import { TASK_EVENT_TYPE, TASK_SSE_EVENT_TYPE, type SSEEvent } from '@/lib/task/types'
function buildEvent(input: Partial<SSEEvent>): SSEEvent {
return {
id: input.id || '1',
type: input.type || TASK_SSE_EVENT_TYPE.LIFECYCLE,
taskId: input.taskId || 'task_1',
projectId: input.projectId || 'project_1',
userId: input.userId || 'user_1',
ts: input.ts || new Date().toISOString(),
payload: input.payload || {},
taskType: input.taskType || null,
targetType: input.targetType || null,
targetId: input.targetId || null,
episodeId: input.episodeId || null,
}
}
describe('task->run event bridge', () => {
it('maps task.stream to step.chunk and normalizes lane by kind', () => {
const event = buildEvent({
type: TASK_SSE_EVENT_TYPE.STREAM,
payload: {
runId: 'run_1',
stepId: 'step_a',
stream: {
kind: 'reasoning',
delta: 'abc',
seq: 1,
},
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(1)
expect(mapped[0]).toMatchObject({
runId: 'run_1',
eventType: RUN_EVENT_TYPE.STEP_CHUNK,
stepKey: 'step_a',
lane: 'reasoning',
})
})
it('uses taskType-based fallback stepKey for stream when stepId missing', () => {
const event = buildEvent({
type: TASK_SSE_EVENT_TYPE.STREAM,
taskType: 'story_to_script_run',
payload: {
runId: 'run_1',
stream: {
kind: 'text',
delta: 'hello',
seq: 1,
},
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(1)
expect(mapped[0]).toMatchObject({
eventType: RUN_EVENT_TYPE.STEP_CHUNK,
stepKey: 'step:story_to_script_run',
lane: 'text',
})
})
it('maps task.processing + done=true to step.start then step.complete', () => {
const event = buildEvent({
payload: {
runId: 'run_2',
stepId: 'step_b',
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
done: true,
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(2)
expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_START)
expect(mapped[1]?.eventType).toBe(RUN_EVENT_TYPE.STEP_COMPLETE)
})
it('maps processing error stage to step.error', () => {
const event = buildEvent({
payload: {
meta: { runId: 'run_3' },
stepId: 'step_c',
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
stage: 'worker_llm_error',
error: {
message: 'boom',
},
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(2)
expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_START)
expect(mapped[1]).toMatchObject({
eventType: RUN_EVENT_TYPE.STEP_ERROR,
runId: 'run_3',
stepKey: 'step_c',
})
})
it('maps task.completed to step.complete and run.complete', () => {
const event = buildEvent({
payload: {
runId: 'run_4',
stepId: 'step_d',
lifecycleType: TASK_EVENT_TYPE.COMPLETED,
},
})
const mapped = mapTaskSSEEventToRunEvents(event)
expect(mapped).toHaveLength(2)
expect(mapped[0]?.eventType).toBe(RUN_EVENT_TYPE.STEP_COMPLETE)
expect(mapped[1]?.eventType).toBe(RUN_EVENT_TYPE.RUN_COMPLETE)
})
it('returns empty when runId is missing', () => {
const event = buildEvent({
payload: {
stepId: 'step_x',
lifecycleType: TASK_EVENT_TYPE.PROCESSING,
},
})
expect(mapTaskSSEEventToRunEvents(event)).toEqual([])
})
})