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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user