diff --git a/.pr-16-diff.txt b/.pr-16-diff.txt deleted file mode 100644 index 3ea1a2a..0000000 --- a/.pr-16-diff.txt +++ /dev/null @@ -1,797 +0,0 @@ -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 deleted file mode 100644 index 0ddb606..0000000 --- a/.pr-16-review-r1.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "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') 으로 의도 문서화 권장." - } - ] -}