Explain blocked schedule runs and let a failed document be processed again

Schedule history now shows a readable reason instead of the engine's raw code,
and under the newest run stopped by knowledge_not_ready it lists the Library
material that blocks it, with a link to fix it in Learning Studio. The list
refreshes while that material is still processing or building.

Upload gets a "Process again" button for a failed document, using the engine's
new reprocess endpoint; a knowledge base the document already has is kept. A
document being processed again no longer shows as "In the Brain".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-28 15:13:03 +07:00
parent b63e9c2ca8
commit d1492b9c76
6 changed files with 159 additions and 13 deletions

View File

@@ -7,16 +7,31 @@ import { toast } from '../components/Toast'
import { describeRule } from '../express/parse'
import { locale, t, type Key } from '../i18n'
import {
archiveSchedule, confirmProduction, isRunSchedule, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, runNow as startRun, updateTopic, type ContentPlan, type Schedule,
archiveSchedule, confirmProduction, isRunSchedule, knowledgeReadiness, listRuns, listSchedules, pauseSchedule, PLAN_ACTIVE, productionStatus, quoteProduction, resumeSchedule,
planProgress, runNow as startRun, updateTopic, type ContentPlan, type RunSummary, type Schedule,
} from '../express/schedules'
const STUDIO_URL: string = import.meta.env.VITE_STUDIO_URL
const fmt = (iso: string | null | undefined) => (iso
? new Intl.DateTimeFormat(locale(), { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(new Date(iso))
: '–')
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled'])
const RUN_STATUSES = new Set(['queued', 'planning', 'running', 'completed', 'failed', 'cancelled', 'paused_configuration', 'paused_budget'])
const runStatus = (s: string) => (RUN_STATUSES.has(s) ? t(`run.${s}` as Key) : s)
const RUN_ERRORS = new Set([
'knowledge_not_ready', 'video_sources_unavailable', 'material_sources_unavailable', 'knowledge_search_failed',
'organization_wallet', 'run_cap', 'monthly_cap', 'reservation_cap', 'price_changed', 'video_price_changed',
'agent_not_active', 'run_access_revoked', 'required_tool_unavailable',
])
const ISSUE_REASONS = new Set([
'document_processing', 'document_failed', 'graph_building', 'graph_not_ready', 'legacy_set_needs_conversion',
'no_knowledge_sources', 'source_not_accessible', 'source_graph_not_ready', 'source_version_changed', 'snapshot_not_ready',
])
const WAITING_REASONS = new Set(['document_processing', 'graph_building', 'snapshot_not_ready'])
const runError = (code: string) => (RUN_ERRORS.has(code) ? t(`runErr.${code}` as Key) : code)
const issueReason = (reason: string) => (ISSUE_REASONS.has(reason) ? t(`issue.${reason}` as Key) : reason)
const isNotReady = (r: RunSummary) => r.safe_error_code === 'knowledge_not_ready' || r.has_readiness_issues
export function SchedulePage() {
const schedules = useQuery({ queryKey: ['schedules'], queryFn: listSchedules, refetchInterval: 30_000 })
@@ -206,17 +221,46 @@ function RunHistory({ agentId }: { agentId: number }) {
if (runs.isPending) return <div className="run-history"><SkeletonLines count={3} label={t('sch.loadingHistory')} /></div>
if (runs.isError) return <div className="run-history"><span className="error">{runs.error.message}</span></div>
if (!runs.data.length) return <div className="run-history"><small>{t('sch.neverRan')}</small></div>
const shown = runs.data.slice(0, 8)
// Readiness is today's state, so it is shown once, under the newest run it stopped.
const blockedId = shown.find(isNotReady)?.id
return (
<ul className="run-history">
{runs.data.slice(0, 8).map(r => (
{shown.map(r => (
<li key={r.id}>
<span>{fmt(r.created_at)}</span>
<span>{r.trigger_kind === 'scheduled' ? t('sch.scheduled') : t('sch.manual')}</span>
<span className={`status ${r.status === 'completed' ? 'ready' : 'draft'}`}><i className="dot"></i>{runStatus(r.status)}</span>
{r.budget_spent != null && <span>{t('common.credits', { n: r.budget_spent })}</span>}
{r.safe_error_code && <span className="error">{r.safe_error_code}</span>}
{r.safe_error_code && <span className="error" title={r.safe_error_code}>{runError(r.safe_error_code)}</span>}
{r.id === blockedId && <ReadinessIssues agentId={agentId} />}
</li>
))}
</ul>
)
}
function ReadinessIssues({ agentId }: { agentId: number }) {
const readiness = useQuery({
queryKey: ['readiness', agentId],
queryFn: () => knowledgeReadiness(agentId),
// Processing and building finish on their own; keep the list current until they do.
refetchInterval: q => (q.state.data?.issues.some(i => WAITING_REASONS.has(i.reason)) ? 5000 : false),
})
if (readiness.isPending) return <div className="run-issues"><SkeletonLines count={1} label={t('sch.loadingHistory')} /></div>
if (readiness.isError) return <div className="run-issues"><span className="error">{readiness.error.message}</span></div>
if (!readiness.data.issues.length) return <div className="run-issues"><small>{t('sch.blockedNone')}</small></div>
return (
<div className="run-issues">
<strong>{t('sch.blockedBy')}</strong>
<ul>
{readiness.data.issues.map(issue => (
<li key={`${issue.kind}-${issue.resource_id}`}>
<span><b>{issue.title}</b> · {issueReason(issue.reason)}</span>
<a href={`${STUDIO_URL}${issue.action_url}`} target="_blank" rel="noreferrer">{t('sch.fixInStudio')}</a>
</li>
))}
</ul>
</div>
)
}