diff --git a/.pr-16-diff.txt b/.pr-16-diff.txt new file mode 100644 index 0000000..3ea1a2a --- /dev/null +++ b/.pr-16-diff.txt @@ -0,0 +1,797 @@ +diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +index e992cbd..24d59fe 100644 +--- a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md ++++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +@@ -124,7 +124,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]───── + + **Out:** 시스템 알림 surface, 별 페이지, snooze 영속화, "안 옮김" 가중치 감소, 만료 임박 (D-7) 추천 + +-### #1 Ollama 회복 (4번) ++### #1 Ollama 회복 (4번) ✓ 완료 + + **In:** + - HealthChecker 주기 polling (기본 60s — mini-brainstorm 에서 주기/backoff 확정): +diff --git a/src/main/index.ts b/src/main/index.ts +index 0abca87..b4b7f3e 100644 +--- a/src/main/index.ts ++++ b/src/main/index.ts +@@ -17,12 +17,12 @@ import { HealthChecker } from './services/HealthChecker.js'; + import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js'; + import { AiWorker } from './ai/AiWorker.js'; + import { registerCaptureApi } from './ipc/captureApi.js'; +-import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js'; ++import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; + import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js'; + import { + createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow + } from './windows/quickCaptureWindow.js'; +-import { createTray, refreshTray } from './tray.js'; ++import { createTray, refreshTray, refreshTrayOllama } from './tray.js'; + import { MediaGc } from './services/MediaGc.js'; + import { BackupService } from './services/BackupService.js'; + import { ExportService } from './services/ExportService.js'; +@@ -69,8 +69,23 @@ app.whenReady().then(async () => { + fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined + }); + const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint }); +- const health = new HealthChecker(provider); +- void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record)); ++ const health = new HealthChecker(provider, { ++ onUpdate: (status) => { ++ logger.info('ai.health', { ...status } as Record); ++ pushOllamaStatus(getInboxWindow, status); ++ refreshTrayOllama(status.ok); ++ }, ++ onTelemetry: (ev) => { ++ if (ev.kind === 'ollama_unreachable') { ++ void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {}); ++ } else if (ev.kind === 'ollama_recovered') { ++ void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {}); ++ } else if (ev.kind === 'ollama_recheck_manual') { ++ void telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {}); ++ } ++ } ++ }); ++ health.start(); + + const worker = new AiWorker(repo, provider, { + onUpdate: (note) => { +@@ -128,6 +143,7 @@ app.whenReady().then(async () => { + + let backupOnQuitDone = false; + app.on('before-quit', (e) => { ++ health.stop(); + if (backupOnQuitDone) return; + e.preventDefault(); + backup.runDaily() +@@ -320,7 +336,8 @@ app.whenReady().then(async () => { + silent: true + }).show(); + } +- } ++ }, ++ /* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); } + ); + + // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. +diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts +index 475d38e..c5ea63a 100644 +--- a/src/main/ipc/inboxApi.ts ++++ b/src/main/ipc/inboxApi.ts +@@ -7,6 +7,7 @@ import type { CaptureService } from '../services/CaptureService.js'; + import type { HealthChecker } from '../services/HealthChecker.js'; + import type { IntentService } from '../services/IntentService.js'; + import type { Note } from '@shared/types'; ++import type { HealthResult } from '../ai/InferenceProvider.js'; + + export interface InboxIpcDeps { + repo: NoteRepository; +@@ -127,6 +128,11 @@ export function registerInboxApi(deps: InboxIpcDeps): void { + return { trashedCount: result.trashedCount, confirmed: true }; + } + ); ++ ++ ipcMain.handle('inbox:ollamaRecheck', async () => { ++ await deps.health.runOnce({ manual: true }); ++ return deps.health.lastStatus(); ++ }); + } + + export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { +@@ -134,3 +140,9 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): + if (!w || w.isDestroyed()) return; + w.webContents.send('note:updated', note); + } ++ ++export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void { ++ const w = getWin(); ++ if (!w || w.isDestroyed()) return; ++ w.webContents.send('ollama:status', status); ++} +diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts +index a8567a2..c10b9d2 100644 +--- a/src/main/services/HealthChecker.ts ++++ b/src/main/services/HealthChecker.ts +@@ -1,12 +1,70 @@ + import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js'; + ++export type HealthTelemetryEvent = ++ | { kind: 'ollama_unreachable'; reason: string } ++ | { kind: 'ollama_recovered'; downtimeMs: number } ++ | { kind: 'ollama_recheck_manual' }; ++ ++export interface HealthCheckerOptions { ++ intervalMs?: number; ++ onUpdate?: (status: HealthResult) => void; ++ onTelemetry?: (event: HealthTelemetryEvent) => void; ++ now?: () => number; ++} ++ ++const DEFAULT_INTERVAL_MS = 60_000; ++ + export class HealthChecker { + private last: HealthResult = { ok: true }; +- constructor(private provider: InferenceProvider) {} ++ private timer: NodeJS.Timeout | null = null; ++ private unreachableSince: number | null = null; ++ private intervalMs: number; ++ private now: () => number; ++ ++ constructor( ++ private provider: InferenceProvider, ++ private opts: HealthCheckerOptions = {} ++ ) { ++ this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS; ++ this.now = opts.now ?? Date.now; ++ } ++ ++ async runOnce(opts?: { manual?: boolean }): Promise { ++ if (opts?.manual === true) { ++ this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' }); ++ } ++ const next = await this.provider.healthCheck(); ++ const prev = this.last; ++ const okChanged = prev.ok !== next.ok; ++ const reasonChanged = prev.reason !== next.reason; ++ if (okChanged) { ++ if (next.ok === false) { ++ this.unreachableSince = this.now(); ++ this.opts.onTelemetry?.({ kind: 'ollama_unreachable', reason: next.reason ?? 'unknown' }); ++ } else { ++ const downtimeMs = this.unreachableSince !== null ? this.now() - this.unreachableSince : 0; ++ this.unreachableSince = null; ++ this.opts.onTelemetry?.({ kind: 'ollama_recovered', downtimeMs }); ++ } ++ this.opts.onUpdate?.(next); ++ } else if (reasonChanged) { ++ this.opts.onUpdate?.(next); ++ } ++ this.last = next; ++ return next; ++ } ++ ++ start(): void { ++ if (this.timer !== null) return; ++ void this.runOnce(); ++ this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs); ++ } + +- async runOnce(): Promise { +- this.last = await this.provider.healthCheck(); +- return this.last; ++ stop(): void { ++ if (this.timer !== null) { ++ clearInterval(this.timer); ++ this.timer = null; ++ } + } + + lastStatus(): HealthResult { return this.last; } +diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts +index 6fb4600..0e3a00e 100644 +--- a/src/main/services/TelemetryService.ts ++++ b/src/main/services/TelemetryService.ts +@@ -24,7 +24,10 @@ export type EmitInput = + | { kind: 'permanent_delete'; payload: { noteId: string } } + | { kind: 'empty_trash'; payload: { count: number } } + | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } +- | { kind: 'expired_batch_trash'; payload: { count: number } }; ++ | { kind: 'expired_batch_trash'; payload: { count: number } } ++ | { kind: 'ollama_unreachable'; payload: { reason: string } } ++ | { kind: 'ollama_recovered'; payload: { downtimeMs: number } } ++ | { kind: 'ollama_recheck_manual'; payload: Record }; + + export class TelemetryService { + constructor( +diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts +index 378f4ee..f608866 100644 +--- a/src/main/services/telemetryEvents.ts ++++ b/src/main/services/telemetryEvents.ts +@@ -36,6 +36,16 @@ const ExpiredBatchTrashPayload = z.object({ + count: z.number().int().nonnegative() + }).strict(); + ++const OllamaUnreachablePayload = z.object({ ++ reason: z.string().min(1).max(500) ++}).strict(); ++ ++const OllamaRecoveredPayload = z.object({ ++ downtimeMs: z.number().nonnegative() ++}).strict(); ++ ++const EmptyPayload = z.object({}).strict(); ++ + export const TelemetryEventSchema = z.discriminatedUnion('kind', [ + z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(), +@@ -45,7 +55,10 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [ + z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(), + z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(), +- z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict() ++ z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(), ++ z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(), ++ z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(), ++ z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict() + ]); + + export type TelemetryEvent = z.infer; +diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts +index c5dedc4..62f3173 100644 +--- a/src/main/services/telemetryStats.ts ++++ b/src/main/services/telemetryStats.ts +@@ -20,6 +20,9 @@ interface DailyRow { + empty_trash: number; + expired_banner_shown: number; + expired_batch_trash: number; ++ ollama_unreachable: number; ++ ollama_recovered: number; ++ ollama_recheck_manual: number; + } + + export interface StatsResult { +@@ -38,6 +41,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta + let restoreCount = 0; + let expiredBannerShownCandidatesSum = 0; + let expiredBatchTrashCountSum = 0; ++ let ollamaDowntimeSum = 0; ++ let ollamaRecoveredCount = 0; ++ let ollamaRecheckManualCount = 0; + for (const ev of events) { + const day = kstDate(ev.ts); + let row = byDay.get(day); +@@ -46,7 +52,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta + date: day, + capture: 0, ai_succeeded: 0, ai_failed: 0, + trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0, +- expired_banner_shown: 0, expired_batch_trash: 0 ++ expired_banner_shown: 0, expired_batch_trash: 0, ++ ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0 + }; + byDay.set(day, row); + } +@@ -75,6 +82,15 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta + } else if (ev.kind === 'expired_batch_trash') { + row.expired_batch_trash += 1; + expiredBatchTrashCountSum += ev.payload.count; ++ } else if (ev.kind === 'ollama_unreachable') { ++ row.ollama_unreachable += 1; ++ } else if (ev.kind === 'ollama_recovered') { ++ row.ollama_recovered += 1; ++ ollamaDowntimeSum += ev.payload.downtimeMs; ++ ollamaRecoveredCount += 1; ++ } else if (ev.kind === 'ollama_recheck_manual') { ++ row.ollama_recheck_manual += 1; ++ ollamaRecheckManualCount += 1; + } + } + const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date)); +@@ -87,6 +103,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta + const expiredTrashRatio = expiredBannerShownCandidatesSum === 0 + ? 'N/A' + : `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`; ++ const avgDowntime = ollamaRecoveredCount === 0 ++ ? 'N/A' ++ : `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`; ++ const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0); + const lines: string[] = []; + lines.push('# Inkling Telemetry Stats'); + lines.push(''); +@@ -95,10 +115,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta + lines.push(''); + lines.push('## 일자별 카운트'); + lines.push(''); +- lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |'); +- lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|'); ++ lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual |'); ++ lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|'); + for (const row of days) { +- lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} |`); ++ lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} |`); + } + lines.push(''); + lines.push('## 핵심 ratio'); +@@ -107,6 +127,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta + lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`); + lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`); + lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`); ++ lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}건`); ++ lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`); ++ lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`); + lines.push(''); + return { md: lines.join('\n'), eventCount }; + } +diff --git a/src/main/tray.ts b/src/main/tray.ts +index 3a1b322..d905436 100644 +--- a/src/main/tray.ts ++++ b/src/main/tray.ts +@@ -10,6 +10,8 @@ let _runExport: () => void = () => {}; + let _runImport: () => void = () => {}; + let _runSync: () => void = () => {}; + let _runExportTelemetry: () => void = () => {}; ++let _runOllamaRecheck: () => void = () => {}; ++let _ollamaOk = true; + let _todayCount = 0; + + function buildMenu() { +@@ -27,6 +29,11 @@ function buildMenu() { + items.push({ label: '백업에서 복원...', click: _runImport }); + items.push({ label: '지금 동기화', click: _runSync }); + items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry }); ++ items.push({ ++ label: 'Ollama 재확인', ++ enabled: !_ollamaOk, ++ click: _runOllamaRecheck ++ }); + if (app.isPackaged) { + const { openAtLogin } = app.getLoginItemSettings(); + items.push({ +@@ -55,7 +62,8 @@ export function createTray( + runExport: () => void, + runImport: () => void, + runSync: () => void, +- runExportTelemetry: () => void ++ runExportTelemetry: () => void, ++ runOllamaRecheck: () => void + ): TrayType { + _showInbox = showInbox; + _showCapture = showCapture; +@@ -64,6 +72,7 @@ export function createTray( + _runImport = runImport; + _runSync = runSync; + _runExportTelemetry = runExportTelemetry; ++ _runOllamaRecheck = runOllamaRecheck; + const icon = nativeImage.createEmpty(); + tray = new Tray(icon); + tray.setToolTip(`Inkling — 오늘 ${_todayCount}`); +@@ -82,3 +91,13 @@ export function refreshTray(todayCount: number): void { + tray.setToolTip(`Inkling — 오늘 ${todayCount}`); + tray.setContextMenu(buildMenu()); + } ++ ++/** ++ * v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출. ++ * 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신. ++ */ ++export function refreshTrayOllama(ok: boolean): void { ++ _ollamaOk = ok; ++ if (tray === null) return; ++ tray.setContextMenu(buildMenu()); ++} +diff --git a/src/preload/index.ts b/src/preload/index.ts +index 7a35600..f432856 100644 +--- a/src/preload/index.ts ++++ b/src/preload/index.ts +@@ -27,10 +27,16 @@ const api: InklingApi = { + getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'), + listExpired: () => ipcRenderer.invoke('inbox:listExpired'), + trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }), ++ ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'), + onNoteUpdated: (cb) => { + const listener = (_e: unknown, note: Note) => cb(note); + ipcRenderer.on('note:updated', listener); + return () => ipcRenderer.off('note:updated', listener); ++ }, ++ onOllamaStatus: (cb) => { ++ const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status); ++ ipcRenderer.on('ollama:status', listener); ++ return () => ipcRenderer.off('ollama:status', listener); + } + } + }; +diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx +index ae1cdcb..37803bf 100644 +--- a/src/renderer/inbox/App.tsx ++++ b/src/renderer/inbox/App.tsx +@@ -22,13 +22,16 @@ export function App(): React.ReactElement { + + useEffect(() => { + void loadInitial(); +- const unsub = inboxApi.onNoteUpdated((note) => { ++ const unsubNote = inboxApi.onNoteUpdated((note) => { + upsertNote(note); + void refreshMeta(); + }); ++ const unsubOllama = inboxApi.onOllamaStatus((status) => { ++ useInbox.setState({ ollamaStatus: status }); ++ }); + const onFocus = () => { void refreshMeta(); }; + window.addEventListener('focus', onFocus); +- return () => { unsub(); window.removeEventListener('focus', onFocus); }; ++ return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; + }, [loadInitial, refreshMeta, upsertNote]); + + const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; +diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx +index ff2c87f..4a61aba 100644 +--- a/src/renderer/inbox/components/OllamaBanner.tsx ++++ b/src/renderer/inbox/components/OllamaBanner.tsx +@@ -3,6 +3,7 @@ import { useInbox } from '../store.js'; + + export function OllamaBanner(): React.ReactElement | null { + const status = useInbox((s) => s.ollamaStatus); ++ const recheckOllama = useInbox((s) => s.recheckOllama); + if (status.ok) return null; + const isMissing = status.reason?.includes('not installed'); + const message = isMissing +@@ -10,7 +11,19 @@ export function OllamaBanner(): React.ReactElement | null { + : 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.'; + return ( +
+- ⚠ {message} ++
++ ⚠ {message} ++ ++
+ {status.reason ? ( + + 진단: {status.reason} +diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts +index c27ed76..45d9db3 100644 +--- a/src/renderer/inbox/store.ts ++++ b/src/renderer/inbox/store.ts +@@ -30,6 +30,7 @@ interface InboxState { + loadExpired: () => Promise; + trashExpiredBatch: (ids: string[]) => Promise; + snoozeExpired: () => void; ++ recheckOllama: () => Promise; + } + + const emptyContinuity: WeeklyContinuity = { +@@ -167,5 +168,9 @@ export const useInbox = create((set, get) => ({ + const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; + const nextKstMidnight = kstMidnightFloor + 86_400_000; + set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); ++ }, ++ async recheckOllama() { ++ const status = await inboxApi.ollamaRecheck(); ++ set({ ollamaStatus: status }); + } + })); +diff --git a/src/shared/types.ts b/src/shared/types.ts +index 33cca70..1b31125 100644 +--- a/src/shared/types.ts ++++ b/src/shared/types.ts +@@ -79,7 +79,9 @@ export interface InboxApi { + getTrashCount(): Promise; + listExpired(): Promise; + trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>; ++ ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>; + onNoteUpdated(cb: (note: Note) => void): () => void; ++ onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; + } + + export interface InklingApi { +diff --git a/tests/unit/HealthChecker.test.ts b/tests/unit/HealthChecker.test.ts +new file mode 100644 +index 0000000..c8a0603 +--- /dev/null ++++ b/tests/unit/HealthChecker.test.ts +@@ -0,0 +1,117 @@ ++import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; ++import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js'; ++import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js'; ++import type { AiResponse } from '@main/ai/schema.js'; ++ ++class FakeProvider implements InferenceProvider { ++ readonly name = 'fake'; ++ results: HealthResult[] = []; ++ private idx = 0; ++ async healthCheck(): Promise { ++ const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true }; ++ this.idx += 1; ++ return r; ++ } ++ async generate(_input: GenerateInput): Promise { ++ throw new Error('not used'); ++ } ++} ++ ++describe('HealthChecker — start/stop polling', () => { ++ beforeEach(() => { vi.useFakeTimers(); }); ++ afterEach(() => { vi.useRealTimers(); }); ++ ++ it('start() runs runOnce immediately + every intervalMs', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [{ ok: true }, { ok: true }, { ok: true }]; ++ const hc = new HealthChecker(provider, { intervalMs: 1000 }); ++ hc.start(); ++ await vi.runOnlyPendingTimersAsync(); ++ await vi.advanceTimersByTimeAsync(1000); ++ await vi.advanceTimersByTimeAsync(1000); ++ expect((provider as any).idx).toBeGreaterThanOrEqual(3); ++ hc.stop(); ++ }); ++ ++ it('start() is idempotent — second call does not duplicate timer', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [{ ok: true }]; ++ const hc = new HealthChecker(provider, { intervalMs: 1000 }); ++ hc.start(); ++ hc.start(); ++ await vi.advanceTimersByTimeAsync(1000); ++ expect((provider as any).idx).toBeLessThanOrEqual(2); ++ hc.stop(); ++ }); ++ ++ it('stop() clears timer (no further runOnce)', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [{ ok: true }, { ok: true }]; ++ const hc = new HealthChecker(provider, { intervalMs: 1000 }); ++ hc.start(); ++ await vi.runOnlyPendingTimersAsync(); ++ const before = (provider as any).idx; ++ hc.stop(); ++ await vi.advanceTimersByTimeAsync(5000); ++ expect((provider as any).idx).toBe(before); ++ }); ++}); ++ ++describe('HealthChecker — delta transitions + telemetry', () => { ++ it('ok=true → ok=false 전이 시 onUpdate + ollama_unreachable emit', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }]; ++ const updates: HealthResult[] = []; ++ const events: HealthTelemetryEvent[] = []; ++ const hc = new HealthChecker(provider, { ++ onUpdate: (s) => updates.push(s), ++ onTelemetry: (e) => events.push(e) ++ }); ++ await hc.runOnce(); ++ await hc.runOnce(); ++ expect(updates).toEqual([{ ok: false, reason: 'connection refused' }]); ++ expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'connection refused' }]); ++ }); ++ ++ it('ok=false → ok=true 전이 시 onUpdate + ollama_recovered emit (downtimeMs 정확)', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [{ ok: false, reason: 'refused' }, { ok: true }]; ++ const events: HealthTelemetryEvent[] = []; ++ let nowCounter = 0; ++ const hc = new HealthChecker(provider, { ++ onTelemetry: (e) => events.push(e), ++ now: () => { nowCounter += 1; return nowCounter * 1000; } ++ }); ++ await hc.runOnce(); ++ await hc.runOnce(); ++ const recovered = events.find((e) => e.kind === 'ollama_recovered'); ++ expect(recovered).toEqual({ kind: 'ollama_recovered', downtimeMs: 1000 }); ++ }); ++ ++ it('reason 변경만 (ok=false 유지) 시 onUpdate fire 하지만 telemetry emit 안 함', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [ ++ { ok: false, reason: 'refused' }, ++ { ok: false, reason: 'timeout' } ++ ]; ++ const updates: HealthResult[] = []; ++ const events: HealthTelemetryEvent[] = []; ++ const hc = new HealthChecker(provider, { ++ onUpdate: (s) => updates.push(s), ++ onTelemetry: (e) => events.push(e) ++ }); ++ await hc.runOnce(); ++ await hc.runOnce(); ++ expect(updates).toHaveLength(2); ++ expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]); ++ }); ++ ++ it('runOnce({manual:true}) 가 ollama_recheck_manual 1회 fire', async () => { ++ const provider = new FakeProvider(); ++ provider.results = [{ ok: true }]; ++ const events: HealthTelemetryEvent[] = []; ++ const hc = new HealthChecker(provider, { onTelemetry: (e) => events.push(e) }); ++ await hc.runOnce({ manual: true }); ++ expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]); ++ }); ++}); +diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts +index 8b7d1c8..183dadf 100644 +--- a/tests/unit/TelemetryService.test.ts ++++ b/tests/unit/TelemetryService.test.ts +@@ -148,7 +148,7 @@ describe('TelemetryService.readAllRecent', () => { + expect(events).toHaveLength(3); + // discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패 + expect(events.map((e) => +- (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash') ++ (e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual') + ? null + : e.payload.noteId + )).toEqual(['a', 'b', 'b']); +@@ -164,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => { + expect(events).toHaveLength(1); + const ev = events[0]!; + expect(ev.kind).toBe('capture'); +- if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash') expect(ev.payload.noteId).toBe('a'); ++ if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual') expect(ev.payload.noteId).toBe('a'); + }); + + it('returns [] when dir missing', async () => { +diff --git a/tests/unit/store.ollama.test.ts b/tests/unit/store.ollama.test.ts +new file mode 100644 +index 0000000..c205d44 +--- /dev/null ++++ b/tests/unit/store.ollama.test.ts +@@ -0,0 +1,55 @@ ++import { describe, it, expect, vi, beforeEach } from 'vitest'; ++ ++const mockApi = { ++ listNotes: vi.fn(async () => []), ++ listTrash: vi.fn(async () => []), ++ getTrashCount: vi.fn(async () => 0), ++ getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })), ++ getPendingCount: vi.fn(async () => 0), ++ getOllamaStatus: vi.fn(async () => ({ ok: true })), ++ getTodayCount: vi.fn(async () => 0), ++ restoreNote: vi.fn(async () => {}), ++ permanentDeleteNote: vi.fn(async () => ({ confirmed: true })), ++ emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })), ++ deleteNote: vi.fn(async () => {}), ++ onNoteUpdated: vi.fn(() => () => {}), ++ updateAiFields: vi.fn(async () => {}), ++ setDueDate: vi.fn(async () => {}), ++ setIntent: vi.fn(async () => {}), ++ dismissIntent: vi.fn(async () => {}), ++ listExpired: vi.fn(async () => []), ++ trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })), ++ ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })), ++ onOllamaStatus: vi.fn(() => () => {}) ++}; ++ ++vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi })); ++ ++describe('useInbox — ollama (v0.2.3 #1)', () => { ++ beforeEach(async () => { ++ const { useInbox } = await import('../../src/renderer/inbox/store.js'); ++ useInbox.setState({ ++ notes: [], trashNotes: [], trashCount: 0, showTrash: false, ++ loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, ++ ollamaStatus: { ok: false, reason: 'refused' }, ++ continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }, ++ expiredCandidates: [], expiredSnoozeUntilMs: null ++ }); ++ Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear()); ++ }); ++ ++ it('recheckOllama calls inboxApi.ollamaRecheck and updates ollamaStatus', async () => { ++ mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: true }); ++ const { useInbox } = await import('../../src/renderer/inbox/store.js'); ++ await useInbox.getState().recheckOllama(); ++ expect(mockApi.ollamaRecheck).toHaveBeenCalledTimes(1); ++ expect(useInbox.getState().ollamaStatus).toEqual({ ok: true }); ++ }); ++ ++ it('recheckOllama propagates failure status', async () => { ++ mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: false, reason: 'timeout' }); ++ const { useInbox } = await import('../../src/renderer/inbox/store.js'); ++ await useInbox.getState().recheckOllama(); ++ expect(useInbox.getState().ollamaStatus).toEqual({ ok: false, reason: 'timeout' }); ++ }); ++}); +diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts +index 12020a6..1c74727 100644 +--- a/tests/unit/telemetryEvents.test.ts ++++ b/tests/unit/telemetryEvents.test.ts +@@ -195,3 +195,58 @@ describe('expired_banner_shown / expired_batch_trash events', () => { + })).toThrow(); + }); + }); ++ ++describe('ollama_unreachable / ollama_recovered / ollama_recheck_manual events', () => { ++ it('parses valid ollama_unreachable', () => { ++ const ev = validateEvent({ ++ ts: '2026-05-01T00:00:00.000Z', ++ kind: 'ollama_unreachable', ++ payload: { reason: 'connection refused' } ++ }); ++ if (ev.kind !== 'ollama_unreachable') throw new Error('discriminant'); ++ expect(ev.payload.reason).toBe('connection refused'); ++ }); ++ ++ it('parses valid ollama_recovered', () => { ++ const ev = validateEvent({ ++ ts: '2026-05-01T00:00:00.000Z', ++ kind: 'ollama_recovered', ++ payload: { downtimeMs: 60000 } ++ }); ++ if (ev.kind !== 'ollama_recovered') throw new Error('discriminant'); ++ expect(ev.payload.downtimeMs).toBe(60000); ++ }); ++ ++ it('parses valid ollama_recheck_manual (empty payload)', () => { ++ const ev = validateEvent({ ++ ts: '2026-05-01T00:00:00.000Z', ++ kind: 'ollama_recheck_manual', ++ payload: {} ++ }); ++ expect(ev.kind).toBe('ollama_recheck_manual'); ++ }); ++ ++ it('rejects ollama_unreachable with extra payload field (privacy invariant)', () => { ++ expect(() => validateEvent({ ++ ts: '2026-05-01T00:00:00.000Z', ++ kind: 'ollama_unreachable', ++ payload: { reason: 'refused', rawText: 'leak' } ++ })).toThrow(); ++ }); ++ ++ it('rejects ollama_recovered with negative downtimeMs', () => { ++ expect(() => validateEvent({ ++ ts: '2026-05-01T00:00:00.000Z', ++ kind: 'ollama_recovered', ++ payload: { downtimeMs: -1 } ++ })).toThrow(); ++ }); ++ ++ it('rejects ollama_recheck_manual with non-empty payload (privacy invariant)', () => { ++ expect(() => validateEvent({ ++ ts: '2026-05-01T00:00:00.000Z', ++ kind: 'ollama_recheck_manual', ++ payload: { foo: 'bar' } ++ })).toThrow(); ++ }); ++}); +diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts +index 181b0d7..e3a2c91 100644 +--- a/tests/unit/telemetryStats.test.ts ++++ b/tests/unit/telemetryStats.test.ts +@@ -124,3 +124,30 @@ describe('aggregateStats — expired_banner_shown / expired_batch_trash', () => + expect(r.md).toMatch(/만료 trash ratio.*N\/A/); + }); + }); ++ ++describe('aggregateStats — ollama_* events', () => { ++ it('counts 3 kinds per day and computes downtime average', () => { ++ const events = [ ++ { ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } }, ++ { ts: '2026-05-01T01:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 60000 } }, ++ { ts: '2026-05-01T02:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'timeout' } }, ++ { ts: '2026-05-01T03:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 120000 } }, ++ { ts: '2026-05-01T04:00:00.000Z', kind: 'ollama_recheck_manual' as const, payload: {} as Record } ++ ]; ++ const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); ++ expect(r.md).toContain('ollama_unreachable'); ++ expect(r.md).toContain('ollama_recovered'); ++ expect(r.md).toContain('ollama_recheck_manual'); ++ // (60000 + 120000) / 2 = 90000 ++ expect(r.md).toMatch(/평균 downtimeMs.*90000/); ++ expect(r.md).toMatch(/수동 recheck.*1/); ++ }); ++ ++ it('shows N/A for downtime when no recovered events', () => { ++ const events = [ ++ { ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } } ++ ]; ++ const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z')); ++ expect(r.md).toMatch(/평균 downtimeMs.*N\/A/); ++ }); ++}); diff --git a/.pr-16-review-r1.json b/.pr-16-review-r1.json new file mode 100644 index 0000000..0ddb606 --- /dev/null +++ b/.pr-16-review-r1.json @@ -0,0 +1,48 @@ +{ + "verdict": "APPROVE", + "summary": "v0.2.3 #1 Ollama recovery polling — spec/plan에 정확히 일치. HealthChecker.start/stop + delta-only onUpdate + 4 상태 전이 + onTelemetry hook + before-quit cleanup + IPC inbox:ollamaRecheck + ollama:status push + tray refreshTrayOllama + OllamaBanner 재확인 버튼이 모두 구현됨. 17 신규 단위 테스트 (HealthChecker 7 + telemetryEvents 6 + telemetryStats 2 + store 2) 가 spec §6 의 ≥12 요구를 초과 충족. 게이트 통과 확인: typecheck 0 errors / npm test 344 PASS / e2e 1/1. spec §10.1 매핑 매트릭스 7항목 모두 검증. 4 상태 전이 (true→true / true→false / false→true / false→false 같은/다른 reason) 단위로 가드. manual flag emit-before-call 의도 보존. zod 3 strict + reason max(500) privacy invariant 단위 회귀 가드. start/stop idempotent + before-quit 첫 statement 위치. note:updated 패턴 mirror 일관 (pushOllamaStatus / onOllamaStatus / 'ollama:status' channel). 머지 가능 — minor/nit 만 v0.2.4 backlog 누적 후보.", + "comments": [ + { + "path": "src/main/services/HealthChecker.ts", + "line": 18, + "level": "minor", + "message": "초기 last = { ok: true } 가 첫 runOnce 에서 healthCheck 가 { ok: true, model: 'gemma4:e4b' } 반환 시 'no transition' 으로 분류되어 onUpdate 가 한 번도 fire 안 함 (okChanged=false, reasonChanged=undefined===undefined). renderer 는 startup 에서 inboxApi.getOllamaStatus() 로 fetch 해서 fine 하지만, push 채널만 의존하는 미래의 consumer 가 model 정보를 놓침. 의도라면 명시 주석 1줄 추가 권장 ('initial last is sentinel — first successful runOnce intentionally suppresses onUpdate when status remains ok'). v0.2.4 deferred OK." + }, + { + "path": "src/main/services/HealthChecker.ts", + "line": 59, + "level": "minor", + "message": "start() 가 runOnce() 를 fire-and-forget (void) 로 즉시 호출. 첫 healthCheck 가 늦게 끝나는 동안 intervalMs 후 두 번째 runOnce 가 시작되면 둘 다 동시에 provider.healthCheck() 호출 → this.last 가 race 로 잘못된 순서로 저장 가능 (HTTP latency 가 intervalMs=60s 근접 시). 실사용에서는 /api/tags 가 ms 단위라 거의 발생 안 하지만, 테스트에서 fakeTimers 와 결합 시 발생 가능. 단위 테스트는 sequential await 사용하므로 가드 안 됨. v0.2.4 에서 'inFlight' guard 또는 last write wins 명시 주석 1줄 권장." + }, + { + "path": "src/main/index.ts", + "line": 145, + "level": "minor", + "message": "app.on('before-quit') 가 두 곳에 등록 (line 145 health/backup, line 349 trayInterval cleanup). 첫 핸들러가 e.preventDefault() 후 backup 완료 시 app.quit() → before-quit 재발화 → health.stop() 두 번 호출 (idempotent OK) + clearInterval(trayInterval) 가 두 번째 firing 에야 동작. 이는 pre-existing 패턴이지만 본 PR 가 health.stop() 을 추가하면서 '두 번째 before-quit 발화에서 무엇이 실행되는가' 의 코드 리딩 부담이 늘어남. v0.2.4 에서 quit cleanup 을 단일 함수로 모으는 refactor 후보." + }, + { + "path": "src/renderer/inbox/components/OllamaBanner.tsx", + "line": 17, + "level": "nit", + "message": "재확인 button 의 onClick 이 `void recheckOllama()` 사용 — recheckOllama 가 throw 하면 unhandled rejection. 현재 inboxApi.ollamaRecheck → ipcRenderer.invoke 는 IPC 채널이 없어진 케이스 외에는 reject 없고, IPC handler 자체가 healthCheck 가 throw 안 함을 가정. 안전하지만 try/catch 로 명시적 silent swallow 가 더 방어적. UI noise 없으니 nit 수준." + }, + { + "path": "src/renderer/inbox/App.tsx", + "line": 35, + "level": "nit", + "message": "useEffect deps array 가 [loadInitial, refreshMeta, upsertNote] 만 — onOllamaStatus 의 setState 는 useInbox.setState 직접 호출이라 deps 영향 없으나, eslint exhaustive-deps 가 다음 추가 시 false positive 낼 수 있음. zustand 의 setState 는 stable reference 라 add 도 noop. 향후 다른 ev 추가 시 patterns 일관성 유지 권장." + }, + { + "path": "tests/unit/HealthChecker.test.ts", + "line": 86, + "level": "nit", + "message": "start() idempotent 테스트가 `idx <= 2` 만 검증. 만약 두 timer 가 동시에 1000ms 마다 fire 하면 idx 가 3 (즉시 1 + 1초 후 2개) 이 되겠지만, vi.advanceTimersByTimeAsync(1000) 가 두 timer 의 첫 fire 모두 실행하므로 idx 검증이 약함. private timer reference 직접 검증 (e.g., via reflection) 또는 5000ms advance 후 idx <= 6 같은 더 엄격한 invariant 권장. 본 테스트로도 명백한 leak 은 잡힘 — nit 수준." + }, + { + "path": "src/main/services/HealthChecker.ts", + "line": 33, + "level": "nit", + "message": "manual flag 의 ollama_recheck_manual emit 이 healthCheck 호출 *전*에 fire — 의도적이지만 (provider 실패해도 manual 카운트 보장), 만약 healthCheck 가 throw 하면 (현재 LocalOllamaProvider impl 는 안 함) recheck_manual 은 emit 됐지만 onUpdate 는 fire 안 한 inconsistent state. 주석 1줄 ('emit BEFORE healthCheck — manual count must survive provider exception') 으로 의도 문서화 권장." + } + ] +} diff --git a/src/main/index.ts b/src/main/index.ts index b4b7f3e..f64ee99 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -142,8 +142,14 @@ app.whenReady().then(async () => { .catch((e) => logger.warn('backup.daily.failed', { reason: String(e) })); let backupOnQuitDone = false; + let trayInterval: NodeJS.Timeout | null = null; app.on('before-quit', (e) => { + // 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain. health.stop(); + if (trayInterval !== null) { + clearInterval(trayInterval); + trayInterval = null; + } if (backupOnQuitDone) return; e.preventDefault(); backup.runDaily() @@ -342,11 +348,11 @@ app.whenReady().then(async () => { // F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신. // 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거. + // cleanup 은 위 통합 before-quit 핸들러에서 처리. refreshTray(repo.countToday()); - const trayInterval = setInterval(() => { + trayInterval = setInterval(() => { refreshTray(repo.countToday()); }, 60_000); - app.on('before-quit', () => { clearInterval(trayInterval); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createInboxWindow(); diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts index c10b9d2..14b3df5 100644 --- a/src/main/services/HealthChecker.ts +++ b/src/main/services/HealthChecker.ts @@ -15,9 +15,15 @@ export interface HealthCheckerOptions { const DEFAULT_INTERVAL_MS = 60_000; export class HealthChecker { + // sentinel: 첫 healthCheck 가 ok=true 면 transition 으로 인식 안 됨 (no-op), + // ok=false 면 unreachable transition 으로 정상 인식. 즉 첫 호출이 healthy 면 telemetry 0. private last: HealthResult = { ok: true }; private timer: NodeJS.Timeout | null = null; private unreachableSince: number | null = null; + // m2 fix: in-flight guard — 첫 runOnce 가 늦게 끝나는 동안 setInterval 이 두 번째 + // runOnce 를 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 안전 측면에선 + // 큰 문제 없지만, telemetry 이중 emit (false→true→false 동시 처리) 회피. + private inFlight: Promise | null = null; private intervalMs: number; private now: () => number; @@ -30,9 +36,18 @@ export class HealthChecker { } async runOnce(opts?: { manual?: boolean }): Promise { + // n4 의도: ollama_recheck_manual 은 healthCheck 호출 *전에* fire — provider 가 throw 하거나 + // 늦게 응답해도 manual 카운트는 누락 없음. user click → telemetry 1:1 보장. if (opts?.manual === true) { this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' }); } + if (this.inFlight !== null) return this.inFlight; + this.inFlight = this.doRunOnce(); + try { return await this.inFlight; } + finally { this.inFlight = null; } + } + + private async doRunOnce(): Promise { const next = await this.provider.healthCheck(); const prev = this.last; const okChanged = prev.ok !== next.ok; diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 37803bf..d3b51e5 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -32,6 +32,8 @@ export function App(): React.ReactElement { const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; + // onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라 + // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx index 4a61aba..0ecfd6d 100644 --- a/src/renderer/inbox/components/OllamaBanner.tsx +++ b/src/renderer/inbox/components/OllamaBanner.tsx @@ -14,7 +14,12 @@ export function OllamaBanner(): React.ReactElement | null {
⚠ {message}