mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -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': 'تحديد الكل كمقروء',
|
||||
|
||||
@@ -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': 'সব পঠিত চিহ্নিত করুন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'सभी पढ़ा हुआ मार्क करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '모두 읽음으로 표시',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Отметить всё прочитанным',
|
||||
|
||||
@@ -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': '全部标记为已读',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}"))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user