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:
@@ -79,6 +79,8 @@ export interface KnowledgeDetail {
|
||||
export const knowledgeDetail = (id: number) => request<KnowledgeDetail>(`/upload/${id}/knowledge`)
|
||||
|
||||
// Agree to the quoted price, build after a top-up, or retry a failed build. Spends workspace credits (402 when short).
|
||||
// Reads a failed document's stored file again, for free. A knowledge base it already has is kept.
|
||||
export const reprocessDocument = (id: number) => request<{ id: number; status: string }>(`/upload/${id}/reprocess`, { method: 'POST' })
|
||||
export const buildKnowledge = (id: number) => request<{ status: string }>(`/upload/${id}/knowledge/build`, { method: 'POST' })
|
||||
|
||||
// The engine asks whether an upload is the next version of a similar document before building it.
|
||||
|
||||
@@ -355,8 +355,22 @@ export interface RunSummary {
|
||||
trigger_kind: string
|
||||
safe_error_code: string | null
|
||||
budget_spent: number | null
|
||||
has_readiness_issues: boolean
|
||||
created_at: string
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
export const listRuns = (id: number) => request<RunSummary[]>(`${AGENTS}/${id}/runs`)
|
||||
|
||||
// What stops a run with knowledge_not_ready. The engine checks the whole Library, so a blocking
|
||||
// document need not be part of the schedule's chosen material. This is the current state, not the run's.
|
||||
export interface ReadinessIssue {
|
||||
kind: 'document' | 'compose_set'
|
||||
resource_id: number
|
||||
title: string
|
||||
reason: string
|
||||
action_url: string
|
||||
}
|
||||
|
||||
export const knowledgeReadiness = (id: number) =>
|
||||
request<{ ready: boolean; issues: ReadinessIssue[] }>(`${AGENTS}/${id}/knowledge-readiness`)
|
||||
|
||||
66
src/i18n.ts
66
src/i18n.ts
@@ -126,7 +126,11 @@ const en = {
|
||||
'brain.needsCreditsNeed': ' (needs {credits})',
|
||||
'brain.failed': 'Could not add this to the Brain.',
|
||||
'brain.retryWith': 'Try again ({credits})',
|
||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is removed: delete it and upload it again.',
|
||||
'brain.failedAfterBuild': 'Processing this file failed after its knowledge was built. Schedules that use the whole Brain stop until it is fixed. Process it again: its knowledge is kept.',
|
||||
'brain.failedProcessing': 'This file could not be processed. Schedules that use the whole Brain stop until it is fixed. Process it again, or delete it.',
|
||||
'brain.failedVideoAfterBuild': 'Processing this video failed after its knowledge was built. Schedules that use the whole Brain stop until it is fixed: upload it again.',
|
||||
'brain.reprocess': 'Process again',
|
||||
'brain.reprocessing': 'Starting…',
|
||||
'brain.confirmCopy': 'Adding this document to the Brain uses {credits} from the workspace.',
|
||||
'brain.confirmCreditsFallback': 'credits',
|
||||
'brain.addToBrain': 'Add to Brain',
|
||||
@@ -244,6 +248,33 @@ const en = {
|
||||
'sch.scheduled': 'Scheduled',
|
||||
'sch.manual': 'Manual',
|
||||
'run.queued': 'Queued', 'run.planning': 'Planning', 'run.running': 'Running', 'run.completed': 'Completed', 'run.failed': 'Failed', 'run.cancelled': 'Cancelled',
|
||||
'run.paused_configuration': 'Needs attention', 'run.paused_budget': 'Paused: credits',
|
||||
'runErr.knowledge_not_ready': 'Not all material in the Library is ready, so the engine will not start.',
|
||||
'runErr.video_sources_unavailable': 'No ready material matched this schedule for a video.',
|
||||
'runErr.material_sources_unavailable': 'No ready material matched this schedule.',
|
||||
'runErr.knowledge_search_failed': 'Searching the material failed. Try again.',
|
||||
'runErr.organization_wallet': 'The organization does not have enough credits.',
|
||||
'runErr.run_cap': 'This run would exceed the per-run credit limit.',
|
||||
'runErr.monthly_cap': 'The monthly credit limit for this schedule is reached.',
|
||||
'runErr.reservation_cap': 'Too many credits are already reserved by other runs.',
|
||||
'runErr.price_changed': 'The price changed. Try again.',
|
||||
'runErr.video_price_changed': 'The video price changed. Try again.',
|
||||
'runErr.agent_not_active': 'The schedule is paused or deleted.',
|
||||
'runErr.run_access_revoked': 'You no longer have permission to run this schedule.',
|
||||
'runErr.required_tool_unavailable': 'A tool this schedule needs is turned off.',
|
||||
'sch.blockedBy': 'Blocking material:',
|
||||
'sch.blockedNone': 'All material is ready now. Try again.',
|
||||
'sch.fixInStudio': 'Open in Learning Studio',
|
||||
'issue.document_processing': 'still processing',
|
||||
'issue.document_failed': 'failed to process; reprocess or delete it',
|
||||
'issue.graph_building': 'knowledge graph still building',
|
||||
'issue.graph_not_ready': 'knowledge graph not prepared yet',
|
||||
'issue.legacy_set_needs_conversion': 'old Compose set; convert it',
|
||||
'issue.no_knowledge_sources': 'Compose set has no sources',
|
||||
'issue.source_not_accessible': 'Compose set uses a document you cannot access',
|
||||
'issue.source_graph_not_ready': 'a source document is not ready',
|
||||
'issue.source_version_changed': 'a source document changed; refresh the set',
|
||||
'issue.snapshot_not_ready': 'Compose graph not prepared yet',
|
||||
|
||||
// Results
|
||||
'res.title': 'Results',
|
||||
@@ -584,7 +615,11 @@ const id: Record<Key, string> = {
|
||||
'brain.needsCreditsNeed': ' (butuh {credits})',
|
||||
'brain.failed': 'Gagal memasukkan ke Otak.',
|
||||
'brain.retryWith': 'Coba lagi ({credits})',
|
||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai file ini dihapus: hapus lalu unggah ulang.',
|
||||
'brain.failedAfterBuild': 'Pemrosesan file ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki. Proses ulang: pengetahuannya tetap disimpan.',
|
||||
'brain.failedProcessing': 'File ini gagal diproses. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki. Proses ulang, atau hapus.',
|
||||
'brain.failedVideoAfterBuild': 'Pemrosesan video ini gagal setelah pengetahuannya dibangun. Jadwal yang memakai seluruh Otak berhenti sampai ini diperbaiki: unggah ulang.',
|
||||
'brain.reprocess': 'Proses ulang',
|
||||
'brain.reprocessing': 'Memulai…',
|
||||
'brain.confirmCopy': 'Memasukkan dokumen ini ke Otak memakai {credits} dari ruang kerja.',
|
||||
'brain.confirmCreditsFallback': 'kredit',
|
||||
'brain.addToBrain': 'Masukkan ke Otak',
|
||||
@@ -699,6 +734,33 @@ const id: Record<Key, string> = {
|
||||
'sch.scheduled': 'Terjadwal',
|
||||
'sch.manual': 'Manual',
|
||||
'run.queued': 'Antre', 'run.planning': 'Merencanakan', 'run.running': 'Berjalan', 'run.completed': 'Selesai', 'run.failed': 'Gagal', 'run.cancelled': 'Dibatalkan',
|
||||
'run.paused_configuration': 'Perlu tindakan', 'run.paused_budget': 'Tertahan: kredit',
|
||||
'runErr.knowledge_not_ready': 'Belum semua materi di Library siap, jadi engine tidak mau mulai.',
|
||||
'runErr.video_sources_unavailable': 'Tidak ada materi siap yang cocok untuk video jadwal ini.',
|
||||
'runErr.material_sources_unavailable': 'Tidak ada materi siap yang cocok dengan jadwal ini.',
|
||||
'runErr.knowledge_search_failed': 'Pencarian materi gagal. Coba lagi.',
|
||||
'runErr.organization_wallet': 'Kredit organisasi tidak cukup.',
|
||||
'runErr.run_cap': 'Run ini melebihi batas kredit per run.',
|
||||
'runErr.monthly_cap': 'Batas kredit bulanan jadwal ini sudah tercapai.',
|
||||
'runErr.reservation_cap': 'Terlalu banyak kredit sudah dipesan oleh run lain.',
|
||||
'runErr.price_changed': 'Harga berubah. Coba lagi.',
|
||||
'runErr.video_price_changed': 'Harga video berubah. Coba lagi.',
|
||||
'runErr.agent_not_active': 'Jadwal sedang dijeda atau sudah dihapus.',
|
||||
'runErr.run_access_revoked': 'Kamu tidak lagi punya izin menjalankan jadwal ini.',
|
||||
'runErr.required_tool_unavailable': 'Alat yang dibutuhkan jadwal ini sedang nonaktif.',
|
||||
'sch.blockedBy': 'Materi yang menahan:',
|
||||
'sch.blockedNone': 'Semua materi sudah siap. Coba lagi.',
|
||||
'sch.fixInStudio': 'Buka di Learning Studio',
|
||||
'issue.document_processing': 'masih diproses',
|
||||
'issue.document_failed': 'gagal diproses; proses ulang atau hapus',
|
||||
'issue.graph_building': 'knowledge graph masih dibangun',
|
||||
'issue.graph_not_ready': 'knowledge graph belum disiapkan',
|
||||
'issue.legacy_set_needs_conversion': 'set Compose lama; perlu dikonversi',
|
||||
'issue.no_knowledge_sources': 'set Compose tidak punya sumber',
|
||||
'issue.source_not_accessible': 'set Compose memakai dokumen yang tidak bisa kamu akses',
|
||||
'issue.source_graph_not_ready': 'salah satu dokumen sumber belum siap',
|
||||
'issue.source_version_changed': 'dokumen sumber berubah; perbarui set-nya',
|
||||
'issue.snapshot_not_ready': 'graph Compose belum disiapkan',
|
||||
|
||||
'res.title': 'Hasil',
|
||||
'res.description': 'Semua konten yang dihasilkan Express: teks, kuis, roleplay, video, audio, dan SCORM.',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, supportedExtensions, uploadDocument,
|
||||
answerVersion, buildKnowledge, deleteDocument, documentFile, knowledgeDetail, listDocuments, NEEDS_ANSWER, reprocessDocument, supportedExtensions, uploadDocument,
|
||||
type Source,
|
||||
} from '../api/engine'
|
||||
import { Icon } from '../components/Icon'
|
||||
@@ -15,8 +15,10 @@ type Phase = 'ready' | 'working' | 'answer' | 'failed'
|
||||
function phaseOf(s: Source): Phase {
|
||||
// A failed document blocks every whole-Brain schedule in the engine, even with a built knowledge base.
|
||||
if (s.status === 'failed' || s.knowledge === 'failed') return 'failed'
|
||||
// A reprocessed document keeps its built knowledge while it is read again, and still blocks schedules.
|
||||
if (s.status !== 'completed') return 'working'
|
||||
if (s.knowledge === 'ready') return 'ready'
|
||||
if (s.knowledge === 'building' || s.status !== 'completed') return 'working'
|
||||
if (s.knowledge === 'building') return 'working'
|
||||
return NEEDS_ANSWER.has(s.knowledge ?? '') ? 'answer' : 'working'
|
||||
}
|
||||
|
||||
@@ -298,8 +300,26 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
||||
const refresh = () => queryClient.invalidateQueries({ queryKey: ['documents'] })
|
||||
const build = useMutation({ mutationFn: () => buildKnowledge(doc.docId), onSuccess: refresh })
|
||||
const version = useMutation({ mutationFn: (link: boolean) => answerVersion(doc.docId, link), onSuccess: refresh })
|
||||
const error = build.error ?? version.error
|
||||
const busy = build.isPending || version.isPending
|
||||
const reprocess = useMutation({ mutationFn: () => reprocessDocument(doc.docId), onSuccess: refresh })
|
||||
const error = build.error ?? version.error ?? reprocess.error
|
||||
const busy = build.isPending || version.isPending || reprocess.isPending
|
||||
|
||||
// A failed file is read again, free; a knowledge base it already has is kept, so Compose sets
|
||||
// that pin it (and block deleting it) keep working. Video goes back through upload instead.
|
||||
if (doc.status === 'failed' && (doc.knowledge === 'ready' || doc.knowledge === 'not_built' || doc.knowledge === null)
|
||||
&& doc.format.toLowerCase() !== 'mp4') {
|
||||
return (
|
||||
<div className="doc-action">
|
||||
<small>{t(doc.knowledge === 'ready' ? 'brain.failedAfterBuild' : 'brain.failedProcessing')}</small>
|
||||
<div className="doc-action-buttons">
|
||||
<button className="btn small primary" disabled={busy} onClick={() => reprocess.mutate()}>
|
||||
{reprocess.isPending ? t('brain.reprocessing') : t('brain.reprocess')}
|
||||
</button>
|
||||
</div>
|
||||
{error && <span className="error">{error.message}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = detail.data
|
||||
if (detail.isPending) return <div className="doc-action"><SkeletonLines label={t('common.loading')} /></div>
|
||||
@@ -323,8 +343,8 @@ function KnowledgeAction({ doc }: { doc: Source }) {
|
||||
actions = <button className="btn small" disabled={busy} onClick={() => build.mutate()}>{t('common.retry')}</button>
|
||||
break
|
||||
case 'ready':
|
||||
// Processing failed after the build: building again is refused, only removing it unblocks schedules.
|
||||
text = t('brain.failedAfterBuild')
|
||||
// Processing failed after the build. Documents are reprocessed above; this is a video.
|
||||
text = t('brain.failedVideoAfterBuild')
|
||||
break
|
||||
case 'failed':
|
||||
text = d.error || t('brain.failed')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ textarea{resize:vertical;line-height:1.6}
|
||||
.run-history li{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||
.run-history .status{margin-left:0}
|
||||
.run-history .error{margin:0;font-size:11px}
|
||||
.run-issues{flex-basis:100%;padding:8px 10px;border-radius:8px;background:#fff7ed;color:#7a4b1f;display:grid;gap:4px}
|
||||
.run-issues ul{list-style:none;margin:0;padding:0;display:grid;gap:4px}
|
||||
.run-issues li{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
|
||||
.run-issues a{color:var(--blue);font-weight:600}
|
||||
@media(max-width:700px){.schedule-actions,.run-history{padding-left:0;margin-left:0}.rule-approve{flex-direction:column;align-items:stretch}}
|
||||
.lang-switch{display:inline-flex;border:1px solid var(--line);border-radius:7px;overflow:hidden;background:white}
|
||||
.lang-switch button{border:0;background:transparent;padding:5px 9px;font-size:10px;font-weight:650;color:#8b97a9}
|
||||
|
||||
Reference in New Issue
Block a user