diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index 7353086f..0f62d5af 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -57,8 +57,12 @@ export interface MemoryV2 { created_at: string; /** 0..1 plugin similarity score; only present when ?q= is set. */ score?: number | null; - /** workspace_id of the peer that originated this memory if propagation is in play. */ - source_workspace_id?: string; + // Note: an earlier iteration of this type carried a `source_workspace_id` + // field rendered as a "from peer" badge. The propagation contract that + // would have populated it ("Reserved for future cross-namespace + // propagation semantics" in memory-plugin-v1.yaml) is unimplemented — + // nothing in the codebase writes that key. Removed in self-review. + // Re-add when propagation gains a concrete shape. } interface MemoriesResponse { @@ -83,6 +87,24 @@ function sanitizeId(id: string): string { return id.replace(/[^a-zA-Z0-9]/g, '-'); } +/** + * Detect a memory-plugin-503 error from the api wrapper's stringified + * Error message. Matches on the literal env-var name rather than the + * status code, because the api shim renders status codes inside a + * larger formatted message and a future status-code reformat would + * silently break the detection. + * + * The substring `MEMORY_PLUGIN_URL` is hard-coded in the handler at + * `workspace-server/internal/handlers/memories_v2.go:available()`, + * so this is a pinned cross-layer contract — drift is caught by both + * the Go test (TestMemoriesV2_PluginUnwired_All503) and the canvas + * test (TestMemoryInspectorPanel — plugin unavailable). + */ +export function isPluginUnavailableError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : ''; + return msg.includes('MEMORY_PLUGIN_URL'); +} + function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); if (diff < 60_000) return `${Math.floor(diff / 1000)}s`; @@ -169,11 +191,10 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setNamespaces(data); setPluginUnavailable(false); } catch (e) { - // 503 indicates the plugin isn't wired. Surface it specially — - // anything else stays as a generic load failure that the + // Plugin-unavailable (503) indicates MEMORY_PLUGIN_URL isn't set. + // Anything else stays as a generic load failure that the // entries-load path will also flag. - const msg = e instanceof Error ? e.message : ''; - if (msg.includes('503') || msg.toLowerCase().includes('plugin is not configured')) { + if (isPluginUnavailableError(e)) { setPluginUnavailable(true); } setNamespaces({ readable: [], writable: [] }); @@ -205,12 +226,11 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { : data.memories; setEntries(sorted); } catch (e) { - const msg = e instanceof Error ? e.message : 'Failed to load memories'; - if (msg.includes('503') || msg.toLowerCase().includes('plugin is not configured')) { + if (isPluginUnavailableError(e)) { setPluginUnavailable(true); setError(null); // surfaced via banner, not row error } else { - setError(msg); + setError(e instanceof Error ? e.message : 'Failed to load memories'); } setEntries([]); } finally { @@ -578,16 +598,6 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) { )} - {/* Source workspace badge (propagated memory) */} - {entry.source_workspace_id && ( - - ⇡{entry.source_workspace_id.slice(0, 6)} - - )} {formatRelativeTime(entry.created_at)} diff --git a/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx b/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx index 9b93ee14..0111451f 100644 --- a/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx +++ b/canvas/src/components/__tests__/MemoryInspectorPanel.test.tsx @@ -13,7 +13,7 @@ * - Search results sort by score descending * - Empty-state copy differs by query / plugin-state / no-data * - Per-row badges render (kind / source / pin / TTL / score / - * source_workspace_id) and TTL countdown handles past/future/null + * score) and TTL countdown handles past/future/null * - Delete (Forget) flow: optimistic removal, confirmation dialog, * server failure rolls back via reload * - formatTTL helper covers s/m/h/d/expired/null/invalid branches @@ -61,6 +61,7 @@ import { api } from '@/lib/api'; import { MemoryInspectorPanel, formatTTL, + isPluginUnavailableError, type MemoryV2, type NamespacesResponse, } from '../MemoryInspectorPanel'; @@ -99,14 +100,13 @@ const MEM_PINNED: MemoryV2 = { created_at: '2026-04-17T12:00:00.000Z', }; -const MEM_PROPAGATED: MemoryV2 = { - id: 'mem-from-peer', +const MEM_RUNTIME_CHECKPOINT: MemoryV2 = { + id: 'mem-checkpoint', namespace: 'team:t-1', - content: 'Cross-workspace fact', + content: 'Runtime checkpoint', kind: 'checkpoint', source: 'runtime', pin: false, - source_workspace_id: 'ws-peer-99', created_at: '2026-04-17T12:00:00.000Z', }; @@ -142,6 +142,33 @@ function stubFetch(memories: MemoryV2[], namespaces: NamespacesResponse = NS_RES }) as typeof api.get); } +// ── isPluginUnavailableError helper ───────────────────────────────────────── + +describe('isPluginUnavailableError', () => { + it('matches the literal env var contract from the server handler', () => { + expect( + isPluginUnavailableError( + new Error('API GET /workspaces/x/v2/memories: 503 {"error":"memory plugin is not configured (set MEMORY_PLUGIN_URL)"}'), + ), + ).toBe(true); + }); + + it('does not false-match on generic 503 errors that don\'t mention the env var', () => { + expect(isPluginUnavailableError(new Error('API GET /foo: 503 something else'))).toBe(false); + }); + + it('does not false-match on plain 4xx errors', () => { + expect(isPluginUnavailableError(new Error('API GET /foo: 401 unauthorized'))).toBe(false); + }); + + it('returns false for non-Error inputs', () => { + expect(isPluginUnavailableError(null)).toBe(false); + expect(isPluginUnavailableError(undefined)).toBe(false); + expect(isPluginUnavailableError('a string')).toBe(false); + expect(isPluginUnavailableError({ message: 'MEMORY_PLUGIN_URL' })).toBe(false); + }); +}); + // ── formatTTL helper ───────────────────────────────────────────────────────── describe('formatTTL', () => { @@ -242,7 +269,7 @@ describe('MemoryInspectorPanel — plugin unavailable', () => { }); it('shows the empty-state explaining plugin disabled', async () => { - mockGet.mockRejectedValue(new Error('HTTP 503')); + mockGet.mockRejectedValue(new Error('API GET /workspaces/x/v2/memories: 503 {"error":"memory plugin is not configured (set MEMORY_PLUGIN_URL)"}')); render(); await waitFor(() => screen.getByText(/Memory plugin disabled/i)); }); @@ -346,8 +373,8 @@ describe('MemoryInspectorPanel — search', () => { // ── Per-row badges ─────────────────────────────────────────────────────────── describe('MemoryInspectorPanel — row badges', () => { - it('renders kind, source, pin, TTL, source-workspace badges per shape', async () => { - stubFetch([MEM_PINNED, MEM_PROPAGATED]); + it('renders kind, source, pin, TTL badges per shape', async () => { + stubFetch([MEM_PINNED, MEM_RUNTIME_CHECKPOINT]); render(); await waitFor(() => { @@ -357,15 +384,13 @@ describe('MemoryInspectorPanel — row badges', () => { expect(pinnedRow.querySelector('[data-testid="source-badge"]')?.textContent).toBe('user'); expect(pinnedRow.querySelector('[data-testid="pin-badge"]')).toBeTruthy(); expect(pinnedRow.querySelector('[data-testid="ttl-badge"]')?.textContent).toMatch(/^⌛\d+[hd]$/); - expect(pinnedRow.querySelector('[data-testid="source-workspace-badge"]')).toBeNull(); - // Propagated memory: kind=checkpoint, source=runtime, no pin, no TTL, source_workspace - const propRow = screen.getByTestId('memory-row-mem-from-peer'); + // Checkpoint memory: kind=checkpoint, source=runtime, no pin, no TTL + const propRow = screen.getByTestId('memory-row-mem-checkpoint'); expect(propRow.querySelector('[data-testid="kind-badge"]')?.textContent).toBe('C'); expect(propRow.querySelector('[data-testid="source-badge"]')?.textContent).toBe('runtime'); expect(propRow.querySelector('[data-testid="pin-badge"]')).toBeNull(); expect(propRow.querySelector('[data-testid="ttl-badge"]')).toBeNull(); - expect(propRow.querySelector('[data-testid="source-workspace-badge"]')?.textContent).toMatch(/^⇡ws-pee/); }); });