From 6d9dbeb52d76953de1b5e0f93cce3d9d22fdb746 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 10:35:25 +0000 Subject: [PATCH 1/5] feat: add dashboard automation controls --- dashboard/build.shared.mjs | 1 + dashboard/graph/src/OverviewPanel.tsx | 27 +- dashboard/hermes-wrapper/src/wrapper.css | 4 + dashboard/holographic/src/CurationPanel.tsx | 989 ++++-------------- .../holographic/src/HolographicMemoryPage.tsx | 73 +- dashboard/holographic/src/api.ts | 183 +++- .../src/curation/ActionReviewGroup.tsx | 268 +++++ .../src/curation/ActivityScroller.tsx | 126 +++ .../src/curation/AutomationConfigSection.tsx | 327 ++++++ .../src/curation/AutomationRunsSection.tsx | 145 +++ .../src/curation/AutomationTaskConfigRow.tsx | 110 ++ .../src/curation/CurationHistoryPanel.tsx | 299 ++++++ .../src/curation/CurrentPreviewSection.tsx | 85 ++ .../src/curation/FactProposalsSection.tsx | 152 +++ .../src/curation/InlineConfirm.tsx | 104 ++ .../src/curation/ManagedSkillsSection.tsx | 298 ++++++ .../src/curation/MemoryOperationsSection.tsx | 43 + .../holographic/src/curation/MetadataRow.tsx | 20 + .../src/curation/RunHistorySection.tsx | 35 + .../src/curation/SchedulerStatusSection.tsx | 74 ++ .../src/curation/SnapshotsSection.tsx | 29 + .../src/curation/automationTasks.ts | 59 ++ .../holographic/src/curation/configTypes.ts | 7 + dashboard/holographic/src/curation/errors.ts | 3 + dashboard/holographic/src/curation/format.ts | 23 + .../holographic/src/curation/historyFormat.ts | 98 ++ .../src/curation/useAutomationConfig.ts | 251 +++++ .../src/curation/useAutomationRuns.ts | 166 +++ .../src/curation/useCurationData.ts | 223 +++- .../src/curation/useFactProposals.ts | 78 ++ .../src/curation/useManagedSkills.ts | 150 +++ dashboard/holographic/src/styles.css | 26 +- dashboard/holographic/src/types.ts | 384 ++++++- dashboard/lib/primitives.css | 18 +- dashboard/lib/primitives.tsx | 14 +- dashboard/savings/src/DiagnosticsPanel.tsx | 184 ++++ dashboard/savings/src/SavingsExplorer.tsx | 10 + dashboard/savings/src/api.ts | 4 + dashboard/savings/src/styles.css | 22 - dashboard/savings/src/types.ts | 49 + dashboard/shell/src/sdk.jsx | 7 +- dashboard/shell/src/styles.css | 6 + dashboard/test/curation-data.vitest.tsx | 763 +++++++++++++- dashboard/test/curation-panel.vitest.tsx | 721 ++++++++++++- dashboard/test/shell-sdk.test.mjs | 15 +- 45 files changed, 5731 insertions(+), 942 deletions(-) create mode 100644 dashboard/holographic/src/curation/ActionReviewGroup.tsx create mode 100644 dashboard/holographic/src/curation/ActivityScroller.tsx create mode 100644 dashboard/holographic/src/curation/AutomationConfigSection.tsx create mode 100644 dashboard/holographic/src/curation/AutomationRunsSection.tsx create mode 100644 dashboard/holographic/src/curation/AutomationTaskConfigRow.tsx create mode 100644 dashboard/holographic/src/curation/CurationHistoryPanel.tsx create mode 100644 dashboard/holographic/src/curation/CurrentPreviewSection.tsx create mode 100644 dashboard/holographic/src/curation/FactProposalsSection.tsx create mode 100644 dashboard/holographic/src/curation/InlineConfirm.tsx create mode 100644 dashboard/holographic/src/curation/ManagedSkillsSection.tsx create mode 100644 dashboard/holographic/src/curation/MemoryOperationsSection.tsx create mode 100644 dashboard/holographic/src/curation/MetadataRow.tsx create mode 100644 dashboard/holographic/src/curation/RunHistorySection.tsx create mode 100644 dashboard/holographic/src/curation/SchedulerStatusSection.tsx create mode 100644 dashboard/holographic/src/curation/SnapshotsSection.tsx create mode 100644 dashboard/holographic/src/curation/automationTasks.ts create mode 100644 dashboard/holographic/src/curation/configTypes.ts create mode 100644 dashboard/holographic/src/curation/errors.ts create mode 100644 dashboard/holographic/src/curation/historyFormat.ts create mode 100644 dashboard/holographic/src/curation/useAutomationConfig.ts create mode 100644 dashboard/holographic/src/curation/useAutomationRuns.ts create mode 100644 dashboard/holographic/src/curation/useFactProposals.ts create mode 100644 dashboard/holographic/src/curation/useManagedSkills.ts create mode 100644 dashboard/savings/src/DiagnosticsPanel.tsx diff --git a/dashboard/build.shared.mjs b/dashboard/build.shared.mjs index ec895f70..7112975f 100644 --- a/dashboard/build.shared.mjs +++ b/dashboard/build.shared.mjs @@ -237,6 +237,7 @@ export async function buildPlugin( export async function buildHolographicPlugin() { await buildPlugin("holographic", "holographic-memory", { tailwind: true, + primitives: true, }); } diff --git a/dashboard/graph/src/OverviewPanel.tsx b/dashboard/graph/src/OverviewPanel.tsx index 1fed4e48..f250ac5d 100644 --- a/dashboard/graph/src/OverviewPanel.tsx +++ b/dashboard/graph/src/OverviewPanel.tsx @@ -16,16 +16,23 @@ import { } from "./types"; import type { GraphNode, GraphOverview } from "./types"; +// Language → design token (with the historical dark hex as fallback), mirroring +// KIND_FAMILY_COLORS in ./types so the bar swatches re-theme with the shell's +// light palette instead of pinning dark-only hex. Each language rides a +// matching --ts-* accent token (javascript shares --ts-amber by hue family). const LANGUAGE_COLORS: Record = { - rust: "#f7c76a", - typescript: "#7aa7ff", - javascript: "#ffd97a", - python: "#67e8a9", - markdown: "#a8c8c0", - json: "#6f9189", - toml: "#6f9189", - shell: "#75f4d2", - web: "#ff7ab6", + rust: "var(--ts-amber, #f7c76a)", + typescript: "var(--ts-blue, #7aa7ff)", + // Reuse the warm amber token (same hue family as the historical JS yellow) + // with the original dark hex as fallback, so the swatch re-themes in light + // mode instead of pinning a dark-only literal. See ./types KIND_FAMILY_COLORS. + javascript: "var(--ts-amber, #ffd97a)", + python: "var(--ts-green, #67e8a9)", + markdown: "var(--ts-text-2, #a8c8c0)", + json: "var(--ts-text-3, #6f9189)", + toml: "var(--ts-text-3, #6f9189)", + shell: "var(--ts-cyan, #75f4d2)", + web: "var(--ts-pink, #ff7ab6)", }; export default function OverviewPanel({ @@ -84,7 +91,7 @@ export default function OverviewPanel({ keyName="label" rows={overview.files_by_language.slice(0, 9).map((row) => ({ label: row.language, - color: LANGUAGE_COLORS[row.language] || "#6f9189", + color: LANGUAGE_COLORS[row.language] || "var(--ts-text-3, #6f9189)", value: fmt(row.count), }))} onPick={(row) => onFilterLanguage(String(row.label))} diff --git a/dashboard/hermes-wrapper/src/wrapper.css b/dashboard/hermes-wrapper/src/wrapper.css index 32fc675e..fb96d3e8 100644 --- a/dashboard/hermes-wrapper/src/wrapper.css +++ b/dashboard/hermes-wrapper/src/wrapper.css @@ -127,6 +127,10 @@ --color-muted: rgba(117, 244, 210, 0.1); --color-muted-foreground: var(--ts-text-3); --color-secondary: rgba(122, 167, 255, 0.1); + /* Gotcha: --color-secondary is a 10%-opacity blue TINT, not a readable text + color, so the Tailwind `text-secondary` utility is near-invisible as + foreground. Use `text-text-secondary` (maps to --ts-text-2) for copy; + reserve `bg-secondary`/`border-secondary` for surfaces. */ --color-destructive: var(--ts-red); --color-warning: var(--ts-amber); --color-success: var(--ts-green); diff --git a/dashboard/holographic/src/CurationPanel.tsx b/dashboard/holographic/src/CurationPanel.tsx index dc9b0876..48701527 100644 --- a/dashboard/holographic/src/CurationPanel.tsx +++ b/dashboard/holographic/src/CurationPanel.tsx @@ -1,44 +1,26 @@ import { - type Key, - type ReactNode, - type RefObject, - useEffect, - useId, - useRef, - useState, -} from "react"; -import { createPortal } from "react-dom"; -import { - ChevronDown, - ChevronRight, History, ListChecks, ScrollText, Wand2, } from "lucide-react"; -import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "./sdk"; +import { Button, Card, CardContent, CardHeader, CardTitle } from "./sdk"; import { Spinner } from "./Spinner"; import { - describe, - diffTags, + countLabel, formatHistoryTime, - formatOplogTime, - isBookkeepingTag, - splitTags, } from "./curation/format"; +import { groupActions } from "./curation/risk"; +import { ActivityScroller } from "./curation/ActivityScroller"; +import { ActionReviewGroup } from "./curation/ActionReviewGroup"; import { - actionRisk, - groupActions, - riskClass, - type ActionGroupDef, - type ActionRisk, -} from "./curation/risk"; -import { useCurationData, type CurationTab } from "./curation/useCurationData"; -import type { - MemoryCurateAction, - MemoryCuratorActivityEvent, - MemoryOplogEvent, -} from "./types"; + useCurationData, + type CurationTab, +} from "./curation/useCurationData"; +import { CurationHistoryPanel } from "./curation/CurationHistoryPanel"; +import { InlineConfirm } from "./curation/InlineConfirm"; +import { isActiveAutomationStatus, type AutomationRunTask } from "./curation/automationTasks"; +import type { SecondsField, TaskField } from "./curation/configTypes"; const DIAGNOSTIC_COUNT_KEYS = new Set([ "contradictions_detected", @@ -50,552 +32,6 @@ const DIAGNOSTIC_COUNT_KEYS = new Set([ "related_clusters", ]); -const COUNT_LABELS: Record = { - delete: "delete", - entity_merge: "entity merges", - entity_classify: "entity classifications", - entity_prune: "junk entities pruned", - junk_entities_pruned: "junk entities pruned", - merge: "fact merges", - orphan_entities: "orphan entities", - orphan_entities_pruned: "orphan entities pruned", - recategorize: "recategorize", - reflect: "reflections", - retag: "retag", -}; - -function countLabel(key: string): string { - return COUNT_LABELS[key] ?? key; -} - -function formatCounts(counts: Array<[string, number]>): string { - if (!counts.length) return "no changes"; - return counts.map(([key, value]) => `${countLabel(key)}=${value}`).join(", "); -} - -function MetadataRow({ - label, - value, -}: { - label: string; - value: ReactNode; -}) { - return ( -
- - {label} - - - {value} - -
- ); -} - -function metadataValue(value: unknown, fallback = "auto"): ReactNode { - if (value === null || value === undefined || value === "") return fallback; - if (typeof value === "boolean") return value ? "yes" : "no"; - return String(value); -} - -function activityTone(level?: string) { - switch ((level || "info").toLowerCase()) { - case "success": - return "text-success"; - case "warning": - return "text-warning"; - case "error": - return "text-destructive"; - default: - return "text-text-secondary"; - } -} - -function formatActivityTime(ts: string): string { - const d = new Date(ts); - if (Number.isNaN(d.getTime())) return "--:--:--"; - try { - return d.toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - } catch { - return "--:--:--"; - } -} - -function activityStatus(events: MemoryCuratorActivityEvent[], loading: boolean): string { - const latest = events[events.length - 1]; - if (!latest) return "idle"; - if (latest.phase === "stale") return "stale"; - if (loading) return "live"; - if (latest.phase === "finish") return "complete"; - if (latest.phase === "lock") return "skipped"; - return "recent"; -} - -function activityStatusClass(status: string): string { - switch (status) { - case "live": - return "border-success/30 bg-success/10 text-success"; - case "stale": - return "border-warning/30 bg-warning/10 text-warning"; - case "complete": - return "border-primary/30 bg-primary/10 text-primary"; - default: - return "border-border bg-muted/30 text-text-tertiary"; - } -} - -/** - * One consistent localized timestamp for every curation history surface - * (plan "saved" chip, history rows, preview metadata). Falls back to the raw - * value when it is not a parseable date. - */ -/** Compact one-line summary of an oplog row's detail payload. */ -function oplogDetailSummary(event: MemoryOplogEvent): string { - const detail = event.detail ?? {}; - const parts = Object.entries(detail) - .filter(([, value]) => value !== null && value !== undefined && value !== "") - .slice(0, 4) - .map(([key, value]) => `${key}=${String(value)}`); - return parts.join(" · "); -} - -function ActivityScroller({ - events, - loading, - error, - scrollRef, -}: { - events: MemoryCuratorActivityEvent[]; - loading: boolean; - error: string; - scrollRef: RefObject; -}) { - const status = activityStatus(events, loading); - const stale = status === "stale"; - return ( -
-
-
- - Live Activity -
-
- {loading && !stale ? : null} - - {status} - - {events.length} events -
-
- {error ? ( -
- {error} -
- ) : null} - {stale ? ( -
- The last curator run stopped reporting activity. Refresh or start a new preview to resume from a fresh run. -
- ) : null} -
- {events.length === 0 ? ( -
- Start a preview or apply run to watch curator activity here. -
- ) : ( -
- {events.map((event, index) => ( -
- {formatActivityTime(event.ts)} - - {event.phase} - - - {event.message} - -
- ))} -
- )} -
-
- ); -} - -function TagBucket({ - label, - tags, - tone, -}: { - label: string; - tags: string[]; - tone: "neutral" | "removed" | "added"; -}) { - if (!tags.length) return null; - const chipClass = - tone === "removed" - ? "border-destructive/30 bg-destructive/10 text-destructive/90 line-through" - : tone === "added" - ? "border-success/30 bg-success/10 text-success" - : "border-border bg-secondary/60 text-text-secondary"; - return ( -
-
- {label} -
-
- {tags.map((tag) => ( - - {tag} - - ))} -
-
- ); -} - -function ActionRow({ action }: { action: MemoryCurateAction; key?: Key }) { - const content = action.content ?? ""; - const [expanded, setExpanded] = useState(false); - const risk = actionRisk(action.op); - const isRetag = action.op === "retag"; - const isDelete = action.op === "delete"; - const isEntityMerge = action.op === "entity_merge"; - const isEntityPrune = action.op === "entity_prune"; - const isEntityClassify = action.op === "entity_classify"; - const isReflect = action.op === "reflect"; - const { oldTags, newTags, kept, removed, added } = isRetag - ? diffTags(action.old_tags, action.tags) - : { oldTags: [], newTags: [], kept: [], removed: [], added: [] }; - const tagsOnlyReordered = isRetag && removed.length === 0 && added.length === 0; - const normalizationOnly = - isRetag && - removed.length > 0 && - added.length === 0 && - removed.every(isBookkeepingTag); - - return ( -
- {/* Header row: badge + operation + tier */} -
- {action.op} - - {risk} - -
-
{describe(action)}
-
- {action.tier && ( - - {action.tier} - - )} -
- - {/* Memory content — tap to expand the full fact (clamped by default) */} - {content && ( -
-
- Memory -
- -
- )} - - {/* Retag: show kept tags and the delta so normalization does not look destructive. */} - {isRetag && ( -
-
- Tag change - {normalizationOnly && ( - - normalization only - - )} -
- - - - {tagsOnlyReordered && ( - - Tags reordered/normalized — no tags added or removed. - - )} - {oldTags.length > 0 && newTags.length === 0 && ( - - This would leave the memory with no tags. - - )} -
- )} - - {isDelete && ( -
- Will be permanently deleted. This cannot be undone. -
- )} - - {isEntityMerge && ( -
- - Consolidates duplicate entity records and preserves their linked memories - under the surviving entity. - - {action.normalized_identity || action.fact_links_moved != null ? ( - - {action.normalized_identity ? `identity=${action.normalized_identity}` : ""} - {action.normalized_identity && action.fact_links_moved != null ? " · " : ""} - {action.fact_links_moved != null ? `links moved=${action.fact_links_moved}` : ""} - - ) : null} -
- )} - - {isEntityPrune && ( -
- - Removes a low-value, junk, or orphan entity reference without changing - the underlying fact text. - - {action.fact_links_removed != null ? ( - links removed={action.fact_links_removed} - ) : null} -
- )} - - {isEntityClassify && ( -
- - Adds a coarse type label used by the entity list, graph, and - curator filters. The entity links and facts are unchanged. - - - {action.old_entity_type ?? "unknown"} → {action.entity_type} - {action.fact_count != null ? ` · linked facts=${action.fact_count}` : ""} - -
- )} - - {isReflect && ( -
- Creates a new {action.category ?? "general"} fact - {action.supersedes?.length - ? `, supersedes ${action.supersedes.map((s) => `#${s}`).join(", ")}` - : ""} - . -
- )} - - {/* Reason from AI */} - {action.reason && ( -
- {action.reason} -
- )} -
- ); -} - -function ActionGroup({ - group, - defaultOpen, -}: { - group: ActionGroupDef & { actions: MemoryCurateAction[] }; - defaultOpen: boolean; - key?: Key; -}) { - const [open, setOpen] = useState(defaultOpen); - if (group.actions.length === 0) return null; - const riskCounts = group.actions.reduce>( - (acc, action) => { - acc[actionRisk(action.op)] += 1; - return acc; - }, - { low: 0, medium: 0, high: 0, review: 0 }, - ); - - return ( -
- - {open ? ( -
- {group.actions.map((action, i) => ( - - ))} -
- ) : null} -
- ); -} - -/** - * Minimal confirm modal — local replacement for the core SPA's `ConfirmDialog` - * (not exposed on the plugin SDK). Rendered through a React portal to - * `document.body` so the `fixed inset-0` overlay escapes `.ts-card`'s - * containing block (`backdrop-filter`) and `overflow-hidden`/`max-h` clipping, - * and reliably covers the full viewport. - */ -function InlineConfirm({ - open, - title, - description, - children, - confirmLabel, - loading, - onCancel, - onConfirm, -}: { - open: boolean; - title: string; - description?: string; - children?: ReactNode; - confirmLabel: string; - loading?: boolean; - onCancel: () => void; - onConfirm: () => void; -}) { - const titleId = useId(); - const dialogRef = useRef(null); - const previouslyFocused = useRef(null); - - useEffect(() => { - if (!open) return; - previouslyFocused.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null; - const dialog = dialogRef.current; - const focusTarget = - dialog?.querySelector( - "button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex='-1'])", - ) ?? dialog; - focusTarget?.focus(); - return () => { - previouslyFocused.current?.focus?.(); - }; - }, [open]); - - useEffect(() => { - if (!open) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - onCancel(); - } - }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [open, onCancel]); - - if (!open) return null; - if (typeof document === "undefined") return null; - - return createPortal( -
{ - if (e.target === e.currentTarget) onCancel(); - }} - className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" - > -
-
-

- {title} -

- {description && ( -

- {description} -

- )} -
- {children ?
{children}
: null} -
- - -
-
-
, - document.body, - ); -} - export default function CurationPanel({ onApplied, }: { @@ -616,9 +52,42 @@ export default function CurationPanel({ statusError, oplog, oplogError, + automationRuns, + automationRunsError, + automationRunActioning, + automationRunError, + automationRunArtifacts, + automationRunArtifact, + automationRunArtifactLoading, + automationRunArtifactError, + factProposals, + factProposalsLoading, + factProposalsError, + factProposalActioning, + managedSkills, + selectedManagedSkillId, + selectedManagedSkill, + managedSkillUsage, + managedSkillRecommendations, + managedSkillImprovementRecommendations, + managedSkillsLoading, + managedSkillsError, + managedSkillActioning, activity, activityLoading, activityError, + configResponse, + configDraft, + configLoading, + configSaving, + configResetting, + configError, + configFieldErrors, + schedulerStatus, + schedulerStatusLoading, + schedulerStatusError, + schedulerActioning, + configDirty, activityRef, panelRef, setConfirmOpen, @@ -627,6 +96,22 @@ export default function CurationPanel({ apply, loadActivity, loadStatus, + loadOplog, + loadAutomationRuns, + loadSchedulerStatus, + loadAutomationRunArtifact, + loadFactProposals, + loadManagedSkills, + loadManagedSkill, + runAutomationTask, + runFactProposalAction, + runManagedSkillAction, + setSchedulerPaused, + updateConfigDraft, + updateConfigTaskDraft, + resetConfigDraft, + resetConfigToDefaults, + saveConfigDraft, } = useCurationData({ onApplied }); const actions = report?.actions ?? []; @@ -641,11 +126,78 @@ export default function CurationPanel({ ); const actionGroups = groupActions(actions); const nonEmptyActionGroups = actionGroups.filter((group) => group.actions.length > 0); + const backendAvailability = configResponse?.backend_availability; + const backendUnavailable = + !configDirty && + configDraft?.backend === "codex_app_server" && + configDraft?.host_mode === "standalone" && + backendAvailability?.available === false; + const backendUnavailableReason = + backendAvailability?.reason ?? "Codex app-server backend is unavailable"; + const automationCanRun = + Boolean(configDraft?.enabled) && + configDraft?.backend === "codex_app_server" && + configDraft?.host_mode === "standalone" && + !backendUnavailable && + !configDirty && + !configSaving && + !automationRunActioning; + const activeAutomationStatus = (task: AutomationRunTask) => + automationRuns.find( + (record) => record.task === task && isActiveAutomationStatus(record.status), + )?.status; + const automationRunTitle = configDirty + ? "Save automation config before running" + : configDraft?.host_mode === "delegated_host" + ? "Delegated host mode owns intelligence runs" + : configDraft?.backend !== "codex_app_server" + ? "Select the Codex app-server backend before running" + : backendUnavailable + ? backendUnavailableReason + : !configDraft?.enabled + ? "Enable automation before running" + : "Run now"; + const automationTaskTitle = (task: AutomationRunTask) => { + const status = activeAutomationStatus(task); + if (status === "queued") return "Automation run is queued"; + if (status === "running") return "Automation run is running"; + return automationRunTitle; + }; + const automationTaskLabel = (task: AutomationRunTask) => { + const status = activeAutomationStatus(task); + if (status === "queued") return "Queued"; + if (status === "running") return "Running"; + return "Run"; + }; + const automationTaskCanRun = (task: AutomationRunTask) => + automationCanRun && !activeAutomationStatus(task); + const updateTaskSeconds = ( + task: AutomationRunTask, + key: SecondsField, + value: string, + ) => { + updateConfigTaskDraft(task, { + [key]: value ? Math.max(1, Number(value) || 1) : null, + }); + }; + const taskFieldError = ( + task: AutomationRunTask, + field: TaskField, + ) => configFieldErrors[`${task}.${field}`]; const planLabel = actions.length ? `Plan ${actions.length}` : "Plan"; const confirmGroupCounts = nonEmptyActionGroups.map((group) => [ group.label, group.actions.length, ] as const); + const selectedUsage = selectedManagedSkill + ? managedSkillUsage[selectedManagedSkill.metadata.id] + : null; + const selectedRecommendation = selectedManagedSkill + ? managedSkillRecommendations[selectedManagedSkill.metadata.id] + : null; + const selectedImprovementRecommendation = selectedManagedSkill + ? managedSkillImprovementRecommendations[selectedManagedSkill.metadata.id] + : null; const tabs: Array<{ id: CurationTab; label: string; Icon: typeof Wand2 }> = [ { id: "plan", label: planLabel, Icon: ListChecks }, { id: "history", label: "History", Icon: History }, @@ -802,7 +354,7 @@ export default function CurationPanel({ {actions.length > 0 ? (
{nonEmptyActionGroups.map((group, i) => ( - -
-
-
- Curator Status -
-
- Scheduler state, last run summary, and recent snapshots. -
-
- -
- {statusError ? ( -
- {statusError} -
- ) : null} - {status ? ( - <> -
-
- Run history -
- - - - - - -
-
-
- Curator configuration -
-
- Settings, not run results — "auto" means the provider - default is used. -
- - - - - - - - - - - - - - - - - - -
-
-
- Recent snapshots -
- {status.snapshots.length ? ( -
- {status.snapshots.map((snapshot) => ( -
- {snapshot.name} -
- ))} -
- ) : ( -
No snapshots found.
- )} -
- - ) : null} -
-
- Recent memory operations -
- {oplogError ? ( -
{oplogError}
- ) : null} - {oplog.length ? ( -
- {oplog.map((event) => ( -
- {formatOplogTime(event.ts)} - - {event.op} - - - {event.fact_id != null ? `#${event.fact_id} ` : ""} - {oplogDetailSummary(event)} - -
- ))} -
- ) : ( -
- No memory operations recorded yet. -
- )} -
- {report ? ( - <> -
- Current Preview -
-
- - {previewSavedAt ? ( - - ) : null} - {previewStale ? ( - - ) : null} - - - - - {report.skipped_actions != null ? ( - - ) : null} - {report.snapshot ? ( - - ) : null} -
- {report.coverage ? ( -
- - - {report.coverage.entity_total != null ? ( - - ) : null} - {report.coverage.entity_scan_remaining != null ? ( - - ) : null} -
- ) : null} - - ) : ( -
- Preview a plan to see current run metadata, signals, and coverage. -
- )} -
+ ) : null} diff --git a/dashboard/holographic/src/HolographicMemoryPage.tsx b/dashboard/holographic/src/HolographicMemoryPage.tsx index e9974fc0..0676d93f 100644 --- a/dashboard/holographic/src/HolographicMemoryPage.tsx +++ b/dashboard/holographic/src/HolographicMemoryPage.tsx @@ -41,6 +41,7 @@ import AssociationGraph from "./AssociationGraph"; import CurationPanel from "./CurationPanel"; import { NUM_BADGE } from "./ui"; import { MiniBar } from "./MiniBar"; +import { Stat } from "../../lib/primitives"; import { categoryColorMap, slotColor } from "./viz/colors"; import { PanelError, PanelLoading } from "./viz/Status"; import { Sparkline } from "./viz/Sparkline"; @@ -107,32 +108,6 @@ function highlighted(snippet?: string, fallback?: string | null) { return parts; } -function Stat({ - label, - value, - hint, -}: { - label: string; - value: string | number; - /** Plain-language explanation surfaced as a native tooltip. */ - hint?: string; -}) { - return ( -
-
- {value} -
-
- {label} -
-
- ); -} - function DataBars({ getLabel, getTone, @@ -215,36 +190,35 @@ function SystemStrip({ return (
- - - + + + 0 ? agentToolsets : "none"} - hint={ + title={ agentToolsets > 0 ? "Agent toolsets the curator can delegate cleanup work to." : "No agent-driven curation is configured. Curation previews and applies still work — they use the built-in deduplication planner." } /> - -
-
- {db.path} -
-
- storage path -
-
+ +
); } @@ -308,25 +282,28 @@ function MemoryHealthCard({
- - - - + + + + - +
diff --git a/dashboard/holographic/src/api.ts b/dashboard/holographic/src/api.ts index b3aa4968..a5e3c568 100644 --- a/dashboard/holographic/src/api.ts +++ b/dashboard/holographic/src/api.ts @@ -9,6 +9,17 @@ import { fetchJSON } from "./sdk"; import type { + AutomationRunRequest, + AutomationSchedulerStatusResponse, + FactProposalListResponse, + FactProposalResponse, + MemoryAgentPlanResponse, + MemoryAutomationConfigPatch, + MemoryAutomationConfigResponse, + MemoryAutomationRunArtifactPayloadResponse, + MemoryAutomationRunArtifactsResponse, + MemoryAutomationRunResponse, + MemoryAutomationRunsResponse, MemoryCurateApplyResponse, MemoryCurateOp, MemoryCurateResponse, @@ -21,9 +32,46 @@ import type { MemoryProjectionResponse, MemorySimilarityResponse, MemoryStatusResponse, + ManagedSkillListResponse, + ManagedSkillResponse, } from "./types"; const BASE = "/api/plugins/holographic"; +const AUTOMATION_BASE = "/api/automation"; + +function withLimit(path: string, limit?: number): string { + const qs = new URLSearchParams(); + if (limit) qs.set("limit", String(limit)); + const suffix = qs.toString(); + return `${path}${suffix ? `?${suffix}` : ""}`; +} + +function managedSkillPath(id: string, action?: string): string { + const path = `${AUTOMATION_BASE}/skills/${encodeURIComponent(id)}`; + return action ? `${path}/${action}` : path; +} + +function factProposalPath(id: string, action?: string): string { + const path = `${AUTOMATION_BASE}/fact-proposals/${encodeURIComponent(id)}`; + return action ? `${path}/${action}` : path; +} + +function postAutomationRun>( + path: string, + body: AutomationRunRequest = {}, +) { + return fetchJSON>(`${AUTOMATION_BASE}/run/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dry_run: true, ...body }), + }); +} + +function postManagedSkillAction(id: string, action: string) { + return fetchJSON(managedSkillPath(id, action), { + method: "POST", + }); +} export const api = { /** Overview + facts + entities + graph (GET /). */ @@ -89,6 +137,28 @@ export const api = { body: JSON.stringify(body), }), + /** Standalone backend memory-curator review (POST /curation/agent-plan). */ + postMemoryAgentPlan: ( + body: { dry_run?: true; max_clusters?: number; min_confidence?: number } = {}, + ) => + fetchJSON(`${BASE}/curation/agent-plan`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dry_run: true, ...body }), + }), + + /** Standalone memory-curator run (POST /api/automation/run/memory-curator). */ + postAutomationRunMemoryCurator: (body: AutomationRunRequest = {}) => + postAutomationRun("memory-curator", body), + + /** Standalone session-reflection run (POST /api/automation/run/session-reflection). */ + postAutomationRunSessionReflection: (body: AutomationRunRequest = {}) => + postAutomationRun("session-reflection", body), + + /** Standalone managed skill-writer run (POST /api/automation/run/skill-writing). */ + postAutomationRunSkillWriting: (body: AutomationRunRequest = {}) => + postAutomationRun("skill-writing", body), + /** Apply explicit curation ops — delete/merge (POST /curate/apply). */ postMemoryCurateApply: (body: { ops: MemoryCurateOp[] }) => fetchJSON(`${BASE}/curate/apply`, { @@ -97,7 +167,7 @@ export const api = { body: JSON.stringify(body), }), - /** Last saved dry-run preview for this Hermes profile (GET /curation/preview). */ + /** Last saved dry-run preview for this project/profile (GET /curation/preview). */ getMemoryCuratorPreview: () => fetchJSON(`${BASE}/curation/preview`), @@ -105,24 +175,113 @@ export const api = { getMemoryCuratorStatus: () => fetchJSON(`${BASE}/curation/status`), + /** Effective automation config plus project override sidecar (GET /curation/config). */ + getMemoryAutomationConfig: () => + fetchJSON(`${BASE}/curation/config`), + + /** Persist project automation config overrides (PATCH /curation/config). */ + patchMemoryAutomationConfig: (body: MemoryAutomationConfigPatch) => + fetchJSON(`${BASE}/curation/config`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + + /** Remove project automation overrides and return effective defaults (DELETE /curation/config). */ + resetMemoryAutomationConfig: () => + fetchJSON(`${BASE}/curation/config`, { + method: "DELETE", + }), + + /** Dashboard-visible automation scheduler state. */ + getAutomationSchedulerStatus: () => + fetchJSON(`${AUTOMATION_BASE}/scheduler/status`), + + /** Pause scheduler dispatch through the scheduler control sidecar. */ + pauseAutomationScheduler: () => + fetchJSON(`${AUTOMATION_BASE}/scheduler/pause`, { + method: "POST", + }), + + /** Resume scheduler dispatch through the scheduler control sidecar. */ + resumeAutomationScheduler: () => + fetchJSON(`${AUTOMATION_BASE}/scheduler/resume`, { + method: "POST", + }), + + /** Recent standalone automation backend runs (GET /curation/runs). */ + getMemoryAutomationRuns: (params: { limit?: number } = {}) => + fetchJSON( + withLimit(`${BASE}/curation/runs`, params.limit), + ), + + /** Artifact metadata for one automation run (GET /api/automation/runs/{run_id}/artifacts). */ + getMemoryAutomationRunArtifacts: (runId: string) => + fetchJSON( + `${AUTOMATION_BASE}/runs/${encodeURIComponent(runId)}/artifacts`, + ), + + /** Verified artifact payload for one automation run artifact kind. */ + getMemoryAutomationRunArtifact: (runId: string, kind: string) => + fetchJSON( + `${AUTOMATION_BASE}/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(kind)}`, + ), + /** Recent structured curator activity (GET /curation/activity). */ - getMemoryCuratorActivity: (params: { limit?: number } = {}) => { - const qs = new URLSearchParams(); - if (params.limit) qs.set("limit", String(params.limit)); - const suffix = qs.toString(); - return fetchJSON( - `${BASE}/curation/activity${suffix ? `?${suffix}` : ""}`, - ); - }, + getMemoryCuratorActivity: (params: { limit?: number } = {}) => + fetchJSON( + withLimit(`${BASE}/curation/activity`, params.limit), + ), /** Recent memory operations from the append-only oplog (GET /oplog). */ - getMemoryOplog: (params: { limit?: number } = {}) => { + getMemoryOplog: (params: { limit?: number } = {}) => + fetchJSON(withLimit(`${BASE}/oplog`, params.limit)), + + /** Profile-owned managed skill packages (GET /api/automation/skills). */ + getManagedSkills: () => + fetchJSON(`${AUTOMATION_BASE}/skills`), + + /** Full managed skill body and metadata (GET /api/automation/skills/{id}). */ + getManagedSkill: (id: string) => + fetchJSON(managedSkillPath(id)), + + /** Approve a pending managed skill (POST /api/automation/skills/{id}/approve). */ + approveManagedSkill: (id: string) => postManagedSkillAction(id, "approve"), + + /** Discard a staged managed skill update without mutating the active revision. */ + discardManagedSkillUpdate: (id: string) => postManagedSkillAction(id, "discard-update"), + + /** Disable an active managed skill (POST /api/automation/skills/{id}/disable). */ + disableManagedSkill: (id: string) => postManagedSkillAction(id, "disable"), + + /** Archive a managed skill without deleting its package. */ + archiveManagedSkill: (id: string) => postManagedSkillAction(id, "archive"), + + /** Restore an archived/disabled skill to pending approval. */ + restoreManagedSkill: (id: string) => postManagedSkillAction(id, "restore"), + + /** Durable session-reflection fact proposals awaiting approval. */ + getFactProposals: (params: { state?: string; limit?: number } = {}) => { const qs = new URLSearchParams(); + if (params.state) qs.set("state", params.state); if (params.limit) qs.set("limit", String(params.limit)); const suffix = qs.toString(); - return fetchJSON( - `${BASE}/oplog${suffix ? `?${suffix}` : ""}`, + return fetchJSON( + `${AUTOMATION_BASE}/fact-proposals${suffix ? `?${suffix}` : ""}`, ); }, + /** Apply an approved fact proposal to the memory store. */ + applyFactProposal: (id: string) => + fetchJSON(factProposalPath(id, "apply"), { + method: "POST", + }), + + /** Reject a pending fact proposal without mutating memory. */ + rejectFactProposal: (id: string, reason?: string) => + fetchJSON(factProposalPath(id, "reject"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: reason || null }), + }), }; diff --git a/dashboard/holographic/src/curation/ActionReviewGroup.tsx b/dashboard/holographic/src/curation/ActionReviewGroup.tsx new file mode 100644 index 00000000..6de665df --- /dev/null +++ b/dashboard/holographic/src/curation/ActionReviewGroup.tsx @@ -0,0 +1,268 @@ +import { type Key, useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { Badge } from "../sdk"; +import { describe, diffTags, isBookkeepingTag } from "./format"; +import { + actionRisk, + riskClass, + type ActionGroupDef, + type ActionRisk, +} from "./risk"; +import type { MemoryCurateAction } from "../types"; + +const RISK_ORDER: ActionRisk[] = ["high", "medium", "low", "review"]; + +function TagBucket({ + label, + tags, + tone, +}: { + label: string; + tags: string[]; + tone: "neutral" | "removed" | "added"; +}) { + if (!tags.length) return null; + const chipClass = + tone === "removed" + ? "border-destructive/30 bg-destructive/10 text-destructive/90 line-through" + : tone === "added" + ? "border-success/30 bg-success/10 text-success" + : "border-border bg-secondary/60 text-text-secondary"; + return ( +
+
+ {label} +
+
+ {tags.map((tag) => ( + + {tag} + + ))} +
+
+ ); +} + +function ActionRow({ action }: { action: MemoryCurateAction; key?: Key }) { + const content = action.content ?? ""; + const [expanded, setExpanded] = useState(false); + const risk = actionRisk(action.op); + const isRetag = action.op === "retag"; + const isDelete = action.op === "delete"; + const isEntityMerge = action.op === "entity_merge"; + const isEntityPrune = action.op === "entity_prune"; + const isEntityClassify = action.op === "entity_classify"; + const isReflect = action.op === "reflect"; + const { oldTags, newTags, kept, removed, added } = isRetag + ? diffTags(action.old_tags, action.tags) + : { oldTags: [], newTags: [], kept: [], removed: [], added: [] }; + const tagsOnlyReordered = isRetag && removed.length === 0 && added.length === 0; + const normalizationOnly = + isRetag && + removed.length > 0 && + added.length === 0 && + removed.every(isBookkeepingTag); + + return ( +
+
+ {action.op} + + {risk} + +
+
{describe(action)}
+
+ {action.tier && ( + + {action.tier} + + )} +
+ + {content && ( +
+
+ Memory +
+ +
+ )} + + {isRetag && ( +
+
+ Tag change + {normalizationOnly && ( + + normalization only + + )} +
+ + + + {tagsOnlyReordered && ( + + Tags reordered/normalized — no tags added or removed. + + )} + {oldTags.length > 0 && newTags.length === 0 && ( + + This would leave the memory with no tags. + + )} +
+ )} + + {isDelete && ( +
+ Will be permanently deleted. This cannot be undone. +
+ )} + + {isEntityMerge && ( +
+ + Consolidates duplicate entity records and preserves their linked memories + under the surviving entity. + + {action.normalized_identity || action.fact_links_moved != null ? ( + + {action.normalized_identity ? `identity=${action.normalized_identity}` : ""} + {action.normalized_identity && action.fact_links_moved != null ? " · " : ""} + {action.fact_links_moved != null ? `links moved=${action.fact_links_moved}` : ""} + + ) : null} +
+ )} + + {isEntityPrune && ( +
+ + Removes a low-value, junk, or orphan entity reference without changing + the underlying fact text. + + {action.fact_links_removed != null ? ( + links removed={action.fact_links_removed} + ) : null} +
+ )} + + {isEntityClassify && ( +
+ + Adds a coarse type label used by the entity list, graph, and + curator filters. The entity links and facts are unchanged. + + + {action.old_entity_type ?? "unknown"} → {action.entity_type} + {action.fact_count != null ? ` · linked facts=${action.fact_count}` : ""} + +
+ )} + + {isReflect && ( +
+ Creates a new {action.category ?? "general"} fact + {action.supersedes?.length + ? `, supersedes ${action.supersedes.map((s) => `#${s}`).join(", ")}` + : ""} + . +
+ )} + + {action.reason && ( +
+ {action.reason} +
+ )} +
+ ); +} + +export function ActionReviewGroup({ + group, + defaultOpen, +}: { + group: ActionGroupDef & { actions: MemoryCurateAction[] }; + defaultOpen: boolean; + key?: Key; +}) { + const [open, setOpen] = useState(defaultOpen); + if (group.actions.length === 0) return null; + const riskCounts = group.actions.reduce>( + (acc, action) => { + acc[actionRisk(action.op)] += 1; + return acc; + }, + { low: 0, medium: 0, high: 0, review: 0 }, + ); + + return ( +
+ + {open ? ( +
+ {group.actions.map((action, i) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/dashboard/holographic/src/curation/ActivityScroller.tsx b/dashboard/holographic/src/curation/ActivityScroller.tsx new file mode 100644 index 00000000..095ac51a --- /dev/null +++ b/dashboard/holographic/src/curation/ActivityScroller.tsx @@ -0,0 +1,126 @@ +import type { RefObject } from "react"; +import { ScrollText } from "lucide-react"; + +import { Spinner } from "../Spinner"; +import type { MemoryCuratorActivityEvent } from "../types"; + +function activityTone(level?: string) { + switch ((level || "info").toLowerCase()) { + case "success": + return "text-success"; + case "warning": + return "text-warning"; + case "error": + return "text-destructive"; + default: + return "text-text-secondary"; + } +} + +function formatActivityTime(ts: string): string { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return "--:--:--"; + try { + return d.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return "--:--:--"; + } +} + +function activityStatus(events: MemoryCuratorActivityEvent[], loading: boolean): string { + const latest = events[events.length - 1]; + if (!latest) return "idle"; + if (latest.phase === "stale") return "stale"; + if (loading) return "live"; + if (latest.phase === "finish") return "complete"; + if (latest.phase === "lock") return "skipped"; + return "recent"; +} + +function activityStatusClass(status: string): string { + switch (status) { + case "live": + return "border-success/30 bg-success/10 text-success"; + case "stale": + return "border-warning/30 bg-warning/10 text-warning"; + case "complete": + return "border-primary/30 bg-primary/10 text-primary"; + default: + return "border-border bg-muted/30 text-text-tertiary"; + } +} + +export function ActivityScroller({ + events, + loading, + error, + scrollRef, +}: { + events: MemoryCuratorActivityEvent[]; + loading: boolean; + error: string; + scrollRef: RefObject; +}) { + const status = activityStatus(events, loading); + const stale = status === "stale"; + return ( +
+
+
+ + Live Activity +
+
+ {loading && !stale ? : null} + + {status} + + {events.length} events +
+
+ {error ? ( +
+ {error} +
+ ) : null} + {stale ? ( +
+ The last curator run stopped reporting activity. Refresh or start a new preview to resume from a fresh run. +
+ ) : null} +
+ {events.length === 0 ? ( +
+ Start a preview or apply run to watch curator activity here. +
+ ) : ( +
+ {events.map((event, index) => ( +
+ {formatActivityTime(event.ts)} + + {event.phase} + + + {event.message} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/dashboard/holographic/src/curation/AutomationConfigSection.tsx b/dashboard/holographic/src/curation/AutomationConfigSection.tsx new file mode 100644 index 00000000..c1b20c52 --- /dev/null +++ b/dashboard/holographic/src/curation/AutomationConfigSection.tsx @@ -0,0 +1,327 @@ +import type { ChangeEvent } from "react"; +import { RotateCcw, Save } from "lucide-react"; + +import { Button, Input } from "../sdk"; +import { Spinner } from "../Spinner"; +import type { + AutomationTaskConfig, + MemoryAutomationConfig, + MemoryAutomationConfigPatch, + SelectableAutomationBackend, +} from "../types"; +import { AutomationTaskConfigRow, ConfigFieldError } from "./AutomationTaskConfigRow"; +import { AUTOMATION_TASKS, type AutomationRunTask } from "./automationTasks"; +import type { ConfigFieldErrors, SecondsField, TaskField } from "./configTypes"; +import { MetadataRow } from "./MetadataRow"; + +function ConfigCheckbox({ + label, + ariaLabel, + checked, + error, + onCheckedChange, +}: { + label: string; + ariaLabel: string; + checked: boolean; + error?: string; + onCheckedChange: (checked: boolean) => void; +}) { + return ( +
+ + +
+ ); +} + +export function AutomationConfigSection({ + configDraft, + configLoading, + configSaving, + configResetting, + configError, + configFieldErrors, + configDirty, + backendUnavailable, + backendUnavailableReason, + automationRunActioning, + automationRunError, + paused, + activeAutomationStatus, + automationTaskCanRun, + automationTaskTitle, + automationTaskLabel, + taskFieldError, + runAutomationTask, + updateConfigDraft, + updateConfigTaskDraft, + updateTaskSeconds, + resetConfigDraft, + resetConfigToDefaults, + saveConfigDraft, +}: { + configDraft: MemoryAutomationConfig | null; + configLoading: boolean; + configSaving: boolean; + configResetting: boolean; + configError: string; + configFieldErrors: ConfigFieldErrors; + configDirty: boolean; + backendUnavailable: boolean; + backendUnavailableReason: string; + automationRunActioning: AutomationRunTask | null; + automationRunError: string; + paused: boolean; + activeAutomationStatus: (task: AutomationRunTask) => string | undefined; + automationTaskCanRun: (task: AutomationRunTask) => boolean; + automationTaskTitle: (task: AutomationRunTask) => string; + automationTaskLabel: (task: AutomationRunTask) => string; + taskFieldError: (task: AutomationRunTask, field: TaskField) => string | undefined; + runAutomationTask: (task: AutomationRunTask) => void; + updateConfigDraft: (patch: MemoryAutomationConfigPatch) => void; + updateConfigTaskDraft: (task: AutomationRunTask, patch: Partial) => void; + updateTaskSeconds: (task: AutomationRunTask, key: SecondsField, value: string) => void; + resetConfigDraft: () => void; + resetConfigToDefaults: () => Promise; + saveConfigDraft: () => Promise; +}) { + return ( +
+
+
+ Automation config +
+ {configLoading ? : null} +
+ {configError ? ( +
+ {configError} +
+ ) : null} + {backendUnavailable ? ( +
+ {backendUnavailableReason} +
+ ) : null} + {automationRunError ? ( +
+ {automationRunError} +
+ ) : null} + {configDraft ? ( +
+
+ updateConfigDraft({ enabled })} + /> + + updateConfigDraft({ require_dashboard_approval }) + } + /> +
+
+ + +
+
+ + + +
+
+ + +
+ {AUTOMATION_TASKS.map((descriptor) => ( +
+ +
+ ))} +
+ + updateConfigDraft({ auto_apply_memory_ops }) + } + /> + updateConfigDraft({ auto_enable_skills })} + /> +
+
+ + + +
+
+ ) : null} + +
+ ); +} diff --git a/dashboard/holographic/src/curation/AutomationRunsSection.tsx b/dashboard/holographic/src/curation/AutomationRunsSection.tsx new file mode 100644 index 00000000..d1b9feaa --- /dev/null +++ b/dashboard/holographic/src/curation/AutomationRunsSection.tsx @@ -0,0 +1,145 @@ +import { Eye } from "lucide-react"; +import type { Key } from "react"; + +import { Badge, Button } from "../sdk"; +import { Spinner } from "../Spinner"; +import { formatHistoryTime } from "./format"; +import { + automationArtifactPreview, + automationRunStatusClass, + automationRunSummary, +} from "./historyFormat"; +import type { + MemoryAutomationRunArtifact, + MemoryAutomationRunArtifactPayloadResponse, + MemoryAutomationRunArtifactsResponse, + MemoryAutomationRunRecord, +} from "../types"; + +function ArtifactButton({ + runId, + artifact, + artifactLoading, + onLoadArtifact, +}: { + key?: Key; + runId: string; + artifact: MemoryAutomationRunArtifact; + artifactLoading: string | null; + onLoadArtifact: (runId: string, kind: string) => void; +}) { + const loadingKey = `${runId}:${artifact.kind}`; + const loading = artifactLoading === loadingKey; + + return ( + + ); +} + +export function AutomationRunsSection({ + runs, + error, + artifacts, + artifact, + artifactLoading, + artifactError, + onLoadArtifact, +}: { + runs: MemoryAutomationRunRecord[]; + error: string; + artifacts: MemoryAutomationRunArtifactsResponse | null; + artifact: MemoryAutomationRunArtifactPayloadResponse | null; + artifactLoading: string | null; + artifactError: string; + onLoadArtifact: (runId: string, kind: string) => void; +}) { + const artifactPreview = automationArtifactPreview(artifact); + + return ( +
+
+ Automation runs +
+ {error ?
{error}
: null} + {runs.length ? ( +
+ {runs.map((record) => ( +
+ + {formatHistoryTime(record.completed_at || record.started_at)} + + + {record.status} + +
+
+ {automationRunSummary(record)} +
+ {record.artifacts?.length ? ( +
+ {record.artifacts.map((runArtifact) => ( + + ))} +
+ ) : null} +
+
+ ))} +
+ ) : ( +
+ No automation runs recorded yet. +
+ )} + {artifactError ? ( +
{artifactError}
+ ) : null} + {artifact ? ( +
+
+ + {artifact.run_id} + + {artifact.artifact.kind} + + {artifact.artifact.sha256} + +
+ {artifacts?.run_id === artifact.run_id && artifacts.artifact_chain ? ( +
+ + {artifacts.artifact_chain.complete ? "chain complete" : "chain pending"} + + {(artifacts.artifact_chain.present_kinds || []).map((kind) => ( + {kind} + ))} +
+ ) : null} +
+            {artifactPreview}
+          
+
+ ) : null} +
+ ); +} diff --git a/dashboard/holographic/src/curation/AutomationTaskConfigRow.tsx b/dashboard/holographic/src/curation/AutomationTaskConfigRow.tsx new file mode 100644 index 00000000..53e89dd3 --- /dev/null +++ b/dashboard/holographic/src/curation/AutomationTaskConfigRow.tsx @@ -0,0 +1,110 @@ +import type { ChangeEvent } from "react"; +import { Power } from "lucide-react"; + +import { Button, Input } from "../sdk"; +import { Spinner } from "../Spinner"; +import type { AutomationTaskConfig } from "../types"; +import type { AutomationRunTask, AutomationTaskDescriptor } from "./automationTasks"; +import type { SecondsField, TaskField } from "./configTypes"; + +const SECONDS_FIELDS: Array<{ key: SecondsField; label: string }> = [ + { key: "interval_secs", label: "Interval seconds" }, + { key: "cooldown_secs", label: "Cooldown seconds" }, + { key: "min_idle_secs", label: "Idle seconds" }, + { key: "stale_lock_secs", label: "Stale lock seconds" }, +]; + +export function ConfigFieldError({ message }: { message?: string }) { + if (!message) return null; + return
{message}
; +} + +export function AutomationTaskConfigRow({ + descriptor, + config, + actioningTask, + activeStatus, + canRun, + runTitle, + runLabel, + fieldError, + onTaskPatch, + onTaskSecondsPatch, + onRun, +}: { + descriptor: AutomationTaskDescriptor; + config: AutomationTaskConfig; + actioningTask: AutomationRunTask | null; + activeStatus?: string; + canRun: boolean; + runTitle: string; + runLabel: string; + fieldError: (task: AutomationRunTask, field: TaskField) => string | undefined; + onTaskPatch: (task: AutomationRunTask, patch: Partial) => void; + onTaskSecondsPatch: (task: AutomationRunTask, key: SecondsField, value: string) => void; + onRun: (task: AutomationRunTask) => void; +}) { + const task = descriptor.id; + const running = actioningTask === task || activeStatus === "running"; + + return ( +
+ +
+ + +
+
+ {SECONDS_FIELDS.map(({ key, label }) => ( + + ))} +
+
+ ); +} diff --git a/dashboard/holographic/src/curation/CurationHistoryPanel.tsx b/dashboard/holographic/src/curation/CurationHistoryPanel.tsx new file mode 100644 index 00000000..d6dced78 --- /dev/null +++ b/dashboard/holographic/src/curation/CurationHistoryPanel.tsx @@ -0,0 +1,299 @@ +import { Button } from "../sdk"; +import { Spinner } from "../Spinner"; +import { AutomationConfigSection } from "./AutomationConfigSection"; +import { AutomationRunsSection } from "./AutomationRunsSection"; +import { CurrentPreviewSection } from "./CurrentPreviewSection"; +import { FactProposalsSection } from "./FactProposalsSection"; +import { ManagedSkillsSection } from "./ManagedSkillsSection"; +import { MemoryOperationsSection } from "./MemoryOperationsSection"; +import { RunHistorySection } from "./RunHistorySection"; +import { SchedulerStatusSection } from "./SchedulerStatusSection"; +import { SnapshotsSection } from "./SnapshotsSection"; +import type { AutomationRunTask } from "./automationTasks"; +import type { ConfigFieldErrors, SecondsField, TaskField } from "./configTypes"; +import type { + AutomationSchedulerStatusResponse, + AutomationTaskConfig, + FactProposalRecord, + ManagedSkill, + MemoryAutomationConfig, + MemoryAutomationConfigPatch, + MemoryAutomationRunArtifactPayloadResponse, + MemoryAutomationRunArtifactsResponse, + MemoryAutomationRunRecord, + MemoryCurateResponse, + MemoryCuratorStatusResponse, + MemoryOplogEvent, + SkillImprovementRecommendation, + SkillStaleRecommendation, + SkillUsageSummary, +} from "../types"; + +interface CurationHistoryPanelProps { + report: MemoryCurateResponse | null; + previewSavedAt: string | null; + previewStale: boolean; + previewStaleReason: string; + actionsLength: number; + actionCounts: Array<[string, number]>; + diagnosticCounts: Array<[string, number]>; + isPlan: boolean; + status: MemoryCuratorStatusResponse | null; + statusLoading: boolean; + statusError: string; + oplog: MemoryOplogEvent[]; + oplogError: string; + automationRuns: MemoryAutomationRunRecord[]; + automationRunsError: string; + automationRunActioning: AutomationRunTask | null; + automationRunError: string; + automationRunArtifacts: MemoryAutomationRunArtifactsResponse | null; + automationRunArtifact: MemoryAutomationRunArtifactPayloadResponse | null; + automationRunArtifactLoading: string | null; + automationRunArtifactError: string; + factProposals: FactProposalRecord[]; + factProposalsLoading: boolean; + factProposalsError: string; + factProposalActioning: string | null; + managedSkills: ManagedSkill[]; + selectedManagedSkillId: string | null; + selectedManagedSkill: ManagedSkill | null; + selectedUsage: SkillUsageSummary | null; + selectedRecommendation: SkillStaleRecommendation | null; + selectedImprovementRecommendation: SkillImprovementRecommendation | null; + managedSkillsLoading: boolean; + managedSkillsError: string; + managedSkillActioning: string | null; + configDraft: MemoryAutomationConfig | null; + configLoading: boolean; + configSaving: boolean; + configResetting: boolean; + configError: string; + configFieldErrors: ConfigFieldErrors; + schedulerStatus: AutomationSchedulerStatusResponse | null; + schedulerStatusLoading: boolean; + schedulerStatusError: string; + schedulerActioning: boolean; + configDirty: boolean; + backendUnavailable: boolean; + backendUnavailableReason: string; + activeAutomationStatus: (task: AutomationRunTask) => string | undefined; + automationTaskCanRun: (task: AutomationRunTask) => boolean; + automationTaskTitle: (task: AutomationRunTask) => string; + automationTaskLabel: (task: AutomationRunTask) => string; + taskFieldError: (task: AutomationRunTask, field: TaskField) => string | undefined; + loadStatus: () => void; + loadOplog: () => void; + loadAutomationRuns: () => void; + loadSchedulerStatus: (force?: boolean) => void; + loadAutomationRunArtifact: (runId: string, kind: string) => void; + loadFactProposals: (force?: boolean) => void; + loadManagedSkills: (force?: boolean) => void; + loadManagedSkill: (skillId: string) => void; + runAutomationTask: (task: AutomationRunTask) => void; + runFactProposalAction: (action: "apply" | "reject", proposalId: string) => void; + runManagedSkillAction: (action: string, skillId: string) => void; + setSchedulerPaused: (paused: boolean) => void; + updateConfigDraft: (patch: MemoryAutomationConfigPatch) => void; + updateConfigTaskDraft: (task: AutomationRunTask, patch: Partial) => void; + updateTaskSeconds: (task: AutomationRunTask, key: SecondsField, value: string) => void; + resetConfigDraft: () => void; + resetConfigToDefaults: () => Promise; + saveConfigDraft: () => Promise; +} + +export function CurationHistoryPanel({ + report, + previewSavedAt, + previewStale, + previewStaleReason, + actionsLength, + actionCounts, + diagnosticCounts, + isPlan, + status, + statusLoading, + statusError, + oplog, + oplogError, + automationRuns, + automationRunsError, + automationRunActioning, + automationRunError, + automationRunArtifacts, + automationRunArtifact, + automationRunArtifactLoading, + automationRunArtifactError, + factProposals, + factProposalsLoading, + factProposalsError, + factProposalActioning, + managedSkills, + selectedManagedSkillId, + selectedManagedSkill, + selectedUsage, + selectedRecommendation, + selectedImprovementRecommendation, + managedSkillsLoading, + managedSkillsError, + managedSkillActioning, + configDraft, + configLoading, + configSaving, + configResetting, + configError, + configFieldErrors, + schedulerStatus, + schedulerStatusLoading, + schedulerStatusError, + schedulerActioning, + configDirty, + backendUnavailable, + backendUnavailableReason, + activeAutomationStatus, + automationTaskCanRun, + automationTaskTitle, + automationTaskLabel, + taskFieldError, + loadStatus, + loadOplog, + loadAutomationRuns, + loadSchedulerStatus, + loadAutomationRunArtifact, + loadFactProposals, + loadManagedSkills, + loadManagedSkill, + runAutomationTask, + runFactProposalAction, + runManagedSkillAction, + setSchedulerPaused, + updateConfigDraft, + updateConfigTaskDraft, + updateTaskSeconds, + resetConfigDraft, + resetConfigToDefaults, + saveConfigDraft, +}: CurationHistoryPanelProps) { + return ( +
+
+
+
+ Curator Status +
+
+ Scheduler state, last run summary, and recent snapshots. +
+
+ +
+ {statusError ? ( +
+ {statusError} +
+ ) : null} + {status ? ( + <> + + + + loadFactProposals(true)} + onAction={runFactProposalAction} + /> + loadManagedSkills(true)} + onLoadSkill={loadManagedSkill} + onAction={runManagedSkillAction} + /> + + + ) : null} + + + +
+ ); +} diff --git a/dashboard/holographic/src/curation/CurrentPreviewSection.tsx b/dashboard/holographic/src/curation/CurrentPreviewSection.tsx new file mode 100644 index 00000000..bf4fbb41 --- /dev/null +++ b/dashboard/holographic/src/curation/CurrentPreviewSection.tsx @@ -0,0 +1,85 @@ +import { formatCounts, formatHistoryTime } from "./format"; +import { MetadataRow } from "./MetadataRow"; +import type { MemoryCurateResponse } from "../types"; + +export function CurrentPreviewSection({ + report, + previewSavedAt, + previewStale, + previewStaleReason, + actionsLength, + actionCounts, + diagnosticCounts, + isPlan, +}: { + report: MemoryCurateResponse | null; + previewSavedAt: string | null; + previewStale: boolean; + previewStaleReason: string; + actionsLength: number; + actionCounts: Array<[string, number]>; + diagnosticCounts: Array<[string, number]>; + isPlan: boolean; +}) { + if (!report) { + return ( +
+ Preview a plan to see current run metadata, signals, and coverage. +
+ ); + } + + return ( + <> +
+ Current Preview +
+
+ + {previewSavedAt ? ( + + ) : null} + {previewStale ? ( + + ) : null} + + + + + {report.skipped_actions != null ? ( + + ) : null} + {report.snapshot ? ( + + ) : null} +
+ {report.coverage ? ( +
+ + + {report.coverage.entity_total != null ? ( + + ) : null} + {report.coverage.entity_scan_remaining != null ? ( + + ) : null} +
+ ) : null} + + ); +} diff --git a/dashboard/holographic/src/curation/FactProposalsSection.tsx b/dashboard/holographic/src/curation/FactProposalsSection.tsx new file mode 100644 index 00000000..1f7d738e --- /dev/null +++ b/dashboard/holographic/src/curation/FactProposalsSection.tsx @@ -0,0 +1,152 @@ +import { Archive, CheckCircle2 } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Button } from "../sdk"; +import { Spinner } from "../Spinner"; +import { + factProposalDetail, + factProposalSummary, + formatUnixTime, + managedSkillStateClass, +} from "./historyFormat"; +import type { FactProposalRecord } from "../types"; + +type FactProposalAction = "apply" | "reject"; + +function ProposalActionButton({ + action, + label, + icon, + proposalId, + pending, + actioning, + outlined = false, + onAction, +}: { + action: FactProposalAction; + label: string; + icon: ReactNode; + proposalId: string; + pending: boolean; + actioning: string | null; + outlined?: boolean; + onAction: (action: FactProposalAction, proposalId: string) => void; +}) { + const loading = actioning?.endsWith(`:${action}`); + + return ( + + ); +} + +export function FactProposalsSection({ + proposals, + loading, + error, + actioning, + onRefresh, + onAction, +}: { + proposals: FactProposalRecord[]; + loading: boolean; + error: string; + actioning: string | null; + onRefresh: () => void; + onAction: (action: FactProposalAction, proposalId: string) => void; +}) { + return ( +
+
+
+
+ Fact proposals +
+
+ Session-reflection facts staged for dashboard approval. +
+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + {proposals.length ? ( +
+ {proposals.map((proposal) => { + const pending = proposal.state === "pending_approval"; + return ( +
+
+
+
+ {factProposalSummary(proposal)} +
+
+ {factProposalDetail(proposal)} +
+
+ + {proposal.state} + +
+
+ updated={formatUnixTime(proposal.updated_at)} +
+ } + proposalId={proposal.proposal_id} + pending={pending} + actioning={actioning} + onAction={onAction} + /> + } + proposalId={proposal.proposal_id} + pending={pending} + actioning={actioning} + outlined + onAction={onAction} + /> +
+
+
+ ); + })} +
+ ) : ( +
+ No fact proposals are waiting in this profile. +
+ )} +
+ ); +} diff --git a/dashboard/holographic/src/curation/InlineConfirm.tsx b/dashboard/holographic/src/curation/InlineConfirm.tsx new file mode 100644 index 00000000..92805684 --- /dev/null +++ b/dashboard/holographic/src/curation/InlineConfirm.tsx @@ -0,0 +1,104 @@ +import { + type ReactNode, + useEffect, + useId, + useRef, +} from "react"; +import { createPortal } from "react-dom"; +import { Button } from "../sdk"; + +export function InlineConfirm({ + open, + title, + description, + children, + confirmLabel, + loading, + onCancel, + onConfirm, +}: { + open: boolean; + title: string; + description?: string; + children?: ReactNode; + confirmLabel: string; + loading?: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + const titleId = useId(); + const dialogRef = useRef(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + previouslyFocused.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + const dialog = dialogRef.current; + const focusTarget = + dialog?.querySelector( + "button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex='-1'])", + ) ?? dialog; + focusTarget?.focus(); + return () => { + previouslyFocused.current?.focus?.(); + }; + }, [open]); + + useEffect(() => { + if (!open) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, onCancel]); + + if (!open) return null; + if (typeof document === "undefined") return null; + + return createPortal( +
{ + if (e.target === e.currentTarget) onCancel(); + }} + className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" + > +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+ {children ?
{children}
: null} +
+ + +
+
+
, + document.body, + ); +} diff --git a/dashboard/holographic/src/curation/ManagedSkillsSection.tsx b/dashboard/holographic/src/curation/ManagedSkillsSection.tsx new file mode 100644 index 00000000..8ebdf7a5 --- /dev/null +++ b/dashboard/holographic/src/curation/ManagedSkillsSection.tsx @@ -0,0 +1,298 @@ +import { Archive, CheckCircle2, Eye, Power, RotateCcw } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Button } from "../sdk"; +import { Spinner } from "../Spinner"; +import { + formatUnixTime, + managedSkillStateClass, + managedSkillSummary, +} from "./historyFormat"; +import type { + ManagedSkill, + ManagedSkillState, + SkillImprovementRecommendation, + SkillStaleRecommendation, + SkillUsageSummary, +} from "../types"; + +type ManagedSkillAction = "approve" | "discard-update" | "disable" | "archive" | "restore"; + +function SkillStateBadge({ state }: { state: ManagedSkillState }) { + return ( + + {state} + + ); +} + +function SkillActionButton({ + action, + label, + icon, + skillId, + actioning, + outlined = true, + onAction, +}: { + action: ManagedSkillAction; + label: string; + icon: ReactNode; + skillId: string; + actioning: string | null; + outlined?: boolean; + onAction: (action: string, skillId: string) => void; +}) { + const loading = actioning?.endsWith(`:${action}`); + + return ( + + ); +} + +export function ManagedSkillsSection({ + skills, + selectedSkillId, + selectedSkill, + selectedUsage, + selectedRecommendation, + selectedImprovementRecommendation, + loading, + error, + actioning, + onRefresh, + onLoadSkill, + onAction, +}: { + skills: ManagedSkill[]; + selectedSkillId: string | null; + selectedSkill: ManagedSkill | null; + selectedUsage: SkillUsageSummary | null; + selectedRecommendation: SkillStaleRecommendation | null; + selectedImprovementRecommendation: SkillImprovementRecommendation | null; + loading: boolean; + error: string; + actioning: string | null; + onRefresh: () => void; + onLoadSkill: (skillId: string) => void; + onAction: (action: string, skillId: string) => void; +}) { + return ( +
+
+
+
+ Managed skills +
+
+ Profile-owned skill drafts and approval state. +
+
+ +
+ {error ? ( +
+ {error} +
+ ) : null} + {skills.length ? ( +
+
+ {skills.map((skill) => { + const selected = selectedSkillId === skill.metadata.id; + return ( + + ); + })} +
+ {selectedSkill ? ( +
+
+
+
+ {selectedSkill.metadata.title} +
+
+ {managedSkillSummary(selectedSkill)} +
+
+ +
+
+ + checksum={selectedSkill.metadata.checksum} + + updated={formatUnixTime(selectedSkill.metadata.updated_at)} + support files={selectedSkill.support_files.length} + pinned={selectedSkill.metadata.pinned ? "yes" : "no"} +
+ {selectedUsage ? ( +
+ + views={selectedUsage.view_count} + + + uses={selectedUsage.use_count} + + + patches={selectedUsage.patch_count} + + last={formatUnixTime(selectedUsage.last_activity_at)} + + targets={selectedUsage.targets.length ? selectedUsage.targets.join(", ") : "none"} + +
+ ) : null} + {selectedRecommendation ? ( +
+ + {selectedRecommendation.recommendation} + + · {selectedRecommendation.reason} +
+ ) : null} + {selectedImprovementRecommendation?.improvement ? ( +
+ + {selectedImprovementRecommendation.recommendation} + + · {selectedImprovementRecommendation.reason} + + priority={selectedImprovementRecommendation.priority} + +
+ ) : null} + {selectedSkill.pending_update ? ( +
+
+ staged update · checksum={selectedSkill.pending_update.metadata.checksum} +
+
+ {selectedSkill.pending_update.metadata.summary} +
+
+ staged={formatUnixTime(selectedSkill.pending_update.staged_at)} + {" · "} + support files={selectedSkill.pending_update.support_files.length} +
+
+ ) : null} +
+                {selectedSkill.body_markdown || "No skill body."}
+              
+
+ + } + skillId={selectedSkill.metadata.id} + actioning={actioning} + outlined={false} + onAction={onAction} + /> + {selectedSkill.pending_update ? ( + } + skillId={selectedSkill.metadata.id} + actioning={actioning} + onAction={onAction} + /> + ) : null} + } + skillId={selectedSkill.metadata.id} + actioning={actioning} + onAction={onAction} + /> + } + skillId={selectedSkill.metadata.id} + actioning={actioning} + onAction={onAction} + /> + } + skillId={selectedSkill.metadata.id} + actioning={actioning} + onAction={onAction} + /> +
+
+ ) : null} +
+ ) : ( +
+ No managed skill drafts are waiting in this profile. +
+ )} +
+ ); +} diff --git a/dashboard/holographic/src/curation/MemoryOperationsSection.tsx b/dashboard/holographic/src/curation/MemoryOperationsSection.tsx new file mode 100644 index 00000000..b24240bf --- /dev/null +++ b/dashboard/holographic/src/curation/MemoryOperationsSection.tsx @@ -0,0 +1,43 @@ +import { formatOplogTime } from "./format"; +import { oplogDetailSummary } from "./historyFormat"; +import type { MemoryOplogEvent } from "../types"; + +export function MemoryOperationsSection({ + events, + error, +}: { + events: MemoryOplogEvent[]; + error: string; +}) { + return ( +
+
+ Recent memory operations +
+ {error ?
{error}
: null} + {events.length ? ( +
+ {events.map((event) => ( +
+ {formatOplogTime(event.ts)} + + {event.op} + + + {event.fact_id != null ? `#${event.fact_id} ` : ""} + {oplogDetailSummary(event)} + +
+ ))} +
+ ) : ( +
+ No memory operations recorded yet. +
+ )} +
+ ); +} diff --git a/dashboard/holographic/src/curation/MetadataRow.tsx b/dashboard/holographic/src/curation/MetadataRow.tsx new file mode 100644 index 00000000..476752cf --- /dev/null +++ b/dashboard/holographic/src/curation/MetadataRow.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +export function MetadataRow({ + label, + value, +}: { + label: string; + value: ReactNode; +}) { + return ( +
+ + {label} + + + {value} + +
+ ); +} diff --git a/dashboard/holographic/src/curation/RunHistorySection.tsx b/dashboard/holographic/src/curation/RunHistorySection.tsx new file mode 100644 index 00000000..af401a59 --- /dev/null +++ b/dashboard/holographic/src/curation/RunHistorySection.tsx @@ -0,0 +1,35 @@ +import { formatHistoryTime } from "./format"; +import { MetadataRow } from "./MetadataRow"; +import type { MemoryCuratorStatusResponse } from "../types"; + +export function RunHistorySection({ + status, +}: { + status: MemoryCuratorStatusResponse; +}) { + return ( +
+
+ Run history +
+ + + + + + +
+ ); +} diff --git a/dashboard/holographic/src/curation/SchedulerStatusSection.tsx b/dashboard/holographic/src/curation/SchedulerStatusSection.tsx new file mode 100644 index 00000000..6e3bf083 --- /dev/null +++ b/dashboard/holographic/src/curation/SchedulerStatusSection.tsx @@ -0,0 +1,74 @@ +import { Button } from "../sdk"; +import { Spinner } from "../Spinner"; +import { schedulerTaskLabel } from "./automationTasks"; +import type { AutomationSchedulerStatusResponse } from "../types"; + +export function SchedulerStatusSection({ + status, + loading, + error, + actioning, + onSetPaused, +}: { + status: AutomationSchedulerStatusResponse | null; + loading: boolean; + error: string; + actioning: boolean; + onSetPaused: (paused: boolean) => void; +}) { + return ( +
+
+
+
+ Scheduler +
+
+ {status?.status ?? "unknown"} +
+
+
+ {loading ? : null} + +
+
+ {error ? ( +
+ {error} +
+ ) : null} + {status?.tasks?.length ? ( +
+ {status.tasks.map((task) => ( +
+ + {schedulerTaskLabel(task.task)} + + + {task.due ? "due" : (task.skip_reason ?? "skipped")} + +
+ ))} +
+ ) : null} +
+ ); +} diff --git a/dashboard/holographic/src/curation/SnapshotsSection.tsx b/dashboard/holographic/src/curation/SnapshotsSection.tsx new file mode 100644 index 00000000..8fab9f39 --- /dev/null +++ b/dashboard/holographic/src/curation/SnapshotsSection.tsx @@ -0,0 +1,29 @@ +import type { MemoryCuratorStatusResponse } from "../types"; + +export function SnapshotsSection({ + snapshots, +}: { + snapshots: MemoryCuratorStatusResponse["snapshots"]; +}) { + return ( +
+
+ Recent snapshots +
+ {snapshots.length ? ( +
+ {snapshots.map((snapshot) => ( +
+ {snapshot.name} +
+ ))} +
+ ) : ( +
No snapshots found.
+ )} +
+ ); +} diff --git a/dashboard/holographic/src/curation/automationTasks.ts b/dashboard/holographic/src/curation/automationTasks.ts new file mode 100644 index 00000000..f61f2eb0 --- /dev/null +++ b/dashboard/holographic/src/curation/automationTasks.ts @@ -0,0 +1,59 @@ +export type AutomationRunTask = "memory_curator" | "session_reflector" | "skill_writer"; + +export type AutomationRunApiMethod = + | "postAutomationRunMemoryCurator" + | "postAutomationRunSessionReflection" + | "postAutomationRunSkillWriting"; + +export type AutomationRunRefreshTarget = + | "memory_preview" + | "fact_proposals" + | "managed_skills"; + +export interface AutomationTaskDescriptor { + id: AutomationRunTask; + runMethod: AutomationRunApiMethod; + refreshTarget: AutomationRunRefreshTarget; + enabledLabel: string; + scheduleLabel: string; + runAriaLabel: string; +} + +export const AUTOMATION_TASKS = [ + { + id: "memory_curator", + runMethod: "postAutomationRunMemoryCurator", + refreshTarget: "memory_preview", + enabledLabel: "Run memory curator", + scheduleLabel: "Memory curator schedule", + runAriaLabel: "Memory curator", + }, + { + id: "session_reflector", + runMethod: "postAutomationRunSessionReflection", + refreshTarget: "fact_proposals", + enabledLabel: "Run session reflector", + scheduleLabel: "Session reflector schedule", + runAriaLabel: "Session reflector", + }, + { + id: "skill_writer", + runMethod: "postAutomationRunSkillWriting", + refreshTarget: "managed_skills", + enabledLabel: "Run skill writer", + scheduleLabel: "Skill writer schedule", + runAriaLabel: "Skill writer", + }, +] satisfies AutomationTaskDescriptor[]; + +export const AUTOMATION_TASK_BY_ID = Object.fromEntries( + AUTOMATION_TASKS.map((descriptor) => [descriptor.id, descriptor]), +) as Record; + +export function isActiveAutomationStatus(status?: string | null): boolean { + return status === "queued" || status === "running"; +} + +export function schedulerTaskLabel(task: string): string { + return AUTOMATION_TASK_BY_ID[task as AutomationRunTask]?.runAriaLabel.toLowerCase() ?? task; +} diff --git a/dashboard/holographic/src/curation/configTypes.ts b/dashboard/holographic/src/curation/configTypes.ts new file mode 100644 index 00000000..36ce7efe --- /dev/null +++ b/dashboard/holographic/src/curation/configTypes.ts @@ -0,0 +1,7 @@ +export type SecondsField = + | "interval_secs" + | "cooldown_secs" + | "min_idle_secs" + | "stale_lock_secs"; +export type TaskField = "schedule" | SecondsField; +export type ConfigFieldErrors = Partial>; diff --git a/dashboard/holographic/src/curation/errors.ts b/dashboard/holographic/src/curation/errors.ts new file mode 100644 index 00000000..7db4add4 --- /dev/null +++ b/dashboard/holographic/src/curation/errors.ts @@ -0,0 +1,3 @@ +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/dashboard/holographic/src/curation/format.ts b/dashboard/holographic/src/curation/format.ts index eeafb479..7877e085 100644 --- a/dashboard/holographic/src/curation/format.ts +++ b/dashboard/holographic/src/curation/format.ts @@ -1,5 +1,28 @@ import type { MemoryCurateAction } from "../types"; +const COUNT_LABELS: Record = { + delete: "delete", + entity_merge: "entity merges", + entity_classify: "entity classifications", + entity_prune: "junk entities pruned", + junk_entities_pruned: "junk entities pruned", + merge: "fact merges", + orphan_entities: "orphan entities", + orphan_entities_pruned: "orphan entities pruned", + recategorize: "recategorize", + reflect: "reflections", + retag: "retag", +}; + +export function countLabel(key: string): string { + return COUNT_LABELS[key] ?? key; +} + +export function formatCounts(counts: Array<[string, number]>): string { + if (!counts.length) return "no changes"; + return counts.map(([key, value]) => `${countLabel(key)}=${value}`).join(", "); +} + export function describe(a: MemoryCurateAction): string { switch (a.op) { case "merge": diff --git a/dashboard/holographic/src/curation/historyFormat.ts b/dashboard/holographic/src/curation/historyFormat.ts new file mode 100644 index 00000000..530d19d2 --- /dev/null +++ b/dashboard/holographic/src/curation/historyFormat.ts @@ -0,0 +1,98 @@ +import { formatHistoryTime } from "./format"; +import type { + FactProposalRecord, + ManagedSkill, + MemoryAutomationRunArtifactPayloadResponse, + MemoryAutomationRunRecord, + MemoryOplogEvent, +} from "../types"; + +export function automationRunStatusClass(status: string): string { + switch (status) { + case "queued": + return "border-primary/30 bg-primary/10 text-primary"; + case "running": + return "border-accent/30 bg-accent/10 text-accent"; + case "succeeded": + return "border-success/30 bg-success/10 text-success"; + case "failed": + return "border-destructive/30 bg-destructive/10 text-destructive"; + case "skipped": + return "border-warning/30 bg-warning/10 text-warning"; + default: + return "border-border bg-muted/30 text-text-tertiary"; + } +} + +export function oplogDetailSummary(event: MemoryOplogEvent): string { + const detail = event.detail ?? {}; + const parts = Object.entries(detail) + .filter(([, value]) => value !== null && value !== undefined && value !== "") + .slice(0, 4) + .map(([key, value]) => `${key}=${String(value)}`); + return parts.join(" · "); +} + +export function automationRunSummary(record: MemoryAutomationRunRecord): string { + const backend = record.model ? `${record.backend}/${record.model}` : record.backend; + const host = record.host_mode ? ` · ${record.host_mode}` : ""; + const counts = `accepted=${record.accepted_count} rejected=${record.rejected_count}`; + const artifacts = record.artifacts?.length ? ` · artifacts=${record.artifacts.length}` : ""; + const fallback = record.fallback_status ? ` · ${record.fallback_status}` : ""; + const suffix = record.error && record.error !== record.fallback_status ? ` · ${record.error}` : ""; + return `${record.task} · ${record.trigger} · ${backend}${host} · ${counts}${artifacts}${fallback}${suffix}`; +} + +export function automationArtifactPreview( + artifact: MemoryAutomationRunArtifactPayloadResponse | null, +): string { + if (!artifact) return ""; + return JSON.stringify(artifact.payload, null, 2); +} + +export function factProposalSummary(proposal: FactProposalRecord): string { + const request = proposal.add_fact_request; + if (request?.content) return request.content; + if (proposal.validation_reason) return proposal.validation_reason; + return proposal.proposal_id; +} + +export function factProposalDetail(proposal: FactProposalRecord): string { + const tags = proposal.add_fact_request?.tags; + const tagsText = Array.isArray(tags) && tags.length ? ` · tags=${tags.join(",")}` : ""; + const category = proposal.add_fact_request?.category + ? ` · category=${proposal.add_fact_request.category}` + : ""; + const factId = proposal.applied_fact_id ? ` · fact=${proposal.applied_fact_id}` : ""; + return `${proposal.run_id}${category}${tagsText}${factId}`; +} + +export function managedSkillStateClass(state: string): string { + switch (state) { + case "active": + return "border-success/30 bg-success/10 text-success"; + case "pending_approval": + return "border-warning/30 bg-warning/10 text-warning"; + case "disabled": + return "border-muted-foreground/30 bg-muted/40 text-text-tertiary"; + case "archived": + return "border-border bg-background/50 text-text-tertiary"; + default: + return "border-border bg-muted/30 text-text-tertiary"; + } +} + +export function managedSkillSummary(skill: ManagedSkill): string { + const source = skill.metadata.provenance?.source || "unknown"; + const actor = skill.metadata.provenance?.actor || "unknown"; + const runId = skill.metadata.provenance?.run_id + ? ` · ${skill.metadata.provenance.run_id}` + : ""; + const pinned = skill.metadata.pinned ? " · pinned" : ""; + return `${skill.metadata.category} · ${source}/${actor}${runId}${pinned}`; +} + +export function formatUnixTime(ts?: number | null): string { + if (!ts) return "never"; + return formatHistoryTime(new Date(ts * 1000).toISOString()) || String(ts); +} diff --git a/dashboard/holographic/src/curation/useAutomationConfig.ts b/dashboard/holographic/src/curation/useAutomationConfig.ts new file mode 100644 index 00000000..3b44dcc3 --- /dev/null +++ b/dashboard/holographic/src/curation/useAutomationConfig.ts @@ -0,0 +1,251 @@ +import { useCallback, useState } from "react"; + +import type { api as defaultApi } from "../api"; +import type { + AutomationSchedulerStatusResponse, + AutomationTaskConfig, + AutomationTaskSet, + MemoryAutomationConfig, + MemoryAutomationConfigPatch, + MemoryAutomationConfigResponse, +} from "../types"; +import { AUTOMATION_TASKS } from "./automationTasks"; +import { errorMessage } from "./errors"; +import type { ConfigFieldErrors } from "./configTypes"; + +type AutomationTaskKey = keyof AutomationTaskSet; +type AutomationConfigApi = Pick< + typeof defaultApi, + | "getAutomationSchedulerStatus" + | "getMemoryAutomationConfig" + | "patchMemoryAutomationConfig" + | "pauseAutomationScheduler" + | "resetMemoryAutomationConfig" + | "resumeAutomationScheduler" +>; + +interface ConfigValidationError { + field?: unknown; + message?: unknown; +} + +interface ErrorWithBody { + body?: { + validation_errors?: ConfigValidationError[]; + }; +} + +function cloneTaskSet(tasks: AutomationTaskSet): AutomationTaskSet { + const cloned: Partial = {}; + for (const { id } of AUTOMATION_TASKS) { + cloned[id] = { ...tasks[id] }; + } + return cloned as AutomationTaskSet; +} + +function cloneConfig(config: MemoryAutomationConfig): MemoryAutomationConfig { + return { + ...config, + tasks: cloneTaskSet(config.tasks), + }; +} + +function configToPatch(config: MemoryAutomationConfig): MemoryAutomationConfigPatch { + const patch: MemoryAutomationConfigPatch = { + enabled: config.enabled, + host_mode: config.host_mode, + model: config.model || null, + timeout_secs: config.timeout_secs, + scheduler_tick_secs: config.scheduler_tick_secs, + max_tokens: config.max_tokens ?? null, + temperature: config.temperature ?? null, + require_dashboard_approval: config.require_dashboard_approval, + auto_apply_memory_ops: config.auto_apply_memory_ops, + auto_enable_skills: config.auto_enable_skills, + ...cloneTaskSet(config.tasks), + }; + if (config.backend !== "external_command") { + patch.backend = config.backend; + } + return patch; +} + +function sameConfig(a: MemoryAutomationConfig | null, b: MemoryAutomationConfig | null) { + return JSON.stringify(a) === JSON.stringify(b); +} + +function configFieldErrorsFromError(err: unknown): ConfigFieldErrors { + const errors = (err as ErrorWithBody)?.body?.validation_errors; + if (!Array.isArray(errors)) return {}; + return Object.fromEntries( + errors + .filter((error) => typeof error.field === "string" && typeof error.message === "string") + .map((error) => [error.field as string, error.message as string]), + ); +} + +export function useAutomationConfig(api: AutomationConfigApi) { + const [configResponse, setConfigResponse] = useState(null); + const [configDraft, setConfigDraft] = useState(null); + const [savedConfig, setSavedConfig] = useState(null); + const [configLoading, setConfigLoading] = useState(false); + const [configSaving, setConfigSaving] = useState(false); + const [configResetting, setConfigResetting] = useState(false); + const [configError, setConfigError] = useState(""); + const [configFieldErrors, setConfigFieldErrors] = useState({}); + const [schedulerStatus, setSchedulerStatus] = + useState(null); + const [schedulerStatusLoading, setSchedulerStatusLoading] = useState(false); + const [schedulerStatusError, setSchedulerStatusError] = useState(""); + const [schedulerActioning, setSchedulerActioning] = useState<"pause" | "resume" | null>(null); + const configDirty = !sameConfig(configDraft, savedConfig); + + const applyConfigResponse = useCallback((response: MemoryAutomationConfigResponse) => { + const effective = cloneConfig(response.effective); + setConfigResponse(response); + setConfigDraft(effective); + setSavedConfig(cloneConfig(response.effective)); + setConfigFieldErrors({}); + }, []); + + const loadConfig = useCallback(() => { + setConfigLoading(true); + setConfigError(""); + setConfigFieldErrors({}); + return api + .getMemoryAutomationConfig() + .then((response) => { + applyConfigResponse(response); + return response; + }) + .catch((err) => { + setConfigError(errorMessage(err)); + throw err; + }) + .finally(() => setConfigLoading(false)); + }, [api, applyConfigResponse]); + + const loadSchedulerStatus = useCallback((showSpinner = false) => { + if (showSpinner) setSchedulerStatusLoading(true); + setSchedulerStatusError(""); + return api + .getAutomationSchedulerStatus() + .then((response) => { + setSchedulerStatus(response); + return response; + }) + .catch((err) => { + setSchedulerStatusError(errorMessage(err)); + throw err; + }) + .finally(() => { + if (showSpinner) setSchedulerStatusLoading(false); + }); + }, [api]); + + const setSchedulerPaused = useCallback(async (paused: boolean) => { + const action = paused ? "pause" : "resume"; + setSchedulerActioning(action); + setSchedulerStatusError(""); + try { + const response = paused + ? await api.pauseAutomationScheduler() + : await api.resumeAutomationScheduler(); + setSchedulerStatus(response); + await loadConfig(); + return response; + } catch (err) { + setSchedulerStatusError(errorMessage(err)); + throw err; + } finally { + setSchedulerActioning(null); + } + }, [api, loadConfig]); + + const updateConfigDraft = useCallback((patch: Partial) => { + setConfigDraft((current) => (current ? { ...current, ...patch } : current)); + }, []); + + const updateConfigTaskDraft = useCallback(( + task: AutomationTaskKey, + patch: Partial, + ) => { + setConfigDraft((current) => { + if (!current) return current; + return { + ...current, + tasks: { + ...current.tasks, + [task]: { + ...current.tasks[task], + ...patch, + }, + }, + }; + }); + }, []); + + const resetConfigDraft = useCallback(() => { + setConfigDraft(savedConfig ? cloneConfig(savedConfig) : null); + setConfigError(""); + setConfigFieldErrors({}); + }, [savedConfig]); + + const saveConfigDraft = useCallback(async () => { + if (!configDraft) return null; + setConfigSaving(true); + setConfigError(""); + setConfigFieldErrors({}); + try { + const response = await api.patchMemoryAutomationConfig(configToPatch(configDraft)); + applyConfigResponse(response); + return response; + } catch (err) { + setConfigError(errorMessage(err)); + setConfigFieldErrors(configFieldErrorsFromError(err)); + throw err; + } finally { + setConfigSaving(false); + } + }, [api, applyConfigResponse, configDraft]); + + const resetConfigToDefaults = useCallback(async () => { + setConfigResetting(true); + setConfigError(""); + setConfigFieldErrors({}); + try { + const response = await api.resetMemoryAutomationConfig(); + applyConfigResponse(response); + return response; + } catch (err) { + setConfigError(errorMessage(err)); + setConfigFieldErrors(configFieldErrorsFromError(err)); + throw err; + } finally { + setConfigResetting(false); + } + }, [api, applyConfigResponse]); + + return { + configResponse, + configDraft, + configLoading, + configSaving, + configResetting, + configError, + configFieldErrors, + schedulerStatus, + schedulerStatusLoading, + schedulerStatusError, + schedulerActioning, + configDirty, + loadConfig, + loadSchedulerStatus, + setSchedulerPaused, + updateConfigDraft, + updateConfigTaskDraft, + resetConfigDraft, + resetConfigToDefaults, + saveConfigDraft, + }; +} diff --git a/dashboard/holographic/src/curation/useAutomationRuns.ts b/dashboard/holographic/src/curation/useAutomationRuns.ts new file mode 100644 index 00000000..211c4d71 --- /dev/null +++ b/dashboard/holographic/src/curation/useAutomationRuns.ts @@ -0,0 +1,166 @@ +import { useCallback, useEffect, useState } from "react"; + +import type { api as defaultApi } from "../api"; +import type { + MemoryAutomationRunArtifactPayloadResponse, + MemoryAutomationRunArtifactsResponse, + MemoryAutomationRunRecord, + MemoryAutomationRunResponse, + MemoryCurateResponse, +} from "../types"; +import { + AUTOMATION_TASK_BY_ID, + isActiveAutomationStatus, + type AutomationRunApiMethod, + type AutomationRunTask, +} from "./automationTasks"; +import { errorMessage } from "./errors"; + +type AutomationRunsApi = Pick< + typeof defaultApi, + | "getMemoryAutomationRunArtifact" + | "getMemoryAutomationRunArtifacts" + | "getMemoryAutomationRuns" + | AutomationRunApiMethod +>; + +function upsertAutomationRun( + records: MemoryAutomationRunRecord[], + record: MemoryAutomationRunRecord, +): MemoryAutomationRunRecord[] { + return [record, ...records.filter((existing) => existing.run_id !== record.run_id)]; +} + +export function useAutomationRuns({ + api, + pollFastMs, + setActiveTab, + setMemoryPreviewFromRun, + loadActivity, + loadStatus, + loadFactProposals, + loadManagedSkills, +}: { + api: AutomationRunsApi; + pollFastMs: number; + setActiveTab: (tab: "history") => void; + setMemoryPreviewFromRun: (report: MemoryCurateResponse) => void; + loadActivity: (showSpinner?: boolean) => void; + loadStatus: () => void; + loadFactProposals: (showSpinner?: boolean) => Promise; + loadManagedSkills: (showSpinner?: boolean) => Promise; +}) { + const [automationRuns, setAutomationRuns] = useState([]); + const [automationRunsError, setAutomationRunsError] = useState(""); + const [automationRunActioning, setAutomationRunActioning] = + useState(null); + const [automationRunError, setAutomationRunError] = useState(""); + const [automationRunArtifact, setAutomationRunArtifact] = + useState(null); + const [automationRunArtifacts, setAutomationRunArtifacts] = + useState(null); + const [automationRunArtifactLoading, setAutomationRunArtifactLoading] = useState( + null, + ); + const [automationRunArtifactError, setAutomationRunArtifactError] = useState(""); + + const loadAutomationRuns = useCallback(() => { + setAutomationRunsError(""); + return api + .getMemoryAutomationRuns({ limit: 20 }) + .then((response) => { + setAutomationRuns(response.records || []); + if (response.error) setAutomationRunsError(response.error); + return response; + }) + .catch((err) => setAutomationRunsError(errorMessage(err))); + }, [api]); + + const loadAutomationRunArtifact = useCallback((runId: string, kind: string) => { + const key = `${runId}:${kind}`; + setAutomationRunArtifactLoading(key); + setAutomationRunArtifactError(""); + return Promise.all([ + api.getMemoryAutomationRunArtifacts(runId), + api.getMemoryAutomationRunArtifact(runId, kind), + ]) + .then(([artifactsResponse, payloadResponse]) => { + setAutomationRunArtifacts(artifactsResponse); + setAutomationRunArtifact(payloadResponse); + if (artifactsResponse.error || payloadResponse.error) { + setAutomationRunArtifactError(artifactsResponse.error || payloadResponse.error || ""); + } + return payloadResponse; + }) + .catch((err) => { + setAutomationRunArtifactError(errorMessage(err)); + throw err; + }) + .finally(() => setAutomationRunArtifactLoading(null)); + }, [api]); + + const runAutomationTask = useCallback(async (task: AutomationRunTask) => { + setAutomationRunActioning(task); + setAutomationRunError(""); + setActiveTab("history"); + try { + const descriptor = AUTOMATION_TASK_BY_ID[task]; + const response = await api[descriptor.runMethod]({ dry_run: true }); + if (response.ledger_record) { + setAutomationRuns((records) => upsertAutomationRun(records, response.ledger_record)); + } + await loadAutomationRuns(); + if ( + descriptor.refreshTarget === "memory_preview" && + response.report && + !isActiveAutomationStatus(response.status) + ) { + const report = (response as MemoryAutomationRunResponse).report; + if (report) setMemoryPreviewFromRun(report); + loadActivity(false); + loadStatus(); + } else if (descriptor.refreshTarget === "fact_proposals") { + await loadFactProposals(false); + } else if (descriptor.refreshTarget === "managed_skills") { + await loadManagedSkills(false); + } + return response; + } catch (err) { + setAutomationRunError(errorMessage(err)); + throw err; + } finally { + setAutomationRunActioning(null); + } + }, [ + api, + loadActivity, + loadAutomationRuns, + loadFactProposals, + loadManagedSkills, + loadStatus, + setActiveTab, + setMemoryPreviewFromRun, + ]); + + useEffect(() => { + if (!automationRuns.some((record) => isActiveAutomationStatus(record.status))) return; + const timer = setTimeout(() => { + void loadAutomationRuns(); + }, pollFastMs); + return () => clearTimeout(timer); + }, [automationRuns, loadAutomationRuns, pollFastMs]); + + return { + automationRuns, + automationRunsError, + automationRunActioning, + automationRunError, + automationRunArtifacts, + automationRunArtifact, + automationRunArtifactLoading, + automationRunArtifactError, + loadAutomationRuns, + loadAutomationRunArtifact, + runAutomationTask, + }; +} diff --git a/dashboard/holographic/src/curation/useCurationData.ts b/dashboard/holographic/src/curation/useCurationData.ts index 6de59317..f8a48cc5 100644 --- a/dashboard/holographic/src/curation/useCurationData.ts +++ b/dashboard/holographic/src/curation/useCurationData.ts @@ -6,9 +6,44 @@ import type { MemoryCuratorStatusResponse, MemoryOplogEvent, } from "../types"; +import type { AutomationRunApiMethod } from "./automationTasks"; +import { errorMessage } from "./errors"; +import { useAutomationConfig } from "./useAutomationConfig"; +import { useAutomationRuns } from "./useAutomationRuns"; +import { useFactProposals } from "./useFactProposals"; +import { useManagedSkills } from "./useManagedSkills"; export type CurationTab = "plan" | "history" | "activity"; +export type CurationApi = Pick< + typeof defaultApi, + | "applyFactProposal" + | "approveManagedSkill" + | "archiveManagedSkill" + | "disableManagedSkill" + | "discardManagedSkillUpdate" + | "getAutomationSchedulerStatus" + | "getFactProposals" + | "getManagedSkill" + | "getManagedSkills" + | "getMemoryAutomationConfig" + | "getMemoryAutomationRunArtifact" + | "getMemoryAutomationRunArtifacts" + | "getMemoryAutomationRuns" + | "getMemoryCuratorActivity" + | "getMemoryCuratorPreview" + | "getMemoryCuratorStatus" + | "getMemoryOplog" + | "patchMemoryAutomationConfig" + | "pauseAutomationScheduler" + | AutomationRunApiMethod + | "postMemoryCurate" + | "rejectFactProposal" + | "resetMemoryAutomationConfig" + | "restoreManagedSkill" + | "resumeAutomationScheduler" +>; + export function useCurationData({ api = defaultApi, onApplied, @@ -16,7 +51,7 @@ export function useCurationData({ pollFastMs = 900, pollIdleMs = 2500, }: { - api?: typeof defaultApi; + api?: CurationApi; onApplied?: () => void; now?: () => string; pollFastMs?: number; @@ -39,6 +74,50 @@ export function useCurationData({ const [activity, setActivity] = useState([]); const [activityLoading, setActivityLoading] = useState(false); const [activityError, setActivityError] = useState(""); + const { + configResponse, + configDraft, + configLoading, + configSaving, + configResetting, + configError, + configFieldErrors, + schedulerStatus, + schedulerStatusLoading, + schedulerStatusError, + schedulerActioning, + configDirty, + loadConfig, + loadSchedulerStatus, + setSchedulerPaused, + updateConfigDraft, + updateConfigTaskDraft, + resetConfigDraft, + resetConfigToDefaults, + saveConfigDraft, + } = useAutomationConfig(api); + const { + managedSkills, + selectedManagedSkillId, + selectedManagedSkill, + managedSkillUsage, + managedSkillRecommendations, + managedSkillImprovementRecommendations, + managedSkillsLoading, + managedSkillsError, + managedSkillActioning, + loadManagedSkills, + loadManagedSkill, + runManagedSkillAction, + } = useManagedSkills(api); + const { + factProposals, + factProposalsLoading, + factProposalsError, + factProposalActioning, + loadFactProposals, + runFactProposalAction, + } = useFactProposals({ api, onApplied }); const activityRef = useRef(null); const previewSavedAtRef = useRef(null); const previewLoadSeq = useRef(0); @@ -57,6 +136,21 @@ export function useCurationData({ setPreviewStaleReason(staleReason); }, []); + const clearSavedPreview = useCallback(() => { + previewSavedAtRef.current = null; + setReport(null); + setPreviewSavedAt(null); + setPreviewStale(false); + setPreviewStaleReason(""); + }, []); + + const setMemoryPreviewFromRun = useCallback((nextReport: MemoryCurateResponse) => { + setReport(nextReport); + setPreviewSavedAt(null); + setPreviewStale(false); + setPreviewStaleReason(""); + }, []); + const loadSavedPreview = useCallback((force = false) => { const ticket = ++previewLoadSeq.current; return api @@ -71,16 +165,12 @@ export function useCurationData({ response.stale_reason || "", ); } else if (!response.report && !loading && !applying) { - previewSavedAtRef.current = null; - setReport(null); - setPreviewSavedAt(null); - setPreviewStale(false); - setPreviewStaleReason(""); + clearSavedPreview(); } return response; }) .catch(() => {}); - }, [api, applySavedPreview, applying, loading]); + }, [api, applySavedPreview, applying, clearSavedPreview, loading]); const loadActivity = useCallback((showSpinner = false) => { if (showSpinner) setActivityLoading(true); @@ -97,7 +187,7 @@ export function useCurationData({ loadSavedPreview(false); } }) - .catch((err) => setActivityError(err instanceof Error ? err.message : String(err))) + .catch((err) => setActivityError(errorMessage(err))) .finally(() => { if (showSpinner) setActivityLoading(false); }); @@ -109,7 +199,7 @@ export function useCurationData({ api .getMemoryCuratorStatus() .then((response) => setStatus(response)) - .catch((err) => setStatusError(err instanceof Error ? err.message : String(err))) + .catch((err) => setStatusError(errorMessage(err))) .finally(() => setStatusLoading(false)); }, [api]); @@ -121,13 +211,44 @@ export function useCurationData({ setOplog(response.events || []); if (response.error) setOplogError(response.error); }) - .catch((err) => setOplogError(err instanceof Error ? err.message : String(err))); + .catch((err) => setOplogError(errorMessage(err))); }, [api]); + const { + automationRuns, + automationRunsError, + automationRunActioning, + automationRunError, + automationRunArtifacts, + automationRunArtifact, + automationRunArtifactLoading, + automationRunArtifactError, + loadAutomationRuns, + loadAutomationRunArtifact, + runAutomationTask, + } = useAutomationRuns({ + api, + pollFastMs, + setActiveTab, + setMemoryPreviewFromRun, + loadActivity, + loadStatus, + loadFactProposals, + loadManagedSkills, + }); + useEffect(() => { loadSavedPreview(true); }, [loadSavedPreview]); + useEffect(() => { + loadConfig().catch(() => {}); + }, [loadConfig]); + + useEffect(() => { + loadSchedulerStatus(false).catch(() => {}); + }, [loadSchedulerStatus]); + const preview = useCallback(async () => { setLoading(true); setError(""); @@ -135,24 +256,20 @@ export function useCurationData({ loadActivity(true); try { const response = await api.postMemoryCurate({ dry_run: true }); - setReport(response); const savedAt = now(); - previewSavedAtRef.current = savedAt; - setPreviewSavedAt(savedAt); - setPreviewStale(false); - setPreviewStaleReason(""); + applySavedPreview(response, savedAt); await loadSavedPreview(true); loadActivity(); loadStatus(); setActiveTab("plan"); return response; } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errorMessage(err)); throw err; } finally { setLoading(false); } - }, [api, loadActivity, loadSavedPreview, loadStatus, now]); + }, [api, applySavedPreview, loadActivity, loadSavedPreview, loadStatus, now]); const apply = useCallback(async () => { previewLoadSeq.current += 1; @@ -162,23 +279,19 @@ export function useCurationData({ loadActivity(true); try { const response = await api.postMemoryCurate({ dry_run: false }); - setReport(response); - previewSavedAtRef.current = null; - setPreviewSavedAt(null); - setPreviewStale(false); - setPreviewStaleReason(""); + applySavedPreview(response, null); setConfirmOpen(false); loadActivity(); loadStatus(); onApplied?.(); return response; } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError(errorMessage(err)); throw err; } finally { setApplying(false); } - }, [api, loadActivity, loadStatus, onApplied]); + }, [api, applySavedPreview, loadActivity, loadStatus, onApplied]); useEffect(() => { if (activeTab === "plan" && !loading && !applying) { @@ -195,8 +308,19 @@ export function useCurationData({ useEffect(() => { if (activeTab === "history") { loadOplog(); + loadAutomationRuns(); + loadSchedulerStatus(false).catch(() => {}); + loadFactProposals(); + loadManagedSkills(); } - }, [activeTab, loadOplog]); + }, [ + activeTab, + loadAutomationRuns, + loadFactProposals, + loadManagedSkills, + loadOplog, + loadSchedulerStatus, + ]); useEffect(() => { if (activeTab === "activity" && activity.length === 0) { @@ -234,17 +358,66 @@ export function useCurationData({ statusError, oplog, oplogError, + automationRuns, + automationRunsError, + automationRunActioning, + automationRunError, + automationRunArtifacts, + automationRunArtifact, + automationRunArtifactLoading, + automationRunArtifactError, + factProposals, + factProposalsLoading, + factProposalsError, + factProposalActioning, + managedSkills, + selectedManagedSkillId, + selectedManagedSkill, + managedSkillUsage, + managedSkillRecommendations, + managedSkillImprovementRecommendations, + managedSkillsLoading, + managedSkillsError, + managedSkillActioning, activity, activityLoading, activityError, + configResponse, + configDraft, + configLoading, + configSaving, + configResetting, + configError, + configFieldErrors, + schedulerStatus, + schedulerStatusLoading, + schedulerStatusError, + schedulerActioning, + configDirty, activityRef, panelRef, setConfirmOpen, setActiveTab, preview, apply, + runAutomationTask, loadActivity, loadStatus, loadOplog, + loadAutomationRuns, + loadAutomationRunArtifact, + loadFactProposals, + runFactProposalAction, + loadManagedSkills, + loadManagedSkill, + runManagedSkillAction, + loadConfig, + loadSchedulerStatus, + setSchedulerPaused, + updateConfigDraft, + updateConfigTaskDraft, + resetConfigDraft, + resetConfigToDefaults, + saveConfigDraft, }; } diff --git a/dashboard/holographic/src/curation/useFactProposals.ts b/dashboard/holographic/src/curation/useFactProposals.ts new file mode 100644 index 00000000..33f1d6fb --- /dev/null +++ b/dashboard/holographic/src/curation/useFactProposals.ts @@ -0,0 +1,78 @@ +import { useCallback, useState } from "react"; + +import type { FactProposalRecord } from "../types"; +import { errorMessage } from "./errors"; +import type { CurationApi } from "./useCurationData"; + +type FactProposalAction = "apply" | "reject"; + +export function useFactProposals({ + api, + onApplied, +}: { + api: CurationApi; + onApplied?: () => void; +}) { + const [factProposals, setFactProposals] = useState([]); + const [factProposalsLoading, setFactProposalsLoading] = useState(false); + const [factProposalsError, setFactProposalsError] = useState(""); + const [factProposalActioning, setFactProposalActioning] = useState(null); + + const loadFactProposals = useCallback((showSpinner = false) => { + if (showSpinner) setFactProposalsLoading(true); + setFactProposalsError(""); + return api + .getFactProposals({ limit: 50 }) + .then((response) => { + setFactProposals(response.proposals || []); + if (response.error) setFactProposalsError(response.error); + return response; + }) + .catch((err) => { + setFactProposalsError(errorMessage(err)); + throw err; + }) + .finally(() => { + if (showSpinner) setFactProposalsLoading(false); + }); + }, [api]); + + const runFactProposalAction = useCallback(async ( + action: FactProposalAction, + id: string, + ) => { + setFactProposalActioning(`${id}:${action}`); + setFactProposalsError(""); + try { + const response = action === "apply" + ? await api.applyFactProposal(id) + : await api.rejectFactProposal(id, "rejected from dashboard"); + setFactProposals((current) => + current.map((proposal) => + proposal.proposal_id === response.proposal.proposal_id + ? response.proposal + : proposal + ) + ); + await loadFactProposals(false); + if (action === "apply") { + onApplied?.(); + } + return response; + } catch (err) { + setFactProposalsError(errorMessage(err)); + throw err; + } finally { + setFactProposalActioning(null); + } + }, [api, loadFactProposals, onApplied]); + + return { + factProposals, + factProposalsLoading, + factProposalsError, + factProposalActioning, + loadFactProposals, + runFactProposalAction, + }; +} diff --git a/dashboard/holographic/src/curation/useManagedSkills.ts b/dashboard/holographic/src/curation/useManagedSkills.ts new file mode 100644 index 00000000..d66a5c5e --- /dev/null +++ b/dashboard/holographic/src/curation/useManagedSkills.ts @@ -0,0 +1,150 @@ +import { useCallback, useState } from "react"; + +import type { + ManagedSkill, + ManagedSkillResponse, + SkillImprovementRecommendation, + SkillStaleRecommendation, + SkillUsageSummary, +} from "../types"; +import { errorMessage } from "./errors"; +import type { CurationApi } from "./useCurationData"; + +type ManagedSkillAction = "approve" | "discard-update" | "disable" | "archive" | "restore"; + +function indexBySkillId(items: T[] = []): Record { + return Object.fromEntries(items.map((item) => [item.skill_id, item])); +} + +export function useManagedSkills(api: CurationApi) { + const [managedSkills, setManagedSkills] = useState([]); + const [selectedManagedSkillId, setSelectedManagedSkillId] = useState(null); + const [selectedManagedSkill, setSelectedManagedSkill] = useState(null); + const [managedSkillUsage, setManagedSkillUsage] = useState>({}); + const [managedSkillRecommendations, setManagedSkillRecommendations] = useState< + Record + >({}); + const [ + managedSkillImprovementRecommendations, + setManagedSkillImprovementRecommendations, + ] = useState>({}); + const [managedSkillsLoading, setManagedSkillsLoading] = useState(false); + const [managedSkillsError, setManagedSkillsError] = useState(""); + const [managedSkillActioning, setManagedSkillActioning] = useState(null); + + const applyManagedSkillResponse = useCallback((response: ManagedSkillResponse) => { + const skillId = response.skill.metadata.id; + setSelectedManagedSkillId(skillId); + setSelectedManagedSkill(response.skill); + if (response.usage_summary) { + setManagedSkillUsage((current) => ({ + ...current, + [skillId]: response.usage_summary, + })); + } + if (response.stale_recommendation) { + setManagedSkillRecommendations((current) => ({ + ...current, + [skillId]: response.stale_recommendation, + })); + } + if (response.improvement_recommendation) { + setManagedSkillImprovementRecommendations((current) => ({ + ...current, + [skillId]: response.improvement_recommendation, + })); + } + }, []); + + const loadManagedSkill = useCallback((id: string) => { + setManagedSkillsError(""); + return api + .getManagedSkill(id) + .then((response) => { + applyManagedSkillResponse(response); + return response.skill; + }) + .catch((err) => { + setManagedSkillsError(errorMessage(err)); + throw err; + }); + }, [api, applyManagedSkillResponse]); + + const loadManagedSkills = useCallback((showSpinner = false) => { + if (showSpinner) setManagedSkillsLoading(true); + setManagedSkillsError(""); + return api + .getManagedSkills() + .then(async (response) => { + const skills = response.skills || []; + setManagedSkills(skills); + setManagedSkillUsage(indexBySkillId(response.usage_summaries)); + setManagedSkillRecommendations(indexBySkillId(response.stale_recommendations)); + setManagedSkillImprovementRecommendations( + indexBySkillId(response.improvement_recommendations), + ); + const nextId = selectedManagedSkillId && skills.some((skill) => + skill.metadata.id === selectedManagedSkillId + ) + ? selectedManagedSkillId + : (skills[0]?.metadata.id ?? null); + setSelectedManagedSkillId(nextId); + if (nextId) { + await loadManagedSkill(nextId); + } else { + setSelectedManagedSkill(null); + } + if (response.error) setManagedSkillsError(response.error); + return response; + }) + .catch((err) => { + setManagedSkillsError(errorMessage(err)); + throw err; + }) + .finally(() => { + if (showSpinner) setManagedSkillsLoading(false); + }); + }, [api, loadManagedSkill, selectedManagedSkillId]); + + const runManagedSkillAction = useCallback(async ( + action: ManagedSkillAction, + id = selectedManagedSkillId, + ) => { + if (!id) return null; + setManagedSkillActioning(`${id}:${action}`); + setManagedSkillsError(""); + try { + const call = { + approve: api.approveManagedSkill, + "discard-update": api.discardManagedSkillUpdate, + disable: api.disableManagedSkill, + archive: api.archiveManagedSkill, + restore: api.restoreManagedSkill, + }[action]; + const response = await call(id); + applyManagedSkillResponse(response); + await loadManagedSkills(false); + return response; + } catch (err) { + setManagedSkillsError(errorMessage(err)); + throw err; + } finally { + setManagedSkillActioning(null); + } + }, [api, applyManagedSkillResponse, loadManagedSkills, selectedManagedSkillId]); + + return { + managedSkills, + selectedManagedSkillId, + selectedManagedSkill, + managedSkillUsage, + managedSkillRecommendations, + managedSkillImprovementRecommendations, + managedSkillsLoading, + managedSkillsError, + managedSkillActioning, + loadManagedSkills, + loadManagedSkill, + runManagedSkillAction, + }; +} diff --git a/dashboard/holographic/src/styles.css b/dashboard/holographic/src/styles.css index 637ed3b3..b5cd539e 100644 --- a/dashboard/holographic/src/styles.css +++ b/dashboard/holographic/src/styles.css @@ -27,6 +27,7 @@ --color-border: rgba(139, 255, 218, 0.16); --color-primary: #75f4d2; --color-secondary: #7aa7ff; + --color-accent: #7aa7ff; --color-muted: rgba(117, 244, 210, 0.1); --color-muted-foreground: #6f9189; --color-destructive: #ff6b7a; @@ -191,23 +192,40 @@ input[type="range"] { gap: 0.35rem; min-height: 4.1rem; align-content: start; + /* Override the shared .tdp-stat defaults with tighter radius, holographic + * sheen, and SystemStrip's denser padding. */ + border: 1px solid var(--hm-line); border-radius: 14px; + padding: 0.5rem 0.75rem; background: linear-gradient(180deg, color-mix(in srgb, var(--hm-text) 4%, transparent), transparent 42%), color-mix(in srgb, var(--hm-bg) 48%, transparent); } -.hm-stat-value { +.hm-stat .tdp-stat-value { min-width: 0; overflow: hidden; text-overflow: ellipsis; overflow-wrap: anywhere; + color: var(--hm-text); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 1.125rem; + line-height: 1; } -.hm-stat-label { +.hm-stat .tdp-stat-label { margin-top: 0; line-height: 1.25; text-transform: lowercase; + color: var(--hm-text-3); + font-size: 0.75rem; + letter-spacing: 0.08em; +} + +/* The storage-path stat shows a long filesystem path; shrink it and let the + * shared .tdp-stat-value ellipsis handle truncation. */ +.hm-path-stat .tdp-stat-value { + font-size: 0.75rem; } /* Reset default browser paragraph margins for plain card copy only. Paragraphs @@ -282,12 +300,12 @@ input[type="range"] { border-radius: 12px; } - .hm-system-strip .hm-stat-value { + .hm-system-strip .hm-stat .tdp-stat-value { font-size: 0.95rem; line-height: 1.1; } - .hm-system-strip .hm-stat-label { + .hm-system-strip .hm-stat .tdp-stat-label { font-size: 0.66rem; letter-spacing: 0.05em; } diff --git a/dashboard/holographic/src/types.ts b/dashboard/holographic/src/types.ts index 08d25b7c..02cd310d 100644 --- a/dashboard/holographic/src/types.ts +++ b/dashboard/holographic/src/types.ts @@ -45,8 +45,8 @@ export interface HolographicFact { trust_score: number; retrieval_count: number; helpful_count: number; - created_at: string; - updated_at: string; + created_at: number; + updated_at: number; has_hrr?: boolean | number; snippet?: string; } @@ -282,11 +282,14 @@ export interface MemoryCurateAction { op: string; tier?: string; reason?: string; + confidence?: number; fact_id?: number; duplicate_of?: number; entity_id?: number; loser?: number; winner?: number; + loser_ids?: number[]; + winner_id?: number; loser_entity?: number; winner_entity?: number; name?: string; @@ -322,11 +325,6 @@ export interface MemoryCurateHygieneCandidate { content?: string; } -/** - * Wire contract for `POST /api/plugins/holographic/curate`: the curation - * plan/report. With `dry_run` the actions are proposals; otherwise - * `applied_counts`/`apply_errors` describe what was actually executed. - */ /** Deterministic rule-based hygiene candidates (never auto-applied). */ export interface MemoryCurateHygieneCandidates { secret_like: MemoryCurateHygieneCandidate[]; @@ -334,11 +332,22 @@ export interface MemoryCurateHygieneCandidates { supersession: MemoryCurateHygieneCandidate[]; } +/** + * Wire contract for `POST /api/plugins/holographic/curate`: the curation + * plan/report. With `dry_run` the actions are proposals; otherwise + * `applied_counts`/`apply_errors` describe what was actually executed. + */ export interface MemoryCurateResponse { provider?: string; ran: boolean; dry_run: boolean; actions: MemoryCurateAction[]; + llm_apply?: { + ops?: MemoryCurateAction[]; + rejected_ops?: unknown[]; + note?: string; + [key: string]: unknown; + }; hygiene_candidates?: MemoryCurateHygieneCandidates; counts: Record; applied_counts?: Record; @@ -372,6 +381,367 @@ export interface MemoryCuratorPreviewResponse { error?: string; } +export type AutomationBackend = "disabled" | "codex_app_server" | "external_command"; +export type SelectableAutomationBackend = Exclude; + +export type AutomationHostMode = "standalone" | "delegated_host"; + +export interface AutomationBackendAvailability { + backend: AutomationBackend; + available: boolean; + executable?: string | null; + reason?: string | null; +} + +export interface AutomationTaskConfig { + enabled: boolean; + schedule?: string | null; + interval_secs?: number | null; + cooldown_secs?: number | null; + min_idle_secs?: number | null; + stale_lock_secs?: number | null; +} + +export interface AutomationTaskSet { + memory_curator: AutomationTaskConfig; + session_reflector: AutomationTaskConfig; + skill_writer: AutomationTaskConfig; +} + +export interface MemoryAutomationConfig { + enabled: boolean; + backend: AutomationBackend; + host_mode: AutomationHostMode; + model?: string | null; + timeout_secs: number; + scheduler_tick_secs: number; + max_tokens?: number | null; + temperature?: number | null; + require_dashboard_approval: boolean; + auto_apply_memory_ops: boolean; + auto_enable_skills: boolean; + tasks: AutomationTaskSet; +} + +export interface AutomationTaskPatch { + enabled?: boolean; + schedule?: string | null; + interval_secs?: number | null; + cooldown_secs?: number | null; + min_idle_secs?: number | null; + stale_lock_secs?: number | null; +} + +export interface MemoryAutomationConfigPatch { + enabled?: boolean; + backend?: SelectableAutomationBackend; + host_mode?: AutomationHostMode; + model?: string | null; + timeout_secs?: number; + scheduler_tick_secs?: number; + max_tokens?: number | null; + temperature?: number | null; + require_dashboard_approval?: boolean; + auto_apply_memory_ops?: boolean; + auto_enable_skills?: boolean; + memory_curator?: AutomationTaskPatch; + session_reflector?: AutomationTaskPatch; + skill_writer?: AutomationTaskPatch; +} + +export interface MemoryAutomationConfigResponse { + global: MemoryAutomationConfig; + project: MemoryAutomationConfigPatch | null; + effective: MemoryAutomationConfig; + backend_availability?: AutomationBackendAvailability; + project_config_path?: string; +} + +export interface AutomationSchedulerTaskStatus { + task: "memory_curator" | "session_reflector" | "skill_writer" | string; + due: boolean; + skip_reason?: string | null; + last_scheduler_run?: MemoryAutomationRunRecord | null; +} + +export interface AutomationSchedulerStatusResponse { + status: string; + paused: boolean; + enabled: boolean; + scheduler_tick_secs: number; + now: number; + project_config_path?: string; + control_path?: string; + tasks: AutomationSchedulerTaskStatus[]; +} + +export interface MemoryAgentPlanResponse> { + run_id: string; + dry_run: true; + status: string; + report?: TReport; + ledger_record: MemoryAutomationRunRecord; + backend_response?: unknown; + error?: string; +} + +export interface AutomationRunRequest { + dry_run?: true; + provider?: string; + query?: string; + evidence_limit?: number; + storage_scope?: "project_local" | "hermes_profile" | string; + hermes_home?: string; + scope?: "all" | "session" | "current" | string; + session_id?: string; + include_summaries?: boolean; + sort?: "recency" | "relevance" | "hybrid" | string; + source?: string; + role?: string; + start_time?: number; + end_time?: number; +} + +export type MemoryAutomationRunResponse> = + MemoryAgentPlanResponse; + +export interface MemoryAutomationRunRecord { + schema_version: number; + run_id: string; + trigger: "manual_cli" | "dashboard" | "scheduler" | string; + task: "memory_curator" | "session_reflector" | "skill_writer" | string; + task_key?: string | null; + backend: string; + host_mode?: "standalone" | "delegated_host" | string | null; + prompt_version?: string | null; + response_schema?: unknown; + strict_json?: boolean | null; + model?: string | null; + status: "queued" | "running" | "succeeded" | "failed" | "skipped" | string; + evidence_hash?: string | null; + input_hash?: string | null; + output_hash?: string | null; + proposed_ops?: unknown; + applied_ops?: unknown; + rejected_ops?: unknown; + validation_report?: unknown; + reviewed_count?: number; + accepted_count: number; + rejected_count: number; + skipped_count?: number; + error?: string | null; + error_classification?: + | "retryable" + | "permanent" + | "timeout" + | "unavailable" + | "malformed_output" + | string + | null; + error_retryable?: boolean | null; + fallback_status?: string | null; + report_ref?: unknown; + artifacts?: MemoryAutomationRunArtifact[]; + started_at: string; + completed_at: string; +} + +export interface MemoryAutomationRunArtifact { + schema_version: number; + kind: string; + path: string; + sha256: string; + summary?: string | null; + created_at: string; +} + +export interface MemoryAutomationRunArtifactsResponse { + run_id: string; + artifacts: MemoryAutomationRunArtifact[]; + artifact_chain?: { + expected_kinds?: string[]; + present_kinds?: string[]; + complete?: boolean; + }; + count: number; + error?: string; +} + +export interface MemoryAutomationRunArtifactPayloadResponse { + run_id: string; + artifact: MemoryAutomationRunArtifact; + payload: unknown; + error?: string; +} + +export interface MemoryAutomationRunsResponse { + records: MemoryAutomationRunRecord[]; + count: number; + limit: number; + error?: string; +} + +export type ManagedSkillSource = "automation_run" | "user_draft" | "import" | string; + +export type ManagedSkillState = + | "pending_approval" + | "active" + | "disabled" + | "archived" + | string; + +export type SkillInstallTarget = + | "cursor" + | "codex" + | "claude" + | "agents" + | "opencode" + | "kimi" + | "kiro" + | "hermes" + | string; + +export interface ManagedSkillProvenance { + source: ManagedSkillSource; + actor: string; + run_id?: string | null; +} + +export interface ManagedSkillMetadata { + id: string; + title: string; + summary: string; + category: string; + targets: SkillInstallTarget[]; + state: ManagedSkillState; + checksum: string; + provenance: ManagedSkillProvenance; + pinned: boolean; + created_at: number; + updated_at: number; +} + +export interface ManagedSupportFile { + path: string; + bytes: number[]; +} + +export interface ManagedSkill { + metadata: ManagedSkillMetadata; + body_markdown: string; + support_files: ManagedSupportFile[]; + pending_update?: ManagedSkillPendingUpdate | null; +} + +export interface ManagedSkillPendingUpdate { + base_checksum: string; + staged_at: number; + metadata: ManagedSkillMetadata; + body_markdown: string; + support_files: ManagedSupportFile[]; +} + +export interface SkillUsageSummary { + schema_version: number; + skill_id: string; + title?: string | null; + category?: string | null; + state?: ManagedSkillState | null; + pinned: boolean; + created_by?: string | null; + provenance_source?: ManagedSkillSource | null; + targets: string[]; + view_count: number; + use_count: number; + patch_count: number; + first_seen_at: number; + last_activity_at: number; + last_viewed_at?: number | null; + last_used_at?: number | null; + last_patched_at?: number | null; +} + +export interface SkillStaleRecommendation { + skill_id: string; + stale: boolean; + recommendation: string; + reason: string; + evidence: string[]; +} + +export interface SkillImprovementRecommendation { + skill_id: string; + improvement: boolean; + recommendation: string; + reason: string; + priority: string; + evidence: string[]; +} + +export interface ManagedSkillListResponse { + profile_root: string; + skills_root: string; + count: number; + skills: ManagedSkill[]; + skill_metadata: ManagedSkillMetadata[]; + usage_summaries?: SkillUsageSummary[]; + stale_recommendations?: SkillStaleRecommendation[]; + improvement_recommendations?: SkillImprovementRecommendation[]; + error?: string; +} + +export interface ManagedSkillResponse { + profile_root: string; + skills_root: string; + skill_dir: string; + skill: ManagedSkill; + usage_summary?: SkillUsageSummary; + stale_recommendation?: SkillStaleRecommendation | null; + improvement_recommendation?: SkillImprovementRecommendation | null; + error?: string; +} + +export type FactProposalState = + | "pending_approval" + | "applied" + | "rejected" + | string; + +export interface FactProposalRecord { + schema_version: number; + proposal_id: string; + run_id: string; + evidence_hash?: string | null; + state: FactProposalState; + add_fact_request?: { + content?: string; + type?: string; + category?: string; + tags?: string[]; + [key: string]: unknown; + } | null; + proposal?: unknown; + validation_reason?: string | null; + validation?: unknown; + reviewer?: string | null; + applied_fact_id?: number | null; + apply_outcome?: unknown; + created_at: number; + updated_at: number; +} + +export interface FactProposalListResponse { + proposals: FactProposalRecord[]; + count: number; + limit: number; + error?: string; +} + +export interface FactProposalResponse { + proposal: FactProposalRecord; + error?: string; +} + /** * Wire contract for `GET /api/plugins/holographic/curation/status`: scheduler * state, resolved curator configuration, and recent snapshot files. diff --git a/dashboard/lib/primitives.css b/dashboard/lib/primitives.css index 673f8f97..852d8702 100644 --- a/dashboard/lib/primitives.css +++ b/dashboard/lib/primitives.css @@ -1,4 +1,13 @@ -/* Shared dashboard primitives (`tdp-*`), prepended to opted-in plugin CSS. */ +/* Shared dashboard primitives (`tdp-*`), prepended to opted-in plugin CSS. + * + * Wrapped in `@layer hermes-plugin` so that, inside a plugin bundle, these + * defaults are merged into the same layer as the plugin's own rules and LOSE + * ties to plugin overrides by source order (primitives are prepended first). + * Plugins whose sheets compile unlayered (tailwind:false — lcm/savings/graph) + * still beat this layer, so their `.hermes-* .tdp-*` overrides continue to win. + * Without this layer, an unlayered `.tdp-stat` would defeat a layered `.hm-stat` + * in the holographic (tailwind:true) bundle, regressing the Stat visuals. */ +@layer hermes-plugin { /* ----------------------------------------------------------- EmptyState */ .tdp-empty { @@ -94,6 +103,11 @@ background: color-mix(in srgb, var(--color-background) 26%, transparent); } +/* When a native tooltip (title) is attached, signal it's hoverable help. */ +.tdp-stat[title] { + cursor: help; +} + .tdp-stat-value { color: var(--color-foreground); font-family: var(--font-mono, ui-monospace, monospace); @@ -231,3 +245,5 @@ border-radius: inherit; background: color-mix(in srgb, var(--color-primary, #5a7fff) 90%, white 10%); } + +} /* end @layer hermes-plugin */ diff --git a/dashboard/lib/primitives.tsx b/dashboard/lib/primitives.tsx index 273a7a66..c2eac3da 100644 --- a/dashboard/lib/primitives.tsx +++ b/dashboard/lib/primitives.tsx @@ -70,22 +70,32 @@ export function SkeletonLines({ ); } -/** Big-value + small-label stat tile. */ +/** Big-value + small-label stat tile. + * + * `title` surfaces a plain-language explanation as a native browser tooltip + * (and a `help` cursor) on the whole tile — used by holographic stat rows that + * need to explain what a number means without adding visible chrome. */ export function Stat({ label, value, hint, + title, variant = "default", className, }: { label: string; value: React.ReactNode; hint?: string; + /** Native tooltip text for the whole tile; sets cursor: help when present. */ + title?: string; variant?: "default" | "compact"; className?: string; }) { return ( -
+
{value}
{label}
{hint &&
{hint}
} diff --git a/dashboard/savings/src/DiagnosticsPanel.tsx b/dashboard/savings/src/DiagnosticsPanel.tsx new file mode 100644 index 00000000..ec8e99f3 --- /dev/null +++ b/dashboard/savings/src/DiagnosticsPanel.tsx @@ -0,0 +1,184 @@ +import React from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "../../lib/sdk"; +import { BarList, EmptyState, Stat } from "../../lib/primitives"; +import { fmtTokens } from "./logic"; +import type { + DiagnosticsCountRow, + DiagnosticsRecentEvent, + DiagnosticsRecentHook, + DiagnosticsResponse, +} from "./types"; + +function fmtRatio(value: number | undefined): string { + return value == null ? "0.00" : value.toFixed(2); +} + +function rowLabel(row: DiagnosticsCountRow, key: string): string { + return String(row[key] || "(none)"); +} + +function countRows( + rows: DiagnosticsCountRow[], + key: string, +): Array<{ label: string; value: number }> { + return rows + .slice(0, 12) + .map((row) => ({ label: rowLabel(row, key), value: Number(row.count) || 0 })); +} + +function EventTable({ rows }: { rows: DiagnosticsRecentEvent[] }) { + if (!rows.length) return No recent events; + return ( +
+ + + + + + + + + + + {rows.slice(0, 10).map((row, index) => ( + + + + + + + ))} + +
KindToolHookOutcome
{row.event_kind || "-"}{row.tool_name || "-"}{row.hook_name || "-"}{row.outcome || "-"}
+
+ ); +} + +function HookTable({ rows }: { rows: DiagnosticsRecentHook[] }) { + if (!rows.length) return No recent hooks; + return ( +
+ + + + + + + + + + + {rows.slice(0, 10).map((row, index) => ( + + + + + + + ))} + +
AgentHookToolPrompt
{row.agent || "-"}{row.hook_name || "-"}{row.tool_name || "-"}{row.prompt_category || "-"}
+
+ ); +} + +export default function DiagnosticsPanel({ data }: { data: DiagnosticsResponse | null }) { + if (!data) return Loading diagnostics...; + + return ( +
+
+ + + + + +
+ +
+ + + + +
+ +
+ + + Tool Categories + + + + + + + + MCP Tools + + + + + + + + Hooks + + + + + + + + Prompt Categories + + + + + + + + Outcomes + + + + + +
+ +
+ + + Recent Events + + + + + + + + Recent Hooks + + + + + +
+ +
+ source: {data.source} +
+
+ ); +} diff --git a/dashboard/savings/src/SavingsExplorer.tsx b/dashboard/savings/src/SavingsExplorer.tsx index 2693284e..6c40790d 100644 --- a/dashboard/savings/src/SavingsExplorer.tsx +++ b/dashboard/savings/src/SavingsExplorer.tsx @@ -13,7 +13,9 @@ import type { PriceTable } from "./pricing"; import SavingsOverviewPanel from "./SavingsOverviewPanel"; import SessionsPanel from "./SessionsPanel"; import ModelsPanel from "./ModelsPanel"; +import DiagnosticsPanel from "./DiagnosticsPanel"; import type { + DiagnosticsResponse, LedgerResponse, ModelsResponse, PricingResponse, @@ -25,6 +27,7 @@ const VIEWS = [ { id: "savings", label: "Savings" }, { id: "sessions", label: "Sessions" }, { id: "models", label: "Models & Pricing" }, + { id: "diagnostics", label: "Diagnostics" }, ] as const; type ViewId = (typeof VIEWS)[number]["id"]; @@ -46,6 +49,7 @@ export default function SavingsExplorer() { const [ledger, setLedger] = useState(null); const [sessions, setSessions] = useState(null); const [models, setModels] = useState(null); + const [diagnostics, setDiagnostics] = useState(null); const [pricing, setPricing] = useState(null); const [error, setError] = useState(""); // Bumped by the Retry button; every fetch effect below depends on it. @@ -112,6 +116,11 @@ export default function SavingsExplorer() { return fetchIntoState(() => api.models({ range }), setModels, { clearBeforeLoad: true }); }, [view, range, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (view !== "diagnostics") return; + return fetchIntoState(() => api.diagnostics(), setDiagnostics, { clearBeforeLoad: true }); + }, [view, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + const sessionStats = overview?.sessions; return ( @@ -193,6 +202,7 @@ export default function SavingsExplorer() { {view === "models" && ( )} + {view === "diagnostics" && }
); } diff --git a/dashboard/savings/src/api.ts b/dashboard/savings/src/api.ts index eb71e9ec..96a4f86b 100644 --- a/dashboard/savings/src/api.ts +++ b/dashboard/savings/src/api.ts @@ -1,6 +1,7 @@ import { fetchJSON } from "../../lib/sdk"; import { qs } from "../../lib/qs"; import type { + DiagnosticsResponse, LedgerResponse, ModelsResponse, PricingResponse, @@ -9,6 +10,7 @@ import type { } from "./types"; const BASE = "/api/plugins/savings"; +const ANALYTICS_BASE = "/api/plugins/analytics"; export const api = { overview: () => fetchJSON(`${BASE}/overview`), @@ -18,5 +20,7 @@ export const api = { fetchJSON(`${BASE}/sessions${qs(params)}`), models: (params: { range?: string } = {}) => fetchJSON(`${BASE}/models${qs(params)}`), + diagnostics: () => + fetchJSON(`${ANALYTICS_BASE}/diagnostics`), pricing: () => fetchJSON(`${BASE}/pricing`), }; diff --git a/dashboard/savings/src/styles.css b/dashboard/savings/src/styles.css index d83b9455..d235b7d9 100644 --- a/dashboard/savings/src/styles.css +++ b/dashboard/savings/src/styles.css @@ -343,28 +343,6 @@ background: color-mix(in srgb, var(--ts-red, #ff7a7a) 8%, transparent); } -.tss-empty { - border: 1px dashed var(--ts-line-strong); - border-radius: var(--ts-radius); - color: var(--ts-text-2); - display: grid; - gap: 0.4rem; - justify-items: start; - padding: 1.4rem 1.2rem; -} - -.tss-empty h3 { - color: var(--ts-text); - font-size: 0.95rem; - margin: 0; -} - -.tss-empty p { - font-size: 0.8rem; - line-height: 1.5; - margin: 0; -} - .tss-empty-mini { color: var(--ts-text-3); font-size: 0.78rem; diff --git a/dashboard/savings/src/types.ts b/dashboard/savings/src/types.ts index cdd05880..d67d6bef 100644 --- a/dashboard/savings/src/types.ts +++ b/dashboard/savings/src/types.ts @@ -142,6 +142,55 @@ export interface ModelsResponse { }; } +export interface DiagnosticsCountRow { + count: number; + [key: string]: string | number; +} + +export interface DiagnosticsRecentEvent { + timestamp?: number | null; + event_kind?: string; + hook_name?: string; + tool_name?: string; + outcome?: string; +} + +export interface DiagnosticsRecentHook { + ts_unix_ms?: number | null; + agent?: string; + hook_name?: string; + session_id?: string; + tool_name?: string; + prompt_category?: string; +} + +export interface DiagnosticsResponse { + available: boolean; + source: string; + message_count: number; + event_count: number; + tool_call_count: number; + mcp_tool_call_count: number; + tracedecay_call_count: number; + hook_call_count: number; + events_per_hour?: number; + ratios: { + events_per_message: number; + tool_calls_per_message: number; + mcp_tool_calls_per_message: number; + hook_calls_per_message: number; + }; + by_event_kind: DiagnosticsCountRow[]; + by_tool: DiagnosticsCountRow[]; + by_mcp_tool: DiagnosticsCountRow[]; + by_tool_category: DiagnosticsCountRow[]; + by_outcome: DiagnosticsCountRow[]; + by_hook: DiagnosticsCountRow[]; + by_prompt_category: DiagnosticsCountRow[]; + recent_events: DiagnosticsRecentEvent[]; + recent_hooks: DiagnosticsRecentHook[]; +} + export interface PricingResponse { source: string; fetched_at: number | null; diff --git a/dashboard/shell/src/sdk.jsx b/dashboard/shell/src/sdk.jsx index dcd93585..849e8d82 100644 --- a/dashboard/shell/src/sdk.jsx +++ b/dashboard/shell/src/sdk.jsx @@ -33,13 +33,16 @@ export async function fetchJSON(url, init) { const res = await fetch(url, init); if (!res.ok) { let detail = `${res.status} ${res.statusText}`; + let body; try { - const body = await res.json(); + body = await res.json(); if (body && body.detail) detail = String(body.detail); } catch { /* non-JSON error body */ } - throw new Error(detail); + const error = new Error(detail); + if (body !== undefined) error.body = body; + throw error; } return res.json(); } diff --git a/dashboard/shell/src/styles.css b/dashboard/shell/src/styles.css index e9dc3c72..9732fbb5 100644 --- a/dashboard/shell/src/styles.css +++ b/dashboard/shell/src/styles.css @@ -137,6 +137,11 @@ --color-muted: rgba(117, 244, 210, 0.1); --color-muted-foreground: var(--ts-text-3); --color-secondary: rgba(122, 167, 255, 0.1); + /* Gotcha: --color-secondary is a 10%-opacity blue TINT, not a readable text + color, so the Tailwind `text-secondary` utility is near-invisible as + foreground. Use `text-text-secondary` (which maps to --ts-text-2) for + secondary copy; reserve `bg-secondary`/`border-secondary` for surfaces. */ + --color-accent: var(--ts-blue); --color-destructive: var(--ts-red); --color-warning: var(--ts-amber); --color-success: var(--ts-green); @@ -779,6 +784,7 @@ select:focus-visible { --color-muted: rgba(0, 137, 123, 0.08); --color-muted-foreground: var(--ts-text-3); --color-secondary: rgba(58, 95, 192, 0.08); + --color-accent: var(--ts-blue); --color-destructive: var(--ts-red); --color-warning: var(--ts-amber); --color-success: var(--ts-green); diff --git a/dashboard/test/curation-data.vitest.tsx b/dashboard/test/curation-data.vitest.tsx index 3c950164..bb3d9a47 100644 --- a/dashboard/test/curation-data.vitest.tsx +++ b/dashboard/test/curation-data.vitest.tsx @@ -1,24 +1,210 @@ -import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { useCurationData } from "../holographic/src/curation/useCurationData"; +import { useCurationData, type CurationApi } from "../holographic/src/curation/useCurationData"; -function deferred() { - let resolve; - let reject; - const promise = new Promise((res, rej) => { +afterEach(() => { + vi.useRealTimers(); +}); + +interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function deferred(): Deferred { + let resolve!: Deferred["resolve"]; + let reject!: Deferred["reject"]; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } -function makeApi(overrides = {}): any { +function makeApi(overrides: Partial = {}): CurationApi { return { getMemoryCuratorPreview: vi.fn().mockResolvedValue({ report: null, saved_at: null }), getMemoryCuratorActivity: vi.fn().mockResolvedValue({ events: [] }), + getMemoryAutomationRuns: vi.fn().mockResolvedValue({ records: [] }), + getAutomationSchedulerStatus: vi.fn().mockResolvedValue({ + status: "paused", + paused: true, + enabled: false, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [ + { task: "memory_curator", due: false, skip_reason: "automation_disabled" }, + { task: "session_reflector", due: false, skip_reason: "automation_disabled" }, + { task: "skill_writer", due: false, skip_reason: "automation_disabled" }, + ], + }), + pauseAutomationScheduler: vi.fn().mockResolvedValue({ + status: "paused", + paused: true, + enabled: false, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [], + }), + resumeAutomationScheduler: vi.fn().mockResolvedValue({ + status: "configured", + paused: false, + enabled: true, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [], + }), + getMemoryAutomationRunArtifacts: vi.fn().mockResolvedValue({ artifacts: [], count: 0 }), + getMemoryAutomationRunArtifact: vi.fn().mockResolvedValue({ + run_id: "memory-run", + artifact: { + schema_version: 1, + kind: "codex_handoff", + path: "automation_artifacts/memory-run/codex_handoff.json", + sha256: "sha256:test", + created_at: "2026-06-24T00:00:00Z", + }, + payload: {}, + }), + getFactProposals: vi.fn().mockResolvedValue({ proposals: [], count: 0, limit: 50 }), + applyFactProposal: vi.fn(), + rejectFactProposal: vi.fn(), + getManagedSkills: vi.fn().mockResolvedValue({ skills: [], skill_metadata: [], count: 0 }), + getManagedSkill: vi.fn(), + approveManagedSkill: vi.fn(), + discardManagedSkillUpdate: vi.fn(), + disableManagedSkill: vi.fn(), + archiveManagedSkill: vi.fn(), + restoreManagedSkill: vi.fn(), postMemoryCurate: vi.fn().mockResolvedValue({ dry_run: true, actions: [], counts: {} }), + postAutomationRunMemoryCurator: vi.fn().mockResolvedValue({ + run_id: "memory-run", + dry_run: true, + status: "skipped", + report: { dry_run: true, actions: [], counts: {}, reason: "automation_disabled" }, + ledger_record: { + schema_version: 1, + run_id: "memory-run", + trigger: "dashboard", + task: "memory_curator", + backend: "disabled", + status: "skipped", + accepted_count: 0, + rejected_count: 0, + error: "automation_disabled", + started_at: "2026-06-24T00:00:00Z", + completed_at: "2026-06-24T00:00:01Z", + }, + }), + postAutomationRunSessionReflection: vi.fn().mockResolvedValue({ + run_id: "reflection-run", + dry_run: true, + status: "skipped", + report: { status: "skipped", reason: "automation_disabled" }, + ledger_record: { + schema_version: 1, + run_id: "reflection-run", + trigger: "dashboard", + task: "session_reflector", + backend: "disabled", + status: "skipped", + accepted_count: 0, + rejected_count: 0, + error: "automation_disabled", + started_at: "2026-06-24T00:00:00Z", + completed_at: "2026-06-24T00:00:01Z", + }, + }), + postAutomationRunSkillWriting: vi.fn().mockResolvedValue({ + run_id: "skill-run", + dry_run: true, + status: "skipped", + report: { status: "skipped", reason: "automation_disabled" }, + ledger_record: { + schema_version: 1, + run_id: "skill-run", + trigger: "dashboard", + task: "skill_writer", + backend: "disabled", + status: "skipped", + accepted_count: 0, + rejected_count: 0, + error: "automation_disabled", + started_at: "2026-06-24T00:00:00Z", + completed_at: "2026-06-24T00:00:01Z", + }, + }), getMemoryCuratorStatus: vi.fn().mockResolvedValue({ runs: [] }), + getMemoryAutomationConfig: vi.fn().mockResolvedValue({ + global: null, + project: null, + effective: { + enabled: false, + backend: "disabled", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: false, schedule: null }, + session_reflector: { enabled: false, schedule: null }, + skill_writer: { enabled: false, schedule: null }, + }, + }, + }), + patchMemoryAutomationConfig: vi.fn().mockImplementation((patch) => + Promise.resolve({ + global: null, + project: patch, + effective: { + enabled: patch.enabled ?? false, + backend: patch.backend ?? "disabled", + host_mode: patch.host_mode ?? "standalone", + model: patch.model ?? null, + timeout_secs: patch.timeout_secs ?? 60, + scheduler_tick_secs: patch.scheduler_tick_secs ?? 60, + max_tokens: patch.max_tokens ?? null, + temperature: patch.temperature ?? null, + require_dashboard_approval: patch.require_dashboard_approval ?? true, + auto_apply_memory_ops: patch.auto_apply_memory_ops ?? false, + auto_enable_skills: patch.auto_enable_skills ?? false, + tasks: { + memory_curator: patch.memory_curator ?? { enabled: false, schedule: null }, + session_reflector: patch.session_reflector ?? { enabled: false, schedule: null }, + skill_writer: patch.skill_writer ?? { enabled: false, schedule: null }, + }, + }, + }), + ), + resetMemoryAutomationConfig: vi.fn().mockResolvedValue({ + global: null, + project: null, + effective: { + enabled: false, + backend: "disabled", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: false, schedule: null }, + session_reflector: { enabled: false, schedule: null }, + skill_writer: { enabled: false, schedule: null }, + }, + }, + }), getMemoryOplog: vi.fn().mockResolvedValue({ events: [] }), ...overrides, }; @@ -47,7 +233,7 @@ describe("useCurationData", () => { await Promise.resolve(); }); - let pending; + let pending!: Promise; act(() => { pending = result.current.preview(); }); @@ -97,7 +283,7 @@ describe("useCurationData", () => { expect(result.current.previewSavedAt).toBe("2026-06-14T12:00:00.000Z"); act(() => result.current.setConfirmOpen(true)); - let pending; + let pending!: Promise; act(() => { pending = result.current.apply(); }); @@ -131,7 +317,7 @@ describe("useCurationData", () => { Object.defineProperty(panel, "offsetParent", { configurable: true, get: () => null }); result.current.panelRef.current = panel; act(() => result.current.setActiveTab("activity")); - api.getMemoryCuratorActivity.mockClear(); + vi.mocked(api.getMemoryCuratorActivity).mockClear(); await act(async () => { vi.advanceTimersByTime(2500); @@ -145,4 +331,559 @@ describe("useCurationData", () => { expect(api.getMemoryCuratorActivity).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); + + it("loads, edits, saves, and resets the automation config draft", async () => { + const api = makeApi(); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft?.enabled).toBe(false); + }); + + expect(result.current.configDraft?.enabled).toBe(false); + + act(() => { + result.current.updateConfigDraft({ + enabled: true, + model: "project-model", + scheduler_tick_secs: 15, + max_tokens: 4096, + temperature: 0.2, + }); + result.current.updateConfigTaskDraft("memory_curator", { + enabled: true, + schedule: "manual", + interval_secs: 900, + cooldown_secs: 300, + min_idle_secs: 120, + stale_lock_secs: 3600, + }); + }); + + expect(result.current.configDirty).toBe(true); + expect(result.current.configDraft.enabled).toBe(true); + expect(result.current.configDraft.tasks.memory_curator.schedule).toBe("manual"); + + await act(async () => { + await result.current.saveConfigDraft(); + }); + + expect(api.patchMemoryAutomationConfig).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: true, + model: "project-model", + scheduler_tick_secs: 15, + max_tokens: 4096, + temperature: 0.2, + memory_curator: { + enabled: true, + schedule: "manual", + interval_secs: 900, + cooldown_secs: 300, + min_idle_secs: 120, + stale_lock_secs: 3600, + }, + }), + ); + expect(result.current.configDirty).toBe(false); + + act(() => result.current.updateConfigDraft({ model: "changed" })); + expect(result.current.configDirty).toBe(true); + act(() => result.current.resetConfigDraft()); + expect(result.current.configDraft.model).toBe("project-model"); + expect(result.current.configDirty).toBe(false); + }); + + it("resets persisted automation overrides back to defaults", async () => { + const api = makeApi({ + getMemoryAutomationConfig: vi.fn().mockResolvedValue({ + global: null, + project: { model: "project-model" }, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: "project-model", + timeout_secs: 90, + scheduler_tick_secs: 20, + max_tokens: 4096, + temperature: 0.2, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: "manual" }, + session_reflector: { enabled: false, schedule: null }, + skill_writer: { enabled: false, schedule: null }, + }, + }, + }), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft?.model).toBe("project-model"); + }); + + await act(async () => { + await result.current.resetConfigToDefaults(); + }); + + expect(api.resetMemoryAutomationConfig).toHaveBeenCalledTimes(1); + expect(result.current.configResponse?.project).toBeNull(); + expect(result.current.configDraft?.model).toBeNull(); + expect(result.current.configDirty).toBe(false); + }); + + it("pauses and resumes scheduler through the dashboard scheduler API", async () => { + const api = makeApi({ + getMemoryAutomationConfig: vi + .fn() + .mockResolvedValueOnce({ + global: null, + project: null, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: "interval" }, + session_reflector: { enabled: false, schedule: null }, + skill_writer: { enabled: false, schedule: null }, + }, + }, + }) + .mockResolvedValueOnce({ + global: null, + project: null, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: "interval" }, + session_reflector: { enabled: false, schedule: null }, + skill_writer: { enabled: false, schedule: null }, + }, + }, + }) + .mockResolvedValueOnce({ + global: null, + project: null, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: "interval" }, + session_reflector: { enabled: false, schedule: null }, + skill_writer: { enabled: false, schedule: null }, + }, + }, + }), + pauseAutomationScheduler: vi.fn().mockResolvedValue({ + status: "paused", + paused: true, + enabled: true, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [{ task: "memory_curator", due: false, skip_reason: "scheduler_paused" }], + }), + resumeAutomationScheduler: vi.fn().mockResolvedValue({ + status: "configured", + paused: false, + enabled: true, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [{ task: "memory_curator", due: true, skip_reason: null }], + }), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft?.enabled).toBe(true); + }); + + await act(async () => { + await result.current.setSchedulerPaused(true); + }); + + expect(api.pauseAutomationScheduler).toHaveBeenCalledTimes(1); + expect(api.getMemoryAutomationConfig).toHaveBeenCalledTimes(2); + expect(result.current.schedulerStatus?.paused).toBe(true); + expect(result.current.configDraft?.enabled).toBe(true); + + await act(async () => { + await result.current.setSchedulerPaused(false); + }); + + expect(api.resumeAutomationScheduler).toHaveBeenCalledTimes(1); + expect(api.getMemoryAutomationConfig).toHaveBeenCalledTimes(3); + expect(result.current.schedulerStatus?.paused).toBe(false); + expect(result.current.configDraft?.enabled).toBe(true); + }); + + it("indexes backend automation config validation errors by field", async () => { + const validationError = Object.assign( + new Error("automation timeout_secs must be greater than zero"), + { + body: { + validation_errors: [ + { + field: "timeout_secs", + message: "automation timeout_secs must be greater than zero", + }, + ], + }, + }, + ); + const api = makeApi({ + patchMemoryAutomationConfig: vi.fn().mockRejectedValue(validationError), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + act(() => { + result.current.updateConfigDraft({ + timeout_secs: 0, + }); + }); + + let saveError: unknown; + await act(async () => { + try { + await result.current.saveConfigDraft(); + } catch (err) { + saveError = err; + } + }); + + expect(saveError).toBe(validationError); + expect(result.current.configFieldErrors.timeout_secs).toContain( + "timeout_secs", + ); + expect(result.current.configDirty).toBe(true); + }); + + it("loads automation run history when the history tab opens", async () => { + const api = makeApi({ + getMemoryAutomationRuns: vi.fn().mockResolvedValue({ + records: [ + { + schema_version: 1, + run_id: "run-1", + trigger: "dashboard", + task: "memory_curator", + backend: "disabled", + status: "skipped", + accepted_count: 0, + rejected_count: 0, + error: "automation_disabled", + started_at: "2026-06-24T00:00:00Z", + completed_at: "2026-06-24T00:00:01Z", + }, + ], + count: 1, + limit: 20, + error: "", + }), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + act(() => result.current.setActiveTab("history")); + + await waitFor(() => { + expect(api.getMemoryAutomationRuns).toHaveBeenCalledWith({ limit: 20 }); + expect(result.current.automationRuns).toHaveLength(1); + }); + }); + + it("loads verified automation run artifact payloads", async () => { + const api = makeApi({ + getMemoryAutomationRunArtifact: vi.fn().mockResolvedValue({ + run_id: "run-1", + artifact: { + schema_version: 1, + kind: "codex_handoff", + path: "automation_artifacts/run-1/codex_handoff.json", + sha256: "sha256:payload", + created_at: "2026-06-24T00:00:00Z", + }, + payload: { + status: "ready_for_review", + next_actions: ["review dashboard artifact payload"], + }, + error: "", + }), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + await act(async () => { + await result.current.loadAutomationRunArtifact("run-1", "codex_handoff"); + }); + + expect(api.getMemoryAutomationRunArtifact).toHaveBeenCalledWith("run-1", "codex_handoff"); + expect(result.current.automationRunArtifact?.payload).toMatchObject({ + status: "ready_for_review", + }); + }); + + it("runs standalone automation tasks and refreshes dependent dashboard data", async () => { + const api = makeApi(); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + await act(async () => { + await result.current.runAutomationTask("session_reflector"); + }); + + expect(api.postAutomationRunSessionReflection).toHaveBeenCalledWith({ dry_run: true }); + expect(api.getMemoryAutomationRuns).toHaveBeenCalledWith({ limit: 20 }); + expect(api.getFactProposals).toHaveBeenCalledWith({ limit: 50 }); + + await act(async () => { + await result.current.runAutomationTask("skill_writer"); + }); + + expect(api.postAutomationRunSkillWriting).toHaveBeenCalledWith({ dry_run: true }); + expect(api.getManagedSkills).toHaveBeenCalled(); + + await act(async () => { + await result.current.runAutomationTask("memory_curator"); + }); + + expect(api.postAutomationRunMemoryCurator).toHaveBeenCalledWith({ dry_run: true }); + expect(api.getMemoryCuratorActivity).toHaveBeenCalled(); + }); + + it("tracks queued automation runs without requiring a report and polls until terminal", async () => { + const queuedRun = { + schema_version: 2, + run_id: "queued-memory-run", + trigger: "dashboard", + task: "memory_curator", + backend: "codex_app_server", + status: "queued", + accepted_count: 0, + rejected_count: 0, + started_at: "2026-06-24T00:00:00Z", + completed_at: "2026-06-24T00:00:00Z", + }; + const succeededRun = { + ...queuedRun, + status: "succeeded", + completed_at: "2026-06-24T00:00:01Z", + }; + const api = makeApi({ + postAutomationRunMemoryCurator: vi.fn().mockResolvedValue({ + run_id: "queued-memory-run", + dry_run: true, + status: "queued", + ledger_record: queuedRun, + }), + getMemoryAutomationRuns: vi.fn() + .mockResolvedValueOnce({ records: [queuedRun], count: 1, limit: 20, error: "" }) + .mockResolvedValueOnce({ records: [queuedRun], count: 1, limit: 20, error: "" }) + .mockResolvedValueOnce({ records: [succeededRun], count: 1, limit: 20, error: "" }), + }); + const { result } = renderHook(() => + useCurationData({ api, pollFastMs: 25 }), + ); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + vi.useFakeTimers(); + + await act(async () => { + await result.current.runAutomationTask("memory_curator"); + }); + + expect(result.current.report).toBeNull(); + expect(result.current.automationRuns[0]).toMatchObject({ + run_id: "queued-memory-run", + status: "queued", + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + + expect(result.current.automationRuns[0]).toMatchObject({ + run_id: "queued-memory-run", + status: "succeeded", + }); + }); + + it("loads and applies fact proposals from the history tab", async () => { + const pendingProposal = { + schema_version: 1, + proposal_id: "prop-1", + run_id: "run-1", + state: "pending_approval", + add_fact_request: { content: "Prefer bounded evidence." }, + created_at: 1782283200, + updated_at: 1782283200, + }; + const appliedProposal = { + ...pendingProposal, + state: "applied", + applied_fact_id: 42, + updated_at: 1782283300, + }; + const api = makeApi({ + getFactProposals: vi.fn() + .mockResolvedValueOnce({ + proposals: [pendingProposal], + count: 1, + limit: 50, + }) + .mockResolvedValue({ + proposals: [appliedProposal], + count: 1, + limit: 50, + }), + applyFactProposal: vi.fn().mockResolvedValue({ proposal: appliedProposal }), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + act(() => result.current.setActiveTab("history")); + + await waitFor(() => { + expect(api.getFactProposals).toHaveBeenCalledWith({ limit: 50 }); + expect(result.current.factProposals[0].proposal_id).toBe("prop-1"); + }); + + await act(async () => { + await result.current.runFactProposalAction("apply", "prop-1"); + }); + + expect(api.applyFactProposal).toHaveBeenCalledWith("prop-1"); + expect(result.current.factProposals[0].state).toBe("applied"); + }); + + it("loads and approves managed skills from the history tab", async () => { + const pendingSkill = { + metadata: { + id: "repo-hygiene", + title: "Repo Hygiene", + summary: "Keep repo maintenance consistent.", + category: "workflow", + state: "pending_approval", + checksum: "abc123", + pinned: false, + created_at: 1782259200, + updated_at: 1782259200, + provenance: { source: "automation_run", actor: "skill_writer", run_id: "run-1" }, + }, + body_markdown: "Use focused checks before broad suites.", + support_files: [], + }; + const activeSkill = { + ...pendingSkill, + metadata: { ...pendingSkill.metadata, state: "active" }, + }; + const api = makeApi({ + getManagedSkills: vi.fn() + .mockResolvedValueOnce({ + skills: [pendingSkill], + skill_metadata: [pendingSkill.metadata], + improvement_recommendations: [ + { + skill_id: "repo-hygiene", + improvement: true, + recommendation: "patch_review", + reason: "repeated patches suggest the skill instructions may still be unstable", + priority: "medium", + evidence: ["patches=2"], + }, + ], + count: 1, + }) + .mockResolvedValue({ + skills: [activeSkill], + skill_metadata: [activeSkill.metadata], + improvement_recommendations: [ + { + skill_id: "repo-hygiene", + improvement: true, + recommendation: "patch_review", + reason: "repeated patches suggest the skill instructions may still be unstable", + priority: "medium", + evidence: ["patches=2"], + }, + ], + count: 1, + }), + getManagedSkill: vi.fn() + .mockResolvedValueOnce({ skill: pendingSkill }) + .mockResolvedValue({ skill: activeSkill }), + approveManagedSkill: vi.fn().mockResolvedValue({ skill: activeSkill }), + }); + const { result } = renderHook(() => useCurationData({ api })); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + act(() => result.current.setActiveTab("history")); + + await waitFor(() => { + expect(api.getManagedSkills).toHaveBeenCalled(); + expect(result.current.selectedManagedSkill?.metadata.id).toBe("repo-hygiene"); + }); + expect( + result.current.managedSkillImprovementRecommendations["repo-hygiene"].recommendation, + ).toBe("patch_review"); + + await act(async () => { + await result.current.runManagedSkillAction("approve", "repo-hygiene"); + }); + + expect(api.approveManagedSkill).toHaveBeenCalledWith("repo-hygiene"); + expect(result.current.selectedManagedSkill?.metadata.state).toBe("active"); + }); }); diff --git a/dashboard/test/curation-panel.vitest.tsx b/dashboard/test/curation-panel.vitest.tsx index 7a8f3977..e6666deb 100644 --- a/dashboard/test/curation-panel.vitest.tsx +++ b/dashboard/test/curation-panel.vitest.tsx @@ -1,16 +1,456 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import CurationPanel from "../holographic/src/CurationPanel"; +const apiMock = vi.hoisted(() => ({ + getMemoryCuratorPreview: vi.fn().mockResolvedValue({ report: null, saved_at: null }), + getMemoryCuratorActivity: vi.fn().mockResolvedValue({ events: [] }), + getMemoryCuratorStatus: vi.fn().mockResolvedValue({ + provider: "tracedecay", + state: { paused: false, run_count: 0 }, + config: { enabled: false }, + snapshots: [], + }), + getMemoryAutomationConfig: vi.fn().mockResolvedValue({ + global: null, + project: null, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: null }, + session_reflector: { enabled: true, schedule: null }, + skill_writer: { enabled: true, schedule: null }, + }, + }, + backend_availability: { + backend: "codex_app_server", + available: true, + executable: "/usr/bin/codex", + reason: null, + }, + }), + patchMemoryAutomationConfig: vi.fn().mockImplementation((patch) => + Promise.resolve({ + global: null, + project: patch, + effective: { + enabled: patch.enabled ?? false, + backend: patch.backend ?? "disabled", + host_mode: patch.host_mode ?? "standalone", + model: patch.model ?? null, + timeout_secs: patch.timeout_secs ?? 60, + scheduler_tick_secs: patch.scheduler_tick_secs ?? 60, + max_tokens: patch.max_tokens ?? null, + temperature: patch.temperature ?? null, + require_dashboard_approval: patch.require_dashboard_approval ?? true, + auto_apply_memory_ops: patch.auto_apply_memory_ops ?? false, + auto_enable_skills: patch.auto_enable_skills ?? false, + tasks: { + memory_curator: patch.memory_curator ?? { enabled: false, schedule: null }, + session_reflector: patch.session_reflector ?? { enabled: false, schedule: null }, + skill_writer: patch.skill_writer ?? { enabled: false, schedule: null }, + }, + }, + backend_availability: { + backend: patch.backend ?? "disabled", + available: (patch.backend ?? "disabled") === "codex_app_server", + executable: "/usr/bin/codex", + reason: null, + }, + }), + ), + resetMemoryAutomationConfig: vi.fn().mockResolvedValue({ + global: null, + project: null, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: null }, + session_reflector: { enabled: true, schedule: null }, + skill_writer: { enabled: true, schedule: null }, + }, + }, + backend_availability: { + backend: "codex_app_server", + available: true, + executable: "/usr/bin/codex", + reason: null, + }, + }), + getAutomationSchedulerStatus: vi.fn().mockResolvedValue({ + status: "configured", + paused: false, + enabled: true, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [ + { task: "memory_curator", due: true, skip_reason: null }, + { task: "session_reflector", due: false, skip_reason: "task_disabled" }, + { task: "skill_writer", due: false, skip_reason: "scheduler_schedule_manual" }, + ], + }), + pauseAutomationScheduler: vi.fn().mockResolvedValue({ + status: "paused", + paused: true, + enabled: false, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [], + }), + resumeAutomationScheduler: vi.fn().mockResolvedValue({ + status: "configured", + paused: false, + enabled: true, + scheduler_tick_secs: 60, + now: 1782283200, + tasks: [], + }), + getMemoryAutomationRuns: vi.fn().mockResolvedValue({ + records: [ + { + schema_version: 1, + run_id: "run-dashboard-1", + trigger: "dashboard", + task: "memory_curator", + backend: "codex_app_server", + model: "gpt-test", + status: "skipped", + evidence_hash: null, + proposed_ops: null, + accepted_count: 0, + rejected_count: 0, + error: "automation_disabled", + artifacts: [ + { + schema_version: 1, + kind: "codex_handoff", + path: "automation_artifacts/run-dashboard-1/codex_handoff.json", + sha256: "sha256:handoff", + summary: "handoff ready", + created_at: "2026-06-24T12:00:01Z", + }, + ], + started_at: "2026-06-24T12:00:00Z", + completed_at: "2026-06-24T12:00:01Z", + }, + ], + count: 1, + limit: 20, + error: "", + }), + getMemoryAutomationRunArtifacts: vi.fn().mockResolvedValue({ + run_id: "run-dashboard-1", + artifacts: [ + { + schema_version: 1, + kind: "codex_handoff", + path: "automation_artifacts/run-dashboard-1/codex_handoff.json", + sha256: "sha256:handoff", + summary: "handoff ready", + created_at: "2026-06-24T12:00:01Z", + }, + ], + artifact_chain: { + expected_kinds: [ + "traces", + "feedback", + "generated_evals", + "validation_gate", + "optimizer_diagnosis", + "codex_handoff", + ], + present_kinds: ["codex_handoff"], + complete: false, + }, + count: 1, + error: "", + }), + getMemoryAutomationRunArtifact: vi.fn().mockResolvedValue({ + run_id: "run-dashboard-1", + artifact: { + schema_version: 1, + kind: "codex_handoff", + path: "automation_artifacts/run-dashboard-1/codex_handoff.json", + sha256: "sha256:handoff", + summary: "handoff ready", + created_at: "2026-06-24T12:00:01Z", + }, + payload: { + status: "ready_for_review", + next_actions: ["review dashboard artifact payload"], + }, + error: "", + }), + getFactProposals: vi.fn().mockResolvedValue({ + proposals: [ + { + schema_version: 1, + proposal_id: "prop-dashboard-1", + run_id: "run-dashboard-1", + state: "pending_approval", + add_fact_request: { + content: "Prefer bounded evidence before adding durable memory.", + category: "workflow", + tags: ["memory", "review"], + }, + created_at: 1782283200, + updated_at: 1782283200, + }, + ], + count: 1, + limit: 50, + error: "", + }), + applyFactProposal: vi.fn().mockResolvedValue({ + proposal: { + schema_version: 1, + proposal_id: "prop-dashboard-1", + run_id: "run-dashboard-1", + state: "applied", + add_fact_request: { + content: "Prefer bounded evidence before adding durable memory.", + category: "workflow", + tags: ["memory", "review"], + }, + applied_fact_id: 42, + created_at: 1782283200, + updated_at: 1782283300, + }, + }), + rejectFactProposal: vi.fn(), + getManagedSkills: vi.fn().mockResolvedValue({ + skills: [ + { + metadata: { + id: "repo-hygiene", + title: "Repo Hygiene", + summary: "Keep repository maintenance tasks consistent.", + category: "workflow", + state: "pending_approval", + checksum: "abc123", + pinned: false, + created_at: 1782302400, + updated_at: 1782302400, + provenance: { + source: "automation_run", + actor: "skill_writer", + run_id: "run-dashboard-1", + }, + }, + body_markdown: "Use focused checks before broad suites.", + support_files: [], + }, + ], + skill_metadata: [], + usage_summaries: [ + { + schema_version: 1, + skill_id: "repo-hygiene", + title: "Repo Hygiene", + category: "workflow", + state: "pending_approval", + pinned: false, + created_by: "skill_writer", + provenance_source: "automation_run", + targets: ["codex", "cursor"], + view_count: 2, + use_count: 1, + patch_count: 0, + first_seen_at: 1782283200, + last_activity_at: 1782283200, + last_viewed_at: 1782283200, + last_used_at: 1782283200, + last_patched_at: null, + }, + ], + stale_recommendations: [ + { + skill_id: "repo-hygiene", + stale: false, + recommendation: "keep", + reason: "recent or meaningful activity is present", + evidence: ["uses=1"], + }, + ], + improvement_recommendations: [ + { + skill_id: "repo-hygiene", + improvement: true, + recommendation: "patch_review", + reason: "repeated patches suggest the skill instructions may still be unstable", + priority: "medium", + evidence: ["patches=2"], + }, + ], + count: 1, + error: "", + }), + getManagedSkill: vi.fn().mockResolvedValue({ + skill: { + metadata: { + id: "repo-hygiene", + title: "Repo Hygiene", + summary: "Keep repository maintenance tasks consistent.", + category: "workflow", + state: "pending_approval", + checksum: "abc123", + pinned: false, + created_at: 1782302400, + updated_at: 1782302400, + provenance: { + source: "automation_run", + actor: "skill_writer", + run_id: "run-dashboard-1", + }, + }, + body_markdown: "Use focused checks before broad suites.", + support_files: [], + }, + usage_summary: { + schema_version: 1, + skill_id: "repo-hygiene", + title: "Repo Hygiene", + category: "workflow", + state: "pending_approval", + pinned: false, + created_by: "skill_writer", + provenance_source: "automation_run", + targets: ["codex", "cursor"], + view_count: 2, + use_count: 1, + patch_count: 0, + first_seen_at: 1782283200, + last_activity_at: 1782283200, + last_viewed_at: 1782283200, + last_used_at: 1782283200, + last_patched_at: null, + }, + stale_recommendation: { + skill_id: "repo-hygiene", + stale: false, + recommendation: "keep", + reason: "recent or meaningful activity is present", + evidence: ["uses=1"], + }, + improvement_recommendation: { + skill_id: "repo-hygiene", + improvement: true, + recommendation: "patch_review", + reason: "repeated patches suggest the skill instructions may still be unstable", + priority: "medium", + evidence: ["patches=2"], + }, + }), + approveManagedSkill: vi.fn().mockResolvedValue({ + skill: { + metadata: { + id: "repo-hygiene", + title: "Repo Hygiene", + summary: "Keep repository maintenance tasks consistent.", + category: "workflow", + state: "active", + checksum: "abc123", + pinned: false, + created_at: 1782302400, + updated_at: 1782302460, + provenance: { + source: "automation_run", + actor: "skill_writer", + run_id: "run-dashboard-1", + }, + }, + body_markdown: "Use focused checks before broad suites.", + support_files: [], + }, + }), + disableManagedSkill: vi.fn(), + archiveManagedSkill: vi.fn(), + restoreManagedSkill: vi.fn(), + getMemoryOplog: vi.fn().mockResolvedValue({ events: [] }), + postAutomationRunMemoryCurator: vi.fn().mockResolvedValue({ + run_id: "queued-memory-curator", + dry_run: true, + status: "queued", + report: { queued: true }, + ledger_record: { + schema_version: 2, + run_id: "queued-memory-curator", + trigger: "dashboard", + task: "memory_curator", + backend: "codex_app_server", + host_mode: "standalone", + model: null, + status: "queued", + accepted_count: 0, + rejected_count: 0, + started_at: "2026-06-24T12:00:02Z", + completed_at: "2026-06-24T12:00:02Z", + }, + }), + postAutomationRunSessionReflection: vi.fn().mockResolvedValue({ + run_id: "queued-session-reflector", + dry_run: true, + status: "queued", + ledger_record: { + schema_version: 2, + run_id: "queued-session-reflector", + trigger: "dashboard", + task: "session_reflector", + backend: "codex_app_server", + host_mode: "standalone", + model: null, + status: "queued", + accepted_count: 0, + rejected_count: 0, + started_at: "2026-06-24T12:00:03Z", + completed_at: "2026-06-24T12:00:03Z", + }, + }), + postAutomationRunSkillWriting: vi.fn().mockResolvedValue({ + run_id: "queued-skill-writer", + dry_run: true, + status: "queued", + ledger_record: { + schema_version: 2, + run_id: "queued-skill-writer", + trigger: "dashboard", + task: "skill_writer", + backend: "codex_app_server", + host_mode: "standalone", + model: null, + status: "queued", + accepted_count: 0, + rejected_count: 0, + started_at: "2026-06-24T12:00:04Z", + completed_at: "2026-06-24T12:00:04Z", + }, + }), + postMemoryCurate: vi.fn(), +})); + vi.mock("../holographic/src/api", () => ({ - api: { - getMemoryCuratorPreview: vi.fn().mockResolvedValue({ report: null, saved_at: null }), - getMemoryCuratorActivity: vi.fn().mockResolvedValue({ events: [] }), - getMemoryCuratorStatus: vi.fn().mockResolvedValue({ runs: [] }), - getMemoryOplog: vi.fn().mockResolvedValue({ events: [] }), - postMemoryCurate: vi.fn(), - }, + api: apiMock, })); describe("CurationPanel", () => { @@ -22,4 +462,269 @@ describe("CurationPanel", () => { expect(tabs).toHaveLength(3); expect(tabs.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "0", "0"]); }); + + it("saves automation runtime limits from dashboard controls", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + const maxTokens = await screen.findByLabelText("Max tokens"); + const temperature = await screen.findByLabelText("Temperature"); + const schedulerTick = await screen.findByLabelText("Scheduler tick seconds"); + + fireEvent.change(maxTokens, { target: { value: "4096" } }); + fireEvent.change(temperature, { target: { value: "0.2" } }); + fireEvent.change(schedulerTick, { target: { value: "15" } }); + fireEvent.click(screen.getByRole("button", { name: /save config/i })); + + await waitFor(() => { + expect(apiMock.patchMemoryAutomationConfig).toHaveBeenCalledWith( + expect.objectContaining({ + scheduler_tick_secs: 15, + max_tokens: 4096, + temperature: 0.2, + }), + ); + }); + }); + + it("resets automation overrides from dashboard controls", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + await screen.findByLabelText("Max tokens"); + fireEvent.click(screen.getByRole("button", { name: /reset defaults/i })); + + await waitFor(() => { + expect(apiMock.resetMemoryAutomationConfig).toHaveBeenCalled(); + }); + }); + + it("shows scheduler state and pauses scheduler from dashboard controls", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + expect(await screen.findByText("Scheduler")).toBeTruthy(); + expect(await screen.findByText("memory curator")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /^pause$/i })); + + await waitFor(() => { + expect(apiMock.pauseAutomationScheduler).toHaveBeenCalled(); + }); + }); + + it("disables automation runs when the configured backend is unavailable", async () => { + apiMock.getMemoryAutomationConfig.mockResolvedValueOnce({ + global: null, + project: null, + effective: { + enabled: true, + backend: "codex_app_server", + host_mode: "standalone", + model: null, + timeout_secs: 60, + scheduler_tick_secs: 60, + max_tokens: null, + temperature: null, + require_dashboard_approval: true, + auto_apply_memory_ops: false, + auto_enable_skills: false, + tasks: { + memory_curator: { enabled: true, schedule: null }, + session_reflector: { enabled: true, schedule: null }, + skill_writer: { enabled: true, schedule: null }, + }, + }, + backend_availability: { + backend: "codex_app_server", + available: false, + executable: "/missing/codex", + reason: "codex app-server backend executable '/missing/codex' was not found", + }, + }); + + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + expect(await screen.findByText(/was not found/i)).toBeTruthy(); + const runButtons = await screen.findAllByRole("button", { name: /^run$/i }); + expect((runButtons[0] as HTMLButtonElement).disabled).toBe(true); + expect(runButtons[0].getAttribute("title")).toContain("was not found"); + }); + + it("renders automation config validation errors inline", async () => { + apiMock.patchMemoryAutomationConfig.mockRejectedValueOnce( + Object.assign( + new Error("automation config validation failed"), + { + body: { + validation_errors: [ + { + field: "timeout_secs", + message: "automation timeout_secs must be greater than zero", + }, + { + field: "backend", + message: "automation backend is not selectable", + }, + { + field: "host_mode", + message: "automation host_mode is invalid", + }, + { + field: "memory_curator.schedule", + message: "memory curator schedule is invalid", + }, + { + field: "session_reflector.interval_secs", + message: "session reflector interval_secs must be greater than zero", + }, + { + field: "skill_writer.stale_lock_secs", + message: "skill writer stale_lock_secs must be greater than zero", + }, + ], + }, + }, + ), + ); + + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + const timeoutInput = await screen.findByLabelText("Timeout seconds"); + fireEvent.change(timeoutInput, { target: { value: "0" } }); + fireEvent.click(screen.getByRole("button", { name: /save config/i })); + + expect(await screen.findByText("automation config validation failed")).toBeTruthy(); + expect(await screen.findByText("automation timeout_secs must be greater than zero")).toBeTruthy(); + expect(await screen.findByText("automation backend is not selectable")).toBeTruthy(); + expect(await screen.findByText("automation host_mode is invalid")).toBeTruthy(); + expect(await screen.findByText("memory curator schedule is invalid")).toBeTruthy(); + expect( + await screen.findByText("session reflector interval_secs must be greater than zero"), + ).toBeTruthy(); + expect( + await screen.findByText("skill writer stale_lock_secs must be greater than zero"), + ).toBeTruthy(); + }); + + it("does not offer the unimplemented external command backend", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + const backend = (await screen.findByLabelText("Backend")) as HTMLSelectElement; + const values = Array.from(backend.options).map((option) => option.value); + expect(values).toEqual(["disabled", "codex_app_server"]); + }); + + it("renders automation run ledger entries in history", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + await waitFor(() => { + expect(apiMock.getMemoryAutomationRuns).toHaveBeenCalledWith({ limit: 20 }); + }); + expect(await screen.findByText("Automation runs")).toBeTruthy(); + expect( + screen.getAllByText(/memory_curator · dashboard · codex_app_server\/gpt-test/).length, + ).toBeGreaterThan(0); + expect(screen.getAllByText(/automation_disabled/).length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole("button", { name: /codex_handoff/i })); + + await waitFor(() => { + expect(apiMock.getMemoryAutomationRunArtifacts).toHaveBeenCalledWith( + "run-dashboard-1", + ); + expect(apiMock.getMemoryAutomationRunArtifact).toHaveBeenCalledWith( + "run-dashboard-1", + "codex_handoff", + ); + }); + expect(await screen.findByText("chain pending")).toBeTruthy(); + expect(await screen.findByText(/ready_for_review/)).toBeTruthy(); + expect(screen.getByText(/review dashboard artifact payload/)).toBeTruthy(); + }); + + it("runs standalone automation tasks from history controls", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + await screen.findByLabelText("Session reflector schedule"); + const runButtons = screen.getAllByRole("button", { name: /^run$/i }); + + fireEvent.click(runButtons[1]); + + await waitFor(() => { + expect(apiMock.postAutomationRunSessionReflection).toHaveBeenCalledWith({ + dry_run: true, + }); + }); + + fireEvent.click(runButtons[2]); + + await waitFor(() => { + expect(apiMock.postAutomationRunSkillWriting).toHaveBeenCalledWith({ + dry_run: true, + }); + }); + + fireEvent.click(runButtons[0]); + + await waitFor(() => { + expect(apiMock.postAutomationRunMemoryCurator).toHaveBeenCalledWith({ + dry_run: true, + }); + }); + }); + + it("renders and applies fact proposals in history", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + await waitFor(() => { + expect(apiMock.getFactProposals).toHaveBeenCalledWith({ limit: 50 }); + }); + expect(await screen.findByText("Fact proposals")).toBeTruthy(); + expect(screen.getByText(/Prefer bounded evidence/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /apply fact/i })); + + await waitFor(() => { + expect(apiMock.applyFactProposal).toHaveBeenCalledWith("prop-dashboard-1"); + }); + }); + + it("renders managed skill approvals in history", async () => { + render(); + + fireEvent.click(screen.getByRole("tab", { name: /history/i })); + + await waitFor(() => { + expect(apiMock.getManagedSkills).toHaveBeenCalled(); + }); + expect(await screen.findByText("Managed skills")).toBeTruthy(); + expect(screen.getAllByText("Repo Hygiene").length).toBeGreaterThan(0); + expect(screen.getByText(/Use focused checks before broad suites/)).toBeTruthy(); + expect(screen.getByText("uses=1")).toBeTruthy(); + expect(screen.getByText(/recent or meaningful activity/)).toBeTruthy(); + expect(screen.getByText("patch_review")).toBeTruthy(); + expect(screen.getByText(/repeated patches/)).toBeTruthy(); + expect(screen.getByText(/priority=medium/)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /approve/i })); + + await waitFor(() => { + expect(apiMock.approveManagedSkill).toHaveBeenCalledWith("repo-hygiene"); + }); + }); }); diff --git a/dashboard/test/shell-sdk.test.mjs b/dashboard/test/shell-sdk.test.mjs index aea1b831..7e4b6e4b 100644 --- a/dashboard/test/shell-sdk.test.mjs +++ b/dashboard/test/shell-sdk.test.mjs @@ -41,15 +41,26 @@ test("fetchJSON returns parsed body on success", async () => { }); test("fetchJSON prefers JSON detail on failure", async () => { + const body = { + detail: "token expired", + validation_errors: [{ field: "token", message: "token expired" }], + }; await withMockedFetch( async () => ({ ok: false, status: 403, statusText: "Forbidden", - json: async () => ({ detail: "token expired" }), + json: async () => body, }), async () => { - await assert.rejects(() => sdk.fetchJSON("/nope"), /token expired/); + await assert.rejects( + async () => sdk.fetchJSON("/nope"), + (err) => { + assert.match(err.message, /token expired/); + assert.deepEqual(err.body, body); + return true; + }, + ); }, ); }); From 144c8b416eb51e523a3eeef628dec290807582f1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 11:09:57 +0000 Subject: [PATCH 2/5] fix: refresh Hermes dashboard automation routes --- dashboard/hermes-wrapper/plugin_api.py | 20 +++++ dashboard/hermes-wrapper/src/entry.js | 5 +- .../src/curation/useAutomationRuns.ts | 68 +++++++++++++++-- .../src/curation/useCurationData.ts | 1 + dashboard/test/curation-data.vitest.tsx | 73 +++++++++++++++++++ dashboard/test/hermes-wrapper.test.mjs | 6 ++ 6 files changed, 165 insertions(+), 8 deletions(-) diff --git a/dashboard/hermes-wrapper/plugin_api.py b/dashboard/hermes-wrapper/plugin_api.py index c3f2f5a9..091111cd 100644 --- a/dashboard/hermes-wrapper/plugin_api.py +++ b/dashboard/hermes-wrapper/plugin_api.py @@ -12,6 +12,8 @@ ``/lcm/*`` -> upstream ``/api/plugins/hermes-lcm/*``, ``/graph/*`` -> upstream ``/api/plugins/graph/*``, and ``/savings/*`` -> upstream ``/api/plugins/savings/*``, + ``/analytics/*`` -> upstream ``/api/plugins/analytics/*``, + ``/automation/*`` -> upstream ``/api/automation/*``, - exposes upstream ``/api/capabilities`` at ``/capabilities`` so the UI (and future Hermes-specific extensions) can feature-detect the backend. @@ -586,6 +588,24 @@ async def post_savings(path: str, request: Request) -> JSONResponse: ) +@router.get("/analytics/{path:path}") +def get_analytics(path: str, request: Request) -> JSONResponse: + return _proxy("GET", f"/api/plugins/analytics/{path}", request, None) + + +@router.get("/automation/{path:path}") +def get_automation(path: str, request: Request) -> JSONResponse: + return _proxy("GET", f"/api/automation/{path}", request, None) + + +@router.post("/automation/{path:path}") +async def post_automation(path: str, request: Request) -> JSONResponse: + body = await request.body() + return await run_in_threadpool( + _proxy, "POST", f"/api/automation/{path}", request, body + ) + + # --------------------------------------------------------------------------- # LLM curation (Hermes-only layer) # diff --git a/dashboard/hermes-wrapper/src/entry.js b/dashboard/hermes-wrapper/src/entry.js index 051299b7..111630c3 100644 --- a/dashboard/hermes-wrapper/src/entry.js +++ b/dashboard/hermes-wrapper/src/entry.js @@ -10,7 +10,8 @@ * 1. evaluates each child bundle against a window Proxy whose * `__HERMES_PLUGIN_SDK__.fetchJSON` rewrites the child's API base * (`/api/plugins/holographic`, `/api/plugins/hermes-lcm`, - * `/api/plugins/graph`, `/api/plugins/savings`) onto this plugin's API + * `/api/plugins/graph`, `/api/plugins/savings`, `/api/automation`, + * `/api/plugins/analytics`) onto this plugin's API * prefix (`/api/plugins/tracedecay/...`), which plugin_api.py * reverse-proxies to a local `tracedecay dashboard` server; * 2. captures the components the child bundles register (without touching @@ -36,6 +37,8 @@ ["/api/plugins/hermes-lcm", "/api/plugins/" + PLUGIN + "/lcm"], ["/api/plugins/graph", "/api/plugins/" + PLUGIN + "/graph"], ["/api/plugins/savings", "/api/plugins/" + PLUGIN + "/savings"], + ["/api/automation", "/api/plugins/" + PLUGIN + "/automation"], + ["/api/plugins/analytics", "/api/plugins/" + PLUGIN + "/analytics"], ]; function rewriteUrl(url) { diff --git a/dashboard/holographic/src/curation/useAutomationRuns.ts b/dashboard/holographic/src/curation/useAutomationRuns.ts index 211c4d71..e997e52b 100644 --- a/dashboard/holographic/src/curation/useAutomationRuns.ts +++ b/dashboard/holographic/src/curation/useAutomationRuns.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { api as defaultApi } from "../api"; import type { @@ -36,6 +36,7 @@ export function useAutomationRuns({ pollFastMs, setActiveTab, setMemoryPreviewFromRun, + loadMemoryPreview, loadActivity, loadStatus, loadFactProposals, @@ -45,12 +46,14 @@ export function useAutomationRuns({ pollFastMs: number; setActiveTab: (tab: "history") => void; setMemoryPreviewFromRun: (report: MemoryCurateResponse) => void; + loadMemoryPreview: (force?: boolean) => Promise; loadActivity: (showSpinner?: boolean) => void; loadStatus: () => void; loadFactProposals: (showSpinner?: boolean) => Promise; loadManagedSkills: (showSpinner?: boolean) => Promise; }) { const [automationRuns, setAutomationRuns] = useState([]); + const automationRunsRef = useRef([]); const [automationRunsError, setAutomationRunsError] = useState(""); const [automationRunActioning, setAutomationRunActioning] = useState(null); @@ -64,17 +67,63 @@ export function useAutomationRuns({ ); const [automationRunArtifactError, setAutomationRunArtifactError] = useState(""); + const refreshCompletedRuns = useCallback(async ( + completedRuns: MemoryAutomationRunRecord[], + ) => { + let refreshMemoryPreview = false; + let refreshFactProposals = false; + let refreshManagedSkills = false; + + for (const run of completedRuns) { + const descriptor = AUTOMATION_TASK_BY_ID[run.task as AutomationRunTask]; + if (!descriptor) continue; + if (descriptor.refreshTarget === "memory_preview") refreshMemoryPreview = true; + if (descriptor.refreshTarget === "fact_proposals") refreshFactProposals = true; + if (descriptor.refreshTarget === "managed_skills") refreshManagedSkills = true; + } + + const refreshes: Promise[] = []; + if (refreshMemoryPreview) { + refreshes.push(loadMemoryPreview(true)); + loadActivity(false); + loadStatus(); + } + if (refreshFactProposals) refreshes.push(loadFactProposals(false)); + if (refreshManagedSkills) refreshes.push(loadManagedSkills(false)); + await Promise.all(refreshes); + }, [ + loadActivity, + loadFactProposals, + loadManagedSkills, + loadMemoryPreview, + loadStatus, + ]); + const loadAutomationRuns = useCallback(() => { setAutomationRunsError(""); return api .getMemoryAutomationRuns({ limit: 20 }) - .then((response) => { - setAutomationRuns(response.records || []); + .then(async (response) => { + const nextRuns = response.records || []; + const previousById = new Map( + automationRunsRef.current.map((run) => [run.run_id, run]), + ); + const completedRuns = nextRuns.filter((run) => { + const previous = previousById.get(run.run_id); + return ( + previous && + isActiveAutomationStatus(previous.status) && + !isActiveAutomationStatus(run.status) + ); + }); + automationRunsRef.current = nextRuns; + setAutomationRuns(nextRuns); if (response.error) setAutomationRunsError(response.error); + if (completedRuns.length > 0) await refreshCompletedRuns(completedRuns); return response; }) .catch((err) => setAutomationRunsError(errorMessage(err))); - }, [api]); + }, [api, refreshCompletedRuns]); const loadAutomationRunArtifact = useCallback((runId: string, kind: string) => { const key = `${runId}:${kind}`; @@ -102,12 +151,17 @@ export function useAutomationRuns({ const runAutomationTask = useCallback(async (task: AutomationRunTask) => { setAutomationRunActioning(task); setAutomationRunError(""); - setActiveTab("history"); + setActiveTab("history"); try { const descriptor = AUTOMATION_TASK_BY_ID[task]; const response = await api[descriptor.runMethod]({ dry_run: true }); - if (response.ledger_record) { - setAutomationRuns((records) => upsertAutomationRun(records, response.ledger_record)); + const ledgerRecord = response.ledger_record; + if (ledgerRecord) { + setAutomationRuns((records) => { + const nextRuns = upsertAutomationRun(records, ledgerRecord); + automationRunsRef.current = nextRuns; + return nextRuns; + }); } await loadAutomationRuns(); if ( diff --git a/dashboard/holographic/src/curation/useCurationData.ts b/dashboard/holographic/src/curation/useCurationData.ts index f8a48cc5..d9bccb43 100644 --- a/dashboard/holographic/src/curation/useCurationData.ts +++ b/dashboard/holographic/src/curation/useCurationData.ts @@ -231,6 +231,7 @@ export function useCurationData({ pollFastMs, setActiveTab, setMemoryPreviewFromRun, + loadMemoryPreview: loadSavedPreview, loadActivity, loadStatus, loadFactProposals, diff --git a/dashboard/test/curation-data.vitest.tsx b/dashboard/test/curation-data.vitest.tsx index bb3d9a47..4dbbe428 100644 --- a/dashboard/test/curation-data.vitest.tsx +++ b/dashboard/test/curation-data.vitest.tsx @@ -754,6 +754,79 @@ describe("useCurationData", () => { }); }); + it("refreshes automation outputs when polling observes terminal runs", async () => { + const queuedMemoryRun = { + schema_version: 2, + run_id: "queued-memory-run", + trigger: "dashboard", + task: "memory_curator", + backend: "codex_app_server", + status: "queued", + accepted_count: 0, + rejected_count: 0, + started_at: "2026-06-24T00:00:00Z", + completed_at: "2026-06-24T00:00:00Z", + }; + const queuedReflectionRun = { + ...queuedMemoryRun, + run_id: "queued-reflection-run", + task: "session_reflector", + }; + const queuedSkillRun = { + ...queuedMemoryRun, + run_id: "queued-skill-run", + task: "skill_writer", + }; + const api = makeApi({ + getMemoryAutomationRuns: vi.fn() + .mockResolvedValueOnce({ + records: [queuedMemoryRun, queuedReflectionRun, queuedSkillRun], + count: 3, + limit: 20, + error: "", + }) + .mockResolvedValueOnce({ + records: [ + { ...queuedMemoryRun, status: "succeeded" }, + { ...queuedReflectionRun, status: "succeeded" }, + { ...queuedSkillRun, status: "succeeded" }, + ], + count: 3, + limit: 20, + error: "", + }), + }); + const { result } = renderHook(() => + useCurationData({ api, pollFastMs: 25 }), + ); + + await waitFor(() => { + expect(result.current.configDraft).toBeTruthy(); + }); + + vi.useFakeTimers(); + + await act(async () => { + await result.current.loadAutomationRuns(); + }); + + vi.mocked(api.getMemoryCuratorPreview).mockClear(); + vi.mocked(api.getMemoryCuratorActivity).mockClear(); + vi.mocked(api.getMemoryCuratorStatus).mockClear(); + vi.mocked(api.getFactProposals).mockClear(); + vi.mocked(api.getManagedSkills).mockClear(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(25); + }); + + expect(api.getMemoryCuratorPreview).toHaveBeenCalledTimes(1); + expect(api.getMemoryCuratorActivity).toHaveBeenCalledTimes(1); + expect(api.getMemoryCuratorStatus).toHaveBeenCalledTimes(1); + expect(api.getFactProposals).toHaveBeenCalledTimes(1); + expect(api.getManagedSkills).toHaveBeenCalledTimes(1); + }); + it("loads and applies fact proposals from the history tab", async () => { const pendingProposal = { schema_version: 1, diff --git a/dashboard/test/hermes-wrapper.test.mjs b/dashboard/test/hermes-wrapper.test.mjs index 07ef347b..475b3cf0 100644 --- a/dashboard/test/hermes-wrapper.test.mjs +++ b/dashboard/test/hermes-wrapper.test.mjs @@ -108,6 +108,8 @@ test("wrapper rewrites child API calls and keeps child registrations isolated", [`${assetBase}/holographic.js`]: ` window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/plugins/holographic"); window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/plugins/holographic/similarity?limit=2"); +window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/automation/run/memory-curator"); +window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/automation/skills?state=pending"); window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/plugins/other"); window.__HERMES_PLUGIN_SDK__.authedFetch("/api/plugins/hermes-lcm/search?q=abc"); window.__boundResult = window.boundProbe(); @@ -126,6 +128,7 @@ window.__HERMES_PLUGINS__.register("graph", function Graph(){ return null; }); [`${assetBase}/savings.js`]: ` window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/plugins/savings/overview"); window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/plugins/savings/ledger?range=30d"); +window.__HERMES_PLUGIN_SDK__.fetchJSON("/api/plugins/analytics/diagnostics"); window.__HERMES_PLUGINS__.register("savings", function Savings(){ return null; }); `, }; @@ -154,12 +157,15 @@ window.__HERMES_PLUGINS__.register("savings", function Savings(){ return null; } [ "/api/plugins/tracedecay/holographic", "/api/plugins/tracedecay/holographic/similarity?limit=2", + "/api/plugins/tracedecay/automation/run/memory-curator", + "/api/plugins/tracedecay/automation/skills?state=pending", "/api/plugins/other", "/api/plugins/tracedecay/lcm/overview", "/api/plugins/tracedecay/graph", "/api/plugins/tracedecay/graph/nodes?limit=5", "/api/plugins/tracedecay/savings/overview", "/api/plugins/tracedecay/savings/ledger?range=30d", + "/api/plugins/tracedecay/analytics/diagnostics", ].sort(), ); assert.deepEqual(loaded.authedFetchCalls.map(([url]) => url).sort(), [ From 9ad434eceaca74d47d88dd7dd2467729851218fc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 11:12:36 +0000 Subject: [PATCH 3/5] refactor: simplify dashboard test and diagnostics helpers --- dashboard/savings/src/DiagnosticsPanel.tsx | 85 +++++++++++++--------- dashboard/test/curation-panel.vitest.tsx | 45 ++++-------- 2 files changed, 66 insertions(+), 64 deletions(-) diff --git a/dashboard/savings/src/DiagnosticsPanel.tsx b/dashboard/savings/src/DiagnosticsPanel.tsx index ec8e99f3..88ca1de2 100644 --- a/dashboard/savings/src/DiagnosticsPanel.tsx +++ b/dashboard/savings/src/DiagnosticsPanel.tsx @@ -9,6 +9,11 @@ import type { DiagnosticsResponse, } from "./types"; +type RecentTableColumn = { + header: string; + value: (row: T) => string | number | null | undefined; +}; + function fmtRatio(value: number | undefined): string { return value == null ? "0.00" : value.toFixed(2); } @@ -26,26 +31,34 @@ function countRows( .map((row) => ({ label: rowLabel(row, key), value: Number(row.count) || 0 })); } -function EventTable({ rows }: { rows: DiagnosticsRecentEvent[] }) { - if (!rows.length) return No recent events; +function RecentTable({ + rows, + empty, + columns, + rowKey, +}: { + rows: T[]; + empty: string; + columns: Array>; + rowKey: (row: T, index: number) => string; +}) { + if (!rows.length) return {empty}; return (
- - - - + {columns.map((column) => ( + + ))} {rows.slice(0, 10).map((row, index) => ( - - - - - + + {columns.map((column) => ( + + ))} ))} @@ -54,31 +67,35 @@ function EventTable({ rows }: { rows: DiagnosticsRecentEvent[] }) { ); } +function EventTable({ rows }: { rows: DiagnosticsRecentEvent[] }) { + return ( + `${row.timestamp || 0}-${index}`} + columns={[ + { header: "Kind", value: (row) => row.event_kind }, + { header: "Tool", value: (row) => row.tool_name }, + { header: "Hook", value: (row) => row.hook_name }, + { header: "Outcome", value: (row) => row.outcome }, + ]} + /> + ); +} + function HookTable({ rows }: { rows: DiagnosticsRecentHook[] }) { - if (!rows.length) return No recent hooks; return ( -
-
KindToolHookOutcome{column.header}
{row.event_kind || "-"}{row.tool_name || "-"}{row.hook_name || "-"}{row.outcome || "-"}
{column.value(row) || "-"}
- - - - - - - - - - {rows.slice(0, 10).map((row, index) => ( - - - - - - - ))} - -
AgentHookToolPrompt
{row.agent || "-"}{row.hook_name || "-"}{row.tool_name || "-"}{row.prompt_category || "-"}
-
+ `${row.ts_unix_ms || 0}-${index}`} + columns={[ + { header: "Agent", value: (row) => row.agent }, + { header: "Hook", value: (row) => row.hook_name }, + { header: "Tool", value: (row) => row.tool_name }, + { header: "Prompt", value: (row) => row.prompt_category }, + ]} + /> ); } diff --git a/dashboard/test/curation-panel.vitest.tsx b/dashboard/test/curation-panel.vitest.tsx index e6666deb..8bd901fe 100644 --- a/dashboard/test/curation-panel.vitest.tsx +++ b/dashboard/test/curation-panel.vitest.tsx @@ -453,6 +453,11 @@ vi.mock("../holographic/src/api", () => ({ api: apiMock, })); +function renderHistoryPanel() { + render(); + fireEvent.click(screen.getByRole("tab", { name: /history/i })); +} + describe("CurationPanel", () => { it("keeps inactive curation tabs keyboard reachable", () => { render(); @@ -464,9 +469,7 @@ describe("CurationPanel", () => { }); it("saves automation runtime limits from dashboard controls", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); const maxTokens = await screen.findByLabelText("Max tokens"); const temperature = await screen.findByLabelText("Temperature"); @@ -489,9 +492,7 @@ describe("CurationPanel", () => { }); it("resets automation overrides from dashboard controls", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); await screen.findByLabelText("Max tokens"); fireEvent.click(screen.getByRole("button", { name: /reset defaults/i })); @@ -502,9 +503,7 @@ describe("CurationPanel", () => { }); it("shows scheduler state and pauses scheduler from dashboard controls", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); expect(await screen.findByText("Scheduler")).toBeTruthy(); expect(await screen.findByText("memory curator")).toBeTruthy(); @@ -545,9 +544,7 @@ describe("CurationPanel", () => { }, }); - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); expect(await screen.findByText(/was not found/i)).toBeTruthy(); const runButtons = await screen.findAllByRole("button", { name: /^run$/i }); @@ -592,9 +589,7 @@ describe("CurationPanel", () => { ), ); - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); const timeoutInput = await screen.findByLabelText("Timeout seconds"); fireEvent.change(timeoutInput, { target: { value: "0" } }); @@ -614,9 +609,7 @@ describe("CurationPanel", () => { }); it("does not offer the unimplemented external command backend", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); const backend = (await screen.findByLabelText("Backend")) as HTMLSelectElement; const values = Array.from(backend.options).map((option) => option.value); @@ -624,9 +617,7 @@ describe("CurationPanel", () => { }); it("renders automation run ledger entries in history", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); await waitFor(() => { expect(apiMock.getMemoryAutomationRuns).toHaveBeenCalledWith({ limit: 20 }); @@ -654,9 +645,7 @@ describe("CurationPanel", () => { }); it("runs standalone automation tasks from history controls", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); await screen.findByLabelText("Session reflector schedule"); const runButtons = screen.getAllByRole("button", { name: /^run$/i }); @@ -687,9 +676,7 @@ describe("CurationPanel", () => { }); it("renders and applies fact proposals in history", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); await waitFor(() => { expect(apiMock.getFactProposals).toHaveBeenCalledWith({ limit: 50 }); @@ -705,9 +692,7 @@ describe("CurationPanel", () => { }); it("renders managed skill approvals in history", async () => { - render(); - - fireEvent.click(screen.getByRole("tab", { name: /history/i })); + renderHistoryPanel(); await waitFor(() => { expect(apiMock.getManagedSkills).toHaveBeenCalled(); From 17da4ac13ebdcc5d70673755580c54e49b65b728 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 15:02:44 +0000 Subject: [PATCH 4/5] refactor: simplify savings fetch effects --- dashboard/savings/src/SavingsExplorer.tsx | 30 ++++++----------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/dashboard/savings/src/SavingsExplorer.tsx b/dashboard/savings/src/SavingsExplorer.tsx index 6c40790d..71ad33cb 100644 --- a/dashboard/savings/src/SavingsExplorer.tsx +++ b/dashboard/savings/src/SavingsExplorer.tsx @@ -1,9 +1,3 @@ -/** - * Savings & Cost tab root: view switcher (Savings / Sessions / Models & - * Pricing) + time-range selector, loading data from - * `/api/plugins/savings/*` and the shared price table from `/pricing`. - */ - import React, { useCallback, useEffect, useMemo, useState } from "react"; import { cn } from "../../lib/sdk"; import { ErrorPanel } from "../../lib/primitives"; @@ -59,15 +53,11 @@ export default function SavingsExplorer() { const retry = useCallback(() => setRetryToken((token) => token + 1), []); - /** - * Runs `fetch()` inside an effect, dropping the response after unmount or - * a dependency change so stale results never overwrite newer ones. - */ - function fetchIntoState( + const fetchIntoState = useCallback(( fetch: () => Promise, setState: (value: T | null) => void, { clearBeforeLoad = false }: { clearBeforeLoad?: boolean } = {}, - ): () => void { + ): () => void => { let active = true; setError(""); if (clearBeforeLoad) setState(null); @@ -82,12 +72,8 @@ export default function SavingsExplorer() { return () => { active = false; }; - } + }, []); - // Each view fetches only what it renders. The overview (meta strip + - // savings stats) and the price table take no range/page params, so they - // load once; `sessions` is the only request that depends on the page, so - // paging the sessions table costs exactly one request. useEffect(() => { const cancelOverview = fetchIntoState(() => api.overview(), setOverview); const cancelPricing = fetchIntoState(() => api.pricing(), setPricing); @@ -95,12 +81,12 @@ export default function SavingsExplorer() { cancelOverview(); cancelPricing(); }; - }, [retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + }, [fetchIntoState, retryToken]); useEffect(() => { if (view !== "savings") return; return fetchIntoState(() => api.ledger({ range }), setLedger, { clearBeforeLoad: true }); - }, [view, range, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + }, [fetchIntoState, view, range, retryToken]); useEffect(() => { if (view !== "sessions") return; @@ -109,17 +95,17 @@ export default function SavingsExplorer() { setSessions, { clearBeforeLoad: true }, ); - }, [view, range, page, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + }, [fetchIntoState, view, range, page, retryToken]); useEffect(() => { if (view !== "models") return; return fetchIntoState(() => api.models({ range }), setModels, { clearBeforeLoad: true }); - }, [view, range, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + }, [fetchIntoState, view, range, retryToken]); useEffect(() => { if (view !== "diagnostics") return; return fetchIntoState(() => api.diagnostics(), setDiagnostics, { clearBeforeLoad: true }); - }, [view, retryToken]); // eslint-disable-line react-hooks/exhaustive-deps + }, [fetchIntoState, view, retryToken]); const sessionStats = overview?.sessions; From a18f5957b2924ac4ac1ce10c670ae5c359c2285e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 25 Jun 2026 19:03:11 +0000 Subject: [PATCH 5/5] ci: retry Windows test flakes broadly --- .config/nextest.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 9bac40b0..32e37a7c 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -6,7 +6,7 @@ slow-timeout = { period = "10s", terminate-after = 36 } final-status-level = "slow" [[profile.ci.overrides]] -filter = 'binary_id(tracedecay::mcp_handler_test)' +filter = 'all()' platform = { host = 'cfg(windows)' } retries = 2 flaky-result = 'pass'