diff --git a/packages/web/src/api/queries.ts b/packages/web/src/api/queries.ts index d46185b..975e341 100644 --- a/packages/web/src/api/queries.ts +++ b/packages/web/src/api/queries.ts @@ -85,6 +85,22 @@ export function useScheduleDream(workspaceId: string) { }); } +import type { components } from "./schema.d.ts"; + +type QueueStatusBody = components["schemas"]["QueueStatus"]; + +// Poll faster while work is in flight so users can watch dreams/representations +// progress; back off to a slow heartbeat when idle. The TanStack Query callback +// form re-reads the cached value each cycle, so the interval adapts on its own. +export const QUEUE_REFETCH_ACTIVE_MS = 2500; +export const QUEUE_REFETCH_IDLE_MS = 10_000; + +export function pickQueueRefetchInterval(data: QueueStatusBody | undefined): number { + if (!data) return QUEUE_REFETCH_IDLE_MS; + const active = (data.in_progress_work_units ?? 0) + (data.pending_work_units ?? 0); + return active > 0 ? QUEUE_REFETCH_ACTIVE_MS : QUEUE_REFETCH_IDLE_MS; +} + export function useQueueStatus(workspaceId: string) { return useQuery({ queryKey: QK.queueStatus(workspaceId), @@ -96,7 +112,7 @@ export function useQueueStatus(workspaceId: string) { return data ?? err(error); }, enabled: Boolean(workspaceId), - refetchInterval: 10_000, + refetchInterval: (query) => pickQueueRefetchInterval(query.state.data), }); } diff --git a/packages/web/src/components/dashboard/Dashboard.tsx b/packages/web/src/components/dashboard/Dashboard.tsx index 6caac67..dfafe7d 100644 --- a/packages/web/src/components/dashboard/Dashboard.tsx +++ b/packages/web/src/components/dashboard/Dashboard.tsx @@ -209,7 +209,7 @@ export function Dashboard() { Queue Status - all workspaces · updates every 10s + all workspaces · live polling diff --git a/packages/web/src/components/workspaces/DreamProgressPanel.tsx b/packages/web/src/components/workspaces/DreamProgressPanel.tsx new file mode 100644 index 0000000..36712f4 --- /dev/null +++ b/packages/web/src/components/workspaces/DreamProgressPanel.tsx @@ -0,0 +1,329 @@ +import { Link } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { Activity, AlertTriangle, CircleDot, Info, Sparkles, TimerReset } from "lucide-react"; +import { QUEUE_REFETCH_ACTIVE_MS, QUEUE_REFETCH_IDLE_MS } from "@/api/queries"; +import type { components } from "@/api/schema.d.ts"; +import { Skeleton } from "@/components/shared/Skeleton"; +import { Body, Caption, Muted, SectionHeading } from "@/components/ui/typography"; +import { useDemo } from "@/hooks/useDemo"; +import { type StaleQueueState, useStaleQueueDetection } from "@/hooks/useStaleQueueDetection"; +import { COLOR } from "@/lib/constants"; +import { formatCount } from "@/lib/utils"; + +type QueueStatus = components["schemas"]["QueueStatus"]; + +export interface DreamProgressPanelProps { + workspaceId: string; + data: QueueStatus | undefined; + isLoading: boolean; + error: Error | null; + /** Override the stale-detection hook output (used by the dev showcase). */ + staleOverride?: StaleQueueState; +} + +function formatElapsed(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +export function DreamProgressPanel({ + workspaceId, + data, + isLoading, + error, + staleOverride, +}: DreamProgressPanelProps) { + const { mask } = useDemo(); + const detected = useStaleQueueDetection(data); + const stale = staleOverride ?? detected; + + const inProgress = data?.in_progress_work_units ?? 0; + const pending = data?.pending_work_units ?? 0; + const completed = data?.completed_work_units ?? 0; + const total = data?.total_work_units ?? 0; + const active = inProgress + pending; + const isActive = active > 0; + + const pollSeconds = isActive + ? Math.round(QUEUE_REFETCH_ACTIVE_MS / 100) / 10 + : Math.round(QUEUE_REFETCH_IDLE_MS / 100) / 10; + + if (error) { + return ( + + + Could not load queue status + + {error.message} + + ); + } + + return ( + + {/* Header */} +
+
+ + Dreams in progress +
+
+ {isActive ? ( + + + + ) : ( + + )} + + {isActive ? `${formatCount(active)} active` : "Idle"} + + + · + + + polling every {pollSeconds}s + +
+
+ + {/* Stale warning */} + {stale.isStale && stale.stalledSince !== null && ( + +
+ +
+ + Stalled for {formatElapsed(stale.elapsedMs)} without forward progress + + + Work has been in-flight since{" "} + {new Date(stale.stalledSince).toLocaleTimeString()} with no advance in the + completed count. A specialist may be hung — check Honcho logs. + +
+
+
+ )} + + {/* Counts */} +
+
+ {( + [ + { label: "Total", value: total, color: "var(--text-1)" }, + { label: "Done", value: completed, color: COLOR.success }, + { label: "In progress", value: inProgress, color: COLOR.warning }, + { label: "Pending", value: pending, color: "var(--text-3)" }, + ] as const + ).map(({ label, value, color }) => ( +
+ {isLoading ? ( + + ) : ( +
+ {formatCount(value)} +
+ )} + + {label} + +
+ ))} +
+ + {/* API limitation note */} +
+ + + Honcho's /queue/status exposes aggregate counts + only. Per-dream observer/observed pair, specialist phase (deduction vs. induction), + and token telemetry are tracked upstream —{" "} + + file an upstream issue + {" "} + to surface them here. + +
+
+ + +
+ ); +} + +function SessionsTable({ + workspaceId, + sessions, + mask, +}: { + workspaceId: string; + sessions: QueueStatus["sessions"]; + mask: (s: string) => string; +}) { + const entries = sessions ? Object.entries(sessions) : []; + if (entries.length === 0) { + return ( +
+
+ + No session-scoped work tracked right now. +
+
+ ); + } + + return ( +
+
+ + + {entries.length} session{entries.length !== 1 ? "s" : ""} with active work + +
+
+ + + + {["Session", "Total", "Done", "In progress", "Pending"].map((h) => ( + + ))} + + + + {entries.map(([sid, s], i) => ( + 0 ? "1px solid var(--border)" : undefined }} + > + + + + + + + ))} + +
+ {h} +
+ + {mask(sid)} + + + {s.total_work_units} + + {s.completed_work_units} + + {s.in_progress_work_units} + + {s.pending_work_units} +
+
+
+ ); +} diff --git a/packages/web/src/components/workspaces/WorkspaceDetail.tsx b/packages/web/src/components/workspaces/WorkspaceDetail.tsx index a08d0f3..fc50112 100644 --- a/packages/web/src/components/workspaces/WorkspaceDetail.tsx +++ b/packages/web/src/components/workspaces/WorkspaceDetail.tsx @@ -1,6 +1,7 @@ import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { AnimatePresence, motion } from "framer-motion"; import { + Activity, Boxes, ChevronDown, CircleDot, @@ -50,6 +51,12 @@ const NAV_SECTIONS = [ to: "webhooks" as const, description: "Manage event webhooks", }, + { + label: "Queue & dreams", + icon: Activity, + to: "queue" as const, + description: "Live view of in-flight work", + }, ] as const; export function WorkspaceDetail() { @@ -107,7 +114,7 @@ export function WorkspaceDetail() { {!isLoading && workspace && (
{/* Nav cards */} -
+
{NAV_SECTIONS.map((s, i) => { const Icon = s.icon; return ( diff --git a/packages/web/src/hooks/useStaleQueueDetection.ts b/packages/web/src/hooks/useStaleQueueDetection.ts new file mode 100644 index 0000000..67ed31a --- /dev/null +++ b/packages/web/src/hooks/useStaleQueueDetection.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from "react"; +import type { components } from "@/api/schema.d.ts"; + +type QueueStatus = components["schemas"]["QueueStatus"]; + +export const STALE_QUEUE_THRESHOLD_MS = 30 * 60 * 1000; + +export interface StaleQueueState { + stalledSince: number | null; + elapsedMs: number; + isStale: boolean; +} + +/** + * Detects stalled queue work without per-work-unit timestamps from the API. + * + * Anchors when in_progress + pending first goes non-zero and resets the anchor + * whenever completed_work_units advances (forward progress). If the anchor + * lives longer than the threshold, the queue is considered stale. + * + * Note: completed_work_units resets on Honcho's periodic queue cleanup; a drop + * is treated as "no forward progress" rather than regression, so the stall + * clock keeps running until either work finishes or completed advances. + */ +export function useStaleQueueDetection( + data: QueueStatus | undefined, + staleThresholdMs: number = STALE_QUEUE_THRESHOLD_MS, +): StaleQueueState { + const [stalledSince, setStalledSince] = useState(null); + const [lastCompleted, setLastCompleted] = useState(0); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 10_000); + return () => clearInterval(id); + }, []); + + useEffect(() => { + if (!data) return; + const active = (data.in_progress_work_units ?? 0) + (data.pending_work_units ?? 0); + const completed = data.completed_work_units ?? 0; + if (active === 0) { + setStalledSince(null); + setLastCompleted(completed); + return; + } + if (completed > lastCompleted) { + setStalledSince(Date.now()); + setLastCompleted(completed); + return; + } + if (stalledSince === null) { + setStalledSince(Date.now()); + setLastCompleted(completed); + } + }, [data, lastCompleted, stalledSince]); + + const elapsedMs = stalledSince !== null ? Math.max(0, now - stalledSince) : 0; + const isStale = stalledSince !== null && elapsedMs > staleThresholdMs; + return { stalledSince, elapsedMs, isStale }; +} diff --git a/packages/web/src/routes/_dev.dream-progress.tsx b/packages/web/src/routes/_dev.dream-progress.tsx new file mode 100644 index 0000000..4a8c0e5 --- /dev/null +++ b/packages/web/src/routes/_dev.dream-progress.tsx @@ -0,0 +1,129 @@ +import { createFileRoute, Navigate } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { Body, PageTitle } from "@/components/ui/typography"; +import { DreamProgressPanel } from "@/components/workspaces/DreamProgressPanel"; +import type { StaleQueueState } from "@/hooks/useStaleQueueDetection"; + +export const Route = createFileRoute("/_dev/dream-progress")({ + component: DreamProgressShowcase, +}); + +const WORKSPACE_ID = "ws_benchmark_alpha"; + +const IDLE = { + total_work_units: 142, + completed_work_units: 142, + in_progress_work_units: 0, + pending_work_units: 0, + sessions: null, +}; + +const ACTIVE = { + total_work_units: 64, + completed_work_units: 38, + in_progress_work_units: 4, + pending_work_units: 22, + sessions: { + sess_2024_q4_eval_run_07: { + total_work_units: 28, + completed_work_units: 17, + in_progress_work_units: 2, + pending_work_units: 9, + }, + sess_2024_q4_eval_run_08: { + total_work_units: 24, + completed_work_units: 14, + in_progress_work_units: 1, + pending_work_units: 9, + }, + sess_diagnostics_cold_start: { + total_work_units: 12, + completed_work_units: 7, + in_progress_work_units: 1, + pending_work_units: 4, + }, + }, +}; + +const STALE = { + total_work_units: 18, + completed_work_units: 11, + in_progress_work_units: 1, + pending_work_units: 6, + sessions: { + sess_induction_specialist_test: { + total_work_units: 18, + completed_work_units: 11, + in_progress_work_units: 1, + pending_work_units: 6, + }, + }, +}; + +const STALE_OVERRIDE: StaleQueueState = { + stalledSince: Date.now() - 35 * 60 * 1000, + elapsedMs: 35 * 60 * 1000, + isStale: true, +}; + +function DreamProgressShowcase() { + if (!import.meta.env.DEV) { + return ; + } + return ( +
+ + Dream Progress — showcase + + Three states rendered with mock data. DEV-only; used for documentation screenshots. + + + +
+

+ Idle +

+ +
+ +
+

+ Active +

+ +
+ +
+

+ Stalled (>30m) +

+ +
+
+ ); +} diff --git a/packages/web/src/routes/workspaces_.$workspaceId_.queue.tsx b/packages/web/src/routes/workspaces_.$workspaceId_.queue.tsx new file mode 100644 index 0000000..721f086 --- /dev/null +++ b/packages/web/src/routes/workspaces_.$workspaceId_.queue.tsx @@ -0,0 +1,44 @@ +import { createFileRoute, useParams } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { Activity } from "lucide-react"; +import { useQueueStatus } from "@/api/queries"; +import { Breadcrumb } from "@/components/layout/Breadcrumb"; +import { Body, PageTitle } from "@/components/ui/typography"; +import { DreamProgressPanel } from "@/components/workspaces/DreamProgressPanel"; + +export const Route = createFileRoute("/workspaces_/$workspaceId_/queue")({ + component: WorkspaceQueuePage, +}); + +function WorkspaceQueuePage() { + const { workspaceId } = useParams({ strict: false }) as { workspaceId: string }; + const { data, isLoading, error } = useQueueStatus(workspaceId); + + return ( +
+ + +
+ + Queue & dreams +
+ + Live view of in-flight dream, representation, and summary work + +
+ +
+ +
+
+ ); +}