From 8caa912f10f8fc2118be0b1ee710d9311d95958c Mon Sep 17 00:00:00 2001 From: Tejas Polakam Date: Wed, 30 Sep 2026 16:29:58 -0400 Subject: [PATCH 1/2] refactor(explorer): remove the task runner's embedded-host mode, which never runs outside Scale's hub The explorer UI was forked from the hosted AgentEnvHub frontend, whose task runner ran inside an iframe in a host pipeline. There the parent page owned the task definition. The runner created the task from the parent's config (POST /api/v1/tasks), re-created it when that config drifted, rotated it to "start over", graded attempts the parent handed down, and reported runs, stars and materialized fields back to the parent. In this repository the host hook is a stub that always reports "not embedded", and the step editor that saved through POST /api/v1/tasks was never reachable. So none of that code runs, and the explorer has no POST /api/v1/tasks route for it to call. Removed: - lib/external-app - every window.parent check: the runner, the Universes Hub picker, the instance viewer's hidden context tab, the app shell's hidden sidebar - the runner's create-from-config, drift tracking, apply-before-run, start-over, grade-attempts, preferred-run, model-picker, run-cap, runs-per-click and parent-reporting code - the unreachable step editor Every removed branch was gated on a value only the host could set, so a top-level explorer page renders and behaves as before. Co-Authored-By: Claude Opus 5.5 --- .../src/components/task-instance-viewer.tsx | 30 +- .../ui/src/components/task-runner-page.tsx | 1856 +---------------- .../ui/src/components/universes-page.tsx | 127 +- .../explorer/ui/src/lib/external-app/index.ts | 61 - .../explorer/ui/src/pages/[[...slug]].tsx | 260 ++- 5 files changed, 209 insertions(+), 2125 deletions(-) delete mode 100644 src/agent_env/explorer/ui/src/lib/external-app/index.ts diff --git a/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx b/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx index 21b639c2..5b3aa993 100644 --- a/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx +++ b/src/agent_env/explorer/ui/src/components/task-instance-viewer.tsx @@ -148,13 +148,8 @@ export function TaskInstanceViewer({ rubricsCriteria, rubricsAggregator, taskSteps, - showRunContext = false, }: { instance: Record; - /** Re-enable the Task Run Context tab while embedded (the iframe chrome hides it). An opt-in for an - * internal embed that needs the raw context to debug a run — the only place the snapshot version and - * collected-artifact keys are visible. */ - showRunContext?: boolean; // List-derived instances may omit task_id; callers that know it (runner/detail // pages) pass it so downstream calls can't send task_id=undefined. taskId?: string; @@ -188,10 +183,6 @@ export function TaskInstanceViewer({ string | null >(null); const [copiedUsersim, setCopiedUsersim] = useState(null); - const [isEmbedded, setIsEmbedded] = useState(false); - useEffect(() => { - setIsEmbedded(window.parent !== window); - }, []); // Per-deployment A2A cards for the Agent Card tab. The registered agent doc carries no card; it lives in // context.deployed_agents[].a2a_card, which the list endpoint strips — fall back to the single-instance GET. const deployedAgentsForCard = ((instance.context as Record | null) @@ -877,29 +868,20 @@ export function TaskInstanceViewer({ : null; const showReviewerOverview = collectedFiles.length > 0 || !!reviewerOverviewS3; - // A failed run defaults to the context tab; this drives both the trigger and that fallback so it isn't a tab that's not rendered. - const contextHidden = isEmbedded && !showRunContext; const liveDefaultValue = hasTrajectories ? 'trajectory' : hasUsersimModels ? `model-${usersimModels[0]!.modelName}` : hasVerifications ? 'verifier' - : contextHidden && hasCompletedRuns - ? 'completed-runs' - : contextHidden && hasTaskMilestones - ? 'task-milestones' - : contextHidden - ? undefined : 'context'; + // A failed run defaults to the context tab. const defaultTabValue = isTerminalCompleted ? hasVerifications ? 'verifier' : liveDefaultValue : isTerminalFailed - ? contextHidden - ? liveDefaultValue - : 'context' + ? 'context' : liveDefaultValue; const instanceError = typeof instance.error === 'string' && instance.error.trim() @@ -1070,11 +1052,9 @@ export function TaskInstanceViewer({ Conversation )} - {!contextHidden && ( - - Task Run Context - - )} + + Task Run Context +
{collapseBarDownloads ? ( diff --git a/src/agent_env/explorer/ui/src/components/task-runner-page.tsx b/src/agent_env/explorer/ui/src/components/task-runner-page.tsx index 4f764ae0..bc425e2c 100644 --- a/src/agent_env/explorer/ui/src/components/task-runner-page.tsx +++ b/src/agent_env/explorer/ui/src/components/task-runner-page.tsx @@ -1,152 +1,13 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { - Play, - Loader2, - CheckCircle2, - XCircle, - Plus, - Save, - RefreshCw, - RotateCcw, - AlertTriangle, -} from 'lucide-react'; -import { AlertDialog, Button, DropdownMenu, Flex } from '@radix-ui/themes'; -import { - useExternalApp, - type SubmissionItem, -} from '../lib/external-app'; +import { Play, Loader2, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'; import { selectFinalScore } from '../lib/verifier-classification'; -import { - materializeInstance, - type MaterializeOptions, -} from '../lib/materialize'; -import { BACKEND_URL, apiFetch, objectContentUrl } from './shared'; +import { BACKEND_URL, apiFetch } from './shared'; import { StepsPipeline } from './steps-pipeline'; import { TaskInstanceViewer, type TaskStepRef } from './task-instance-viewer'; -import { - type StepState, - type StepType, - ADDABLE_STEP_TYPES, - STEP_TYPE_LABELS, - STEP_COLORS, - EVALUATOR_STEP_TYPES, - makeDefaultStep, - getRecommendedStep, - getJsonError, - stepToDict, - stepFromDict, - StepEditor, -} from './task-steps-shared'; - -/* ------------------------------------------------------------------ */ -/* Config drift fingerprinting */ -/* ------------------------------------------------------------------ */ - -// Key-sorted JSON so equal content hashes identically; drops undefined keys (they don't survive JSON round-trips). -function stableStringify(value: unknown): string { - if (value === null || typeof value !== 'object') { - return JSON.stringify(value) ?? 'null'; - } - if (Array.isArray(value)) { - return `[${value.map(stableStringify).join(',')}]`; - } - const obj = value as Record; - const keys = Object.keys(obj) - .filter(k => obj[k] !== undefined) - .sort(); - return `{${keys - .map(k => `${JSON.stringify(k)}:${stableStringify(obj[k])}`) - .join(',')}}`; -} - -// Fingerprint of a config's run-affecting content (steps only; id/project_id excluded). -// Call only on the parent INIT_STATE config — a stored task is backend-normalized and would always differ. -function configFingerprint( - config: { steps?: unknown } | null | undefined, -): string | null { - if (!config || config.steps == null) return null; - return stableStringify(config.steps); -} - -// Short stable digest (cyrb53) of a config fingerprint — a compact opaque drift key. -function shortHash(str: string): string { - let h1 = 0xdeadbeef; - let h2 = 0x41c6ce57; - for (let i = 0; i < str.length; i++) { - const ch = str.charCodeAt(i); - h1 = Math.imul(h1 ^ ch, 2654435761); - h2 = Math.imul(h2 ^ ch, 1597334677); - } - h1 = - Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ - Math.imul(h2 ^ (h2 >>> 13), 3266489909); - h2 = - Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ - Math.imul(h1 ^ (h1 >>> 13), 3266489909); - return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36); -} - -// Drift key for a parent config: a short hash of its steps, compared input-to-input. -function configHash( - config: { steps?: unknown } | null | undefined, -): string | null { - const fp = configFingerprint(config); - return fp === null ? null : shortHash(fp); -} - -const MIN_RUNS_PER_CLICK = 1; -const MAX_RUNS_PER_CLICK = 5; - -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - -interface TaskRunnerConfig { - task_id?: string; - task_config?: { - id?: string; - steps: Record[]; - project_id?: string; - }; - auto_run?: boolean; - allow_edit_evaluator?: boolean; - max_runs?: number; - preferred_run_count?: number; - runs_per_click?: number; - /** Drop context/materialized from instances, leaving bodies only on preferred_instances. - * Opt-in; no-op without preferred_run_count (nothing is starred). */ - omit_instance_bodies?: boolean; - /** Models offered per run, applied as agent_model on POST /run. prompt_agent prefers it over the - * step's model, so switching models keeps every attempt on one version (comparable pass@k). */ - agent_models?: string[]; - /** Reveals the Task ID + pipeline sections when embedded, as does is_runnable_interactively. */ - is_editable?: boolean; - /** Show the manual "Run" button when embedded (auto_run is independent). This or is_editable also - * reveals the Task ID + pipeline sections. */ - is_runnable_interactively?: boolean; - /** Per-instance derived fields to materialize onto each instance; see lib/materialize.ts. */ - materialize?: MaterializeOptions; - /** Show the Task Run Context tab while embedded (hidden in the iframe chrome). Explicit so a config - * can enable it on one grading step without turning it on for every step. */ - show_run_context?: boolean; - /** Attempts to grade (a runner step's starred instances), one run each resumed - * against that attempt's context. `agent_model` must match the attempt. */ - grade_attempts?: Array<{ - instance_id?: string; - agent_model?: string; - context?: Record | null; - /** Per-step param overrides, keyed by task step id. Cannot ride - * `context_json`: the backend shallow-merges `user_overrides` over it. */ - step_overrides?: Record>; - }>; - /** Index a graded run resumes at. Must be the judge's own `deploy_agent`, not the - * verifier, or `rubrics_verifier` fails with "Agent 'judge' not found". */ - grade_start_step?: number; -} +import { type StepType, EVALUATOR_STEP_TYPES } from './task-steps-shared'; type Phase = | 'initializing' - | 'creating' | 'idle' | 'running' | 'completed' @@ -179,39 +40,14 @@ function getInstanceScore(inst: Record): number | null { const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); -/** Steps that stand up the judge before it grades, so a resume must include them. */ -function isJudgeSetupStep(type: unknown) { - return ( - type === 'deploy_agent' || type === 'load_artifact' || type === 'run_code' - ); -} - -/** Steps a graded run resumes at; grading replays these against a prior context. */ -function isGradingStep(type: unknown) { - return type === 'rubrics_verifier' || type === 'run_openclaw_unit_test'; -} - function isTerminal(inst: Record) { return TERMINAL_STATUSES.has(String(inst.status ?? '')); } -/** Order-independent signature of a preferred-run selection. */ -function preferredKey(ids: Iterable) { - return [...ids].sort().join(','); -} - /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ -// A transient failure backs off and retries on a later submission; only a terminal status caches a miss. -const WARM_BACKOFF_MS = [15_000, 60_000, 300_000]; - -/** Over the cap or gone — retrying cannot help, unlike a 5xx/429. */ -function isTerminalWarmFailure(status: number): boolean { - return status === 413 || status === 404 || status === 410; -} - export function TaskRunnerPage({ taskId: urlTaskId, onEditEvaluator, @@ -225,9 +61,6 @@ export function TaskRunnerPage({ savedContextJson?: Record | null; onContextCaptured?: (ctx: Record) => void; }) { - const externalApp = useExternalApp(); - const isEmbedded = typeof window !== 'undefined' && window.parent !== window; - /* --- core state --- */ const [phase, setPhase] = useState('initializing'); const [resolvedTaskId, setResolvedTaskId] = useState(null); @@ -253,186 +86,6 @@ export function TaskRunnerPage({ [onContextCaptured], ); - /* --- steps (for editing) --- */ - const [steps, setSteps] = useState([]); - /** Never set: the editor and its save path are kept but unreachable, because the control plane - * serves tasks read-only (`POST /api/v1/tasks` is not routed). Restoring the entry point means - * adding that route first, so the button cannot appear before the endpoint it calls. */ - const [editing, setEditing] = useState(false); - const [selectedStepIndex, setSelectedStepIndex] = useState( - null, - ); - const [saving, setSaving] = useState(false); - const nextStepKeyRef = useRef(0); - - /* --- run limit --- */ - const [maxRuns, setMaxRuns] = useState(null); - - const [runsPerClick, setRunsPerClick] = useState(1); - - /* --- preferred runs --- */ - const [preferredRunCount, setPreferredRunCount] = useState( - null, - ); - // Both read through refs — sendResults is memoised and gates omission on them. - const preferredRunCountRef = useRef(null); - preferredRunCountRef.current = preferredRunCount; - const [omitInstanceBodies, setOmitInstanceBodies] = useState(false); - const omitInstanceBodiesRef = useRef(false); - omitInstanceBodiesRef.current = omitInstanceBodies; - const [agentModels, setAgentModels] = useState([]); - // Empty only while no list has arrived; a provided list always resolves to one of its - // entries, so a run never silently falls back to the config's own model. - const [agentModel, setAgentModel] = useState(''); - // handleRun is memoised, so read the selection through a ref to avoid a stale value. - const agentModelRef = useRef(''); - agentModelRef.current = agentModel; - const [preferredInstanceIds, setPreferredInstanceIds] = useState>( - new Set(), - ); - const preferredInstanceIdsRef = useRef>(new Set()); - preferredInstanceIdsRef.current = preferredInstanceIds; - // The task whose selection we report. Set once task identity is settled; null = nothing to say about stars yet. - const selectionOwnerRef = useRef(null); - // The task WE have expressed a selection for (star toggle / version reset). Once set, INIT_STATE echoes - // for this task are stale and ignored; until then a late seed is the real saved selection, not an echo. - const selectionIntentRef = useRef(null); - // What the parent last heard from us, per task. Only a divergence is worth posting; equality avoids an INIT_STATE↔SUBMISSION loop. - const lastSentPreferredRef = useRef<{ taskId: string; key: string } | null>( - null, - ); - // Adopt `ids` as taskId's selection, baselined as what the parent already holds — so the next divergence reads as a real user change. - const adoptSelection = useCallback((taskId: string, ids: string[]) => { - selectionOwnerRef.current = taskId; - lastSentPreferredRef.current = { taskId, key: preferredKey(ids) }; - setPreferredInstanceIds(prev => { - // Preserve the reference when the content is unchanged — the re-send - // effect depends on this state by identity. - if (ids.length === prev.size && ids.every(id => prev.has(id))) - return prev; - return new Set(ids); - }); - }, []); - /** Adopt a selection of OUR making, locking out any later parent seed. */ - const claimSelection = useCallback( - (taskId: string, ids: string[]) => { - selectionIntentRef.current = taskId; - adoptSelection(taskId, ids); - }, - [adoptSelection], - ); - - /* --- which derived fields to materialize onto each instance --- */ - const [materialize, setMaterialize] = useState< - MaterializeOptions | undefined - >(undefined); - // sendResults is memoised on [isEmbedded, externalApp], so it would close over - // a stale value — read through a ref, same as preferredInstanceIds. - const materializeRef = useRef(undefined); - materializeRef.current = materialize; - // s3_uri -> records. Keyed by uri (immutable per run) so a re-send does not - // re-download; a failed fetch stores [] so it is not retried on every send. - const trajectoryCacheRef = useRef>(new Map()); - // uri/key -> { failures, retryAfter epoch ms }. Bounds the RATE of retries, not - // their number. - const warmAttemptsRef = useRef< - Map - >(new Map()); - const warmBlocked = useCallback( - (key: string) => - Date.now() < (warmAttemptsRef.current.get(key)?.retryAfter ?? 0), - [], - ); - // One pending timer, aimed at the soonest cooldown and re-armed after every wake-up, so a later - // deadline still gets scheduled when an earlier key already succeeded. - const warmRetryTimerRef = useRef<{ at: number; id: number } | null>(null); - const armWarmRetryRef = useRef<() => void>(() => undefined); - const armWarmRetry = useCallback(() => { - const now = Date.now(); - let soonest = Infinity; - for (const { retryAfter } of warmAttemptsRef.current.values()) { - if (retryAfter > now && retryAfter < soonest) soonest = retryAfter; - } - if (soonest === Infinity) return; // nothing cooling down - const pending = warmRetryTimerRef.current; - if (pending && pending.at <= soonest) return; // an earlier wake-up covers it - if (pending) window.clearTimeout(pending.id); - const id = window.setTimeout( - () => { - warmRetryTimerRef.current = null; - const b = latestBatchRef.current; - if (b.taskId) - sendResultsRef.current(b.taskId, b.version, b.allInstances); - // Anything still cooling down gets the next wake-up. - armWarmRetryRef.current(); - }, - // +250ms so the deadline the skip-filters compare against has passed. - soonest - now + 250, - ); - warmRetryTimerRef.current = { at: soonest, id }; - }, []); - armWarmRetryRef.current = armWarmRetry; - - const noteWarmFailure = useCallback( - (key: string) => { - const failures = (warmAttemptsRef.current.get(key)?.failures ?? 0) + 1; - const backoff = - WARM_BACKOFF_MS[Math.min(failures - 1, WARM_BACKOFF_MS.length - 1)] ?? - 0; - warmAttemptsRef.current.set(key, { - failures, - retryAfter: Date.now() + backoff, - }); - armWarmRetry(); - }, - [armWarmRetry], - ); - - // A terminal failure is never retried, so its entry must not keep a wake-up alive. - const forgetWarmKey = useCallback((key: string) => { - warmAttemptsRef.current.delete(key); - }, []); - - useEffect( - () => () => { - if (warmRetryTimerRef.current) { - window.clearTimeout(warmRetryTimerRef.current.id); - } - }, - [], - ); - - // task_version -> that version's steps. Immutable, so one GET per version. - const taskStepsCacheRef = useRef[]>>( - new Map(), - ); - - /* --- embed interactivity flags (opt-in from parent app) --- */ - const [isEditable, setIsEditable] = useState(false); - const [isRunnableInteractively, setIsRunnableInteractively] = useState(false); - - /* --- latest task_config from parent (used by Apply / Start Fresh) --- */ - const [parentTaskConfig, setParentTaskConfig] = useState<{ - id?: string; - steps: Record[]; - project_id?: string; - } | null>(null); - // Fingerprint of the parent config the resolved version reflects. null = unconfirmed (fresh resume: - // treat as drifted until the first Run). Always derived from the parent config, never the stored task. - const [appliedConfigHash, setAppliedConfigHash] = useState( - null, - ); - const appliedConfigHashRef = useRef(null); - appliedConfigHashRef.current = appliedConfigHash; - - /* --- opt-in from the config: keep the Task Run Context tab in an embed --- */ - const [showRunContext, setShowRunContext] = useState(false); - /* --- attempts handed down by an upstream runner step, graded one run each --- */ - const [gradeAttempts, setGradeAttempts] = useState< - NonNullable - >([]); - const [gradeStartStep, setGradeStartStep] = useState(null); - /* --- instances --- */ const [instances, setInstances] = useState[]>([]); const [selectedInstanceId, setSelectedInstanceId] = useState( @@ -442,299 +95,14 @@ export function TaskRunnerPage({ const lastWorkflowIdRef = useRef(null); const snapshotRef = useRef(''); - // Render-synced refs so effects/callbacks can read the latest values without - // re-subscribing (adding them as deps would re-fire the auto-run effects). + // Render-synced refs so callbacks can read the latest values without re-subscribing. const instancesRef = useRef(instances); instancesRef.current = instances; const resolvedTaskIdRef = useRef(resolvedTaskId); resolvedTaskIdRef.current = resolvedTaskId; - // Ref-mirror so buildBody reads the latest project without a handleRun dep - // (adding one would re-fire the auto-run effects). - const parentTaskConfigRef = useRef(parentTaskConfig); - parentTaskConfigRef.current = parentTaskConfig; - - - /* --- track last processed inputs to detect new configs --- */ - const lastInputsRef = useRef | null | undefined>( - undefined, - ); - // Content signature of the last processed init. The identity guard above only catches the same object; - // the host re-emits a fresh INIT_STATE after every SUBMISSION, so dedupe on content to avoid a - // fetch→setSteps→sendResult loop that re-runs dagre.layout until the tab OOMs. - const lastInitKeyRef = useRef(null); - - /* --- lifecycle signal to parent (created / resumed / updated) --- */ - const sendResult = useCallback( - ( - taskId: string, - version: number | null, - status: string, - appliedConfigHash?: string | null, - ) => { - if (!isEmbedded) return; - const metadata: Record = { - task_id: taskId, - version: version ?? undefined, - }; - // Persist the input-side hash of the applied config so a later resume detects drift input-to-input. - // Every caller re-stamps it so it survives a host that replaces (rather than merges) the output item. - if (appliedConfigHash != null) { - metadata.applied_config_hash = appliedConfigHash; - } - const item: SubmissionItem = { - content: { - id: 'task-runner-result', - type: 'json', - data: { task_id: taskId, version, status }, - }, - metadata, - }; - externalApp.sendSubmission([item]); - }, - [isEmbedded, externalApp], - ); + // The page is not remounted per task, so it initialises once per mount. + const initializedRef = useRef(false); - /* --- batch all terminal instances with full context --- */ - /** Warm the trajectory cache, then re-send so the records land. */ - const warmTrajectories = useCallback( - async (instances: Record[], onDone: () => void) => { - // Every turn, not just the newest — records are embedded per turn. - const uris = instances - .flatMap(inst => { - const ctx = (inst.context ?? {}) as Record; - const rs = Array.isArray(ctx.prompt_responses) - ? (ctx.prompt_responses as Record[]) - : []; - return rs.map(r => r.agent_trajectory_s3_uri); - }) - .filter( - (u): u is string => - typeof u === 'string' && - u !== '' && - !trajectoryCacheRef.current.has(u) && - !warmBlocked(u), - ); - if (uris.length === 0) return; - await Promise.all( - uris.map(async uri => { - try { - const res = await apiFetch(objectContentUrl(uri)); - if (res.ok) { - const data: unknown = await res.json(); - trajectoryCacheRef.current.set( - uri, - Array.isArray(data) ? data : [], - ); - warmAttemptsRef.current.delete(uri); - } else if (isTerminalWarmFailure(res.status)) { - // Over the size cap or gone: retrying cannot help, so cache the miss. - trajectoryCacheRef.current.set(uri, []); - forgetWarmKey(uri); - } else { - noteWarmFailure(uri); - } - } catch { - // Network-level failure — transient until proven otherwise. - noteWarmFailure(uri); - } - }), - ); - onDone(); - }, - [noteWarmFailure, warmBlocked, forgetWarmKey], - ); - - /** Fetch step dicts per task_version in this send (for materialized.systemPrompt). One GET per version; - * versions are immutable, so each is fetched at most once. */ - const warmTaskSteps = useCallback( - async ( - taskId: string, - instances: Record[], - onDone: () => void, - ) => { - const versions = [ - ...new Set( - instances - .map(inst => inst.task_version) - .filter( - (v): v is number => - typeof v === 'number' && !taskStepsCacheRef.current.has(v), - ), - ), - ]; - if (versions.length === 0) return; - await Promise.all( - versions.map(async v => { - try { - const res = await apiFetch( - `${BACKEND_URL}/api/v1/tasks/${encodeURIComponent( - taskId, - )}?version=${v}`, - ); - const key = `steps:${v}`; - if (res.ok) { - const data = (await res.json()) as Record; - taskStepsCacheRef.current.set( - v, - Array.isArray(data.steps) - ? (data.steps as Record[]) - : [], - ); - warmAttemptsRef.current.delete(key); - } else if (isTerminalWarmFailure(res.status)) { - // A deleted version is never coming back. - taskStepsCacheRef.current.set(v, []); - forgetWarmKey(key); - } else { - noteWarmFailure(key); - } - } catch { - noteWarmFailure(`steps:${v}`); - } - }), - ); - onDone(); - }, - [noteWarmFailure, warmBlocked, forgetWarmKey], - ); - - const sendResults = useCallback( - ( - taskId: string, - version: number | null, - allInstances: Record[], - ) => { - if (!isEmbedded) return; - // A warm re-send after a rotation would post the OLD task_id and roll the run set back; the filter below misses it. - if (resolvedTaskIdRef.current !== taskId) return; - // Newest batch wins: the warm passes re-send after an await, by which point a - // later poll may have submitted a bigger one. They read this instead. - latestBatchRef.current = { taskId, version, allInstances }; - // Each instance doc carries its task, so the payload (not clear-timing) decides what's filed under - // taskId. A batch that outlived its task contributes nothing; an all-foreign batch drops out below. - const terminalInstances = allInstances - .filter(isTerminal) - .filter( - inst => inst.task_id == null || String(inst.task_id) === taskId, - ); - if (terminalInstances.length === 0) return; - // Only completed runs may be preferred — never failed/cancelled/errored - // ones, even if their id somehow ended up in the set. - const isStarred = (inst: Record) => - inst.status === 'completed' && - preferredInstanceIdsRef.current.has(String(inst.instance_id)); - - // Wire contract: with omit_instance_bodies on, instances carries ids only — bodies ride - // preferred_instances, context_omitted = "not sent". A starred run is bodyless here but full there, so it still needs materializing. - const toShape = (inst: Record, full: boolean) => { - const base = { - instance_id: String(inst.instance_id), - status: String(inst.status), - task_version: - typeof inst.task_version === 'number' - ? inst.task_version - : undefined, - created_at_utc: - inst.created_at_utc != null - ? String(inst.created_at_utc) - : undefined, - completed_at_utc: - inst.completed_at_utc != null - ? String(inst.completed_at_utc) - : undefined, - duration_seconds: - typeof inst.duration_seconds === 'number' - ? inst.duration_seconds - : undefined, - }; - if (!full) return { ...base, context_omitted: true }; - return { - ...base, - context: (inst.context as Record) ?? {}, - materialized: materializeInstance( - inst, - materializeRef.current, - trajectoryCacheRef.current, - taskStepsCacheRef.current, - ), - }; - }; - - // Interlocked on `preferred_run_count` even when opted in: without stars - // there is no `preferred_instances`, so this would drop every body. - const omitBodies = - omitInstanceBodiesRef.current && preferredRunCountRef.current != null; - const carriesBody = (inst: Record) => - !omitBodies || isStarred(inst); - - const preferredList = terminalInstances - .filter(isStarred) - .map(inst => toShape(inst, true)); - // Every post routes through here, incl. the poll path (no INIT_STATE wait). Until we own this task's - // selection, an empty list means "not told yet", not "no stars"; emit [] only once owned, else - // "cleared last star" is indistinguishable from silence and the parent re-echoes the stale list. - const ownsPreferred = selectionOwnerRef.current === taskId; - const metadata: Record = { - task_id: taskId, - version: version ?? undefined, - }; - // Shares the 'task-runner-result' id with sendResult, so re-stamp the drift baseline here too (input-side, via ref). - if (appliedConfigHashRef.current != null) { - metadata.applied_config_hash = appliedConfigHashRef.current; - } - const item: SubmissionItem = { - content: { - id: 'task-runner-result', - type: 'json', - data: { - task_id: taskId, - version, - instances: terminalInstances.map(inst => - toShape(inst, !omitBodies), - ), - ...((ownsPreferred || preferredList.length > 0) && { - preferred_instances: preferredList, - }), - }, - }, - metadata, - }; - externalApp.sendSubmission([item]); - if (ownsPreferred) { - // Single writer for the baseline, keyed on the local selection (our intent). Leaving it to the - // re-send effect would let it go stale so a toggle-back reads as a no-op and is dropped. - lastSentPreferredRef.current = { - taskId, - key: preferredKey(preferredInstanceIdsRef.current), - }; - } - const fullInstances = terminalInstances.filter(carriesBody); - // systemPrompt needs the ran version's steps — same fire-and-forget shape. - void warmTaskSteps(taskId, fullInstances, () => { - const b = latestBatchRef.current; - sendResultsRef.current(b.taskId, b.version, b.allInstances); - }); - const resend = () => { - const b = latestBatchRef.current; - sendResultsRef.current(b.taskId, b.version, b.allInstances); - }; - // Records download after the first submission, then a re-send embeds them. Self-limiting: a second pass finds all cached. - if (materializeRef.current?.trajectory) { - void warmTrajectories(fullInstances, resend); - } - }, - [isEmbedded, externalApp, warmTaskSteps, warmTrajectories], - ); - - // The most recent batch handed to sendResults — see the note in its body. - const latestBatchRef = useRef<{ - taskId: string; - version: number | null; - allInstances: Record[]; - }>({ taskId: '', version: null, allInstances: [] }); - - const sendResultsRef = useRef(sendResults); - sendResultsRef.current = sendResults; const setLastContextJsonRef = useRef(setLastContextJson); setLastContextJsonRef.current = setLastContextJson; @@ -749,7 +117,6 @@ export function TaskRunnerPage({ /* --- poll instances from API --- */ const instanceIdsRef = useRef>(new Set()); - const sentResultIdsRef = useRef>(new Set()); const pendingRunRef = useRef(false); pendingRunRef.current = pendingRun; @@ -768,8 +135,8 @@ export function TaskRunnerPage({ ); if (!res.ok) return; const data = await res.json(); - // The resolved task can change mid-flight (parent switch, save/upsert). Everything below writes - // "current task's runs", so a late response must be dropped, not applied. + // Everything below writes "current task's runs", so a response for a task this page has since + // moved off must be dropped, not applied. if (resolvedTaskIdRef.current !== resolvedTaskId) return; const items = (data.items ?? []) as Record[]; const snap = JSON.stringify( @@ -803,27 +170,6 @@ export function TaskRunnerPage({ }); } - // Re-send full batch whenever a new terminal instance appears - const terminalIds = new Set( - items - .filter(isTerminal) - .map((i: Record) => String(i.instance_id)), - ); - const hasNew = [...terminalIds].some( - id => !sentResultIdsRef.current.has(id), - ); - if (hasNew) { - sentResultIdsRef.current = new Set([ - ...sentResultIdsRef.current, - ...terminalIds, - ]); - sendResultsRef.current( - resolvedTaskId, - (items[0]?.task_version as number) ?? null, - items, - ); - } - snapshotRef.current = snap; instanceIdsRef.current = new Set(items.map(i => String(i.instance_id))); setInstances(items); @@ -832,26 +178,6 @@ export function TaskRunnerPage({ } }, [resolvedTaskId]); - // A parent-driven task switch changes resolvedTaskId without clearing instances, so the on-screen rows - // are still the previous task's. Clear here so a stale star can't file them under the new task id; - // this is also what makes claiming the selection before the fetch lands safe. - const prevResolvedTaskIdRef = useRef(null); - useEffect(() => { - const prev = prevResolvedTaskIdRef.current; - prevResolvedTaskIdRef.current = resolvedTaskId; - // Only a switch between two real tasks — not the first resolve, and not the - // same-id version bumps, which clear up after themselves. - if (!resolvedTaskId || prev === null || prev === resolvedTaskId) return; - setInstances([]); - setSelectedInstanceId(null); - sentResultIdsRef.current = new Set(); - instanceIdsRef.current = new Set(); - snapshotRef.current = ''; - // The intent lapses with the task we left, else A→B→A refuses the seed and leaves B's stars on A. - // Safe because anything expressed was already posted at the time. - selectionIntentRef.current = null; - }, [resolvedTaskId]); - // Poll while a run is active; stop once every instance is terminal. Deriving stop from instance status // (not phase, which never goes terminal here) lets a fresh run flip polling back on. const allInstancesTerminal = @@ -860,7 +186,6 @@ export function TaskRunnerPage({ if ( !resolvedTaskId || phase === 'initializing' || - phase === 'creating' || // 'error'/'failed' are terminal: a failed init can leave resolvedTaskId // set with no instances, so the instance-derived stop below never trips. phase === 'error' || @@ -873,64 +198,31 @@ export function TaskRunnerPage({ return () => clearInterval(interval); }, [resolvedTaskId, phase, pollInstances, allInstancesTerminal, pendingRun]); - // Re-send results when the starred selection changes after all runs are terminal (pollInstances only - // fires for NEW terminal instances). Post only a divergence from what the parent last heard; a missing - // baseline or one from another task means we don't own this task's selection yet (a switch changes - // resolvedTaskId without clearing instances), so posting then would file the old runs under the new task. - useEffect(() => { - const terminal = instancesRef.current.filter(isTerminal); - if (terminal.length === 0 || !resolvedTaskId) return; - if ( - terminal.some( - inst => inst.task_id != null && String(inst.task_id) !== resolvedTaskId, - ) - ) - return; - const prev = lastSentPreferredRef.current; - if (!prev || prev.taskId !== resolvedTaskId) return; - if (prev.key === preferredKey(preferredInstanceIds)) return; - sendResultsRef.current( - resolvedTaskId, - (terminal[0]?.task_version as number) ?? null, - instancesRef.current, - ); - }, [preferredInstanceIds, resolvedTaskId]); - /* --- run task --- */ - // Lifted out of `handleRun` so batch grading can send per-attempt bodies; - // `handleRun`'s `count` only repeats one identical body. const buildRunBody = useCallback( (opts?: { version?: number; start_step?: number; - agent_model?: string; context_json?: Record | null; // Prefer over context_json when re-running one of this task's instances: // `instances` comes from GET /instances, which strips a2a_card. context_from_instance_id?: string; // From-start re-run: inherit only the prior run's user_overrides. overrides_from_instance_id?: string; - step_overrides?: Record>; }) => { const body: Record = {}; body.version = opts?.version ?? taskVersion ?? undefined; // priority=0 (interactive): a human is waiting. Sent explicitly so intent survives backend default changes. body.priority = 0; if (opts?.start_step != null) body.start_step = opts.start_step; - // Per-run model override — no upsert, so attempts stay on one task version. - const model = opts?.agent_model ?? agentModelRef.current; - if (model) body.agent_model = model; // Mutually exclusive server-side. if (opts?.context_from_instance_id) body.context_from_instance_id = opts.context_from_instance_id; else if (opts?.context_json) body.context_json = opts.context_json; if (opts?.overrides_from_instance_id) body.overrides_from_instance_id = opts.overrides_from_instance_id; - if (opts?.step_overrides && Object.keys(opts.step_overrides).length > 0) - body.step_overrides = opts.step_overrides; - // Forward the host's project (config or ?projectId=), NOT ?taskId=. Only-if-truthy so "" can't defeat the backend fallback. + // Only-if-truthy so "" can't defeat the backend fallback. const projectId = - parentTaskConfigRef.current?.project_id || new URLSearchParams(window.location.search).get('projectId') || undefined; if (projectId) body.project_id = projectId; @@ -943,31 +235,14 @@ export function TaskRunnerPage({ async (opts?: { version?: number; start_step?: number; - agent_model?: string; context_json?: Record | null; context_from_instance_id?: string; overrides_from_instance_id?: string; - count?: number; }) => { if (!resolvedTaskId || pendingRun) return; - const requested = Math.max( - MIN_RUNS_PER_CLICK, - Math.floor(opts?.count ?? 1), - ); - const count = - maxRuns != null - ? Math.max( - 0, - Math.min(requested, maxRuns - instancesRef.current.length), - ) - : requested; - if (count === 0) return; setPendingRun(true); setErrorMsg(null); - - const buildBody = () => buildRunBody(opts); - - const startOne = async (): Promise => { + try { const res = await apiFetch( `${BACKEND_URL}/api/v1/tasks/${encodeURIComponent( resolvedTaskId, @@ -977,7 +252,7 @@ export function TaskRunnerPage({ headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(buildBody()), + body: JSON.stringify(buildRunBody(opts)), }, ); if (!res.ok) { @@ -988,44 +263,14 @@ export function TaskRunnerPage({ ); } const data = await res.json(); - return (data.workflow_id as string | null) ?? null; - }; - - const results = await Promise.allSettled( - Array.from({ length: count }, () => startOne()), - ); - const firstOk = results.find( - (r): r is PromiseFulfilledResult => - r.status === 'fulfilled', - ); - lastWorkflowIdRef.current = firstOk?.value ?? null; - const failed = results.filter(r => r.status === 'rejected').length; - if (!firstOk) { - const firstErr = results.find( - (r): r is PromiseRejectedResult => r.status === 'rejected', - ); + lastWorkflowIdRef.current = (data.workflow_id as string | null) ?? null; + } catch (e) { + lastWorkflowIdRef.current = null; setPendingRun(false); - setErrorMsg( - firstErr?.reason instanceof Error - ? firstErr.reason.message - : 'Failed to start run', - ); - } else if (failed > 0) { - // Some launches failed but at least one started: keep pendingRun and warn (fewer than runs_per_click). - setErrorMsg( - `Started ${ - count - failed - } of ${count} runs; ${failed} failed to launch.`, - ); + setErrorMsg(e instanceof Error ? e.message : 'Failed to start run'); } }, - [ - resolvedTaskId, - taskVersion, - pendingRun, - maxRuns, - buildRunBody, - ], + [resolvedTaskId, pendingRun, buildRunBody], ); /* --- cancel instance --- */ @@ -1083,264 +328,29 @@ export function TaskRunnerPage({ lastWorkflowIdRef.current = null; }, [resolvedTaskId]); - /* --- create task --- */ - const createTask = useCallback( - async (config: { - id: string; - steps: Record[]; - project_id?: string; - }) => { - setPhase('creating'); - - // No URL rewriting: config values are already fetchable URLs in the - // standalone hub, so they are used verbatim. - const translatedConfig = config; - - try { - const res = await apiFetch(`${BACKEND_URL}/api/v1/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(translatedConfig), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error( - data.detail || `Failed to create task (${res.status})`, - ); - } - const data = await res.json(); - return data as { id: string; version: number }; - } catch (e) { - setPhase('error'); - setErrorMsg(e instanceof Error ? e.message : 'Failed to create task'); - return null; - } - }, - [], - ); - /* --- initialization effect --- */ useEffect(() => { - // In embedded mode, wait for useExternalApp to be ready - if (isEmbedded && !externalApp.isReady) return; - - // Skip if we already processed these exact inputs - if (lastInputsRef.current === externalApp.receivedInputs) return; - lastInputsRef.current = externalApp.receivedInputs; - - // The host wraps inputs as {state, value, error} before posting INIT_STATE. Unwrap .value to reach the TaskRunnerConfig. - const inputs = - (externalApp.receivedInputs as { value?: TaskRunnerConfig } | null) - ?.value ?? null; - const output = externalApp.receivedOutput; - - // Capture config from parent - setShowRunContext(inputs?.show_run_context === true); - // Unconditional, like `materialize`: a config that stops asking for this must - // not keep doing it. - setOmitInstanceBodies(inputs?.omit_instance_bodies === true); - // Unconditional, like `materialize`: a stale attempt list would keep offering - // to grade instances the upstream step no longer stars. - setGradeAttempts( - Array.isArray(inputs?.grade_attempts) - ? inputs.grade_attempts.filter( - a => a && typeof a === 'object' && !!a.context, - ) - : [], - ); - setGradeStartStep( - typeof inputs?.grade_start_step === 'number' && - Number.isInteger(inputs.grade_start_step) && - inputs.grade_start_step >= 0 - ? inputs.grade_start_step - : null, - ); - if (inputs?.max_runs != null) setMaxRuns(inputs.max_runs); - if (inputs?.preferred_run_count != null) - setPreferredRunCount(inputs.preferred_run_count); - // Unconditional, unlike the fields above: leaving stale options active would keep - // downloading and uploading for a config that no longer asks for it. - setMaterialize(inputs?.materialize); - if (Array.isArray(inputs?.agent_models)) { - // Trimmed and de-duplicated: a hand-written config list picks up stray whitespace - // and repeats, and either would show up as a bogus dropdown entry. - const models = [ - ...new Set( - inputs.agent_models - .filter((m): m is string => typeof m === 'string') - .map(m => m.trim()) - .filter(Boolean), - ), - ]; - setAgentModels(models); - // A provided list means the run's model is explicit — default to the first entry - // rather than falling back to whatever the task config happens to pin. - setAgentModel(prev => (models.includes(prev) ? prev : models[0] ?? '')); - } else { - // A config that stops offering models must not leave a stale selection behind. - setAgentModels([]); - setAgentModel(''); - } - if (inputs?.runs_per_click != null) - setRunsPerClick( - Math.max( - MIN_RUNS_PER_CLICK, - Math.min(MAX_RUNS_PER_CLICK, Math.floor(inputs.runs_per_click)), - ), - ); - if (inputs?.is_editable != null) setIsEditable(inputs.is_editable); - if (inputs?.is_runnable_interactively != null) - setIsRunnableInteractively(inputs.is_runnable_interactively); - - // Stash the parent's task_config so Run can upsert it on demand (drift - // detection + Run-gated apply) without re-mounting the iframe. - if (inputs?.task_config) setParentTaskConfig(inputs.task_config); - - // Resumption priority: - // 1. Previous submission output (reload case) - const savedTaskId = output?.items?.[0]?.metadata?.task_id as - | string - | undefined; - // Input-side config hash persisted on the last create/apply, round-tripped via submission metadata. Seeds the resume baseline. - const savedConfigHash = output?.items?.[0]?.metadata - ?.applied_config_hash as string | undefined; - // 2. task_id from parent inputs - const inputTaskId = inputs?.task_id; - // 3. task_id from URL - const existingTaskId = savedTaskId ?? inputTaskId ?? urlTaskId; - - // Seed the starred selection from the saved submission on reload — but only while we have no selection - // of our own for this task, so an echo of the pre-un-star list can't overwrite a newer local one. - const savedPreferredInstances = ( - output?.items?.[0]?.content as - | { - data?: { - preferred_instances?: { instance_id?: string; status?: string }[]; - }; - } - | undefined - )?.data?.preferred_instances; - if (existingTaskId && selectionIntentRef.current !== existingTaskId) { - adoptSelection( - existingTaskId, - (Array.isArray(savedPreferredInstances) ? savedPreferredInstances : []) - // Only completed runs are preferable — never seed a stale - // failed/cancelled selection back into the star state. - .filter(inst => inst.status === 'completed') - .map(inst => String(inst.instance_id)) - .filter(id => id && id !== 'undefined'), - ); - } - - // Break the INIT_STATE↔SUBMISSION loop: if this init resolves to the task we already handled, skip the - // heavy path (fetchTask→setSteps→sendResult, which re-runs dagre and posts another SUBMISSION). - const initKey = JSON.stringify({ - task: existingTaskId ?? null, - cfgId: inputs?.task_config?.id ?? null, - }); - if (initKey === lastInitKeyRef.current) return; - lastInitKeyRef.current = initKey; - - if (existingTaskId) { - // Resume: fetch the stored task only — Run applies parent edits. Seed the drift baseline from the - // persisted hash (input-to-input): same config → no warning; newer parent → warning + apply; absent → safe fallback. - setAppliedConfigHash(savedConfigHash ?? null); - appliedConfigHashRef.current = savedConfigHash ?? null; - setResolvedTaskId(existingTaskId); - fetchTask(existingTaskId) - .then(data => { - setTask(data); - setTaskVersion(data.version ?? null); - const loadedSteps = ( - (data.steps ?? []) as Record[] - ).map((s: Record) => - stepFromDict(s, nextStepKeyRef.current++), - ); - setSteps(loadedSteps); - setPhase('idle'); - // Send submission so the parent has the task_id; re-stamp the hash it already holds (idempotent) so the baseline survives a replace. - sendResult( - existingTaskId, - data.version ?? null, - 'resumed', - savedConfigHash, - ); - }) - .catch(e => { - setPhase('error'); - setErrorMsg(e instanceof Error ? e.message : 'Failed to load task'); - }); - } else if (inputs?.task_config) { - // Create new task — fill in id/project_id from pipeline context if missing - const urlParams = new URLSearchParams(window.location.search); - const pipelineTaskId = urlParams.get('taskId'); - const pipelineProjectId = urlParams.get('projectId'); - const rawConfig = inputs.task_config; - const config = { - ...rawConfig, - id: - rawConfig.id || - (pipelineTaskId ? `aeh-${pipelineTaskId}` : `aeh-${Date.now()}`), - project_id: - rawConfig.project_id || - pipelineProjectId || - pipelineTaskId || - undefined, - }; - const autoRun = inputs.auto_run !== false; - const loadedSteps = config.steps.map((s: Record) => - stepFromDict(s, nextStepKeyRef.current++), - ); - setSteps(loadedSteps); - - createTask(config).then(result => { - if (!result) return; - setResolvedTaskId(result.id); - setTaskVersion(result.version); - // A just-upserted task has no selection of ours yet: adopt an empty one so a later "cleared last - // star" is postable, while still letting the parent's output seed us when it lands. - adoptSelection(result.id, []); - // Created this task FROM the parent config, so the version reflects it → no drift. Persist the hash for a later resume. - const baseline = configHash(inputs.task_config); - setAppliedConfigHash(baseline); - appliedConfigHashRef.current = baseline; - sendResult(result.id, result.version, 'created', baseline); - - // Fetch full task for metadata - fetchTask(result.id) - .then(data => setTask(data)) - .catch(() => {}); - - // Auto-run only on first creation (version 1). On reload the upsert bumps the version, so >1 means it already existed. - if (autoRun && result.version <= 1) { - autoRunTriggeredRef.current = false; - setPhase('running'); - // handleRun needs resolvedTaskId — triggered from auto-run effect. - } else { - setPhase('idle'); - } - }); - } else if (!isEmbedded && !urlTaskId) { - // Standalone with no task ID + if (initializedRef.current) return; + initializedRef.current = true; + if (!urlTaskId) { setPhase('error'); setErrorMsg( - 'No task configuration provided. Pass a task ID in the URL (/task-runner/) or embed this page with task config.', + 'No task ID provided. Pass a task ID in the URL (/task-runner/).', ); - } else if (isEmbedded) { - setPhase('error'); - setErrorMsg('No task configuration or task ID received from parent app.'); + return; } - }, [ - isEmbedded, - externalApp.isReady, - externalApp.receivedInputs, - externalApp.receivedOutput, - urlTaskId, - fetchTask, - createTask, - sendResult, - adoptSelection, - ]); + setResolvedTaskId(urlTaskId); + fetchTask(urlTaskId) + .then(data => { + setTask(data); + setTaskVersion(data.version ?? null); + setPhase('idle'); + }) + .catch(e => { + setPhase('error'); + setErrorMsg(e instanceof Error ? e.message : 'Failed to load task'); + }); + }, [urlTaskId, fetchTask]); // Ref to always access the latest handleEvaluatorSaved without stale closures const handleEvaluatorSavedRef = useRef< @@ -1348,272 +358,8 @@ export function TaskRunnerPage({ | null >(null); - /* --- step editing callbacks --- */ - const addStep = useCallback((type: StepType) => { - setSteps(prev => { - const newStep = makeDefaultStep(type, nextStepKeyRef.current++); - if (type === 'deploy_agent') { - const allEnvIds = prev - .filter(s => s.type === 'deploy_env' && s.fields.env_id) - .map(s => String(s.fields.env_id)); - newStep.fields.env_ids = allEnvIds.join(', '); - } - return [...prev, newStep]; - }); - }, []); - - const moveStep = useCallback((index: number, direction: -1 | 1) => { - setSteps(prev => { - const target = index + direction; - if (target < 0 || target >= prev.length) return prev; - const next = [...prev]; - const a = next[index]!, - b = next[target]!; - next[index] = b; - next[target] = a; - return next; - }); - setSelectedStepIndex(prev => (prev === index ? index + direction : prev)); - }, []); - - const removeStep = useCallback((index: number) => { - setSteps(prev => prev.filter((_, i) => i !== index)); - }, []); - - const updateStep = useCallback( - (index: number, updates: Partial) => { - setSteps(prev => - prev.map((s, i) => (i === index ? { ...s, ...updates } : s)), - ); - }, - [], - ); - - const updateField = useCallback( - (index: number, key: string, value: unknown) => { - setSteps(prev => - prev.map((s, i) => - i === index ? { ...s, fields: { ...s.fields, [key]: value } } : s, - ), - ); - }, - [], - ); - - /* --- save & re-run --- */ - const handleSave = useCallback(async () => { - if (steps.length === 0) return; - if (steps.some(s => !s.id.trim())) return; - if (steps.some(s => getJsonError(s.advancedJson) !== null)) return; - - setSaving(true); - try { - const body = { - id: resolvedTaskId ?? `task-${Date.now()}`, - steps: steps.map(stepToDict), - }; - const res = await apiFetch(`${BACKEND_URL}/api/v1/tasks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.detail || `Failed (${res.status})`); - } - const data = await res.json(); - setResolvedTaskId(data.id); - setTaskVersion(data.version); - setTask(prev => - prev - ? { ...prev, version: data.version, steps: steps.map(stepToDict) } - : prev, - ); - setEditing(false); - sendResult(data.id, data.version, 'updated'); - // Reset instances for the new version. Claiming (not a bare set) keeps the clear ours so an INIT_STATE echo can't re-apply old stars. - setInstances([]); - claimSelection(data.id, []); - sentResultIdsRef.current = new Set(); - setSelectedInstanceId(null); - snapshotRef.current = ''; - } catch (e) { - setErrorMsg(e instanceof Error ? e.message : 'Failed to save task'); - } finally { - setSaving(false); - } - }, [steps, resolvedTaskId, sendResult, claimSelection]); - - /* --- upsert the parent's latest task_config onto the current task --- */ - // Returns the upserted { id, version } (or null). Does NOT run — that's the caller's job, so apply-then-run is one explicit gesture. - const applyLatestConfig = useCallback(async (): Promise<{ - id: string; - version: number; - } | null> => { - if (!parentTaskConfig) return null; - const urlParams = new URLSearchParams(window.location.search); - const pipelineTaskId = urlParams.get('taskId'); - const pipelineProjectId = urlParams.get('projectId'); - - const writeId = - resolvedTaskId ?? - parentTaskConfig.id ?? - (pipelineTaskId ? `aeh-${pipelineTaskId}` : `aeh-${Date.now()}`); - - const config = { - ...parentTaskConfig, - id: writeId, - project_id: - parentTaskConfig.project_id || - pipelineProjectId || - pipelineTaskId || - undefined, - }; - - // Reset instance UI — the version bump invalidates the polled list until the next poll. Claimed on writeId (the id we POST). - setInstances([]); - claimSelection(writeId, []); - sentResultIdsRef.current = new Set(); - setSelectedInstanceId(null); - snapshotRef.current = ''; - - const result = await createTask(config); - if (!result) return null; - setResolvedTaskId(result.id); - setTaskVersion(result.version); - // Baseline = hash of the parent config just applied (input-side). Clears the drift warning until the parent differs; persisted for resume. - const baseline = configHash(parentTaskConfig); - setAppliedConfigHash(baseline); - appliedConfigHashRef.current = baseline; - sendResult(result.id, result.version, 'updated', baseline); - - fetchTask(result.id) - .then(data => { - setTask(data); - const loadedSteps = ( - (data.steps ?? []) as Record[] - ).map((s: Record) => - stepFromDict(s, nextStepKeyRef.current++), - ); - setSteps(loadedSteps); - }) - .catch(() => {}); - - setPhase('idle'); - return result; - }, [ - parentTaskConfig, - resolvedTaskId, - createTask, - fetchTask, - sendResult, - claimSelection, - ]); - - /* --- Run button: apply the parent's latest config first if it drifted --- */ - // "Apply Latest Config" is absorbed into Run. Reconcile only here, on an explicit click — never in an - // effect — so the INIT_STATE↔SUBMISSION loop can't form. Drift is computed input-to-input from the ref. - const handleRunLatest = useCallback(async () => { - const parentHash = configHash(parentTaskConfig); - const needsApply = - parentTaskConfig != null && - parentHash !== null && - (appliedConfigHashRef.current === null || - parentHash !== appliedConfigHashRef.current); - if (needsApply) { - const result = await applyLatestConfig(); - if (!result) return; - // resolvedTaskId is unchanged by apply (same id, new version); pass the - // new version explicitly so we run what we just applied, not stale state. - await handleRun({ version: result.version, count: runsPerClick }); - return; - } - await handleRun({ count: runsPerClick }); - }, [parentTaskConfig, applyLatestConfig, handleRun, runsPerClick]); - - // Drift signal for the warning banner: the parent's current config differs from what was last applied (seeded on resume from the persisted hash). - const parentConfigHash = useMemo( - () => configHash(parentTaskConfig), - [parentTaskConfig], - ); - const configDrifted = - !!resolvedTaskId && - parentConfigHash !== null && - parentConfigHash !== appliedConfigHash; - - // Whether to surface the Run control. Standalone always; embedded once a task exists this session. - // Run absorbs "Apply Latest Config", so without it an embedded session has no button. - const canRun = !isEmbedded || isRunnableInteractively || !!resolvedTaskId; - - // Cap concurrent runs at 1 unless the parent opted into max_runs. Blocks a new run while one is active so - // a CB can't spawn parallel expensive interactive envs; re-runnable once terminal (per-run Cancel escapes). + // One run at a time: Run is blocked while one is active; per-run Cancel escapes. const activeRunExists = pendingRun || instances.some(i => !isTerminal(i)); - const blockedByActiveCap = maxRuns == null && activeRunExists; - - // Auto-run effect: triggers when resolvedTaskId is set after creation with auto_run - const autoRunTriggeredRef = useRef(false); - useEffect(() => { - if (phase === 'running' && resolvedTaskId && !autoRunTriggeredRef.current) { - autoRunTriggeredRef.current = true; - handleRun(); - } - }, [phase, resolvedTaskId, handleRun]); - - /* --- start a new run set, abandoning the current one --- */ - // The host drops the previous id's entries, keyed on data.task_id. Below, in order: derive the id from - // parentTaskConfig.id (so suffixes don't stack); disarm auto-run (a rotated id upserts at v1); CLAIM the selection. - const [resettingRunSet, setResettingRunSet] = useState(false); - const handleResetRunSet = useCallback(async () => { - const parent = parentTaskConfigRef.current; - if (!resolvedTaskId || resettingRunSet || !parent) return; - const rotated = `${parent.id || resolvedTaskId}-r${Date.now().toString( - 36, - )}`; - - setResettingRunSet(true); - setErrorMsg(null); - const urlParams = new URLSearchParams(window.location.search); - const pipelineTaskId = urlParams.get('taskId'); - const result = await createTask({ - ...parent, - id: rotated, - project_id: - parent.project_id || - urlParams.get('projectId') || - pipelineTaskId || - undefined, - }); - if (!result) { - setResettingRunSet(false); - return; - } - - autoRunTriggeredRef.current = true; - - setInstances([]); - setSelectedInstanceId(null); - sentResultIdsRef.current = new Set(); - instanceIdsRef.current = new Set(); - snapshotRef.current = ''; - setLastContextJson(null); - setResolvedTaskId(result.id); - setTaskVersion(result.version); - claimSelection(result.id, []); - const baseline = configHash(parent); - setAppliedConfigHash(baseline); - appliedConfigHashRef.current = baseline; - setPhase('idle'); - // Trips the host reset: new `task_id`, no `instances` key. - sendResult(result.id, result.version, 'created', baseline); - setResettingRunSet(false); - }, [ - resolvedTaskId, - resettingRunSet, - createTask, - claimSelection, - sendResult, - setLastContextJson, - ]); /* --- handle return from evaluator editor --- */ // Store pending eval rerun params so the effect below can fire startRun @@ -1635,20 +381,12 @@ export function TaskRunnerPage({ setResolvedTaskId(savedTaskId); setTaskVersion(newVersion); setInstances([]); - claimSelection(savedTaskId, []); - sentResultIdsRef.current = new Set(); snapshotRef.current = ''; // Refresh task data try { const data = await fetchTask(savedTaskId); setTask(data); - const loadedSteps = ( - (data.steps ?? []) as Record[] - ).map((s: Record) => - stepFromDict(s, nextStepKeyRef.current++), - ); - setSteps(loadedSteps); // Use override start step, or find first evaluator step const stepDicts = (data.steps ?? []) as Record[]; @@ -1695,7 +433,7 @@ export function TaskRunnerPage({ ); } }, - [fetchTask, lastContextJson, lastContextSource, claimSelection], + [fetchTask, lastContextJson, lastContextSource], ); handleEvaluatorSavedRef.current = handleEvaluatorSaved; @@ -1719,11 +457,7 @@ export function TaskRunnerPage({ }, [pendingEvalRun, resolvedTaskId, handleRun]); /* --- derived state --- */ - const taskSteps = (task?.steps ?? steps.map(stepToDict)) as Record< - string, - unknown - >[]; - const displaySteps = editing ? steps.map(stepToDict) : taskSteps; + const taskSteps = (task?.steps ?? []) as Record[]; // Slim {id, type} list for TaskInstanceViewer (groups collected artifacts by collect step). Memoized off // `task` so identity is stable across polls, else the viewer's trajectory effect re-fires and collapses trajectories. @@ -1741,149 +475,6 @@ export function TaskRunnerPage({ })); }, [task]); - // First grading step; re-running from here replays grading against the prior - // run's context. -1 when the task grades nothing. - const verifierStepIndex = displaySteps.findIndex(s => isGradingStep(s.type)); - - // Config-supplied resume point, else derived. Indexed off the PARENT config (applyLatestConfig refreshes `task` async, so a task-derived index lags a version). - const parentGradingStepIndex = useMemo(() => { - const steps = parentTaskConfig?.steps; - if (!steps) return -1; - const first = steps.findIndex(st => isGradingStep(st.type)); - if (first < 0) return -1; - // A rubrics_verifier naming an agent needs it DEPLOYED, so resuming at the verifier would skip its - // deploy_agent/load_artifact. Walk back over the contiguous setup steps for that agent. - const judge = steps[first]?.agent_name; - if ( - steps[first]?.type !== 'rubrics_verifier' || - typeof judge !== 'string' || - judge === '' - ) - return first; - let start = first; - while (start > 0) { - const prev = steps[start - 1]; - if (prev?.agent_name !== judge || !isJudgeSetupStep(prev?.type)) break; - start -= 1; - } - return start; - }, [parentTaskConfig]); - const resumeStep = - gradeStartStep ?? - (parentGradingStepIndex >= 0 ? parentGradingStepIndex : verifierStepIndex); - - // One run per attempt, each resumed at the grading step against that attempt's - // context. - const gradeAllAttempts = useCallback(async () => { - if (!resolvedTaskId || pendingRun || resumeStep < 0) return; - const attempts = gradeAttempts.filter(a => !!a.context); - if (attempts.length === 0) return; - - // Grade what the parent currently says, like handleRunLatest: `grade_start_step` - // indexes the new step list, and the steps carry the verifier source. - let version = taskVersion ?? undefined; - const parentHash = configHash(parentTaskConfig); - if ( - parentTaskConfig != null && - parentHash !== null && - (appliedConfigHashRef.current === null || - parentHash !== appliedConfigHashRef.current) - ) { - const applied = await applyLatestConfig(); - if (!applied) return; - version = applied.version; - } - - const room = - maxRuns != null - ? Math.max(0, maxRuns - instancesRef.current.length) - : attempts.length; - const planned = attempts.slice(0, room); - if (planned.length === 0) { - setErrorMsg( - `Run cap reached (${maxRuns}) — nothing graded. Clear runs and retry.`, - ); - return; - } - - setPendingRun(true); - setErrorMsg(null); - const failures: string[] = []; - - // Launched together, like handleRun's `count` fan-out: every attempt differs - // only in `context_json` / `agent_model`, so there is nothing to serialize. - const results = await Promise.allSettled( - planned.map(async attempt => { - const res = await apiFetch( - `${BACKEND_URL}/api/v1/tasks/${encodeURIComponent( - resolvedTaskId, - )}/run`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - buildRunBody({ - version, - start_step: resumeStep, - agent_model: attempt.agent_model, - context_json: attempt.context ?? null, - step_overrides: attempt.step_overrides, - }), - ), - }, - ); - if (!res.ok) { - const errData = await res.json().catch(() => ({})); - throw new Error( - `${attempt.instance_id ?? 'attempt'}: ${ - errData.detail || `HTTP ${res.status}` - }`, - ); - } - const data = await res.json(); - return (data.workflow_id as string | null) ?? null; - }), - ); - - const started = results.filter(r => r.status === 'fulfilled').length; - for (const r of results) - if (r.status === 'rejected') - failures.push( - r.reason instanceof Error ? r.reason.message : 'failed to launch', - ); - const lastWorkflowId = - results.find( - (r): r is PromiseFulfilledResult => - r.status === 'fulfilled' && !!r.value, - )?.value ?? null; - - lastWorkflowIdRef.current = lastWorkflowId; - if (started === 0) { - setPendingRun(false); - setErrorMsg(`Failed to start grading. ${failures[0] ?? ''}`.trim()); - } else if (failures.length > 0) { - // Keep pendingRun: the started runs are coming. Name the shortfall rather - // than letting a partial grade read as a complete one. - setErrorMsg( - `Grading ${started} of ${attempts.length} attempt(s); ${failures.length} failed to launch. ${failures[0]}`, - ); - } else if (planned.length < attempts.length) { - setErrorMsg( - `Grading ${planned.length} of ${attempts.length} attempt(s) — run cap (${maxRuns}) reached.`, - ); - } - }, [ - resolvedTaskId, - pendingRun, - resumeStep, - gradeAttempts, - maxRuns, - taskVersion, - buildRunBody, - parentTaskConfig, - applyLatestConfig, - ]); - const rubricsStep = taskSteps.find(s => s.type === 'rubrics_verifier') as | Record | undefined; @@ -1921,22 +512,21 @@ export function TaskRunnerPage({
{/* Task ID */} - {resolvedTaskId && - (!isEmbedded || isEditable || isRunnableInteractively) && ( -
- - Task ID - -

- {resolvedTaskId} - {taskVersion != null && ( - - v{taskVersion} - - )} -

-
- )} + {resolvedTaskId && ( +
+ + Task ID + +

+ {resolvedTaskId} + {taskVersion != null && ( + + v{taskVersion} + + )} +

+
+ )} {/* Error message */} {errorMsg && ( @@ -1946,125 +536,9 @@ export function TaskRunnerPage({ )} {/* Pipeline visualization */} - {(!isEmbedded || isEditable || isRunnableInteractively) && - displaySteps.length > 0 && ( -
- -
- )} - - {/* Step editor (when editing) */} - {editing && ( + {taskSteps.length > 0 && (
-

- Steps ({steps.length}) -

- {steps.length === 0 ? ( -

- No steps. Add a step to get started. -

- ) : ( -
- {steps.map((step, i) => ( -
setSelectedStepIndex(i)}> - -
- ))} -
- )} - {(() => { - const recommended = getRecommendedStep(steps); - return ( -
- {recommended && ( - - )} - - - - - - {ADDABLE_STEP_TYPES.map(type => ( - addStep(type)} - > - - {STEP_TYPE_LABELS[type]} - - ))} - - -
- ); - })()} -
- - -
-
- )} - - {/* Creating progress */} - {phase === 'creating' && ( -
- -

Creating task...

+
)} @@ -2072,190 +546,43 @@ export function TaskRunnerPage({ {phase === 'initializing' && (
-

- {isEmbedded - ? 'Waiting for configuration from parent...' - : 'Initializing...'} -

+

Initializing...

)} {/* Runs */} - {resolvedTaskId && phase !== 'initializing' && phase !== 'creating' && ( + {resolvedTaskId && phase !== 'initializing' && (

Runs ({instances.length})

- {preferredRunCount != null && ( - - Preferred: {preferredInstanceIds.size}/{preferredRunCount}{' '} - selected - - )}
- {/* Drift warning — the parent's latest config isn't applied yet. - "Apply Latest Config" is folded into Run, so clicking Run picks - up these changes. */} - {configDrifted && canRun && ( -
- - - Your last run is saved and shown below. This task has newer - changes that aren’t part of it — click Run only if you want to - start a new run with those changes. - -
- )} - - {/* Run / Starting button, plus the run-set reset */} + {/* Run / Starting button */}
- {canRun && - (pendingRun ? ( -
- - - Starting... - - -
- ) : ( - <> - {/* Per-run model, only when the config offers a list. `agent_model` is a - RUN parameter, so switching models does not upsert a new version and - every attempt stays comparable (prompt_agent prefers - context.agent_model over the step's own `model`). */} - {agentModels.length > 0 && ( - - )} - {/* Grading: one run per attempt handed down by the upstream runner - step, each resumed at the grading step against that attempt's - context. Replaces "Run" rather than sitting beside it — a fresh - run of a grading task would score a new attempt nobody asked - for, which is the confusing part worth designing out. */} - {gradeAttempts.length > 0 && resumeStep >= 0 ? ( - - ) : ( - - )} - - ))} - - {/* Disabled while a run is live: rotating orphans the in-flight - instance — filtered out by task_id, never posted — mid-sandbox. - Said inline too: `title` is suppressed on a disabled button. */} - {parentTaskConfig && instances.length > 0 && ( -
- {activeRunExists && !resettingRunSet && ( - - Available once the current run finishes - - )} - - - - - - - Discard these runs and start over? - - - All {instances.length} run - {instances.length === 1 ? '' : 's'} - {preferredInstanceIds.size > 0 && - ` and ${preferredInstanceIds.size} starred selection${ - preferredInstanceIds.size === 1 ? '' : 's' - }`}{' '} - will be removed from this step, and a fresh run set takes - their place. - -
- - - This cannot be undone, and any attempt that has not been - graded becomes ungradable. - -
- - - - - - - - -
-
+ {pendingRun ? ( +
+ + + Starting... + +
+ ) : ( + )}
@@ -2269,10 +596,6 @@ export function TaskRunnerPage({ const isActive = status === 'running' || status === 'provisioning'; const isSelected = selectedInstanceId === id; - const isPreferred = preferredInstanceIds.has(id); - const atCap = - preferredRunCount != null && - preferredInstanceIds.size >= preferredRunCount; return ( - )} ); })} @@ -2392,7 +691,6 @@ export function TaskRunnerPage({ rubricsCriteria={rubricsCriteria} rubricsAggregator={rubricsAggregator} taskSteps={viewerTaskSteps} - showRunContext={showRunContext} /> ); })()} diff --git a/src/agent_env/explorer/ui/src/components/universes-page.tsx b/src/agent_env/explorer/ui/src/components/universes-page.tsx index fd5bfaab..3656ab33 100644 --- a/src/agent_env/explorer/ui/src/components/universes-page.tsx +++ b/src/agent_env/explorer/ui/src/components/universes-page.tsx @@ -1,9 +1,5 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { Search, X, RefreshCw } from 'lucide-react'; -import { - useExternalApp, - type SubmissionItem, -} from '../lib/external-app'; import { BACKEND_URL, apiFetch, @@ -19,20 +15,6 @@ import { type UniverseType, } from '../lib/universe-types'; -/** Iframe-host config via INIT_STATE.payload.inputs.value. All fields optional — omitting it still gives a - * working hub. Currently a single pre-filter knob; more picker policies can be added as optional fields. */ -interface UniversesConfig { - type_filter?: UniverseType; -} - -/** Shape posted back to the parent as a SUBMISSION `data` item. Same for picked and newly-created universes; `source` distinguishes for host analytics. */ -interface UniversePickSubmission { - universe_id: string; - version: number; - type: UniverseType; - source: 'picked' | 'created'; -} - export const UNIVERSE_PAGE_SIZE = 10; export type { UniverseType }; @@ -54,19 +36,6 @@ export function UniversesPage({ }) { const lockedType = universeType; - // Iframe-host integration, auto-detected via window.parent !== window. When embedded: card click selects - // (two-step), a sticky bar emits a SUBMISSION, and Create auto-submits instead of navigating. Standalone unchanged. - const externalApp = useExternalApp(); - // window.parent !== window is stable for the component's life, but recomputing inline risks a hydration - // render flipping the value (SSR → client) and retriggering the INIT_STATE effect. Memo'd once. - const isEmbedded = useMemo( - () => typeof window !== 'undefined' && window.parent !== window, - [], - ); - const [pickedUniverse, setPickedUniverse] = - useState(null); - const lastEmbedInputsRef = useRef(undefined); - const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -180,36 +149,6 @@ export function UniversesPage({ fetchUniverses(searchQuery.trim(), runGroupIdQuery.trim(), queriedTypes); }, [searchQuery, runGroupIdQuery, queriedTypes, fetchUniverses]); - // Consume INIT_STATE from the host: unwrap `.value` to reach UniversesConfig. React only to new inputs (tracked by ref). - useEffect(() => { - if (!isEmbedded || !externalApp.isReady) return; - if (lastEmbedInputsRef.current === externalApp.receivedInputs) return; - lastEmbedInputsRef.current = externalApp.receivedInputs; - const inputs = - (externalApp.receivedInputs as { value?: UniversesConfig } | null) - ?.value ?? null; - if (!inputs) return; - if (inputs.type_filter && !lockedType) { - setTypeFilter(inputs.type_filter); - } - }, [isEmbedded, externalApp.isReady, externalApp.receivedInputs, lockedType]); - - // Post a SUBMISSION back to the host. Shape matches the task-runner pattern so both pages share the host message handler. - const submitToHost = useCallback( - (data: UniversePickSubmission) => { - const item: SubmissionItem = { - content: { - id: 'universe-pick', - type: 'json', - data: data as unknown as Record, - }, - metadata: { universe_id: data.universe_id, version: data.version }, - }; - externalApp.sendSubmission([item]); - }, - [externalApp], - ); - const hasMore = items.length < total; // Float an exact id match above same-substring universes (backend id filter is substring, newest-first). @@ -352,37 +291,16 @@ export function UniversesPage({ const itemType: UniverseType = normalizeUniverseType(item.type); const id = String(item.id); const v = item.version != null ? Number(item.version) : undefined; - // Embed pick mode: a card is "selected" when it matches the picked universe. Selection lives in local state. - const isPickedHere = - isEmbedded && - pickedUniverse?.universe_id === id && - (v == null || pickedUniverse?.version === v); return ( { // Pin a version only for a run-group-scoped view (each card = a specific version); otherwise undefined → detail defaults to latest. const pinned = runGroupIdQuery.trim() ? v : undefined; - if (isEmbedded) { - // Two-step picker: card click toggles selection; the "Use this universe" bar emits the submission. - setPickedUniverse(prev => - prev && - prev.universe_id === id && - prev.version === (v ?? 1) - ? null - : { - universe_id: id, - version: v ?? 1, - type: itemType, - source: 'picked', - }, - ); - } else if (selectionMode) { + if (selectionMode) { onSelect?.(id, v, itemType); } else { onSelectUniverse?.(id, pinned); @@ -403,47 +321,6 @@ export function UniversesPage({ )} )} - - - {/* Sticky two-step picker action bar — only when embedded and the - user has a card selected. Floats over the bottom edge of the - iframe viewport. */} - {isEmbedded && pickedUniverse && ( -
-
- - Selected - -
- - {pickedUniverse.universe_id} - - - v{pickedUniverse.version} - - - {pickedUniverse.type} - -
-
-
- - -
-
- )}
); } diff --git a/src/agent_env/explorer/ui/src/lib/external-app/index.ts b/src/agent_env/explorer/ui/src/lib/external-app/index.ts deleted file mode 100644 index 58741f38..00000000 --- a/src/agent_env/explorer/ui/src/lib/external-app/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * No-op stand-in for a host `use-external-app` hook. The standalone app is the - * top-level page (never embedded in a host frame), so this keeps the hook's shape - * and always reports "not embedded"; every embedded-only branch is guarded on - * `isReady` / `receivedInputs` and simply never runs. - */ - -export interface DataUrlItemContent { - id: string; - type: 'dataUrl'; - data: string; -} - -export interface JsonItemContent { - id: string; - type: 'json'; - data: Record; -} - -export type SubmissionItemContent = DataUrlItemContent | JsonItemContent; - -export type SubmissionItem = { - content: SubmissionItemContent; - metadata?: Record; -}; - -export interface SubmissionPayload { - items: SubmissionItem[]; -} - -export type BeforeNextHandler = () => unknown; - -export interface ExternalApp { - /** True only when running embedded in a host frame; always false standalone. */ - isReady: boolean; - /** Inputs posted in by the host frame. */ - receivedInputs: Record | null; - receivedOutput: SubmissionPayload | null; - skipRegeneration: boolean; - /** Wraps the items in a SubmissionPayload. */ - sendSubmission: (items: SubmissionItem[]) => void; - registerBeforeNextHandler: (handler: BeforeNextHandler | null) => void; - assignmentId: string | null; - taskId: string | null; -} - -/** No-op external-app client: the standalone hub is never embedded. */ -export function useExternalApp(_options?: { - onBeforeNext?: BeforeNextHandler; -}): ExternalApp { - return { - isReady: false, - receivedInputs: null, - receivedOutput: null, - skipRegeneration: false, - sendSubmission: () => {}, - registerBeforeNextHandler: () => {}, - assignmentId: null, - taskId: null, - }; -} diff --git a/src/agent_env/explorer/ui/src/pages/[[...slug]].tsx b/src/agent_env/explorer/ui/src/pages/[[...slug]].tsx index 3698e3d6..4bdf60ef 100644 --- a/src/agent_env/explorer/ui/src/pages/[[...slug]].tsx +++ b/src/agent_env/explorer/ui/src/pages/[[...slug]].tsx @@ -159,13 +159,6 @@ export default function App() { string, unknown > | null>(null); - const [isEmbedded, setIsEmbedded] = useState(false); - - /* --- Detect iframe / host after mount to avoid SSR hydration mismatch --- */ - useEffect(() => { - setIsEmbedded(window.parent !== window); - }, []); - /* --- Initialize state from URL on mount --- */ useEffect(() => { const { page, entityId } = currentRoute(); @@ -278,145 +271,142 @@ export default function App() { const sidebarActivePage = DETAIL_TO_PARENT[activePage] ?? activePage; - const hideSidebar = isEmbedded; const navGroups = NAV_GROUPS; return (
{/* Sidebar */} - {!hideSidebar && ( - - )} + +
+ {/* Main content — gate on initialized to avoid flashing home before URL is read */}
From 6770c097ba3856931defa420a19d15534db7f05c Mon Sep 17 00:00:00 2001 From: Tejas Polakam Date: Wed, 30 Sep 2026 17:24:41 -0400 Subject: [PATCH 2/2] test(explorer): cover the task runner's run lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner's run flow (the request a run sends, a refused launch, picking up the started run from the instance poll, when polling stops, cancel) lived inside the page component, where the UI's node-only smoke runner cannot reach it. It now lives in lib/task-runner-run.ts, unchanged, and task-runner-run.smoke.ts drives it with a fake fetch: a run starts and surfaces its workflow id, a refused launch throws the backend's detail instead of leaving the page on "Starting…", the started run is selected once it appears, polling continues until it does, and cancel encodes both ids. Co-Authored-By: Claude Opus 5.5 --- .../ui/src/components/task-runner-page.tsx | 156 ++++++------------ .../ui/src/lib/task-runner-run.smoke.ts | 136 +++++++++++++++ .../explorer/ui/src/lib/task-runner-run.ts | 137 +++++++++++++++ 3 files changed, 319 insertions(+), 110 deletions(-) create mode 100644 src/agent_env/explorer/ui/src/lib/task-runner-run.smoke.ts create mode 100644 src/agent_env/explorer/ui/src/lib/task-runner-run.ts diff --git a/src/agent_env/explorer/ui/src/components/task-runner-page.tsx b/src/agent_env/explorer/ui/src/components/task-runner-page.tsx index bc425e2c..ec83898f 100644 --- a/src/agent_env/explorer/ui/src/components/task-runner-page.tsx +++ b/src/agent_env/explorer/ui/src/components/task-runner-page.tsx @@ -1,5 +1,16 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { Play, Loader2, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'; +import { + allTerminal, + buildRunBody as runBodyFor, + cancelRun, + isTerminal, + reconcileInstances, + shouldPoll, + startRun, + workflowIdOf, + type RunOptions, +} from '../lib/task-runner-run'; import { selectFinalScore } from '../lib/verifier-classification'; import { BACKEND_URL, apiFetch } from './shared'; import { StepsPipeline } from './steps-pipeline'; @@ -38,12 +49,6 @@ function getInstanceScore(inst: Record): number | null { ); } -const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); - -function isTerminal(inst: Record) { - return TERMINAL_STATUSES.has(String(inst.status ?? '')); -} - /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ @@ -139,27 +144,21 @@ export function TaskRunnerPage({ // moved off must be dropped, not applied. if (resolvedTaskIdRef.current !== resolvedTaskId) return; const items = (data.items ?? []) as Record[]; - const snap = JSON.stringify( - items.map(i => `${i.instance_id}:${i.status}:${i.current_step}`), + const poll = reconcileInstances( + items, + snapshotRef.current, + instanceIdsRef.current, + pendingRunRef.current, ); - if (snap === snapshotRef.current) return; + if (!poll) return; - // Detect new instance for pending run - if (pendingRunRef.current) { - const newInst = items.find( - i => !instanceIdsRef.current.has(String(i.instance_id)), - ); - if (newInst) { - setPendingRun(false); - lastWorkflowIdRef.current = null; - setSelectedInstanceId(String(newInst.instance_id)); - } + if (poll.startedInstanceId) { + setPendingRun(false); + lastWorkflowIdRef.current = null; + setSelectedInstanceId(poll.startedInstanceId); } - // Capture context from most recent completed instance - const completed = items.find( - (inst: Record) => inst.status === 'completed', - ); + const completed = poll.latestCompleted; if (completed) { const ctx = completed.context as Record | undefined; // `ctx` is from the list endpoint (a2a_card stripped) — keep the id instead. @@ -170,8 +169,8 @@ export function TaskRunnerPage({ }); } - snapshotRef.current = snap; - instanceIdsRef.current = new Set(items.map(i => String(i.instance_id))); + snapshotRef.current = poll.snapshot; + instanceIdsRef.current = poll.ids; setInstances(items); } catch { /* ignore */ @@ -180,19 +179,10 @@ export function TaskRunnerPage({ // Poll while a run is active; stop once every instance is terminal. Deriving stop from instance status // (not phase, which never goes terminal here) lets a fresh run flip polling back on. - const allInstancesTerminal = - instances.length > 0 && instances.every(isTerminal); + const allInstancesTerminal = allTerminal(instances); useEffect(() => { - if ( - !resolvedTaskId || - phase === 'initializing' || - // 'error'/'failed' are terminal: a failed init can leave resolvedTaskId - // set with no instances, so the instance-derived stop below never trips. - phase === 'error' || - phase === 'failed' - ) + if (!shouldPoll(resolvedTaskId, phase, allInstancesTerminal, pendingRun)) return; - if (allInstancesTerminal && !pendingRun) return; pollInstances(); const interval = setInterval(pollInstances, 5000); return () => clearInterval(interval); @@ -200,70 +190,27 @@ export function TaskRunnerPage({ /* --- run task --- */ const buildRunBody = useCallback( - (opts?: { - version?: number; - start_step?: number; - context_json?: Record | null; - // Prefer over context_json when re-running one of this task's instances: - // `instances` comes from GET /instances, which strips a2a_card. - context_from_instance_id?: string; - // From-start re-run: inherit only the prior run's user_overrides. - overrides_from_instance_id?: string; - }) => { - const body: Record = {}; - body.version = opts?.version ?? taskVersion ?? undefined; - // priority=0 (interactive): a human is waiting. Sent explicitly so intent survives backend default changes. - body.priority = 0; - if (opts?.start_step != null) body.start_step = opts.start_step; - // Mutually exclusive server-side. - if (opts?.context_from_instance_id) - body.context_from_instance_id = opts.context_from_instance_id; - else if (opts?.context_json) body.context_json = opts.context_json; - if (opts?.overrides_from_instance_id) - body.overrides_from_instance_id = opts.overrides_from_instance_id; - // Only-if-truthy so "" can't defeat the backend fallback. - const projectId = - new URLSearchParams(window.location.search).get('projectId') || - undefined; - if (projectId) body.project_id = projectId; - return body; - }, + (opts?: RunOptions) => + runBodyFor( + opts, + taskVersion, + new URLSearchParams(window.location.search).get('projectId'), + ), [taskVersion], ); const handleRun = useCallback( - async (opts?: { - version?: number; - start_step?: number; - context_json?: Record | null; - context_from_instance_id?: string; - overrides_from_instance_id?: string; - }) => { + async (opts?: RunOptions) => { if (!resolvedTaskId || pendingRun) return; setPendingRun(true); setErrorMsg(null); try { - const res = await apiFetch( - `${BACKEND_URL}/api/v1/tasks/${encodeURIComponent( - resolvedTaskId, - )}/run`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(buildRunBody(opts)), - }, + lastWorkflowIdRef.current = await startRun( + apiFetch, + BACKEND_URL, + resolvedTaskId, + buildRunBody(opts), ); - if (!res.ok) { - // Surface the backend's `detail` (e.g. "Budget has been exceeded!") instead of a bare status code. - const errData = await res.json().catch(() => ({})); - throw new Error( - errData.detail || `Failed to start run (${res.status})`, - ); - } - const data = await res.json(); - lastWorkflowIdRef.current = (data.workflow_id as string | null) ?? null; } catch (e) { lastWorkflowIdRef.current = null; setPendingRun(false); @@ -279,21 +226,12 @@ export function TaskRunnerPage({ if (!resolvedTaskId) return; // Cancel by the run's workflow_id (the route that exists) — there is no // per-instance cancel route. Read it off the instance's context.metadata. - const inst = instancesRef.current.find( - i => String(i.instance_id) === instanceId, + const workflowId = workflowIdOf( + instancesRef.current.find(i => String(i.instance_id) === instanceId), ); - const context = inst?.context as Record | undefined; - const metadata = context?.metadata as Record | undefined; - const workflowId = metadata?.workflow_id as string | undefined; if (!workflowId) return; try { - const res = await apiFetch( - `${BACKEND_URL}/api/v1/tasks/${encodeURIComponent( - resolvedTaskId, - )}/cancel-run?workflow_id=${encodeURIComponent(workflowId)}`, - { method: 'POST' }, - ); - if (!res.ok) throw new Error(`Cancel failed (${res.status})`); + await cancelRun(apiFetch, BACKEND_URL, resolvedTaskId, workflowId); // Mark cancelled only after the backend confirms — no false success. setInstances(prev => prev.map(i => @@ -314,13 +252,11 @@ export function TaskRunnerPage({ if (!resolvedTaskId || !lastWorkflowIdRef.current) return; setPendingRun(false); try { - await apiFetch( - `${BACKEND_URL}/api/v1/tasks/${encodeURIComponent( - resolvedTaskId, - )}/cancel-run?workflow_id=${encodeURIComponent( - lastWorkflowIdRef.current, - )}`, - { method: 'POST' }, + await cancelRun( + apiFetch, + BACKEND_URL, + resolvedTaskId, + lastWorkflowIdRef.current, ); } catch { /* best-effort */ diff --git a/src/agent_env/explorer/ui/src/lib/task-runner-run.smoke.ts b/src/agent_env/explorer/ui/src/lib/task-runner-run.smoke.ts new file mode 100644 index 00000000..7ddfd786 --- /dev/null +++ b/src/agent_env/explorer/ui/src/lib/task-runner-run.smoke.ts @@ -0,0 +1,136 @@ +/** + * Smoke test for the task runner's run lifecycle: a run starts, appears in the instance list, + * and a refused launch surfaces the backend's reason instead of leaving the page on "Starting…". + * + * Runner: plain TS, throws on assertion failure. From this package: + * npx tsx src/lib/task-runner-run.smoke.ts + */ +import { + allTerminal, + buildRunBody, + cancelRun, + reconcileInstances, + shouldPoll, + startRun, + workflowIdOf, + type Fetch, +} from './task-runner-run'; + +function assert(cond: unknown, msg: string): void { + if (!cond) { + console.error(`✗ ${msg}`); + throw new Error(msg); + } + console.log(`✓ ${msg}`); +} + +interface Call { + url: string; + init?: RequestInit; +} + +function fakeFetch(status: number, body: unknown): { fetch: Fetch; calls: Call[] } { + const calls: Call[] = []; + const fetch: Fetch = async (url, init) => { + calls.push({ url, init }); + const text = typeof body === 'string' ? body : JSON.stringify(body); + return new Response(text, { status }); + }; + return { fetch, calls }; +} + +function onlyCall(calls: Call[]): Call { + const [call, ...rest] = calls; + if (!call || rest.length) throw new Error(`expected one request, got ${calls.length}`); + return call; +} + +async function rejects(p: Promise): Promise { + try { + await p; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + return '(resolved)'; +} + +async function main(): Promise { + const TASK = '@local/agentenv-framework/hello/hello'; + + // The request a run sends. + const body = buildRunBody({ start_step: 2, context_from_instance_id: 'i-1', context_json: { x: 1 } }, 3, ''); + assert(body.version === 3 && body.priority === 0, 'run body: task version, interactive priority'); + assert(body.start_step === 2, 'run body: start step'); + assert(body.context_from_instance_id === 'i-1' && !('context_json' in body), 'run body: instance context wins over inline context'); + assert(!('project_id' in body), 'run body: an empty projectId is not sent'); + assert(buildRunBody({ version: 5 }, 3, 'p-9').version === 5, 'run body: an explicit version wins'); + assert(buildRunBody(undefined, null, 'p-9').project_id === 'p-9', 'run body: projectId is forwarded'); + + // A run starts. + const ok = fakeFetch(200, { workflow_id: 'local-abc', instance_id: 'i-2', status: 'QUEUED' }); + const workflowId = await startRun(ok.fetch, 'http://h', TASK, body); + const startCall = onlyCall(ok.calls); + assert(workflowId === 'local-abc', 'start: resolves to the workflow id'); + assert( + startCall.url === 'http://h/api/v1/tasks/%40local%2Fagentenv-framework%2Fhello%2Fhello/run', + 'start: POSTs to /run with the task id as one encoded segment', + ); + assert(startCall.init?.method === 'POST', 'start: method is POST'); + assert(JSON.parse(String(startCall.init?.body)).start_step === 2, 'start: sends the run body'); + + // A refused launch. + const refused = fakeFetch(402, { detail: 'Budget has been exceeded!' }); + assert( + (await rejects(startRun(refused.fetch, '', TASK, body))) === 'Budget has been exceeded!', + "failed launch: throws the backend's detail", + ); + const broken = fakeFetch(500, 'oops'); + assert( + (await rejects(startRun(broken.fetch, '', TASK, body))) === 'Failed to start run (500)', + 'failed launch: a body without detail falls back to the status', + ); + + // The started run appears in the list. + const before = [{ instance_id: 'i-1', status: 'completed', current_step: 3, context: { a: 1 } }]; + const first = reconcileInstances(before, '', new Set(), false); + assert(first !== null && first.startedInstanceId === null, 'poll: a first page selects nothing when no run is pending'); + assert(first?.latestCompleted?.instance_id === 'i-1', 'poll: the newest completed run seeds a re-run'); + assert(reconcileInstances(before, first!.snapshot, first!.ids, true) === null, 'poll: an unchanged page is ignored'); + const after = [{ instance_id: 'i-2', status: 'running', current_step: 0 }, ...before]; + const appeared = reconcileInstances(after, first!.snapshot, first!.ids, true); + assert(appeared?.startedInstanceId === 'i-2', 'poll: the pending run is picked up once it appears'); + assert(appeared?.ids.has('i-2') && appeared.ids.has('i-1'), 'poll: known ids include the new run'); + const progressed = reconcileInstances( + [{ instance_id: 'i-2', status: 'running', current_step: 1 }, ...before], appeared!.snapshot, appeared!.ids, false); + assert(progressed !== null && progressed.startedInstanceId === null, 'poll: step progress updates without re-selecting'); + + // When polling stops. + const done = allTerminal(before); + assert(done && !allTerminal([]) && !allTerminal(after), 'terminal: only a non-empty, all-finished list is terminal'); + assert(shouldPoll(TASK, 'idle', done, true), 'polling continues while a started run has not appeared'); + assert(!shouldPoll(TASK, 'idle', done, false), 'polling stops once every run is finished'); + assert(shouldPoll(TASK, 'idle', allTerminal(after), false), 'polling continues while a run is live'); + assert(shouldPoll(TASK, 'idle', allTerminal([]), false), 'polling continues before any run exists'); + for (const phase of ['initializing', 'error', 'failed']) + assert(!shouldPoll(TASK, phase, false, true), `polling stops in phase ${phase}`); + assert(!shouldPoll(null, 'idle', false, true), 'polling needs a task id'); + + // Cancel. + assert(workflowIdOf({ context: { metadata: { workflow_id: 'local-abc' } } }) === 'local-abc', 'cancel: reads the workflow id off the run'); + assert(workflowIdOf(undefined) === undefined, 'cancel: no run, no workflow id'); + const cancelled = fakeFetch(200, { canceled: true }); + await cancelRun(cancelled.fetch, '', TASK, 'local-a/b'); + assert( + onlyCall(cancelled.calls).url === '/api/v1/tasks/%40local%2Fagentenv-framework%2Fhello%2Fhello/cancel-run?workflow_id=local-a%2Fb', + 'cancel: POSTs cancel-run with both ids encoded', + ); + assert( + (await rejects(cancelRun(fakeFetch(404, {}).fetch, '', TASK, 'x'))) === 'Cancel failed (404)', + 'cancel: a refused cancel throws', + ); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/agent_env/explorer/ui/src/lib/task-runner-run.ts b/src/agent_env/explorer/ui/src/lib/task-runner-run.ts new file mode 100644 index 00000000..d2306e33 --- /dev/null +++ b/src/agent_env/explorer/ui/src/lib/task-runner-run.ts @@ -0,0 +1,137 @@ +/** The task runner's run lifecycle without React: the request a run sends, how a failed launch + * surfaces, how a poll of the task's instances is reconciled, and when polling may stop. */ + +export type Instance = Record; +export type Fetch = (input: string, init?: RequestInit) => Promise; + +export interface RunOptions { + version?: number; + start_step?: number; + context_json?: Record | null; + // Prefer over context_json when re-running one of this task's instances: + // `instances` comes from GET /instances, which strips a2a_card. + context_from_instance_id?: string; + // From-start re-run: inherit only the prior run's user_overrides. + overrides_from_instance_id?: string; +} + +const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled']); + +export function isTerminal(inst: Instance): boolean { + return TERMINAL_STATUSES.has(String(inst.status ?? '')); +} + +export function allTerminal(instances: Instance[]): boolean { + return instances.length > 0 && instances.every(isTerminal); +} + +function taskUrl(base: string, taskId: string): string { + return `${base}/api/v1/tasks/${encodeURIComponent(taskId)}`; +} + +export function buildRunBody( + opts: RunOptions | undefined, + taskVersion: number | null, + projectId: string | null, +): Record { + const body: Record = {}; + body.version = opts?.version ?? taskVersion ?? undefined; + // priority=0 (interactive): a human is waiting. Sent explicitly so intent survives backend default changes. + body.priority = 0; + if (opts?.start_step != null) body.start_step = opts.start_step; + // Mutually exclusive server-side. + if (opts?.context_from_instance_id) + body.context_from_instance_id = opts.context_from_instance_id; + else if (opts?.context_json) body.context_json = opts.context_json; + if (opts?.overrides_from_instance_id) + body.overrides_from_instance_id = opts.overrides_from_instance_id; + // Only-if-truthy so "" can't defeat the backend fallback. + if (projectId) body.project_id = projectId; + return body; +} + +/** POST /run. Resolves to the run's workflow id; a refused launch throws the backend's + * `detail` (e.g. "Budget has been exceeded!") rather than a bare status code. */ +export async function startRun( + fetchFn: Fetch, + base: string, + taskId: string, + body: Record, +): Promise { + const res = await fetchFn(`${taskUrl(base, taskId)}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + throw new Error(errData.detail || `Failed to start run (${res.status})`); + } + const data = await res.json(); + return (data.workflow_id as string | null) ?? null; +} + +/** Cancel by the run's workflow id, the only cancel route there is. */ +export async function cancelRun( + fetchFn: Fetch, + base: string, + taskId: string, + workflowId: string, +): Promise { + const res = await fetchFn( + `${taskUrl(base, taskId)}/cancel-run?workflow_id=${encodeURIComponent(workflowId)}`, + { method: 'POST' }, + ); + if (!res.ok) throw new Error(`Cancel failed (${res.status})`); +} + +export function workflowIdOf(inst: Instance | undefined): string | undefined { + const context = inst?.context as Instance | undefined; + const metadata = context?.metadata as Instance | undefined; + return metadata?.workflow_id as string | undefined; +} + +export interface InstancePoll { + snapshot: string; + ids: Set; + /** The run the page was waiting on, once it appears; clears "Starting…". */ + startedInstanceId: string | null; + /** The newest completed instance, whose context seeds a re-run. */ + latestCompleted: Instance | null; +} + +/** Reconcile one GET /instances page against the last one; null when nothing shown changed. */ +export function reconcileInstances( + items: Instance[], + previousSnapshot: string, + knownIds: Set, + pendingRun: boolean, +): InstancePoll | null { + const snapshot = JSON.stringify( + items.map(i => `${i.instance_id}:${i.status}:${i.current_step}`), + ); + if (snapshot === previousSnapshot) return null; + const started = pendingRun + ? items.find(i => !knownIds.has(String(i.instance_id))) + : undefined; + return { + snapshot, + ids: new Set(items.map(i => String(i.instance_id))), + startedInstanceId: started ? String(started.instance_id) : null, + latestCompleted: items.find(i => i.status === 'completed') ?? null, + }; +} + +/** Poll while a run is live or a started run has not appeared yet. 'error'/'failed' are + * terminal: a failed init can leave a task id with no instances, so the instance-derived + * stop would never trip. */ +export function shouldPoll( + taskId: string | null, + phase: string, + instancesAllTerminal: boolean, + pendingRun: boolean, +): boolean { + if (!taskId || phase === 'initializing' || phase === 'error' || phase === 'failed') + return false; + return !(instancesAllTerminal && !pendingRun); +}