From 3df72c68ef6c49522d37234556df81de8d74f0c7 Mon Sep 17 00:00:00 2001 From: Iqbal Date: Fri, 23 Oct 2026 11:02:10 +0700 Subject: [PATCH] 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 --- src/express/results.test.ts | 15 ++++++++++++++- src/express/results.ts | 16 +++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/express/results.test.ts b/src/express/results.test.ts index 64fcd4b..9a63e79 100644 --- a/src/express/results.test.ts +++ b/src/express/results.test.ts @@ -1,5 +1,5 @@ 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' 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') }) }) + +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') + }) +}) diff --git a/src/express/results.ts b/src/express/results.ts index 79f8d17..621fe8b 100644 --- a/src/express/results.ts +++ b/src/express/results.ts @@ -5,7 +5,7 @@ import { request } from '../api/client' import { t } from '../i18n' 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' @@ -79,6 +79,12 @@ interface RawSource { const ORDER: MaterialStatus[] = ['failed', 'rejected', 'preparing', 'generating', 'ready', 'accepted'] 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(['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 { if (schedule.rule.type === 'content' && schedule.rule.kind === 'video') return videosOf(schedule) const { agent, rule } = schedule @@ -87,9 +93,9 @@ async function materialsOf(schedule: Schedule): Promise { request(`${AGENTS}/${agent.id}/material-drafts`), productionStatus(agent.id).catch(() => null), ]) - const titles = new Map() + const items = new Map() 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). const byRun = new Map() @@ -101,8 +107,8 @@ async function materialsOf(schedule: Schedule): Promise { const parts = group.map(g => ({ id: g.id, revision: g.revision, kind: g.output_kind, status: g.status })) return { id: d.id, agentId: agent.id, kind: kind === 'audio' || kind === 'scorm' ? kind : d.output_kind, - status: combined(parts), revision: d.revision, parts, - planTitle: titles.get(d.run_id) ?? null, + status: withPlanItem(combined(parts), items.get(d.run_id)?.status), revision: d.revision, parts, + 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, } })