feat(memory-tree): status panel + on/off toggle (#1856 Part 1) (#2719)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
YOMXXX
2026-05-27 20:09:59 +05:30
committed by GitHub
co-authored by Claude
parent 8b702c726b
commit 21ab20f105
21 changed files with 1640 additions and 89 deletions
@@ -0,0 +1,179 @@
/**
* Vitest for `<MemoryTreeStatusPanel />`. 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<typeof import('../../utils/tauriCommands')>();
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> = {}): 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('<MemoryTreeStatusPanel />', () => {
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(<MemoryTreeStatusPanel />);
// 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<MemoryTreePipelineStatus>(r => {
resolve = r;
})
);
render(<MemoryTreeStatusPanel />);
// 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(<MemoryTreeStatusPanel />);
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(<MemoryTreeStatusPanel />);
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(<MemoryTreeStatusPanel />);
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(<MemoryTreeStatusPanel onToast={onToast} />);
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' })
);
});
});
});
@@ -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<void>;
} {
const [status, setStatus] = useState<MemoryTreePipelineStatus | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const cancelledRef = useRef(false);
const statusRef = useRef<MemoryTreePipelineStatus | null>(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<typeof setTimeout> | 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<ToastNotification, 'id'>) => 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 `<MemorySources>` 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 (
<div className="space-y-3" data-testid="memory-tree-status-panel">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('memoryTree.status.title')}
</h2>
</div>
{error && !loading ? (
<div
role="alert"
className="flex items-center justify-between gap-3 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-700 dark:text-coral-300"
data-testid="memory-tree-status-error">
<span>{t('memoryTree.status.fetchError')}</span>
<button
type="button"
onClick={() => {
void refresh();
}}
className="rounded-md border border-coral-300 dark:border-coral-500/40 bg-white dark:bg-neutral-900 px-2 py-1 text-xs font-medium text-coral-700 dark:text-coral-300 hover:bg-coral-50 dark:hover:bg-coral-500/20">
{t('memoryTree.status.retry')}
</button>
</div>
) : null}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3" data-testid="memory-tree-status-tiles">
{/* Status tile ── color-coded pill */}
<div className={tileClass}>
<div className={labelClass}>{t('memoryTree.status.statusTile')}</div>
{loading || !status ? (
<div className={skeletonClass} />
) : (
<>
<div className="flex items-center gap-2">
<span
aria-hidden
className={`inline-block h-2 w-2 rounded-full ${statusDotClass(statusKind)}`}
/>
<span className={valueClass} data-testid="memory-tree-status-label">
{statusLabel}
</span>
</div>
{status.reason ? (
<div className="mt-0.5 text-[11px] text-stone-500 dark:text-neutral-400">
{status.reason}
</div>
) : null}
</>
)}
</div>
{/* Last-sync tile */}
<div className={tileClass}>
<div className={labelClass}>{t('memoryTree.status.lastSyncTile')}</div>
{loading || !status ? (
<div className={skeletonClass} />
) : (
<div className={valueClass} data-testid="memory-tree-last-sync">
{formatRelativeMs(status.last_sync_ms, t, t('memoryTree.status.never'))}
</div>
)}
</div>
{/* Total chunks tile */}
<div className={tileClass}>
<div className={labelClass}>{t('memoryTree.status.totalChunksTile')}</div>
{loading || !status ? (
<div className={skeletonClass} />
) : (
<div className={valueClass} data-testid="memory-tree-total-chunks">
{new Intl.NumberFormat().format(status.total_chunks)}
</div>
)}
</div>
{/* Wiki size tile */}
<div className={tileClass}>
<div className={labelClass}>{t('memoryTree.status.wikiSizeTile')}</div>
{loading || !status ? (
<div className={skeletonClass} />
) : (
<div className={valueClass} data-testid="memory-tree-wiki-size">
{formatBytes(status.wiki_size_bytes)}
</div>
)}
</div>
</div>
{/* Auto-sync toggle row — markup mirrors AIPanel's inline ToggleRow */}
<div
className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2"
data-testid="memory-tree-status-toggle-row">
<div className="min-w-0">
<div className="text-sm font-medium text-stone-900 dark:text-neutral-100">
{t('memoryTree.status.autoSyncLabel')}
</div>
<div className="text-xs text-stone-500 dark:text-neutral-400">
{t('memoryTree.status.autoSyncDescription')}
</div>
</div>
<button
type="button"
role="switch"
aria-label={t('memoryTree.status.autoSyncLabel')}
aria-checked={checked}
disabled={toggleBusy || loading || !status}
onClick={() => {
void handleToggle();
}}
data-testid="memory-tree-status-toggle"
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:cursor-wait disabled:opacity-60 ${
checked ? 'bg-primary-500' : 'bg-stone-300 dark:bg-neutral-700'
}`}>
<span
aria-hidden
className={`inline-block h-4 w-4 transform rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
checked ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
);
}
// Re-export the hook so unit tests can opt into the polling subscription
// directly without re-implementing it.
export { useMemoryTreeStatus };
@@ -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 (
<div className="space-y-4" data-testid="memory-workspace">
<MemoryTreeStatusPanel onToast={onToast} />
<MemorySources syncableToolkits={SYNCABLE_TOOLKITS} pollIntervalMs={5000} onToast={onToast} />
<VaultPanel onToast={onToast} />
<WhatsAppMemorySection />
+25
View File
@@ -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': 'تحديد الكل كمقروء',
+25
View File
@@ -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': 'সব পঠিত চিহ্নিত করুন',
+25
View File
@@ -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',
+25
View File
@@ -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',
+25
View File
@@ -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',
+25
View File
@@ -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',
+25
View File
@@ -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': 'सभी पढ़ा हुआ मार्क करें',
+25
View File
@@ -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',
+25
View File
@@ -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',
+25
View File
@@ -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': '모두 읽음으로 표시',
+25
View File
@@ -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',
+25
View File
@@ -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': 'Отметить всё прочитанным',
+25
View File
@@ -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': '全部标记为已读',
+30
View File
@@ -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',
+7 -89
View File
@@ -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<IntelligenceTab>('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. */}
</div>
<div className="flex items-center gap-3">
{activeTab === 'memory' && (
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${systemStatusDot}`} />
<span className="text-xs text-stone-400 dark:text-neutral-500">
{systemStatusLabel}
</span>
</div>
)}
{activeTab === 'memory' &&
(ingestionStatus.running || ingestionStatus.queueDepth > 0) && (
<div
className="flex items-center gap-1.5 px-2 py-0.5 rounded-full border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300"
title={
ingestionStatus.running
? ingestionStatus.currentTitle
? t('memory.ingestingTitle').replace(
'{title}',
ingestionStatus.currentTitle
)
: t('memory.ingesting')
: t('memory.ingestionQueued')
}>
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
<span className="text-[11px] font-medium">
{ingestionStatus.running
? t('memory.ingesting')
: t('memory.ingestionQueued')}
{ingestionStatus.queueDepth > 0 && ` · ${ingestionStatus.queueDepth}`}
</span>
</div>
)}
{/* 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. */}
</div>
</div>
{/* Tab content */}
+116
View File
@@ -678,3 +678,119 @@ export async function memoryTreeBackfillStatus(): Promise<BackfillStatus> {
);
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<MemoryTreePipelineStatus> {
console.debug('[memory-tree-rpc] memoryTreePipelineStatus: entry');
const resp = await callCoreRpc<
MemoryTreePipelineStatus | ResultEnvelope<MemoryTreePipelineStatus>
>({ 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<MemoryTreeSetEnabledResponse> {
console.debug('[memory-tree-rpc] memoryTreeSetEnabled: entry enabled=%s', enabled);
const resp = await callCoreRpc<
MemoryTreeSetEnabledResponse | ResultEnvelope<MemoryTreeSetEnabledResponse>
>({ 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;
}
+131
View File
@@ -44,6 +44,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
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<RegisteredController> {
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<String, Value>) -> ControllerFuture {
})
}
fn handle_pipeline_status(_params: Map<String, Value>) -> 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<String, Value>) -> ControllerFuture {
Box::pin(async move {
let req = parse_value::<rpc::SetEnabledRequest>(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<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
+493
View File
@@ -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<String>,
/// 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<RpcOutcome<PipelineStatusResponse>, 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<i64>) = 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<PipelineJobCounts, String> {
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<String>) {
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<RpcOutcome<SetEnabledResponse>, 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);
}
}