first commit
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { reconcileActiveRunsFromTasks } from '@/lib/run-runtime/reconcile'
|
||||
import { RUN_STATUS, RUN_STEP_STATUS } from '@/lib/run-runtime/types'
|
||||
import { TASK_STATUS, TASK_TYPE } from '@/lib/task/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestUser } from '../../helpers/billing-fixtures'
|
||||
|
||||
describe('run runtime reconcileActiveRunsFromTasks', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
})
|
||||
|
||||
it('marks a running run completed when the linked task already completed', async () => {
|
||||
const user = await createTestUser()
|
||||
const finishedAt = new Date('2026-03-30T08:00:00.000Z')
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: 'project-run-complete',
|
||||
episodeId: 'episode-run-complete',
|
||||
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-run-complete',
|
||||
status: TASK_STATUS.COMPLETED,
|
||||
progress: 100,
|
||||
payload: { episodeId: 'episode-run-complete' },
|
||||
result: {
|
||||
episodeId: 'episode-run-complete',
|
||||
persistedClips: 12,
|
||||
},
|
||||
queuedAt: new Date('2026-03-30T07:55:00.000Z'),
|
||||
startedAt: new Date('2026-03-30T07:56:00.000Z'),
|
||||
finishedAt,
|
||||
},
|
||||
})
|
||||
const run = await prisma.graphRun.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: 'project-run-complete',
|
||||
episodeId: 'episode-run-complete',
|
||||
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
taskId: task.id,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-run-complete',
|
||||
status: RUN_STATUS.RUNNING,
|
||||
leaseOwner: 'worker:story-to-script',
|
||||
leaseExpiresAt: new Date('2026-03-30T08:05:00.000Z'),
|
||||
heartbeatAt: new Date('2026-03-30T07:59:30.000Z'),
|
||||
queuedAt: new Date('2026-03-30T07:55:00.000Z'),
|
||||
startedAt: new Date('2026-03-30T07:56:00.000Z'),
|
||||
},
|
||||
})
|
||||
await prisma.graphStep.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
stepKey: 'story_to_script_persist',
|
||||
stepTitle: 'Persist screenplay',
|
||||
status: RUN_STEP_STATUS.RUNNING,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 4,
|
||||
stepTotal: 4,
|
||||
startedAt: new Date('2026-03-30T07:58:00.000Z'),
|
||||
},
|
||||
})
|
||||
|
||||
const reconciled = await reconcileActiveRunsFromTasks()
|
||||
|
||||
expect(reconciled).toEqual([{
|
||||
runId: run.id,
|
||||
taskId: task.id,
|
||||
nextStatus: 'completed',
|
||||
reason: 'linked task already completed',
|
||||
}])
|
||||
|
||||
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
|
||||
expect(refreshedRun).toMatchObject({
|
||||
status: RUN_STATUS.COMPLETED,
|
||||
output: {
|
||||
episodeId: 'episode-run-complete',
|
||||
persistedClips: 12,
|
||||
},
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
heartbeatAt: null,
|
||||
})
|
||||
expect(refreshedRun?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
|
||||
|
||||
const refreshedStep = await prisma.graphStep.findUnique({
|
||||
where: {
|
||||
runId_stepKey: {
|
||||
runId: run.id,
|
||||
stepKey: 'story_to_script_persist',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(refreshedStep).toMatchObject({
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
})
|
||||
expect(refreshedStep?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
|
||||
})
|
||||
|
||||
it('marks a running run failed when the linked task already failed', async () => {
|
||||
const user = await createTestUser()
|
||||
const finishedAt = new Date('2026-03-30T09:00:00.000Z')
|
||||
const task = await prisma.task.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: 'project-run-failed',
|
||||
episodeId: 'episode-run-failed',
|
||||
type: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-run-failed',
|
||||
status: TASK_STATUS.FAILED,
|
||||
progress: 72,
|
||||
payload: { episodeId: 'episode-run-failed' },
|
||||
errorCode: 'WATCHDOG_TIMEOUT',
|
||||
errorMessage: 'Task heartbeat timeout',
|
||||
queuedAt: new Date('2026-03-30T08:50:00.000Z'),
|
||||
startedAt: new Date('2026-03-30T08:51:00.000Z'),
|
||||
finishedAt,
|
||||
},
|
||||
})
|
||||
const run = await prisma.graphRun.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: 'project-run-failed',
|
||||
episodeId: 'episode-run-failed',
|
||||
workflowType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
taskType: TASK_TYPE.STORY_TO_SCRIPT_RUN,
|
||||
taskId: task.id,
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-run-failed',
|
||||
status: RUN_STATUS.RUNNING,
|
||||
leaseOwner: 'worker:story-to-script',
|
||||
leaseExpiresAt: new Date('2026-03-30T08:55:00.000Z'),
|
||||
heartbeatAt: new Date('2026-03-30T08:54:00.000Z'),
|
||||
queuedAt: new Date('2026-03-30T08:50:00.000Z'),
|
||||
startedAt: new Date('2026-03-30T08:51:00.000Z'),
|
||||
},
|
||||
})
|
||||
await prisma.graphStep.create({
|
||||
data: {
|
||||
runId: run.id,
|
||||
stepKey: 'screenplay_clip-1',
|
||||
stepTitle: 'Screenplay clip 1',
|
||||
status: RUN_STEP_STATUS.RUNNING,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 3,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date('2026-03-30T08:52:00.000Z'),
|
||||
},
|
||||
})
|
||||
|
||||
const reconciled = await reconcileActiveRunsFromTasks()
|
||||
|
||||
expect(reconciled).toEqual([{
|
||||
runId: run.id,
|
||||
taskId: task.id,
|
||||
nextStatus: 'failed',
|
||||
reason: 'linked task already failed',
|
||||
}])
|
||||
|
||||
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
|
||||
expect(refreshedRun).toMatchObject({
|
||||
status: RUN_STATUS.FAILED,
|
||||
errorCode: 'WATCHDOG_TIMEOUT',
|
||||
errorMessage: 'Task heartbeat timeout',
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
heartbeatAt: null,
|
||||
})
|
||||
expect(refreshedRun?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
|
||||
|
||||
const refreshedStep = await prisma.graphStep.findUnique({
|
||||
where: {
|
||||
runId_stepKey: {
|
||||
runId: run.id,
|
||||
stepKey: 'screenplay_clip-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(refreshedStep).toMatchObject({
|
||||
status: RUN_STEP_STATUS.FAILED,
|
||||
lastErrorCode: 'WATCHDOG_TIMEOUT',
|
||||
lastErrorMessage: 'Task heartbeat timeout',
|
||||
})
|
||||
expect(refreshedStep?.finishedAt?.toISOString()).toBe(finishedAt.toISOString())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,343 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { retryFailedStep } from '@/lib/run-runtime/service'
|
||||
import { RUN_STATUS, RUN_STEP_STATUS } from '@/lib/run-runtime/types'
|
||||
import { prisma } from '../../helpers/prisma'
|
||||
import { resetBillingState } from '../../helpers/db-reset'
|
||||
import { createTestUser } from '../../helpers/billing-fixtures'
|
||||
|
||||
describe('run runtime retryFailedStep invalidation', () => {
|
||||
beforeEach(async () => {
|
||||
await resetBillingState()
|
||||
})
|
||||
|
||||
it('invalidates downstream story-to-script steps and artifacts', async () => {
|
||||
const user = await createTestUser()
|
||||
const run = await prisma.graphRun.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: 'project-retry-story',
|
||||
episodeId: 'episode-retry-story',
|
||||
workflowType: 'story_to_script_run',
|
||||
taskType: 'story_to_script_run',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-retry-story',
|
||||
status: RUN_STATUS.FAILED,
|
||||
queuedAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.graphStep.createMany({
|
||||
data: [
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'analyze_characters',
|
||||
stepTitle: 'Analyze Characters',
|
||||
status: RUN_STEP_STATUS.FAILED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 1,
|
||||
stepTotal: 5,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
lastErrorCode: 'STEP_FAILED',
|
||||
lastErrorMessage: 'characters failed',
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'analyze_locations',
|
||||
stepTitle: 'Analyze Locations',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 2,
|
||||
stepTotal: 5,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'split_clips',
|
||||
stepTitle: 'Split Clips',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 3,
|
||||
stepTotal: 5,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'screenplay_clip-a',
|
||||
stepTitle: 'Screenplay A',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 4,
|
||||
stepTotal: 5,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'screenplay_clip-b',
|
||||
stepTitle: 'Screenplay B',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 5,
|
||||
stepTotal: 5,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await prisma.graphArtifact.createMany({
|
||||
data: [
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'analyze_characters',
|
||||
artifactType: 'analysis.characters',
|
||||
refId: 'episode-retry-story',
|
||||
payload: { rows: [{ name: 'Hero' }] },
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'analyze_locations',
|
||||
artifactType: 'analysis.locations',
|
||||
refId: 'episode-retry-story',
|
||||
payload: { rows: [{ name: 'City' }] },
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'split_clips',
|
||||
artifactType: 'clips',
|
||||
refId: 'episode-retry-story',
|
||||
payload: { clips: [{ id: 'clip-a' }] },
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'screenplay_clip-a',
|
||||
artifactType: 'screenplay.clip',
|
||||
refId: 'clip-a',
|
||||
payload: { scenes: [{ id: 1 }] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const retried = await retryFailedStep({
|
||||
runId: run.id,
|
||||
userId: user.id,
|
||||
stepKey: 'analyze_characters',
|
||||
})
|
||||
|
||||
expect(retried?.retryAttempt).toBe(2)
|
||||
expect(retried?.invalidatedStepKeys.slice().sort()).toEqual([
|
||||
'analyze_characters',
|
||||
'screenplay_clip-a',
|
||||
'screenplay_clip-b',
|
||||
'split_clips',
|
||||
])
|
||||
|
||||
const steps = await prisma.graphStep.findMany({
|
||||
where: { runId: run.id },
|
||||
orderBy: { stepIndex: 'asc' },
|
||||
})
|
||||
const stepMap = new Map(steps.map((step) => [step.stepKey, step]))
|
||||
expect(stepMap.get('analyze_characters')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.PENDING,
|
||||
currentAttempt: 2,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
})
|
||||
expect(stepMap.get('split_clips')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.PENDING,
|
||||
currentAttempt: 0,
|
||||
})
|
||||
expect(stepMap.get('screenplay_clip-a')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.PENDING,
|
||||
currentAttempt: 0,
|
||||
})
|
||||
expect(stepMap.get('analyze_locations')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
})
|
||||
|
||||
const artifacts = await prisma.graphArtifact.findMany({
|
||||
where: { runId: run.id },
|
||||
orderBy: { stepKey: 'asc' },
|
||||
})
|
||||
expect(artifacts.map((artifact) => artifact.stepKey)).toEqual(['analyze_locations'])
|
||||
|
||||
const refreshedRun = await prisma.graphRun.findUnique({ where: { id: run.id } })
|
||||
expect(refreshedRun?.status).toBe(RUN_STATUS.RUNNING)
|
||||
expect(refreshedRun?.errorCode).toBeNull()
|
||||
expect(refreshedRun?.errorMessage).toBeNull()
|
||||
})
|
||||
|
||||
it('invalidates only the dependent storyboard branch plus voice analyze', async () => {
|
||||
const user = await createTestUser()
|
||||
const run = await prisma.graphRun.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: 'project-retry-storyboard',
|
||||
episodeId: 'episode-retry-storyboard',
|
||||
workflowType: 'script_to_storyboard_run',
|
||||
taskType: 'script_to_storyboard_run',
|
||||
targetType: 'NovelPromotionEpisode',
|
||||
targetId: 'episode-retry-storyboard',
|
||||
status: RUN_STATUS.FAILED,
|
||||
queuedAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.graphStep.createMany({
|
||||
data: [
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-1_phase1',
|
||||
stepTitle: 'Clip 1 Phase 1',
|
||||
status: RUN_STEP_STATUS.FAILED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 1,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
lastErrorCode: 'STEP_FAILED',
|
||||
lastErrorMessage: 'phase1 failed',
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-1_phase2_cinematography',
|
||||
stepTitle: 'Clip 1 Phase 2 Cine',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 2,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-1_phase2_acting',
|
||||
stepTitle: 'Clip 1 Phase 2 Acting',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 3,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-1_phase3_detail',
|
||||
stepTitle: 'Clip 1 Phase 3',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 4,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-2_phase3_detail',
|
||||
stepTitle: 'Clip 2 Phase 3',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 5,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'voice_analyze',
|
||||
stepTitle: 'Voice Analyze',
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
stepIndex: 6,
|
||||
stepTotal: 6,
|
||||
startedAt: new Date(),
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await prisma.graphArtifact.createMany({
|
||||
data: [
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-1_phase1',
|
||||
artifactType: 'storyboard.clip.phase1',
|
||||
refId: 'clip-1',
|
||||
payload: { panels: [] },
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-1_phase2_cinematography',
|
||||
artifactType: 'storyboard.clip.phase2.cine',
|
||||
refId: 'clip-1',
|
||||
payload: { rules: [] },
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'clip_clip-2_phase3_detail',
|
||||
artifactType: 'storyboard.clip.phase3',
|
||||
refId: 'clip-2',
|
||||
payload: { panels: [] },
|
||||
},
|
||||
{
|
||||
runId: run.id,
|
||||
stepKey: 'voice_analyze',
|
||||
artifactType: 'voice.lines',
|
||||
refId: 'episode-retry-storyboard',
|
||||
payload: { lines: [] },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const retried = await retryFailedStep({
|
||||
runId: run.id,
|
||||
userId: user.id,
|
||||
stepKey: 'clip_clip-1_phase1',
|
||||
})
|
||||
|
||||
expect(retried?.retryAttempt).toBe(2)
|
||||
expect(retried?.invalidatedStepKeys.slice().sort()).toEqual([
|
||||
'clip_clip-1_phase1',
|
||||
'clip_clip-1_phase2_acting',
|
||||
'clip_clip-1_phase2_cinematography',
|
||||
'clip_clip-1_phase3_detail',
|
||||
'voice_analyze',
|
||||
])
|
||||
|
||||
const steps = await prisma.graphStep.findMany({
|
||||
where: { runId: run.id },
|
||||
orderBy: { stepIndex: 'asc' },
|
||||
})
|
||||
const stepMap = new Map(steps.map((step) => [step.stepKey, step]))
|
||||
expect(stepMap.get('clip_clip-1_phase1')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.PENDING,
|
||||
currentAttempt: 2,
|
||||
})
|
||||
expect(stepMap.get('clip_clip-1_phase2_cinematography')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.PENDING,
|
||||
currentAttempt: 0,
|
||||
})
|
||||
expect(stepMap.get('voice_analyze')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.PENDING,
|
||||
currentAttempt: 0,
|
||||
})
|
||||
expect(stepMap.get('clip_clip-2_phase3_detail')).toMatchObject({
|
||||
status: RUN_STEP_STATUS.COMPLETED,
|
||||
currentAttempt: 1,
|
||||
})
|
||||
|
||||
const artifacts = await prisma.graphArtifact.findMany({
|
||||
where: { runId: run.id },
|
||||
orderBy: { stepKey: 'asc' },
|
||||
})
|
||||
expect(artifacts.map((artifact) => artifact.stepKey)).toEqual(['clip_clip-2_phase3_detail'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user