From 21ab20f1054861396e1769a6736c962f7502ed32 Mon Sep 17 00:00:00 2001 From: YOMXXX Date: Wed, 27 May 2026 22:39:59 +0800 Subject: [PATCH] feat(memory-tree): status panel + on/off toggle (#1856 Part 1) (#2719) Co-authored-by: Claude --- .../MemoryTreeStatusPanel.test.tsx | 179 +++++++ .../intelligence/MemoryTreeStatusPanel.tsx | 357 +++++++++++++ .../intelligence/MemoryWorkspace.tsx | 2 + app/src/lib/i18n/chunks/ar-1.ts | 25 + app/src/lib/i18n/chunks/bn-1.ts | 25 + app/src/lib/i18n/chunks/de-1.ts | 25 + app/src/lib/i18n/chunks/en-1.ts | 25 + app/src/lib/i18n/chunks/es-1.ts | 25 + app/src/lib/i18n/chunks/fr-1.ts | 25 + app/src/lib/i18n/chunks/hi-1.ts | 25 + app/src/lib/i18n/chunks/id-1.ts | 25 + app/src/lib/i18n/chunks/it-1.ts | 25 + app/src/lib/i18n/chunks/ko-1.ts | 25 + app/src/lib/i18n/chunks/pt-1.ts | 25 + app/src/lib/i18n/chunks/ru-1.ts | 25 + app/src/lib/i18n/chunks/zh-CN-1.ts | 25 + app/src/lib/i18n/en.ts | 30 ++ app/src/pages/Intelligence.tsx | 96 +--- app/src/utils/tauriCommands/memoryTree.ts | 116 +++++ src/openhuman/memory/schema.rs | 131 +++++ src/openhuman/memory_tree/tree/rpc.rs | 493 ++++++++++++++++++ 21 files changed, 1640 insertions(+), 89 deletions(-) create mode 100644 app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx create mode 100644 app/src/components/intelligence/MemoryTreeStatusPanel.tsx diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx new file mode 100644 index 000000000..3b373e078 --- /dev/null +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.test.tsx @@ -0,0 +1,179 @@ +/** + * Vitest for ``. Covers the four-tile dashboard, + * the toggle round-trip (calls `memoryTreeSetEnabled` and re-fetches), + * paused/error rendering branches, and the failure → retry path. + * + * Fake timers are pinned in `beforeEach` so `Date.now()` (in + * `formatRelativeMs` and the `payload()` helper) yields the same value on + * every assertion, and the polling `setTimeout` cannot race CI runners. + * The first `fetchOnce()` still resolves as a microtask via `waitFor`, so + * the suite never needs to advance timers manually. + */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MemoryTreePipelineStatus } from '../../utils/tauriCommands'; +import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel'; + +const mockPipelineStatus = vi.fn(); +const mockSetEnabled = vi.fn(); + +vi.mock('../../utils/tauriCommands', async importOriginal => { + // Inherit everything else (types, sibling wrappers) verbatim so the panel + // sees the same module shape as production — only the two new helpers + // under test get swapped for spies. + const actual = await importOriginal(); + return { + ...actual, + memoryTreePipelineStatus: (...args: unknown[]) => mockPipelineStatus(...args), + memoryTreeSetEnabled: (...args: unknown[]) => mockSetEnabled(...args), + }; +}); + +/** Stable wall-clock used by every test in this file. */ +const FIXED_NOW_MS = new Date('2026-01-01T12:00:00.000Z').getTime(); + +function payload(overrides: Partial = {}): MemoryTreePipelineStatus { + return { + status: 'running', + reason: null, + last_sync_ms: FIXED_NOW_MS - 5 * 60 * 1000, // 5 minutes ago (stable under fake timers) + total_chunks: 1234, + wiki_size_bytes: 2 * 1024 * 1024, // 2 MiB + pipeline_jobs: { ready: 0, running: 0, failed: 0 }, + is_syncing: false, + is_paused: false, + ...overrides, + }; +} + +describe('', () => { + beforeEach(() => { + // `shouldAdvanceTime` lets fake `setTimeout`/`setInterval` tick at the + // real-time cadence so RTL's `waitFor` (and the panel's polling loop) + // make progress without each test having to call + // `vi.advanceTimersByTime` manually — while `setSystemTime` still + // freezes `Date.now()` so `formatRelativeMs` resolves deterministically. + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date(FIXED_NOW_MS)); + mockPipelineStatus.mockReset(); + mockSetEnabled.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders the four tiles with formatted values once the status loads', async () => { + mockPipelineStatus.mockResolvedValueOnce(payload()); + render(); + + // Status label flows in from the wire status. + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/running/i); + }); + + // Number is thousands-separated via Intl.NumberFormat. + expect(screen.getByTestId('memory-tree-total-chunks')).toHaveTextContent('1,234'); + // 2 MiB formatter renders "2.0 MiB". + expect(screen.getByTestId('memory-tree-wiki-size')).toHaveTextContent(/2\.0 MiB/); + // last_sync_ms ~5 min ago bucketed to "5 min ago". + expect(screen.getByTestId('memory-tree-last-sync')).toHaveTextContent(/min ago/); + }); + + it('shows skeleton placeholders before the first status payload resolves', async () => { + // Suspend the promise so the panel paints its loading state. + let resolve: (v: MemoryTreePipelineStatus) => void = () => {}; + mockPipelineStatus.mockReturnValueOnce( + new Promise(r => { + resolve = r; + }) + ); + render(); + // Tiles container is on-screen; status-label has not been written yet. + expect(screen.getByTestId('memory-tree-status-tiles')).toBeInTheDocument(); + expect(screen.queryByTestId('memory-tree-status-label')).toBeNull(); + // Resolve to unblock the cleanup path. + await act(async () => { + resolve(payload()); + }); + }); + + it('toggles auto-sync by calling memoryTreeSetEnabled and re-fetching', async () => { + mockPipelineStatus + .mockResolvedValueOnce(payload({ status: 'running', is_paused: false })) + .mockResolvedValueOnce( + payload({ status: 'paused', is_paused: true, reason: 'scheduler gate mode = off' }) + ); + mockSetEnabled.mockResolvedValueOnce({ enabled: false, changed: true, mode: 'off' }); + + render(); + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/running/i); + }); + + const toggle = screen.getByTestId('memory-tree-status-toggle'); + expect(toggle.getAttribute('aria-checked')).toBe('true'); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(mockSetEnabled).toHaveBeenCalledWith(false); + }); + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/paused/i); + }); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + }); + + it('renders a paused pill with the reason from the wire payload', async () => { + mockPipelineStatus.mockResolvedValueOnce( + payload({ status: 'paused', is_paused: true, reason: 'scheduler gate mode = off' }) + ); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/paused/i); + }); + expect(screen.getByText(/scheduler gate mode = off/i)).toBeInTheDocument(); + expect(screen.getByTestId('memory-tree-status-toggle').getAttribute('aria-checked')).toBe( + 'false' + ); + }); + + it('shows an error banner with retry button when the fetch rejects', async () => { + mockPipelineStatus.mockRejectedValueOnce(new Error('rpc went boom')); + mockPipelineStatus.mockResolvedValueOnce(payload({ status: 'idle', total_chunks: 0 })); + render(); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-error')).toBeInTheDocument(); + }); + + const retry = screen.getByRole('button', { name: /retry/i }); + fireEvent.click(retry); + + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/idle/i); + }); + }); + + it('reports toggle errors via the onToast callback', async () => { + mockPipelineStatus.mockResolvedValueOnce(payload({ status: 'running', is_paused: false })); + mockSetEnabled.mockRejectedValueOnce(new Error('disk write failed')); + const onToast = vi.fn(); + + render(); + await waitFor(() => { + expect(screen.getByTestId('memory-tree-status-label')).toHaveTextContent(/running/i); + }); + + fireEvent.click(screen.getByTestId('memory-tree-status-toggle')); + await waitFor(() => { + expect(onToast).toHaveBeenCalledWith( + expect.objectContaining({ type: 'error', message: 'disk write failed' }) + ); + }); + }); +}); diff --git a/app/src/components/intelligence/MemoryTreeStatusPanel.tsx b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx new file mode 100644 index 000000000..df6a4ba75 --- /dev/null +++ b/app/src/components/intelligence/MemoryTreeStatusPanel.tsx @@ -0,0 +1,357 @@ +/** + * Memory Tree status panel — 4 stat tiles + on/off toggle. + * + * Replaces the temporary `useConsciousItems`-driven pill in + * `Intelligence.tsx`; addresses issue #1856 Part 1. + * + * The toggle writes `config.scheduler_gate.mode = "off"` via the + * `memory_tree_set_enabled` RPC and relies on the scheduler-gate + * hot-reload to pause all LLM-bound background work cooperatively. + * It does NOT pause the 20-min Composio fetch loop yet (#1856 Part 2 + * follow-up). + * + * Polling cadence mirrors `useMemoryIngestionStatus`: 1.5s while + * syncing/active jobs, 4s otherwise — the same heuristic we use + * elsewhere so the dashboard feels lively without thrashing the + * core. + * + * Layout & color conventions copied verbatim from + * `MemoryStatsBar.tsx` (tiles) and the inline `ToggleRow` in + * `settings/panels/AIPanel.tsx` (switch markup). + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { ToastNotification } from '../../types/intelligence'; +import { + memoryTreePipelineStatus, + type MemoryTreePipelineStatus, + memoryTreeSetEnabled, +} from '../../utils/tauriCommands'; + +/** Translator function shape exposed by `useT()`. */ +type TFn = (key: string, fallback?: string) => string; + +/** + * Adaptive polling cadence — match the existing memory ingestion + * panel so the two surfaces feel like one. + */ +const FAST_POLL_MS = 1500; +const DEFAULT_POLL_MS = 4000; + +/** + * Public hook so unit tests (and any future caller) can subscribe to the + * pipeline-status stream without re-implementing the polling dance. + */ +function useMemoryTreeStatus(): { + status: MemoryTreePipelineStatus | null; + loading: boolean; + error: string | null; + refresh: () => Promise; +} { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const cancelledRef = useRef(false); + const statusRef = useRef(null); + statusRef.current = status; + + const fetchOnce = useCallback(async () => { + console.debug('[ui-flow][memory-tree-status] fetchOnce: entry'); + try { + const next = await memoryTreePipelineStatus(); + if (cancelledRef.current) return; + setStatus(next); + setError(null); + console.debug( + '[ui-flow][memory-tree-status] fetchOnce: ok status=%s total=%d', + next.status, + next.total_chunks + ); + } catch (err) { + if (cancelledRef.current) return; + const message = err instanceof Error ? err.message : String(err); + console.warn('[ui-flow][memory-tree-status] fetchOnce: error %s', message); + setError(message); + } finally { + if (!cancelledRef.current) setLoading(false); + } + }, []); + + useEffect(() => { + cancelledRef.current = false; + let timer: ReturnType | null = null; + + const tick = async () => { + await fetchOnce(); + if (cancelledRef.current) return; + const live = statusRef.current; + const fast = live?.is_syncing || (live?.pipeline_jobs?.running ?? 0) > 0; + timer = setTimeout(tick, fast ? FAST_POLL_MS : DEFAULT_POLL_MS); + }; + + void tick(); + + return () => { + cancelledRef.current = true; + if (timer) clearTimeout(timer); + }; + }, [fetchOnce]); + + return { status, loading, error, refresh: fetchOnce }; +} + +interface MemoryTreeStatusPanelProps { + onToast?: (toast: Omit) => void; +} + +/** + * Format a millisecond timestamp as a coarse "5 min ago" style label. + * Returns the localized `Never` placeholder when `ms` is 0/falsy. + * + * Intentionally light — no dayjs dependency, no plural rules. Buckets + * (just-now / seconds / minutes / hours / days) are enough for the status + * tile; the precise timestamp is one level deeper in the workspace UI. + * + * Strings flow through `t()` from `useT()` so the panel localizes + * cleanly. `{count}` placeholders are substituted client-side because + * `t()` does not interpolate (see `I18nContext.tsx`). + */ +function formatRelativeMs(ms: number, t: TFn, neverLabel: string): string { + if (!ms || ms <= 0) return neverLabel; + const diffMs = Date.now() - ms; + if (diffMs < 0) return neverLabel; // clock skew safety + const sec = Math.floor(diffMs / 1000); + if (sec < 30) return t('memoryTree.status.justNow'); + if (sec < 60) return t('memoryTree.status.secondsAgo').replace('{count}', String(sec)); + const min = Math.floor(sec / 60); + if (min < 60) { + if (min === 1) return t('memoryTree.status.minuteAgo'); + return t('memoryTree.status.minutesAgo').replace('{count}', String(min)); + } + const hr = Math.floor(min / 60); + if (hr < 24) { + if (hr === 1) return t('memoryTree.status.hourAgo'); + return t('memoryTree.status.hoursAgo').replace('{count}', String(hr)); + } + const day = Math.floor(hr / 24); + if (day === 1) return t('memoryTree.status.dayAgo'); + return t('memoryTree.status.daysAgo').replace('{count}', String(day)); +} + +/** + * Format a raw byte count as KiB / MiB / GiB — sized to the order of + * magnitude. Negative / zero ⇒ `0 B`. + */ +function formatBytes(n: number): string { + if (!n || n <= 0) return '0 B'; + const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; + let v = n; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + // 1 decimal place once we're past bytes, integer for plain bytes. + return `${i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`; +} + +/** Map the wire status to a dot color token + animation flag. */ +function statusDotClass(kind: MemoryTreePipelineStatus['status']): string { + switch (kind) { + case 'running': + return 'bg-sage-400'; + case 'syncing': + return 'bg-sage-500 animate-pulse'; + case 'paused': + return 'bg-stone-400 dark:bg-neutral-500'; + case 'error': + return 'bg-coral-500'; + case 'idle': + default: + return 'bg-stone-400 dark:bg-neutral-500'; + } +} + +/** + * Memory Tree status panel — render the four-tile dashboard plus the + * auto-sync toggle. Designed to mount above `` in + * `MemoryWorkspace` so it surfaces in both the Intelligence page and + * Settings → Memory data without extra wiring. + */ +export function MemoryTreeStatusPanel({ onToast }: MemoryTreeStatusPanelProps) { + const { t } = useT(); + const { status, loading, error, refresh } = useMemoryTreeStatus(); + const [toggleBusy, setToggleBusy] = useState(false); + + const handleToggle = useCallback(async () => { + if (!status || toggleBusy) return; + const nextEnabled = status.is_paused; // currently paused ⇒ enable + console.debug('[ui-flow][memory-tree-status] toggle: entry next_enabled=%s', nextEnabled); + setToggleBusy(true); + try { + await memoryTreeSetEnabled(nextEnabled); + await refresh(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.warn('[ui-flow][memory-tree-status] toggle: error %s', message); + onToast?.({ type: 'error', title: t('memoryTree.status.toggleFailed'), message }); + } finally { + setToggleBusy(false); + } + }, [status, toggleBusy, refresh, onToast, t]); + + const statusKind = status?.status ?? 'idle'; + const statusLabel: string = (() => { + switch (statusKind) { + case 'running': + return t('memoryTree.status.statusRunning'); + case 'paused': + return t('memoryTree.status.statusPaused'); + case 'syncing': + return t('memoryTree.status.statusSyncing'); + case 'error': + return t('memoryTree.status.statusError'); + case 'idle': + default: + return t('memoryTree.status.statusIdle'); + } + })(); + + const checked = !(status?.is_paused ?? false); + + const tileClass = + 'rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 p-3 transition-colors hover:bg-stone-100 dark:hover:bg-neutral-800'; + const labelClass = + 'text-[11px] uppercase tracking-wide text-stone-500 dark:text-neutral-400 mb-1'; + const valueClass = 'text-xl font-semibold text-stone-900 dark:text-neutral-100'; + const skeletonClass = 'h-7 w-16 rounded bg-stone-200 dark:bg-neutral-800 animate-pulse'; + + return ( +
+
+

+ {t('memoryTree.status.title')} +

+
+ + {error && !loading ? ( +
+ {t('memoryTree.status.fetchError')} + +
+ ) : null} + +
+ {/* Status tile ── color-coded pill */} +
+
{t('memoryTree.status.statusTile')}
+ {loading || !status ? ( +
+ ) : ( + <> +
+ + + {statusLabel} + +
+ {status.reason ? ( +
+ {status.reason} +
+ ) : null} + + )} +
+ + {/* Last-sync tile */} +
+
{t('memoryTree.status.lastSyncTile')}
+ {loading || !status ? ( +
+ ) : ( +
+ {formatRelativeMs(status.last_sync_ms, t, t('memoryTree.status.never'))} +
+ )} +
+ + {/* Total chunks tile */} +
+
{t('memoryTree.status.totalChunksTile')}
+ {loading || !status ? ( +
+ ) : ( +
+ {new Intl.NumberFormat().format(status.total_chunks)} +
+ )} +
+ + {/* Wiki size tile */} +
+
{t('memoryTree.status.wikiSizeTile')}
+ {loading || !status ? ( +
+ ) : ( +
+ {formatBytes(status.wiki_size_bytes)} +
+ )} +
+
+ + {/* Auto-sync toggle row — markup mirrors AIPanel's inline ToggleRow */} +
+
+
+ {t('memoryTree.status.autoSyncLabel')} +
+
+ {t('memoryTree.status.autoSyncDescription')} +
+
+ +
+
+ ); +} + +// Re-export the hook so unit tests can opt into the polling subscription +// directly without re-implementing it. +export { useMemoryTreeStatus }; diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index 4dc84bb70..35b177b1e 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -39,6 +39,7 @@ import { } from '../../utils/tauriCommands'; import { MemoryGraph } from './MemoryGraph'; import { MemorySources } from './MemorySources'; +import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel'; import { ObsidianVaultSection } from './ObsidianVaultSection'; import { VaultPanel } from './VaultPanel'; import { WhatsAppMemorySection } from './WhatsAppMemorySection'; @@ -220,6 +221,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) { return (
+ diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index 4971d7960..c8d60bd4c 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -180,6 +180,31 @@ const ar1: TranslationMap = { 'memory.tab.calls': 'المكالمات', 'memory.tab.settings': 'الإعدادات', 'memory.analyzeNow': 'تحليل الآن', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'التنبيهات', 'alerts.empty': 'لا توجد تنبيهات بعد', 'alerts.markAllRead': 'تحديد الكل كمقروء', diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 249e303c5..25bfe0c54 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -182,6 +182,31 @@ const bn1: TranslationMap = { 'memory.tab.calls': 'কল', 'memory.tab.settings': 'সেটিংস', 'memory.analyzeNow': 'এখনই বিশ্লেষণ করুন', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'সতর্কতা', 'alerts.empty': 'এখনো কোনো সতর্কতা নেই', 'alerts.markAllRead': 'সব পঠিত চিহ্নিত করুন', diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index 3e8fb7af6..f05ee79fd 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -224,6 +224,31 @@ const de1: TranslationMap = { 'memory.tab.calls': 'Anrufe', 'memory.tab.settings': 'Einstellungen', 'memory.analyzeNow': 'Jetzt analysieren', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Warnungen', 'alerts.empty': 'Noch keine Benachrichtigungen', 'alerts.markAllRead': 'Alle als gelesen markieren', diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index 4ef4ed2da..45c025cf7 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -466,6 +466,31 @@ const en1: TranslationMap = { 'memory.tab.calls': 'Calls', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Alerts', 'alerts.empty': 'No alerts yet', 'alerts.markAllRead': 'Mark all as read', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index 0874b86d4..961aae1db 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -188,6 +188,31 @@ const es1: TranslationMap = { 'memory.tab.calls': 'Llamadas', 'memory.tab.settings': 'Configuración', 'memory.analyzeNow': 'Analizar ahora', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Alertas', 'alerts.empty': 'Sin alertas aún', 'alerts.markAllRead': 'Marcar todo como leído', diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index aa41875bf..62a2d42e8 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -188,6 +188,31 @@ const fr1: TranslationMap = { 'memory.tab.calls': 'Appels', 'memory.tab.settings': 'Paramètres', 'memory.analyzeNow': 'Analyser maintenant', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Alertes', 'alerts.empty': "Aucune alerte pour l'instant", 'alerts.markAllRead': 'Tout marquer comme lu', diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index cd0c10e28..b90e96a7e 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -180,6 +180,31 @@ const hi1: TranslationMap = { 'memory.tab.calls': 'कॉल्स', 'memory.tab.settings': 'सेटिंग्स', 'memory.analyzeNow': 'अभी एनालाइज़ करें', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'अलर्ट', 'alerts.empty': 'अभी कोई अलर्ट नहीं', 'alerts.markAllRead': 'सभी पढ़ा हुआ मार्क करें', diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index 94009b131..a44393f21 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -181,6 +181,31 @@ const id1: TranslationMap = { 'memory.tab.calls': 'Panggilan', 'memory.tab.settings': 'Pengaturan', 'memory.analyzeNow': 'Analisis Sekarang', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Peringatan', 'alerts.empty': 'Belum ada peringatan', 'alerts.markAllRead': 'Tandai semua sudah dibaca', diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index fea724795..94eb04a1b 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -186,6 +186,31 @@ const it1: TranslationMap = { 'memory.tab.calls': 'Chiamate', 'memory.tab.settings': 'Impostazioni', 'memory.analyzeNow': 'Analizza ora', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Avvisi', 'alerts.empty': 'Nessun avviso', 'alerts.markAllRead': 'Segna tutti come letti', diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 4da368dd6..9a01ac35a 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -180,6 +180,31 @@ const ko1: TranslationMap = { 'memory.tab.calls': '통화', 'memory.tab.settings': '설정', 'memory.analyzeNow': '지금 분석', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': '알림', 'alerts.empty': '아직 알림이 없습니다', 'alerts.markAllRead': '모두 읽음으로 표시', diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index a939c0e3c..c575234ab 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -187,6 +187,31 @@ const pt1: TranslationMap = { 'memory.tab.calls': 'Chamadas', 'memory.tab.settings': 'Configurações', 'memory.analyzeNow': 'Analisar Agora', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Alertas', 'alerts.empty': 'Nenhum alerta ainda', 'alerts.markAllRead': 'Marcar tudo como lido', diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index 8b20d7c14..ce1fd8ac3 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -181,6 +181,31 @@ const ru1: TranslationMap = { 'memory.tab.calls': 'Звонки', 'memory.tab.settings': 'Настройки', 'memory.analyzeNow': 'Анализировать сейчас', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': 'Оповещения', 'alerts.empty': 'Оповещений пока нет', 'alerts.markAllRead': 'Отметить всё прочитанным', diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 649055505..db7fcd2ee 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -176,6 +176,31 @@ const zhCN1: TranslationMap = { 'memory.tab.calls': '调用记录', 'memory.tab.settings': '设置', 'memory.analyzeNow': '立即分析', + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', 'alerts.title': '通知', 'alerts.empty': '暂无通知', 'alerts.markAllRead': '全部标记为已读', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index bcff636ab..2af7398dd 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -273,6 +273,36 @@ const en: TranslationMap = { 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + // Memory Tree status panel (#1856 Part 1) + 'memoryTree.status.title': 'Memory Tree', + 'memoryTree.status.autoSyncLabel': 'Auto-sync', + 'memoryTree.status.autoSyncDescription': + 'Pause to stop new ingestion. Existing wiki stays queryable.', + 'memoryTree.status.statusTile': 'Status', + 'memoryTree.status.lastSyncTile': 'Last sync', + 'memoryTree.status.totalChunksTile': 'Total chunks', + 'memoryTree.status.wikiSizeTile': 'Wiki size', + 'memoryTree.status.statusRunning': 'Running', + 'memoryTree.status.statusPaused': 'Paused', + 'memoryTree.status.statusSyncing': 'Syncing', + 'memoryTree.status.statusError': 'Error', + 'memoryTree.status.statusIdle': 'Idle', + 'memoryTree.status.never': 'Never', + 'memoryTree.status.fetchError': "Couldn't fetch Memory Tree status", + 'memoryTree.status.retry': 'Retry', + 'memoryTree.status.toggleFailed': "Couldn't toggle auto-sync", + // Relative-time buckets surfaced by the last-sync tile. `{count}` is + // replaced client-side at the call site (the runtime `t()` does not + // interpolate — see I18nContext.tsx). + 'memoryTree.status.justNow': 'just now', + 'memoryTree.status.secondsAgo': '{count}s ago', + 'memoryTree.status.minuteAgo': '1 min ago', + 'memoryTree.status.minutesAgo': '{count} min ago', + 'memoryTree.status.hourAgo': '1 hr ago', + 'memoryTree.status.hoursAgo': '{count} hr ago', + 'memoryTree.status.dayAgo': '1 day ago', + 'memoryTree.status.daysAgo': '{count} days ago', + // Notifications / Alerts 'alerts.title': 'Alerts', 'alerts.empty': 'No alerts yet', diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 73ea640cf..871854067 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -8,13 +8,10 @@ import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTa import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace'; import { ToastContainer } from '../components/intelligence/Toast'; import PillTabBar from '../components/PillTabBar'; -import { useConsciousItems } from '../hooks/useConsciousItems'; import { useIntelligenceSocket, useIntelligenceSocketManager, } from '../hooks/useIntelligenceSocket'; -import { useIntelligenceStats } from '../hooks/useIntelligenceStats'; -import { useMemoryIngestionStatus } from '../hooks/useMemoryIngestionStatus'; import { useSubconscious } from '../hooks/useSubconscious'; import { useT } from '../lib/i18n/I18nContext'; import type { @@ -26,18 +23,16 @@ type IntelligenceTab = 'memory' | 'subconscious' | 'calls' | 'dreams' | 'tasks'; export default function Intelligence() { const { t } = useT(); - const { aiStatus } = useIntelligenceStats(); - const { status: ingestionStatus } = useMemoryIngestionStatus(); const [activeTab, setActiveTab] = useState('memory'); - // `useConsciousItems` is kept solely for the `isRunning` signal that - // drives the system-status pill in the Memory-tab header. The items - // themselves used to feed the actionable-cards count badge (now hidden, - // and the rendering surface — IntelligenceMemoryTab — is gone). When - // the status pill is rewired to a memory_tree-native source, drop this - // hook entirely. - const { isRunning } = useConsciousItems(); + // The legacy header pills (system-status + Ingesting/Queued chips) were + // sourced from `useConsciousItems` + `useMemoryIngestionStatus`. They are + // replaced by the Memory Tree status panel (#1856 Part 1), rendered inside + // `MemoryWorkspace`, which polls `memory_tree_pipeline_status` for a much + // richer dashboard. The hooks themselves still exist for any future + // consumers / tests; we just no longer feed them into a half-baked pill + // up here. // useUpdateActionableItem / useSnoozeActionableItem hooks were the // mutations behind handleComplete / Dismiss / Snooze. Removed along @@ -92,44 +87,6 @@ export default function Intelligence() { } }, [socketConnected, socketManager]); - // System status — `itemsLoading` (the actionable-items + screen-items - // loading flag) used to feed the "loading" branch here, but both feeds - // are gone now. `isRunning` from useConsciousItems still surfaces the - // background analysis loop signal until that pill is rewired to - // memory_tree. - const systemStatus = isRunning - ? 'loading' - : socketConnected && aiStatus === 'ready' - ? 'ready' - : !socketConnected - ? 'disconnected' - : aiStatus; - - const systemStatusLabel = isRunning - ? t('common.loading') - : systemStatus === 'ready' - ? t('common.success') - : systemStatus === 'loading' - ? t('common.loading') - : systemStatus === 'disconnected' - ? t('welcome.connecting') - : systemStatus === 'initializing' - ? t('welcome.connecting') - : systemStatus === 'error' - ? t('common.error') - : t('misc.rehydrating'); - - const systemStatusDot = - isRunning || systemStatus === 'loading' - ? 'bg-amber-400 animate-pulse' - : systemStatus === 'ready' - ? 'bg-sage-400' - : systemStatus === 'disconnected' || systemStatus === 'initializing' - ? 'bg-amber-400 animate-pulse' - : systemStatus === 'error' - ? 'bg-coral-400' - : 'bg-stone-600'; - const tabs: { id: IntelligenceTab; label: string; comingSoon?: boolean }[] = [ { id: 'memory', label: t('memory.tab.memory') }, { id: 'subconscious', label: t('memory.tab.subconscious') }, @@ -185,45 +142,6 @@ export default function Intelligence() { matches anything visible. Hidden until a memory_tree -native count signal is exposed. */}
-
- {activeTab === 'memory' && ( -
-
- - {systemStatusLabel} - -
- )} - {activeTab === 'memory' && - (ingestionStatus.running || ingestionStatus.queueDepth > 0) && ( -
-
- - {ingestionStatus.running - ? t('memory.ingesting') - : t('memory.ingestionQueued')} - {ingestionStatus.queueDepth > 0 && ` · ${ingestionStatus.queueDepth}`} - -
- )} - {/* Analyze Now / Refresh button removed — the new - MemoryWorkspace fetches via memory_tree RPCs that - don't need a manual trigger. The actionable-cards - flow (handleAnalyzeNow) is no longer reachable from - the Memory tab; left in scope only for the legacy - subconscious/dreams tabs that still use it. */} -
{/* Tab content */} diff --git a/app/src/utils/tauriCommands/memoryTree.ts b/app/src/utils/tauriCommands/memoryTree.ts index 8e30bb23f..b1086d969 100644 --- a/app/src/utils/tauriCommands/memoryTree.ts +++ b/app/src/utils/tauriCommands/memoryTree.ts @@ -678,3 +678,119 @@ export async function memoryTreeBackfillStatus(): Promise { ); return out; } + +// ── memory_tree_pipeline_status (#1856 Part 1) ─────────────────────────── + +/** + * Coarse status string emitted by `memory_tree_pipeline_status`. Mapped + * verbatim to a colored pill in the status panel — `paused` is the only + * state the toggle directly influences. + */ +export type MemoryTreePipelineStatusKind = 'running' | 'paused' | 'syncing' | 'error' | 'idle'; + +/** + * Per-state job counters returned in {@link MemoryTreePipelineStatus}. Mirrors + * the Rust `PipelineJobCounts` struct exactly — snake_case carried through. + */ +export interface MemoryTreePipelineJobCounts { + /** Jobs queued and waiting for a worker. */ + ready: number; + /** Jobs currently being processed by a worker. */ + running: number; + /** Jobs that exhausted retries and remain in the table for diagnosis. */ + failed: number; +} + +/** + * Aggregated Memory Tree health snapshot returned by + * `openhuman.memory_tree_pipeline_status`. The UI status panel polls this + * (every ~1.5s while syncing, ~4s otherwise) and renders the four tiles + * directly from the payload — no client-side derivation required. + */ +export interface MemoryTreePipelineStatus { + /** UI status pill — one of `running` / `paused` / `syncing` / `error` / `idle`. */ + status: MemoryTreePipelineStatusKind; + /** + * Optional human-readable reason. Present when `status` is `paused` + * (carries the gate mode) or `error` (carries the failed-job count); + * `null` otherwise. + */ + reason: string | null; + /** Epoch ms of the most-recent chunk timestamp. Zero when the store is empty. */ + last_sync_ms: number; + /** Total `mem_tree_chunks` rows across all sources. */ + total_chunks: number; + /** Recursive on-disk size of the `wiki/` sub-tree under the memory_tree content root, in bytes. */ + wiki_size_bytes: number; + /** Snapshot of `mem_tree_jobs` by status. */ + pipeline_jobs: MemoryTreePipelineJobCounts; + /** Convenience flag: at least one job is currently `running`. */ + is_syncing: boolean; + /** Convenience flag: scheduler-gate mode is `off`. */ + is_paused: boolean; +} + +/** + * Fetch the Memory Tree pipeline status snapshot. Cheap and idempotent — + * the handler runs three SQL counters + one recursive dir walk. Safe to + * poll at ~1.5s intervals while the panel is mounted. + * + * Backed by `openhuman.memory_tree_pipeline_status` (#1856 Part 1). + */ +export async function memoryTreePipelineStatus(): Promise { + console.debug('[memory-tree-rpc] memoryTreePipelineStatus: entry'); + const resp = await callCoreRpc< + MemoryTreePipelineStatus | ResultEnvelope + >({ method: 'openhuman.memory_tree_pipeline_status', params: {} }); + const out = unwrapResult(resp); + console.debug( + '[memory-tree-rpc] memoryTreePipelineStatus: exit status=%s total=%d syncing=%s paused=%s', + out.status, + out.total_chunks, + out.is_syncing, + out.is_paused + ); + return out; +} + +// ── memory_tree_set_enabled (#1856 Part 1) ─────────────────────────────── + +/** + * Wire shape returned by `openhuman.memory_tree_set_enabled`. `changed=false` + * means the persisted mode already matched the request (idempotent toggle). + */ +export interface MemoryTreeSetEnabledResponse { + /** Echo of the requested enabled state (post-write). */ + enabled: boolean; + /** True when the persisted mode flipped; false when the call was a no-op. */ + changed: boolean; + /** New scheduler-gate mode as wire string (`auto` / `off`). */ + mode: string; +} + +/** + * Toggle Memory Tree auto-sync. `enabled=true` flips the scheduler-gate to + * `auto`; `enabled=false` flips it to `off`, which pauses every LLM-bound + * background worker cooperatively at their next `wait_for_capacity()` + * await on the Rust side. + * + * Backed by `openhuman.memory_tree_set_enabled` (#1856 Part 1). The 20-min + * Composio fetch loop is *not* paused by this toggle yet — that lands in + * #1856 Part 2. + */ +export async function memoryTreeSetEnabled( + enabled: boolean +): Promise { + console.debug('[memory-tree-rpc] memoryTreeSetEnabled: entry enabled=%s', enabled); + const resp = await callCoreRpc< + MemoryTreeSetEnabledResponse | ResultEnvelope + >({ method: 'openhuman.memory_tree_set_enabled', params: { enabled } }); + const out = unwrapResult(resp); + console.debug( + '[memory-tree-rpc] memoryTreeSetEnabled: exit enabled=%s changed=%s mode=%s', + out.enabled, + out.changed, + out.mode + ); + return out; +} diff --git a/src/openhuman/memory/schema.rs b/src/openhuman/memory/schema.rs index 579d3733e..3e2b3221f 100644 --- a/src/openhuman/memory/schema.rs +++ b/src/openhuman/memory/schema.rs @@ -44,6 +44,8 @@ pub fn all_controller_schemas() -> Vec { schemas("flush_now"), schemas("wipe_all"), schemas("reset_tree"), + schemas("pipeline_status"), + schemas("set_enabled"), ] } @@ -123,6 +125,14 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("reset_tree"), handler: handle_reset_tree, }, + RegisteredController { + schema: schemas("pipeline_status"), + handler: handle_pipeline_status, + }, + RegisteredController { + schema: schemas("set_enabled"), + handler: handle_set_enabled, + }, ] } @@ -689,6 +699,112 @@ pub fn schemas(function: &str) -> ControllerSchema { }, ], }, + "pipeline_status" => ControllerSchema { + namespace: NAMESPACE, + function: "pipeline_status", + description: "Aggregated Memory Tree health snapshot (#1856 Part 1). \ + Returns a coarse `status` string (running/paused/syncing/error/idle), \ + an optional human-readable reason, the most-recent chunk timestamp, \ + the total chunk count, the on-disk wiki size in bytes, and per-state \ + job counters from `mem_tree_jobs`. Polled by the Memory Tree status \ + panel; cheap enough to call every couple of seconds.", + inputs: vec![], + outputs: vec![ + FieldSchema { + name: "status", + ty: TypeSchema::Enum { + variants: vec!["running", "paused", "syncing", "error", "idle"], + }, + comment: "Coarse, UI-shaped status. paused wins over error wins \ + over syncing wins over running wins over idle.", + required: true, + }, + FieldSchema { + name: "reason", + ty: TypeSchema::Option(Box::new(TypeSchema::String)), + comment: "Human-readable reason for the current status — present \ + for `paused` (gate mode) and `error` (failed-job count).", + required: false, + }, + FieldSchema { + name: "last_sync_ms", + ty: TypeSchema::I64, + comment: "Epoch ms of the newest chunk timestamp across all \ + sources; 0 when the store is empty.", + required: true, + }, + FieldSchema { + name: "total_chunks", + ty: TypeSchema::U64, + comment: "Total rows in `mem_tree_chunks`.", + required: true, + }, + FieldSchema { + name: "wiki_size_bytes", + ty: TypeSchema::U64, + comment: "Recursive on-disk size of the `wiki/` sub-tree under the \ + memory_tree content root. 0 when the directory does not exist yet.", + required: true, + }, + FieldSchema { + name: "pipeline_jobs", + ty: TypeSchema::Json, + comment: "Object with `ready` / `running` / `failed` counters \ + from `mem_tree_jobs`.", + required: true, + }, + FieldSchema { + name: "is_syncing", + ty: TypeSchema::Bool, + comment: "True when at least one job is in `running` state.", + required: true, + }, + FieldSchema { + name: "is_paused", + ty: TypeSchema::Bool, + comment: "True when scheduler-gate mode is `off`.", + required: true, + }, + ], + }, + "set_enabled" => ControllerSchema { + namespace: NAMESPACE, + function: "set_enabled", + description: "Toggle Memory Tree auto-sync (#1856 Part 1). \ + Flips `config.scheduler_gate.mode` between `auto` (enabled=true) \ + and `off` (enabled=false), persists the change, and hot-reloads \ + the live scheduler-gate so in-flight workers observe the new \ + policy at their next `wait_for_capacity` await. The 20-min \ + Composio fetch loop is NOT paused by this toggle yet — that \ + lands in #1856 Part 2.", + inputs: vec![FieldSchema { + name: "enabled", + ty: TypeSchema::Bool, + comment: "True ⇒ scheduler-gate mode = auto. False ⇒ mode = off.", + required: true, + }], + outputs: vec![ + FieldSchema { + name: "enabled", + ty: TypeSchema::Bool, + comment: "Echo of the requested enabled state.", + required: true, + }, + FieldSchema { + name: "changed", + ty: TypeSchema::Bool, + comment: "True when the persisted mode actually flipped; \ + false for no-ops.", + required: true, + }, + FieldSchema { + name: "mode", + ty: TypeSchema::String, + comment: "New scheduler-gate mode as wire string (`auto` / `off`).", + required: true, + }, + ], + }, "memory_backfill_status" => ControllerSchema { namespace: NAMESPACE, function: "memory_backfill_status", @@ -924,6 +1040,21 @@ fn handle_reset_tree(_params: Map) -> ControllerFuture { }) } +fn handle_pipeline_status(_params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + to_json(rpc::pipeline_status_rpc(&config).await?) + }) +} + +fn handle_set_enabled(params: Map) -> ControllerFuture { + Box::pin(async move { + let req = parse_value::(Value::Object(params))?; + let mut config = config_rpc::load_config_with_timeout().await?; + to_json(rpc::set_enabled_rpc(&mut config, req).await?) + }) +} + fn parse_value(v: Value) -> Result { serde_json::from_value(v).map_err(|e| format!("invalid params: {e}")) } diff --git a/src/openhuman/memory_tree/tree/rpc.rs b/src/openhuman/memory_tree/tree/rpc.rs index c3c7dd228..bf220acaa 100644 --- a/src/openhuman/memory_tree/tree/rpc.rs +++ b/src/openhuman/memory_tree/tree/rpc.rs @@ -310,6 +310,352 @@ pub async fn backfill_status_rpc( )) } +// ── pipeline_status / set_enabled (#1856 Part 1) ───────────────────────── + +/// Per-status counters for the `mem_tree_jobs` table — snapshot returned by +/// the `memory_tree_pipeline_status` RPC. Only the three states the status +/// panel surfaces are exposed; `done` / `cancelled` are intentionally +/// omitted to keep the wire payload small. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PipelineJobCounts { + /// Jobs queued and waiting for a worker (`status = 'ready'`). + pub ready: u64, + /// Jobs currently being processed by a worker (`status = 'running'`). + pub running: u64, + /// Jobs that exhausted retries and remain in the table for diagnosis + /// (`status = 'failed'`). + pub failed: u64, +} + +/// Response from the `memory_tree_pipeline_status` RPC (#1856 Part 1). +/// +/// Aggregates "is the Memory Tree healthy?" signals into a single payload +/// the UI status panel can render without secondary fetches: +/// +/// - `status` is a coarse, UI-shaped string (`running`/`paused`/`syncing`/ +/// `error`/`idle`) derived from the other fields so the frontend stays +/// purely presentational. +/// - `wiki_size_bytes` is a recursive walk of the on-disk `wiki/` sub-tree +/// under the memory-tree content root; recomputed every call (cheap for +/// typical workspaces). The walk is scoped to `wiki/` so the figure +/// reflects the user-visible wiki only — not the sibling `raw/`, +/// `email/`, `chat/`, `document/` staging directories. +/// - `pipeline_jobs` is a snapshot of the queue — running > 0 implies +/// active sync, failed > 0 implies degraded. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PipelineStatusResponse { + /// Aggregated status string: `running` | `paused` | `syncing` | `error` + /// | `idle`. Derivation: + /// 1. `is_paused` (scheduler-gate `off`) wins → `paused`. + /// 2. otherwise failed > 0 → `error`. + /// 3. otherwise running > 0 → `syncing`. + /// 4. otherwise total_chunks > 0 → `running`. + /// 5. otherwise → `idle`. + pub status: String, + /// Optional human-readable reason — populated when status is + /// `paused` or `error`. `None` otherwise. + pub reason: Option, + /// Epoch milliseconds of the most-recent chunk timestamp across all + /// sources. Zero when the store is empty. + pub last_sync_ms: i64, + /// Total `mem_tree_chunks` rows across all sources. + pub total_chunks: u64, + /// Recursive byte size of the on-disk `wiki/` sub-tree under the + /// memory-tree content root. Zero when the `wiki/` directory does not + /// exist yet or cannot be read. Scoped to `wiki/` so the value matches + /// the user-visible "Wiki size" tile (#1856 follow-up). + pub wiki_size_bytes: u64, + /// Snapshot counts from `mem_tree_jobs`. + pub pipeline_jobs: PipelineJobCounts, + /// Convenience flag: at least one job is currently `running`. + pub is_syncing: bool, + /// Convenience flag: scheduler-gate is in `off` mode, so all LLM-bound + /// background work is paused cooperatively. + pub is_paused: bool, +} + +/// `memory_tree_pipeline_status` RPC handler (#1856 Part 1). +/// +/// Aggregates `list_sources` + `count_by_status` + a recursive disk-size +/// probe into the [`PipelineStatusResponse`] the UI status panel renders. +/// All blocking work is dispatched onto `spawn_blocking` so the async +/// runtime isn't held during SQLite or filesystem I/O. +pub async fn pipeline_status_rpc( + config: &Config, +) -> Result, String> { + use crate::openhuman::config::SchedulerGateMode; + use crate::openhuman::memory_queue::store as queue_store; + use crate::openhuman::memory_queue::types::JobStatus; + + log::debug!("[memory-tree][rpc] pipeline_status: entry"); + + // Chunk aggregates — total count + latest timestamp from + // `mem_tree_chunks` in a single SQL round-trip so we don't materialise + // the full source list just to sum two columns. + let cfg_for_sources = config.clone(); + let (total_chunks, last_sync_ms) = + tokio::task::spawn_blocking(move || -> Result<(u64, i64), String> { + chunk_store::with_connection(&cfg_for_sources, |conn| { + let (count, max_ts): (i64, Option) = conn.query_row( + "SELECT COUNT(*), MAX(timestamp_ms) FROM mem_tree_chunks", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + Ok((count.max(0) as u64, max_ts.unwrap_or(0).max(0))) + }) + .map_err(|e| format!("chunk aggregates: {e:#}")) + }) + .await + .map_err(|e| { + let msg = format!("pipeline_status join error: {e}"); + log::warn!("[memory-tree][rpc] pipeline_status: {msg}"); + msg + })??; + + // Job counters — three parallel-safe blocking calls. + let cfg_for_jobs = config.clone(); + let pipeline_jobs = + tokio::task::spawn_blocking(move || -> Result { + let ready = queue_store::count_by_status(&cfg_for_jobs, JobStatus::Ready) + .map_err(|e| format!("count_by_status(ready): {e:#}"))?; + let running = queue_store::count_by_status(&cfg_for_jobs, JobStatus::Running) + .map_err(|e| format!("count_by_status(running): {e:#}"))?; + let failed = queue_store::count_by_status(&cfg_for_jobs, JobStatus::Failed) + .map_err(|e| format!("count_by_status(failed): {e:#}"))?; + Ok(PipelineJobCounts { + ready, + running, + failed, + }) + }) + .await + .map_err(|e| { + let msg = format!("pipeline_status job-count join error: {e}"); + log::warn!("[memory-tree][rpc] pipeline_status: {msg}"); + msg + })??; + + // Disk size — best-effort. Permission errors etc. degrade to 0 with a + // warn log rather than failing the whole RPC. Scoped to the `wiki/` + // sub-directory so the tile lives up to its "Wiki size" label — the + // sibling `raw/` / `email/` / `chat/` / `document/` staging directories + // hold pre-canonicalised content and should not roll into the figure + // surfaced to the user (#1856 CodeRabbit feedback). + let wiki_root = config.memory_tree_content_root().join("wiki"); + let wiki_size_bytes = tokio::task::spawn_blocking(move || compute_dir_size_bytes(&wiki_root)) + .await + .map_err(|e| { + let msg = format!("pipeline_status size-walk join error: {e}"); + log::warn!("[memory-tree][rpc] pipeline_status: {msg}"); + msg + })?; + + let is_paused = config.scheduler_gate.mode == SchedulerGateMode::Off; + let is_syncing = pipeline_jobs.running > 0; + + let (status, reason) = derive_pipeline_status( + is_paused, + config.scheduler_gate.mode, + is_syncing, + pipeline_jobs.failed, + total_chunks, + ); + + let payload = PipelineStatusResponse { + status: status.clone(), + reason: reason.clone(), + last_sync_ms, + total_chunks, + wiki_size_bytes, + pipeline_jobs, + is_syncing, + is_paused, + }; + + log::debug!( + "[memory-tree][rpc] pipeline_status: ok status={status} total_chunks={total_chunks} wiki_size_bytes={wiki_size_bytes} ready={r} running={n} failed={f} reason={reason:?}", + r = payload.pipeline_jobs.ready, + n = payload.pipeline_jobs.running, + f = payload.pipeline_jobs.failed, + ); + + Ok(RpcOutcome::single_log( + payload, + format!( + "memory_tree: pipeline_status status={status} total_chunks={total_chunks} is_paused={is_paused} is_syncing={is_syncing}", + ), + )) +} + +/// Recursive byte-count of files under `root`. Returns `0` when the root +/// does not exist or any traversal error occurs (best-effort; the status +/// panel is a UI convenience, not an audit surface). +fn compute_dir_size_bytes(root: &std::path::Path) -> u64 { + if !root.exists() { + return 0; + } + let mut total: u64 = 0; + for entry in walkdir::WalkDir::new(root).follow_links(false) { + match entry { + Ok(e) if e.file_type().is_file() => { + if let Ok(meta) = e.metadata() { + total = total.saturating_add(meta.len()); + } + } + Ok(_) => {} + Err(err) => { + // Both `err.path()` and `walkdir::Error`'s `Display` impl + // embed the absolute on-disk path (which lives under the + // user's home directory), so we redact: log only whether a + // path was attached and the underlying `io::ErrorKind`. + // That's enough for diagnosis while keeping the user's + // workspace layout out of the log file. + log::warn!( + "[memory-tree][rpc] pipeline_status: dir walk error has_path={} kind={:?}", + err.path().is_some(), + err.io_error().map(|e| e.kind()) + ); + } + } + } + total +} + +/// Pure derivation of `(status, reason)` from raw signals. Split out so the +/// unit tests can exercise the precedence rules without spinning up a +/// store. +fn derive_pipeline_status( + is_paused: bool, + mode: crate::openhuman::config::SchedulerGateMode, + is_syncing: bool, + failed: u64, + total_chunks: u64, +) -> (String, Option) { + if is_paused { + return ( + "paused".to_string(), + Some(format!("scheduler gate mode = {}", mode.as_str())), + ); + } + if failed > 0 { + return ( + "error".to_string(), + Some(format!("{failed} failed job(s) in pipeline")), + ); + } + if is_syncing { + return ("syncing".to_string(), None); + } + if total_chunks > 0 { + return ("running".to_string(), None); + } + ("idle".to_string(), None) +} + +/// Request shape for `memory_tree_set_enabled`. Single field — the caller +/// asks to enable (auto-mode) or pause (off-mode) all LLM-bound background +/// work. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SetEnabledRequest { + /// `true` ⇒ scheduler-gate mode becomes `auto`. `false` ⇒ `off`. + pub enabled: bool, +} + +/// Response shape for `memory_tree_set_enabled`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SetEnabledResponse { + /// Echo of the requested `enabled` state (post-write). + pub enabled: bool, + /// `true` when the saved mode actually flipped; `false` for no-ops. + pub changed: bool, + /// New scheduler-gate mode as wire string (`auto` / `off`). + pub mode: String, +} + +/// `memory_tree_set_enabled` RPC handler (#1856 Part 1). +/// +/// Flips `config.scheduler_gate.mode` to either `Auto` (enabled) or `Off` +/// (paused), persists to disk via `config.save()`, and hot-reloads the +/// live scheduler-gate state so any in-flight workers immediately observe +/// the new policy at their next `wait_for_capacity()` await. +/// +/// Notes: +/// - This is intentionally a single-field RPC (no batched +/// `MemoryTreeSettingsPatch`) — keeps the surface tight while #1856 +/// Part 2 work lands the broader settings story. +/// - The 20-min Composio fetch loop is *not* paused by this toggle yet — +/// that requires a separate `Notify` signal and is queued for Part 2. +pub async fn set_enabled_rpc( + config: &mut Config, + req: SetEnabledRequest, +) -> Result, String> { + use crate::openhuman::config::SchedulerGateMode; + + let prev_mode = config.scheduler_gate.mode; + let new_mode = if req.enabled { + SchedulerGateMode::Auto + } else { + SchedulerGateMode::Off + }; + + log::debug!( + "[memory-tree][rpc] set_enabled: requested enabled={} prev_mode={} new_mode={}", + req.enabled, + prev_mode.as_str(), + new_mode.as_str(), + ); + + if prev_mode == new_mode { + log::info!( + "[memory-tree][rpc] set_enabled: no-op (mode already {})", + new_mode.as_str() + ); + return Ok(RpcOutcome::single_log( + SetEnabledResponse { + enabled: req.enabled, + changed: false, + mode: new_mode.as_str().to_string(), + }, + format!( + "memory_tree: set_enabled no-op enabled={} mode={}", + req.enabled, + new_mode.as_str() + ), + )); + } + + config.scheduler_gate.mode = new_mode; + config.save().await.map_err(|e| { + let msg = format!("set_enabled: config.save failed: {e}"); + log::warn!("[memory-tree][rpc] {msg}"); + msg + })?; + + // Hot-reload the live gate state — workers re-poll inside + // `wait_for_capacity` and pick up the new policy without a restart. + crate::openhuman::scheduler_gate::gate::update_config(config.scheduler_gate.clone()); + + log::info!( + "[memory-tree][rpc] set_enabled: scheduler_gate.mode {} -> {} (enabled={})", + prev_mode.as_str(), + new_mode.as_str(), + req.enabled, + ); + + Ok(RpcOutcome::single_log( + SetEnabledResponse { + enabled: req.enabled, + changed: true, + mode: new_mode.as_str().to_string(), + }, + format!( + "memory_tree: set_enabled enabled={} mode={} changed=true", + req.enabled, + new_mode.as_str() + ), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -561,4 +907,151 @@ mod tests { ); assert!(s1.in_progress, "pending>0 forces in_progress=true"); } + + // ── pipeline_status / set_enabled (#1856 Part 1) ───────────────────── + + /// `derive_pipeline_status` precedence is locked in here so the UI can + /// rely on the wire status string without re-deriving it from the raw + /// counters. + #[test] + fn derive_pipeline_status_precedence_matches_spec() { + use crate::openhuman::config::SchedulerGateMode; + + // paused beats everything else + let (s, reason) = derive_pipeline_status(true, SchedulerGateMode::Off, true, 5, 100); + assert_eq!(s, "paused"); + assert!(reason.unwrap().contains("off")); + + // error beats syncing / running / idle + let (s, reason) = derive_pipeline_status(false, SchedulerGateMode::Auto, true, 2, 100); + assert_eq!(s, "error"); + assert!(reason.unwrap().contains("2 failed")); + + // syncing beats running / idle + let (s, reason) = derive_pipeline_status(false, SchedulerGateMode::Auto, true, 0, 100); + assert_eq!(s, "syncing"); + assert!(reason.is_none()); + + // running when chunks exist but nothing in flight + let (s, _) = derive_pipeline_status(false, SchedulerGateMode::Auto, false, 0, 100); + assert_eq!(s, "running"); + + // idle when the store is empty and nothing is in flight + let (s, _) = derive_pipeline_status(false, SchedulerGateMode::Auto, false, 0, 0); + assert_eq!(s, "idle"); + } + + /// On a fresh workspace the panel must report `idle` with zero + /// counters — the UI uses this to swap the loading skeleton for a + /// "no memory yet" state. + #[tokio::test] + async fn pipeline_status_returns_idle_for_empty_store() { + let (_tmp, cfg) = test_config(); + let out = pipeline_status_rpc(&cfg).await.unwrap().value; + assert_eq!(out.status, "idle"); + assert_eq!(out.total_chunks, 0); + assert_eq!(out.last_sync_ms, 0); + assert_eq!(out.pipeline_jobs.ready, 0); + assert_eq!(out.pipeline_jobs.running, 0); + assert_eq!(out.pipeline_jobs.failed, 0); + assert!(!out.is_syncing); + assert!(!out.is_paused); + assert_eq!(out.wiki_size_bytes, 0, "no content dir yet"); + assert!(out.reason.is_none()); + } + + /// When the scheduler gate is `off`, the aggregated status flips to + /// `paused` regardless of the rest of the signals. This is the + /// invariant the toggle relies on. + #[tokio::test] + async fn pipeline_status_reflects_paused_when_scheduler_off() { + use crate::openhuman::config::SchedulerGateMode; + + let (_tmp, mut cfg) = test_config(); + cfg.scheduler_gate.mode = SchedulerGateMode::Off; + let out = pipeline_status_rpc(&cfg).await.unwrap().value; + assert_eq!(out.status, "paused"); + assert!(out.is_paused); + let reason = out.reason.expect("paused must carry a reason"); + assert!(reason.contains("off"), "reason should name the mode"); + } + + /// `pipeline_status` reflects chunks that have been ingested — total + /// count rolls up and `last_sync_ms` picks up the most-recent + /// timestamp from `mem_tree_chunks`. + #[tokio::test] + async fn pipeline_status_reports_chunk_aggregates_after_ingest() { + let (_tmp, cfg) = test_config(); + + // Seed one document so `mem_tree_chunks` is non-empty. + ingest_rpc( + &cfg, + IngestRequest { + source_kind: SourceKind::Document, + source_id: "doc-status".into(), + owner: "alice".into(), + tags: vec![], + payload: serde_json::to_value(sample_document( + "Status", + "Pipeline status smoke document.", + )) + .unwrap(), + }, + ) + .await + .unwrap(); + + let out = pipeline_status_rpc(&cfg).await.unwrap().value; + assert!(out.total_chunks > 0, "ingest must populate chunk count"); + assert!( + out.last_sync_ms > 0, + "ingest must populate last_sync_ms (got {})", + out.last_sync_ms + ); + // No jobs running ⇒ running status, not syncing/error. + assert_eq!(out.status, "running"); + assert!(!out.is_syncing); + } + + /// `set_enabled` flips the persisted scheduler-gate mode and reports + /// `changed=true`; calling it again with the same value is a no-op + /// reporting `changed=false`. Uses an isolated `config_path` under + /// the workspace tempdir so `config.save()` doesn't touch the + /// host's real ~/.openhuman directory. + #[tokio::test] + async fn set_enabled_toggles_scheduler_gate_mode() { + use crate::openhuman::config::SchedulerGateMode; + + let (tmp, mut cfg) = test_config(); + // Pin config_path inside the tempdir so `save()` stays sandboxed. + cfg.config_path = tmp.path().join("config.toml"); + + assert_eq!(cfg.scheduler_gate.mode, SchedulerGateMode::Auto); + + let off = set_enabled_rpc(&mut cfg, SetEnabledRequest { enabled: false }) + .await + .unwrap() + .value; + assert!(!off.enabled); + assert!(off.changed); + assert_eq!(off.mode, "off"); + assert_eq!(cfg.scheduler_gate.mode, SchedulerGateMode::Off); + + // Calling with the same value must report no-op. + let again = set_enabled_rpc(&mut cfg, SetEnabledRequest { enabled: false }) + .await + .unwrap() + .value; + assert!(!again.changed, "duplicate toggle must be a no-op"); + + // Flip back. + let on = set_enabled_rpc(&mut cfg, SetEnabledRequest { enabled: true }) + .await + .unwrap() + .value; + assert!(on.enabled); + assert!(on.changed); + assert_eq!(on.mode, "auto"); + assert_eq!(cfg.scheduler_gate.mode, SchedulerGateMode::Auto); + } }