feat: initial release v0.3.0
This commit is contained in:
140
tests/unit/run-runtime/graph-executor.test.ts
Normal file
140
tests/unit/run-runtime/graph-executor.test.ts
Normal 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',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
136
tests/unit/run-runtime/langgraph-pipeline.test.ts
Normal file
136
tests/unit/run-runtime/langgraph-pipeline.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
133
tests/unit/run-runtime/task-bridge.test.ts
Normal file
133
tests/unit/run-runtime/task-bridge.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user