fix(react-ui): surface layered pipeline status in Data Sync (GH-4690) (#5113)

This commit is contained in:
Mega Mind
2026-07-22 14:49:12 +03:00
committed by GitHub
parent 353e07c117
commit f55376b40e
19 changed files with 718 additions and 4 deletions
@@ -10,6 +10,7 @@
*/
import debug from 'debug';
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useT } from '../../lib/i18n/I18nContext';
import { CoreStateContext } from '../../providers/coreStateContext';
@@ -35,10 +36,19 @@ import {
openhumanGetMemorySyncSettings,
openhumanUpdateMemorySyncSettings,
} from '../../utils/tauriCommands/config';
import { memoryTreeFlushSource } from '../../utils/tauriCommands/memoryTree';
import {
memoryTreeFlushSource,
memoryTreePipelineStatus,
type MemoryTreePipelineStatus,
} from '../../utils/tauriCommands/memoryTree';
import Button from '../ui/Button';
import { AddMemorySourceDialog } from './AddMemorySourceDialog';
import { ConfirmationModal } from './ConfirmationModal';
import {
deriveSourcePipelineHealth,
pipelineIssueMessageKey,
type SourcePipelineHealth,
} from './sourcePipelineStatus';
import { SourceSettingsPanel } from './SourceSettingsPanel';
const log = debug('intelligence:memory-sync');
@@ -124,6 +134,7 @@ export function MemorySourcesRegistry({
pollIntervalMs = 5000,
}: MemorySourcesRegistryProps) {
const { t } = useT();
const navigate = useNavigate();
// Read the core snapshot directly (not via the throwing `useCoreState`
// hook) so this component still renders in unit tests that mount it
// without a CoreStateProvider — there `ctx` is null and `isAuthenticated`
@@ -132,6 +143,11 @@ export function MemorySourcesRegistry({
const isAuthenticated = coreState?.snapshot.auth.isAuthenticated ?? false;
const [sources, setSources] = useState<MemorySourceEntry[]>([]);
const [statuses, setStatuses] = useState<SourceStatus[]>([]);
// Global downstream pipeline health (GH-4690) — the same snapshot the
// Brain > Memory > Sync panel renders. Folded into each row so a source that
// ingested but failed embeddings/extraction/tree-build shows a warning rather
// than a clean synced badge. `null` until the first poll resolves.
const [pipeline, setPipeline] = useState<MemoryTreePipelineStatus | null>(null);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
// RC#1 (#3295): use a Set so multiple sources can show "syncing" concurrently.
@@ -268,7 +284,7 @@ export function MemorySourcesRegistry({
const refresh = useCallback(async () => {
try {
const [list, stats] = await Promise.all([
const [list, stats, health] = await Promise.all([
listMemorySources().catch(err => {
console.warn('[ui-flow][memory-sources] list failed', err);
return [] as MemorySourceEntry[];
@@ -277,9 +293,17 @@ export function MemorySourcesRegistry({
console.warn('[ui-flow][memory-sources] status_list failed', err);
return [] as SourceStatus[];
}),
// GH-4690: downstream pipeline health. Best-effort — a failure here
// must never hide the source list, so we fall back to `null` (rows then
// rely on the precise per-source pending-chunk signal alone).
memoryTreePipelineStatus().catch(err => {
console.warn('[ui-flow][memory-sources] pipeline_status failed', err);
return null;
}),
]);
setSources(list);
setStatuses(stats);
setPipeline(health);
// RC#5 (#3295): The 5s poll is the safety net for missed completed/failed events.
// If a source is in syncingIds but the poll shows it's no longer active (no
// in-progress status indicator from the server), we clear it here. In practice
@@ -534,6 +558,8 @@ export function MemorySourcesRegistry({
key={source.id}
source={source}
status={statusById.get(source.id) ?? null}
pipeline={pipeline}
isAuthenticated={isAuthenticated}
isSyncing={syncingIds.has(source.id) || syncProgress.has(source.id)}
isBuilding={buildingId === source.id}
progress={syncProgress.get(source.id) ?? null}
@@ -546,6 +572,16 @@ export function MemorySourcesRegistry({
onToggleSettings={handleToggleSettings}
onSettingsSaved={handleSettingsSaved}
onToast={onToast}
onViewHealth={() => {
console.debug('[ui-flow][memory-sources] view memory health from source row');
navigate('/brain?tab=sync');
}}
onSignIn={() => {
console.debug(
'[ui-flow][memory-sources] sign-in prompt from stored-without-vectors'
);
navigate('/auth');
}}
/>
))}
</ul>
@@ -692,6 +728,10 @@ function MemorySyncSchedule({ lastSyncMs, onToast }: MemorySyncScheduleProps) {
interface SourceRowProps {
source: MemorySourceEntry;
status: SourceStatus | null;
/** Global downstream pipeline health (GH-4690); `null` before first poll. */
pipeline: MemoryTreePipelineStatus | null;
/** Whether a backend session exists — gates the "Sign in to enable" prompt. */
isAuthenticated: boolean;
isSyncing: boolean;
isBuilding: boolean;
progress: SyncProgress | null;
@@ -704,11 +744,17 @@ interface SourceRowProps {
onToggleSettings: (sourceId: string) => void;
onSettingsSaved: (updated: MemorySourceEntry) => void;
onToast?: (toast: Omit<ToastNotification, 'id'>) => void;
/** Navigate to Brain > Memory > Sync (the full memory-health panel). */
onViewHealth: () => void;
/** Navigate to sign-in (embeddings need a backend session). */
onSignIn: () => void;
}
function SourceRow({
source,
status,
pipeline,
isAuthenticated,
isSyncing,
isBuilding,
progress,
@@ -721,6 +767,8 @@ function SourceRow({
onToggleSettings,
onSettingsSaved,
onToast,
onViewHealth,
onSignIn,
}: SourceRowProps) {
const { t } = useT();
const icon = SOURCE_KIND_ICONS[source.kind] ?? '📄';
@@ -728,6 +776,21 @@ function SourceRow({
const detail = sourceDetail(source);
const lastSync = status ? relativeTimestamp(status.last_chunk_at_ms, t) : null;
// GH-4690: layered pipeline verdict. Only meaningful in a settled state —
// during an active sync (`progress`) or right after one (`result`) the row
// renders live progress / a terminal chip instead, and `chunks_pending` is
// legitimately transient, so we suppress the warning until things settle.
const settled = !progress && !result;
const health: SourcePipelineHealth = settled
? deriveSourcePipelineHealth(status, pipeline)
: { state: 'none', issues: [], authRelated: false };
const ingestedOnly = health.state === 'ingested_only';
if (ingestedOnly) {
console.debug(
`[ui-flow][memory-sources] source=${source.id} ingested-only issues=${health.issues.join(',')}`
);
}
return (
<li className="flex flex-col gap-2 py-3" data-testid={`memory-source-row-${source.kind}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
@@ -743,7 +806,13 @@ function SourceRow({
<span className="rounded-md bg-surface-subtle px-1.5 py-0.5 text-[10px] font-medium text-content-muted">
{kindLabel}
</span>
{status && status.chunks_synced > 0 && <FreshnessPill freshness={status.freshness} />}
{status &&
status.chunks_synced > 0 &&
(ingestedOnly ? (
<IngestedOnlyPill sourceId={source.id} />
) : (
<FreshnessPill freshness={status.freshness} />
))}
</div>
{detail && <p className="mt-0.5 truncate pl-7 text-xs text-content-faint">{detail}</p>}
{progress && (
@@ -811,6 +880,37 @@ function SourceRow({
)}
</div>
)}
{ingestedOnly && (
<div
className="mt-2 flex flex-col gap-1 rounded-md border border-amber-200 bg-amber-50 px-2.5 py-1.5 pl-7 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
data-testid={`memory-source-pipeline-warning-${source.id}`}
role="status">
{health.issues.map(issue => (
<div key={issue} className="flex items-start gap-1.5">
<WarnIcon />
<span className="break-words">{t(pipelineIssueMessageKey(issue))}</span>
</div>
))}
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 pl-[18px]">
{health.authRelated && !isAuthenticated && (
<button
type="button"
onClick={onSignIn}
data-testid={`memory-source-signin-${source.id}`}
className="font-medium text-amber-900 underline underline-offset-2 hover:text-amber-950 dark:text-amber-100 dark:hover:text-white">
{t('sync.pipeline.signInToEnable')}
</button>
)}
<button
type="button"
onClick={onViewHealth}
data-testid={`memory-source-view-health-${source.id}`}
className="font-medium text-amber-900 underline underline-offset-2 hover:text-amber-950 dark:text-amber-100 dark:hover:text-white">
{t('sync.pipeline.viewHealth')}
</button>
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<button
@@ -905,6 +1005,24 @@ function FreshnessPill({ freshness }: { freshness: FreshnessLabel }) {
return <span className={`rounded-md px-2 py-0.5 text-[10px] font-medium ${cls}`}>{label}</span>;
}
/**
* GH-4690: the "Ingested only" warning pill. Replaces the clean freshness pill
* when a source ingested chunks but the downstream retrieval pipeline failed
* (no vectors / extraction / degraded tree). Amber, matching the degraded
* badges in the Brain > Memory > Sync panel, so raw sync ≠ retrieval-ready is
* unmistakable at a glance.
*/
function IngestedOnlyPill({ sourceId }: { sourceId: string }) {
const { t } = useT();
return (
<span
className="rounded-md bg-amber-100 px-2 py-0.5 text-[10px] font-medium text-amber-800 dark:bg-amber-500/20 dark:text-amber-200"
data-testid={`memory-source-ingested-only-${sourceId}`}>
{t('sync.pipeline.ingestedOnly')}
</span>
);
}
function relativeTimestamp(epochMs: number | null, t: (k: string) => string): string | null {
if (epochMs === null) return null;
const delta = Date.now() - epochMs;
@@ -50,8 +50,22 @@ vi.mock('../../../services/memorySourcesService', () => ({
}));
// ── tauriCommands mock ────────────────────────────────────────────────────────
// memoryTreePipelineStatus is polled for downstream pipeline health (GH-4690);
// default to a healthy running snapshot so rows keep their clean synced state.
vi.mock('../../../utils/tauriCommands/memoryTree', () => ({
memoryTreeFlushSource: vi.fn().mockResolvedValue({ seals_fired: 0 }),
memoryTreePipelineStatus: vi
.fn()
.mockResolvedValue({
status: 'running',
reason: null,
last_sync_ms: 0,
total_chunks: 0,
wiki_size_bytes: 0,
pipeline_jobs: { ready: 0, running: 0, failed: 0 },
is_syncing: false,
is_paused: false,
}),
}));
// ── helpers ───────────────────────────────────────────────────────────────────
@@ -16,6 +16,10 @@ import {
openhumanGetMemorySyncSettings,
openhumanUpdateMemorySyncSettings,
} from '../../../utils/tauriCommands/config';
import {
memoryTreePipelineStatus,
type MemoryTreePipelineStatus,
} from '../../../utils/tauriCommands/memoryTree';
import { MemorySourcesRegistry } from '../MemorySourcesRegistry';
// Mock the entire service so we don't hit RPC
@@ -34,9 +38,23 @@ vi.mock('../../../services/memorySourcesService', async () => {
};
});
// Mock tauriCommands/memoryTree — not needed in these tests
// Mock tauriCommands/memoryTree. The registry now also polls
// memoryTreePipelineStatus for downstream health (GH-4690); default it to a
// healthy running snapshot so no source row shows a spurious warning.
vi.mock('../../../utils/tauriCommands/memoryTree', () => ({
memoryTreeFlushSource: vi.fn().mockResolvedValue({ seals_fired: 0 }),
memoryTreePipelineStatus: vi
.fn()
.mockResolvedValue({
status: 'running',
reason: null,
last_sync_ms: 0,
total_chunks: 0,
wiki_size_bytes: 0,
pipeline_jobs: { ready: 0, running: 0, failed: 0 },
is_syncing: false,
is_paused: false,
}),
}));
// Mock the memory-sync schedule config RPCs (#3302).
@@ -51,6 +69,24 @@ const mockedUpdate = vi.mocked(service.updateMemorySource);
const mockedApplyAllIn = vi.mocked(service.applyAllIn);
const mockedGetSync = vi.mocked(openhumanGetMemorySyncSettings);
const mockedUpdateSync = vi.mocked(openhumanUpdateMemorySyncSettings);
const mockedPipeline = vi.mocked(memoryTreePipelineStatus);
/** A healthy `memory_tree_pipeline_status` snapshot (no degradation). */
function healthyPipeline(
overrides: Partial<MemoryTreePipelineStatus> = {}
): MemoryTreePipelineStatus {
return {
status: 'running',
reason: null,
last_sync_ms: 0,
total_chunks: 0,
wiki_size_bytes: 0,
pipeline_jobs: { ready: 0, running: 0, failed: 0 },
is_syncing: false,
is_paused: false,
...overrides,
};
}
function syncSettings(overrides: Record<string, unknown> = {}) {
return {
@@ -493,4 +529,121 @@ describe('MemorySourcesRegistry', () => {
await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(2));
expect(await screen.findByText('Reloaded Repo')).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Layered pipeline status — Data Sync must not show a clean "synced" badge
// when the downstream retrieval pipeline failed (GH-4690).
// -------------------------------------------------------------------------
describe('layered pipeline warnings (GH-4690)', () => {
it('no regression: a fully healthy sync keeps the clean freshness badge', async () => {
mockedList.mockResolvedValue([makeSource({ id: 'src_1', label: 'Healthy Repo' })]);
mockedStatus.mockResolvedValue([
{
source_id: 'src_1',
chunks_synced: 5,
chunks_pending: 0,
last_chunk_at_ms: Date.now(),
freshness: 'recent',
},
]);
mockedPipeline.mockResolvedValue(healthyPipeline());
renderWithProviders(<MemorySourcesRegistry pollIntervalMs={0} />);
await screen.findByText('Healthy Repo');
// Clean state: freshness pill shows, no "Ingested only" warning surfaces.
await waitFor(() => {
expect(screen.getByText('Recent')).toBeInTheDocument();
});
expect(screen.queryByTestId('memory-source-ingested-only-src_1')).not.toBeInTheDocument();
expect(screen.queryByTestId('memory-source-pipeline-warning-src_1')).not.toBeInTheDocument();
});
it('flags "Stored without vectors" from per-source pending chunks', async () => {
mockedList.mockResolvedValue([makeSource({ id: 'src_1', label: 'Notes' })]);
mockedStatus.mockResolvedValue([
{
source_id: 'src_1',
chunks_synced: 1,
chunks_pending: 1,
last_chunk_at_ms: Date.now(),
freshness: 'recent',
},
]);
// Even with a "healthy" global snapshot, the per-source pending chunks
// are the precise signal that this source has no vectors.
mockedPipeline.mockResolvedValue(healthyPipeline());
renderWithProviders(<MemorySourcesRegistry pollIntervalMs={0} />);
await screen.findByText('Notes');
expect(await screen.findByTestId('memory-source-ingested-only-src_1')).toBeInTheDocument();
expect(screen.getByTestId('memory-source-pipeline-warning-src_1')).toHaveTextContent(
/Stored without vectors/i
);
// Clean freshness badge must be gone.
expect(screen.queryByText('Recent')).not.toBeInTheDocument();
// A link to the full memory-health panel is offered.
expect(screen.getByTestId('memory-source-view-health-src_1')).toBeInTheDocument();
});
it('offers "Sign in to enable" when embeddings fail for lack of a backend session', async () => {
mockedList.mockResolvedValue([makeSource({ id: 'src_1', label: 'Notes' })]);
mockedStatus.mockResolvedValue([
{
source_id: 'src_1',
chunks_synced: 2,
chunks_pending: 2,
last_chunk_at_ms: Date.now(),
freshness: 'idle',
},
]);
mockedPipeline.mockResolvedValue(
healthyPipeline({
status: 'error',
first_blocking_cause: {
code: 'auth_missing',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.auth_missing',
},
})
);
renderWithProviders(<MemorySourcesRegistry pollIntervalMs={0} />);
await screen.findByText('Notes');
// Unauthenticated (no CoreStateProvider ⇒ isAuthenticated false), auth-related.
const signIn = await screen.findByTestId('memory-source-signin-src_1');
expect(signIn).toBeInTheDocument();
// Clicking must not throw (navigates within the MemoryRouter).
fireEvent.click(signIn);
});
it('surfaces extraction failure from the global pipeline health', async () => {
mockedList.mockResolvedValue([makeSource({ id: 'src_1', label: 'Notes' })]);
mockedStatus.mockResolvedValue([
{
source_id: 'src_1',
chunks_synced: 5,
chunks_pending: 0,
last_chunk_at_ms: Date.now(),
freshness: 'recent',
},
]);
mockedPipeline.mockResolvedValue(
healthyPipeline({
status: 'degraded',
first_blocking_cause: {
code: 'extraction_timeout',
class: 'transient',
remediation_key: 'memory.health.remediation.extraction_timeout',
},
})
);
renderWithProviders(<MemorySourcesRegistry pollIntervalMs={0} />);
await screen.findByText('Notes');
expect(await screen.findByTestId('memory-source-ingested-only-src_1')).toBeInTheDocument();
expect(screen.getByTestId('memory-source-pipeline-warning-src_1')).toHaveTextContent(
/extraction failed/i
);
});
});
});
@@ -0,0 +1,185 @@
/**
* Unit tests for the layered Data Sync pipeline-health derivation (GH-4690).
* Covers each warning layer plus the healthy no-regression case.
*/
import { describe, expect, it } from 'vitest';
import type { SourceStatus } from '../../services/memorySourcesService';
import type { MemoryTreePipelineStatus } from '../../utils/tauriCommands/memoryTree';
import {
deriveSourcePipelineHealth,
pipelineIssueMessageKey,
type SourcePipelineIssueKind,
} from './sourcePipelineStatus';
function makeStatus(overrides: Partial<SourceStatus> = {}): SourceStatus {
return {
source_id: 'src_1',
chunks_synced: 5,
chunks_pending: 0,
last_chunk_at_ms: 1_000,
freshness: 'recent',
...overrides,
};
}
function makePipeline(overrides: Partial<MemoryTreePipelineStatus> = {}): MemoryTreePipelineStatus {
return {
status: 'running',
reason: null,
last_sync_ms: 1_000,
total_chunks: 5,
wiki_size_bytes: 0,
pipeline_jobs: { ready: 0, running: 0, failed: 0 },
is_syncing: false,
is_paused: false,
...overrides,
};
}
describe('deriveSourcePipelineHealth', () => {
it('returns none when nothing has been ingested yet', () => {
const h = deriveSourcePipelineHealth(makeStatus({ chunks_synced: 0 }), makePipeline());
expect(h.state).toBe('none');
expect(h.issues).toEqual([]);
});
it('returns none when status is null (pre-load)', () => {
const h = deriveSourcePipelineHealth(null, makePipeline());
expect(h.state).toBe('none');
});
// -- No regression: fully healthy sync stays clean -------------------------
it('reports retrieval_ready when everything is healthy', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_synced: 5, chunks_pending: 0 }),
makePipeline({ status: 'running' })
);
expect(h.state).toBe('retrieval_ready');
expect(h.issues).toEqual([]);
expect(h.authRelated).toBe(false);
});
it('stays retrieval_ready when the pipeline snapshot is missing but chunks are embedded', () => {
const h = deriveSourcePipelineHealth(makeStatus({ chunks_pending: 0 }), null);
expect(h.state).toBe('retrieval_ready');
expect(h.issues).toEqual([]);
});
// -- Layer 1: embeddings ---------------------------------------------------
it('flags stored_without_vectors from per-source pending chunks alone', () => {
// The exact issue repro: "1 chunk / 1 pending" with no pipeline snapshot.
const h = deriveSourcePipelineHealth(makeStatus({ chunks_synced: 1, chunks_pending: 1 }), null);
expect(h.state).toBe('ingested_only');
expect(h.issues).toContain('stored_without_vectors');
});
it('flags stored_without_vectors from the global semantic_recall latch', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 0 }),
makePipeline({ status: 'degraded', degraded: { semantic_recall: true, structure: false } })
);
expect(h.issues).toContain('stored_without_vectors');
// A recall-degraded state must NOT also add the generic tree_degraded noise.
expect(h.issues).not.toContain('tree_degraded');
});
it('marks authRelated when the blocking cause is a missing backend session', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 3 }),
makePipeline({
status: 'error',
first_blocking_cause: {
code: 'auth_missing',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.auth_missing',
},
})
);
expect(h.issues).toContain('stored_without_vectors');
expect(h.authRelated).toBe(true);
});
it('does not mark authRelated for a non-auth embeddings cause', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 3 }),
makePipeline({
status: 'error',
first_blocking_cause: {
code: 'embeddings_unconfigured',
class: 'unrecoverable',
remediation_key: 'memory.health.remediation.embeddings_unconfigured',
},
})
);
expect(h.authRelated).toBe(false);
});
// -- Layer 2: extraction ---------------------------------------------------
it('flags extraction_failed from an extraction_timeout blocking cause', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 0 }),
makePipeline({
status: 'degraded',
first_blocking_cause: {
code: 'extraction_timeout',
class: 'transient',
remediation_key: 'memory.health.remediation.extraction_timeout',
},
})
);
expect(h.state).toBe('ingested_only');
expect(h.issues).toContain('extraction_failed');
});
it('flags extraction_failed from the structure degraded flag', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 0 }),
makePipeline({ status: 'degraded', degraded: { semantic_recall: false, structure: true } })
);
expect(h.issues).toContain('extraction_failed');
});
// -- Layer 3: memory tree --------------------------------------------------
it('flags tree_degraded for a generic degraded status with no specific layer', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 0 }),
makePipeline({ status: 'degraded' })
);
expect(h.state).toBe('ingested_only');
expect(h.issues).toEqual(['tree_degraded']);
});
it('flags tree_degraded for an error status too', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_pending: 0 }),
makePipeline({ status: 'error' })
);
expect(h.issues).toContain('tree_degraded');
});
// -- Multiple layers at once (the full-failure scenario) -------------------
it('surfaces every failed layer together, embeddings first', () => {
const h = deriveSourcePipelineHealth(
makeStatus({ chunks_synced: 1, chunks_pending: 1 }),
makePipeline({ status: 'degraded', degraded: { semantic_recall: true, structure: true } })
);
expect(h.state).toBe('ingested_only');
// Embeddings + extraction; the generic tree layer is suppressed because
// more specific layers already explain the degradation.
expect(h.issues).toEqual(['stored_without_vectors', 'extraction_failed']);
});
});
describe('pipelineIssueMessageKey', () => {
it('maps every issue kind to a stable i18n key', () => {
const kinds: SourcePipelineIssueKind[] = [
'stored_without_vectors',
'extraction_failed',
'tree_degraded',
];
for (const k of kinds) {
expect(pipelineIssueMessageKey(k)).toMatch(/^sync\.pipeline\./);
}
});
});
@@ -0,0 +1,124 @@
/**
* Layered pipeline-health derivation for Data Sync source rows (GH-4690).
*
* Raw sync ≠ retrieval-ready. A source can be "synced" (its documents were
* ingested into `mem_tree_chunks`) while the downstream retrieval pipeline
* silently failed underneath it: embeddings were never created, spaCy/LLM
* extraction failed, or the memory tree is degraded. Before this, Data Sync
* showed a clean freshness badge in all those cases and the user only learned
* the truth in Brain > Memory > Sync or the raw logs.
*
* This module folds two signals the core already exposes into one per-row
* verdict so the row can honestly say "Ingested only" instead of "synced":
*
* 1. **Per-source, precise** — `SourceStatus.chunks_pending` is the SQL count
* of this source's chunks whose `embedding IS NULL` (see
* `memory_sources/status.rs`). `> 0` in a settled state means those chunks
* were stored WITHOUT vectors → semantic search can't reach them.
* 2. **Global pipeline health** — `memory_tree_pipeline_status` (the same RPC
* that drives the Brain > Memory > Sync "Degraded" panel) carries the
* process-wide `degraded` snapshot + `first_blocking_cause`. Embedding /
* extraction / tree-degraded causes there are attributed to the whole
* pipeline, so we surface them on rows that actually contributed chunks.
*
* The function is pure so it can be unit-tested exhaustively without a DOM.
*/
import type { SourceStatus } from '../../services/memorySourcesService';
import type { MemoryTreePipelineStatus } from '../../utils/tauriCommands/memoryTree';
/** One failed layer of the post-ingest pipeline, in severity-display order. */
export type SourcePipelineIssueKind =
| 'stored_without_vectors'
| 'extraction_failed'
| 'tree_degraded';
/** Coarse retrieval-readiness state for a single source row. */
export type SourcePipelineState = 'none' | 'retrieval_ready' | 'ingested_only';
export interface SourcePipelineHealth {
/**
* `none` — nothing ingested yet (no badge changes; row renders as before).
* `retrieval_ready` — chunks synced AND no downstream failure (clean state).
* `ingested_only` — chunks synced but ≥1 downstream layer failed (warn).
*/
state: SourcePipelineState;
/** The failed layers, deduped, in display order. Empty unless `ingested_only`. */
issues: SourcePipelineIssueKind[];
/**
* True when the embeddings failure is attributable to a missing backend
* session / auth (the "No backend session for cloud embeddings" case). Drives
* the "Sign in to enable" affordance — only meaningful with
* `stored_without_vectors`.
*/
authRelated: boolean;
}
/**
* Compute the layered pipeline verdict for one source row.
*
* `status` is this source's `memory_sources_status_list` entry; `pipeline` is
* the global `memory_tree_pipeline_status` snapshot (may be `null` when that
* RPC hasn't resolved / failed — the per-source signal still stands on its own).
*/
export function deriveSourcePipelineHealth(
status: SourceStatus | null,
pipeline: MemoryTreePipelineStatus | null
): SourcePipelineHealth {
const synced = status?.chunks_synced ?? 0;
// Nothing ingested yet → don't invent a warning. The row keeps its existing
// (empty / mid-sync) rendering.
if (synced <= 0) {
return { state: 'none', issues: [], authRelated: false };
}
const degraded = pipeline?.degraded;
const causeCode = pipeline?.first_blocking_cause?.code ?? degraded?.cause?.code ?? null;
const issues: SourcePipelineIssueKind[] = [];
// Layer 1 — embeddings. Precise per-source signal (chunks with NULL
// embedding) OR the global "semantic recall degraded" latch (no usable
// embeddings provider). Either means this source's chunks aren't vector-searchable.
const storedWithoutVectors =
(status?.chunks_pending ?? 0) > 0 || degraded?.semantic_recall === true;
if (storedWithoutVectors) {
issues.push('stored_without_vectors');
}
// Layer 2 — extraction. `degraded.structure` exists but has no production
// producer today (test-only), so the live signal is the typed
// `extraction_timeout` blocking cause ("the memory extraction model is
// timing out"). Honour both.
const extractionFailed = degraded?.structure === true || causeCode === 'extraction_timeout';
if (extractionFailed) {
issues.push('extraction_failed');
}
// Layer 3 — memory tree. A global degraded/error status that isn't already
// explained by a more specific layer above (e.g. storage-degraded, a failed
// job). Retrieval for every synced source may return stale results.
const treeDegraded =
(pipeline?.status === 'degraded' || pipeline?.status === 'error') &&
!storedWithoutVectors &&
!extractionFailed;
if (treeDegraded) {
issues.push('tree_degraded');
}
const authRelated = storedWithoutVectors && causeCode === 'auth_missing';
return { state: issues.length > 0 ? 'ingested_only' : 'retrieval_ready', issues, authRelated };
}
/** i18n key for a given issue's row warning message. */
export function pipelineIssueMessageKey(kind: SourcePipelineIssueKind): string {
switch (kind) {
case 'stored_without_vectors':
return 'sync.pipeline.storedWithoutVectors';
case 'extraction_failed':
return 'sync.pipeline.extractionFailed';
case 'tree_degraded':
return 'sync.pipeline.treeDegraded';
}
}
+7
View File
@@ -7246,6 +7246,13 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'حالة الخصوصية',
'privacy.status.external': 'خارج الجهاز',
'privacy.status.local': 'على الجهاز',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'تم الاستيعاب فقط',
'sync.pipeline.storedWithoutVectors': 'مخزَّن بدون متجهات. البحث الدلالي غير متاح.',
'sync.pipeline.signInToEnable': 'سجّل الدخول للتفعيل',
'sync.pipeline.extractionFailed': 'فشل استخراج بنية الذاكرة. قد يكون الويكي غير مكتمل.',
'sync.pipeline.treeDegraded': 'شجرة الذاكرة متدهورة. قد يُرجع الاسترجاع نتائج قديمة.',
'sync.pipeline.viewHealth': 'عرض حالة الذاكرة',
};
export default messages;
+7
View File
@@ -7415,6 +7415,13 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'গোপনীয়তা স্থিতি',
'privacy.status.external': 'ডিভাইসের বাইরে',
'privacy.status.local': 'ডিভাইসে',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'শুধু গৃহীত',
'sync.pipeline.storedWithoutVectors': 'ভেক্টর ছাড়াই সংরক্ষিত। শব্দার্থিক অনুসন্ধান অনুপলব্ধ।',
'sync.pipeline.signInToEnable': 'সক্রিয় করতে সাইন ইন করুন',
'sync.pipeline.extractionFailed': 'মেমরি কাঠামো নিষ্কাশন ব্যর্থ হয়েছে। উইকি অসম্পূর্ণ হতে পারে।',
'sync.pipeline.treeDegraded': 'মেমরি ট্রি অবনমিত। পুনরুদ্ধার পুরনো ফলাফল দিতে পারে।',
'sync.pipeline.viewHealth': 'মেমরির স্বাস্থ্য দেখুন',
};
export default messages;
+10
View File
@@ -7635,6 +7635,16 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Datenschutzstatus',
'privacy.status.external': 'Außerhalb des Geräts',
'privacy.status.local': 'Auf dem Gerät',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Nur aufgenommen',
'sync.pipeline.storedWithoutVectors':
'Ohne Vektoren gespeichert. Semantische Suche nicht verfügbar.',
'sync.pipeline.signInToEnable': 'Zum Aktivieren anmelden',
'sync.pipeline.extractionFailed':
'Extraktion der Speicherstruktur fehlgeschlagen. Das Wiki ist möglicherweise unvollständig.',
'sync.pipeline.treeDegraded':
'Speicherbaum beeinträchtigt. Die Abfrage liefert möglicherweise veraltete Ergebnisse.',
'sync.pipeline.viewHealth': 'Speicherzustand anzeigen',
};
export default messages;
+8
View File
@@ -3349,6 +3349,14 @@ const en: TranslationMap = {
'sync.failedToLoad': 'Failed to load sync status',
'sync.noContent': 'No content has been synced into memory yet. Connect an integration to start.',
// Data Sync layered pipeline status (GH-4690) — raw sync ≠ retrieval-ready
'sync.pipeline.ingestedOnly': 'Ingested only',
'sync.pipeline.storedWithoutVectors': 'Stored without vectors. Semantic search unavailable.',
'sync.pipeline.signInToEnable': 'Sign in to enable',
'sync.pipeline.extractionFailed': 'Memory structure extraction failed. Wiki may be incomplete.',
'sync.pipeline.treeDegraded': 'Memory tree degraded. Retrieval may return stale results.',
'sync.pipeline.viewHealth': 'View memory health',
// Memory Sync Schedule (global cadence)
'memorySyncInterval.title': 'Sync schedule',
'memorySyncInterval.lastSynced': 'Last synced',
+10
View File
@@ -7570,6 +7570,16 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Estado de privacidad',
'privacy.status.external': 'Fuera del dispositivo',
'privacy.status.local': 'En el dispositivo',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Solo ingerido',
'sync.pipeline.storedWithoutVectors':
'Almacenado sin vectores. La búsqueda semántica no está disponible.',
'sync.pipeline.signInToEnable': 'Inicia sesión para activar',
'sync.pipeline.extractionFailed':
'Error al extraer la estructura de memoria. Es posible que el wiki esté incompleto.',
'sync.pipeline.treeDegraded':
'Árbol de memoria degradado. La recuperación puede devolver resultados obsoletos.',
'sync.pipeline.viewHealth': 'Ver estado de la memoria',
};
export default messages;
+10
View File
@@ -7604,6 +7604,16 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'État de confidentialité',
'privacy.status.external': 'Hors de lappareil',
'privacy.status.local': 'Sur lappareil',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Ingéré uniquement',
'sync.pipeline.storedWithoutVectors':
'Enregistré sans vecteurs. Recherche sémantique indisponible.',
'sync.pipeline.signInToEnable': 'Connectez-vous pour activer',
'sync.pipeline.extractionFailed':
"Échec de l'extraction de la structure mémoire. Le wiki peut être incomplet.",
'sync.pipeline.treeDegraded':
'Arbre mémoire dégradé. La récupération peut renvoyer des résultats obsolètes.',
'sync.pipeline.viewHealth': "Voir l'état de la mémoire",
};
export default messages;
+7
View File
@@ -7412,6 +7412,13 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'गोपनीयता स्थिति',
'privacy.status.external': 'डिवाइस के बाहर',
'privacy.status.local': 'डिवाइस पर',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'केवल अंतर्ग्रहीत',
'sync.pipeline.storedWithoutVectors': 'वेक्टर के बिना संग्रहीत। सिमेंटिक खोज अनुपलब्ध।',
'sync.pipeline.signInToEnable': 'सक्षम करने के लिए साइन इन करें',
'sync.pipeline.extractionFailed': 'मेमोरी संरचना निष्कर्षण विफल रहा। विकी अपूर्ण हो सकता है।',
'sync.pipeline.treeDegraded': 'मेमोरी ट्री अवक्रमित। पुनर्प्राप्ति पुराने परिणाम दे सकती है।',
'sync.pipeline.viewHealth': 'मेमोरी स्वास्थ्य देखें',
};
export default messages;
+8
View File
@@ -7450,6 +7450,14 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Status privasi',
'privacy.status.external': 'Di luar perangkat',
'privacy.status.local': 'Di perangkat',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Hanya diserap',
'sync.pipeline.storedWithoutVectors': 'Disimpan tanpa vektor. Pencarian semantik tidak tersedia.',
'sync.pipeline.signInToEnable': 'Masuk untuk mengaktifkan',
'sync.pipeline.extractionFailed': 'Ekstraksi struktur memori gagal. Wiki mungkin tidak lengkap.',
'sync.pipeline.treeDegraded':
'Pohon memori menurun. Pengambilan mungkin mengembalikan hasil usang.',
'sync.pipeline.viewHealth': 'Lihat kesehatan memori',
};
export default messages;
+10
View File
@@ -7558,6 +7558,16 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Stato privacy',
'privacy.status.external': 'Fuori dal dispositivo',
'privacy.status.local': 'Sul dispositivo',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Solo acquisito',
'sync.pipeline.storedWithoutVectors':
'Memorizzato senza vettori. Ricerca semantica non disponibile.',
'sync.pipeline.signInToEnable': 'Accedi per attivare',
'sync.pipeline.extractionFailed':
'Estrazione della struttura della memoria non riuscita. Il wiki potrebbe essere incompleto.',
'sync.pipeline.treeDegraded':
'Albero di memoria degradato. Il recupero potrebbe restituire risultati obsoleti.',
'sync.pipeline.viewHealth': 'Visualizza lo stato della memoria',
};
export default messages;
+8
View File
@@ -7327,6 +7327,14 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': '개인정보 상태',
'privacy.status.external': '기기 외',
'privacy.status.local': '기기 내',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': '수집만 완료',
'sync.pipeline.storedWithoutVectors': '벡터 없이 저장됨. 의미 검색을 사용할 수 없습니다.',
'sync.pipeline.signInToEnable': '사용하려면 로그인하세요',
'sync.pipeline.extractionFailed': '메모리 구조 추출에 실패했습니다. 위키가 불완전할 수 있습니다.',
'sync.pipeline.treeDegraded':
'메모리 트리가 저하되었습니다. 검색이 오래된 결과를 반환할 수 있습니다.',
'sync.pipeline.viewHealth': '메모리 상태 보기',
};
export default messages;
+10
View File
@@ -7528,6 +7528,16 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Stan prywatności',
'privacy.status.external': 'Poza urządzeniem',
'privacy.status.local': 'Na urządzeniu',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Tylko pobrano',
'sync.pipeline.storedWithoutVectors':
'Zapisano bez wektorów. Wyszukiwanie semantyczne niedostępne.',
'sync.pipeline.signInToEnable': 'Zaloguj się, aby włączyć',
'sync.pipeline.extractionFailed':
'Ekstrakcja struktury pamięci nie powiodła się. Wiki może być niekompletne.',
'sync.pipeline.treeDegraded':
'Drzewo pamięci osłabione. Wyszukiwanie może zwracać nieaktualne wyniki.',
'sync.pipeline.viewHealth': 'Zobacz kondycję pamięci',
};
export default messages;
+9
View File
@@ -7538,6 +7538,15 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Estado de privacidade',
'privacy.status.external': 'Fora do dispositivo',
'privacy.status.local': 'No dispositivo',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Apenas ingerido',
'sync.pipeline.storedWithoutVectors': 'Armazenado sem vetores. Pesquisa semântica indisponível.',
'sync.pipeline.signInToEnable': 'Inicie sessão para ativar',
'sync.pipeline.extractionFailed':
'Falha na extração da estrutura de memória. O wiki pode estar incompleto.',
'sync.pipeline.treeDegraded':
'Árvore de memória degradada. A recuperação pode retornar resultados desatualizados.',
'sync.pipeline.viewHealth': 'Ver a saúde da memória',
};
export default messages;
+9
View File
@@ -7498,6 +7498,15 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': 'Состояние конфиденциальности',
'privacy.status.external': 'Вне устройства',
'privacy.status.local': 'На устройстве',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': 'Только загружено',
'sync.pipeline.storedWithoutVectors': 'Сохранено без векторов. Семантический поиск недоступен.',
'sync.pipeline.signInToEnable': 'Войдите, чтобы включить',
'sync.pipeline.extractionFailed':
'Не удалось извлечь структуру памяти. Вики может быть неполной.',
'sync.pipeline.treeDegraded':
'Дерево памяти деградировало. Поиск может возвращать устаревшие результаты.',
'sync.pipeline.viewHealth': 'Показать состояние памяти',
};
export default messages;
+7
View File
@@ -7010,6 +7010,13 @@ const messages: TranslationMap = {
'privacy.status.ariaLabel': '隐私状态',
'privacy.status.external': '设备外',
'privacy.status.local': '本地设备',
// Data Sync layered pipeline status (GH-4690)
'sync.pipeline.ingestedOnly': '仅已导入',
'sync.pipeline.storedWithoutVectors': '已存储但无向量。语义搜索不可用。',
'sync.pipeline.signInToEnable': '登录以启用',
'sync.pipeline.extractionFailed': '记忆结构提取失败。维基可能不完整。',
'sync.pipeline.treeDegraded': '记忆树已降级。检索可能返回过时的结果。',
'sync.pipeline.viewHealth': '查看记忆健康状况',
};
export default messages;