Show results whose plan item stopped as failed

The engine can stop a plan item without updating its drafts: a run paused
on material_quote_changed leaves the draft "preparing" for good, so the
result showed Preparing forever. A draft still at work now counts as
failed once its plan item is failed, blocked, cancelled or skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-23 11:02:10 +07:00
parent f219b26f71
commit 3df72c68ef
2 changed files with 25 additions and 6 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { correctIndex, questionsOf, toMarkdown, type Material } from './results' import { correctIndex, questionsOf, toMarkdown, withPlanItem, type Material } from './results'
import { sentencesOf, scriptLang } from '../components/AudioPlayer' import { sentencesOf, scriptLang } from '../components/AudioPlayer'
describe('correctIndex', () => { describe('correctIndex', () => {
@@ -52,3 +52,16 @@ describe('audio script helpers', () => {
expect(scriptLang('This is the lesson that we will share with the team.')).toBe('en') expect(scriptLang('This is the lesson that we will share with the team.')).toBe('en')
}) })
}) })
describe('withPlanItem', () => {
it('fails a draft still at work when its plan item stopped', () => {
expect(withPlanItem('preparing', 'failed')).toBe('failed')
expect(withPlanItem('generating', 'blocked')).toBe('failed')
})
it('keeps the draft status otherwise', () => {
expect(withPlanItem('preparing', 'generating')).toBe('preparing')
expect(withPlanItem('ready', 'failed')).toBe('ready')
expect(withPlanItem('preparing', undefined)).toBe('preparing')
})
})

View File

@@ -5,7 +5,7 @@
import { request } from '../api/client' import { request } from '../api/client'
import { t } from '../i18n' import { t } from '../i18n'
import { kindLabel, type ContentKind, type Rule } from './parse' import { kindLabel, type ContentKind, type Rule } from './parse'
import { expressAgents, PLAN_ACTIVE, productionStatus, type Schedule } from './schedules' import { expressAgents, PLAN_ACTIVE, productionStatus, type PlanItem, type Schedule } from './schedules'
const AGENTS = '/organizations/current/agents' const AGENTS = '/organizations/current/agents'
@@ -79,6 +79,12 @@ interface RawSource {
const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted'] const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted']
const combined = (parts: Part[]) => ORDER.find(s => parts.some(p => p.status === s)) ?? 'preparing' const combined = (parts: Part[]) => ORDER.find(s => parts.some(p => p.status === s)) ?? 'preparing'
// The engine can stop a plan item without updating its drafts (e.g. a run paused on a changed
// quote leaves the draft "preparing" for good), so a stopped item overrides a draft still at work.
const STOPPED = new Set<PlanItem['status']>(['failed', 'blocked', 'cancelled', 'skipped'])
export const withPlanItem = (status: MaterialStatus, item: PlanItem['status'] | undefined): MaterialStatus =>
item && STOPPED.has(item) && (status === 'preparing' || status === 'generating') ? 'failed' : status
async function materialsOf(schedule: Schedule): Promise<Material[]> { async function materialsOf(schedule: Schedule): Promise<Material[]> {
if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule) if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule)
const { agent, rule } = schedule const { agent, rule } = schedule
@@ -87,9 +93,9 @@ async function materialsOf(schedule: Schedule): Promise<Material[]> {
request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`), request<RawMaterial[]>(`${AGENTS}/${agent.id}/material-drafts`),
productionStatus(agent.id).catch(() => null), productionStatus(agent.id).catch(() => null),
]) ])
const titles = new Map<number, string>() const items = new Map<number, PlanItem>()
for (const plan of status?.plans ?? []) { for (const plan of status?.plans ?? []) {
for (const item of plan.items ?? []) if (item.run_id) titles.set(item.run_id, item.title) for (const item of plan.items ?? []) if (item.run_id) items.set(item.run_id, item)
} }
// One run makes one result; its drafts are its parts (one, or the lesson + quiz of a SCORM module). // One run makes one result; its drafts are its parts (one, or the lesson + quiz of a SCORM module).
const byRun = new Map<number, RawMaterial[]>() const byRun = new Map<number, RawMaterial[]>()
@@ -101,8 +107,8 @@ async function materialsOf(schedule: Schedule): Promise<Material[]> {
const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status })) const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status }))
return { return {
id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind, id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind,
status: combined(parts), revision: d.revision, parts, status: withPlanItem(combined(parts), items.get(d.run_id)?.status), revision: d.revision, parts,
planTitle: titles.get(d.run_id) ?? null, planTitle: items.get(d.run_id)?.title ?? null,
composeSetId: d.compose_set_id, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule, composeSetId: d.compose_set_id, sources: d.sources.map(s => s.title), createdAt: d.created_at, rule,
} }
}) })