From f36b9ecb5b877159b6c7db839712a579186b26cd Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:16:14 +0900 Subject: [PATCH 01/13] =?UTF-8?q?docs(spec):=20v0.2.3=20#1=20Ollama=20?= =?UTF-8?q?=ED=9A=8C=EB=B3=B5=20polling=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mini-brainstorm 결과 3개 결정: - Q1=A polling 주기 60s - Q2=A 절대 중단 안 함 - Q3=A constant (no backoff) HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events (ollama_unreachable / ollama_recovered / ollama_recheck_manual) + main → renderer push (ollama:status) + manual recheck (banner + tray). T1-T7 작업 순서 + 단위 ≥ 12개. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-01-v023-ollama-recovery-design.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md diff --git a/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md b/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md new file mode 100644 index 0000000..5859436 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md @@ -0,0 +1,322 @@ +# v0.2.3 #1 Ollama 회복 polling 설계 + +**작성일:** 2026-05-01 +**저자:** 김태현 (dlsrks0734@gmail.com) +**문서 성격:** v0.2.3 cut 7항목 중 4번째 항목 (#1 Ollama 회복) 의 mini-brainstorm 결정 + design. roadmap §3 #1 의 In/Out 위에서 §8 미결정 3항목 (polling 주기 / 실패 N회 중단 / backoff) 결정 + 추가 동작 사양 명시. + +**선행 문서:** +- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #1, §8 +- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15) + +--- + +## 1. 결정 요약 + +| Q | 결정 | 근거 | +|---|------|------| +| Q1 polling 주기 | **A 60s** | 회복 latency ≤ 1분 충분. `/api/tags` 호출 가벼워 부하 미미. dogfood 1인 사용 패턴 (분당 ~1 capture) 과 같은 톤. | +| Q2 실패 N회 후 중단 | **A 절대 중단 안 함** | 부하 무시 가능. 중단 시 사용자 마찰 (재확인 버튼 또는 재시작) 만 남김. | +| Q3 exponential backoff | **A constant 60s** | Q1 결론 + Q2 결론 연결 — backoff 효과 없고 회복 latency 만 늘어남. | + +--- + +## 2. HealthChecker 확장 + +### 2.1 시그너처 + +```ts +// src/main/services/HealthChecker.ts +export interface HealthCheckerOptions { + intervalMs?: number; // default 60_000 + onUpdate?: (status: HealthResult) => void; // delta only — status 가 변할 때만 fire + onTelemetry?: (event: HealthTelemetryEvent) => void; // emit hook (testability) + now?: () => number; // testability +} + +export type HealthTelemetryEvent = + | { kind: 'ollama_unreachable'; reason: string } + | { kind: 'ollama_recovered'; downtimeMs: number }; + +export class HealthChecker { + constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {} + + async runOnce(): Promise; // 기존 메서드 유지 (manual recheck + start 진입점) + start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만) + stop(): void; // clearInterval (idempotent) + lastStatus(): HealthResult; +} +``` + +### 2.2 상태 전이 로직 + +`runOnce()` 안에서 `result = await provider.healthCheck()` 후: + +| 전이 | 동작 | +|------|------| +| ok=true → ok=true (변화 없음) | no-op | +| ok=true → ok=false | `unreachableSince = now()`. `onUpdate(result)` 호출. `onTelemetry({kind:'ollama_unreachable', reason})` | +| ok=false → ok=true | `downtimeMs = now() - unreachableSince`. `onUpdate(result)`. `onTelemetry({kind:'ollama_recovered', downtimeMs})`. `unreachableSince = null` | +| ok=false → ok=false (reason 동일) | no-op | +| ok=false → ok=false (reason 다름) | `onUpdate(result)` (UI 갱신). telemetry emit **안 함** (ratio 노이즈 회피) | + +### 2.3 start/stop + +- `start()`: `runOnce()` 즉시 1회 + `setInterval(runOnce, intervalMs)` 등록. timer 이미 있으면 no-op. +- `stop()`: `clearInterval(timer)`. timer null 로 set. +- App quit hook (`app.on('before-quit')`) 에서 `health.stop()` — leak 방지. + +--- + +## 3. main wiring + IPC + +### 3.1 main/index.ts 변경 + +기존: +```ts +const health = new HealthChecker(provider); +void health.runOnce().then((h) => logger.info('ai.health', { ...h })); +``` + +신규: +```ts +const health = new HealthChecker(provider, { + onUpdate: (status) => pushOllamaStatus(getInboxWindow, status), + 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(() => {}); + } +}); +health.start(); + +app.on('before-quit', () => health.stop()); +``` + +### 3.2 IPC 채널 + +| 채널 | 방향 | 용도 | +|------|------|------| +| `inbox:ollamaStatus` | renderer → main | 기존 — `health.lastStatus()` 반환. startup / refreshMeta 시 fetch. | +| `inbox:ollamaRecheck` | renderer → main → renderer | 신규 — main 이 `health.runOnce()` 호출, 결과 status push, telemetry `ollama_recheck_manual` emit. | +| `ollama:status` (push) | main → renderer | 신규 — onUpdate fire 시 main 이 webContents.send. (note:updated 패턴 mirroring) | + +`inbox:ollamaStatus` 는 변경 없음 (기존 IPC 호환). + +`pushOllamaStatus(getInboxWindow, status)` helper 추가 (`pushNoteUpdated` 의 자매): + +```ts +// src/main/ipc/inboxApi.ts +export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void { + const w = getWin(); + if (!w || w.isDestroyed()) return; + w.webContents.send('ollama:status', status); +} +``` + +`inbox:ollamaRecheck` handler: + +```ts +ipcMain.handle('inbox:ollamaRecheck', async () => { + await deps.health.runOnce(); // status 변경 시 onUpdate 자동 fire + void deps.telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {}); + return deps.health.lastStatus(); +}); +``` + +--- + +## 4. store + UI + +### 4.1 store.ts 확장 + +`InboxState` 에 신규 action + push subscriber: + +```ts +recheckOllama: () => Promise; +``` + +`loadInitial` 의 `useEffect` 에서 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 와 동일 패턴): + +```ts +// App.tsx useEffect +const unsubOllama = inboxApi.onOllamaStatus((status) => { + set({ ollamaStatus: status }); +}); +return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; +``` + +`recheckOllama` action: +```ts +async recheckOllama() { + const status = await inboxApi.ollamaRecheck(); + set({ ollamaStatus: status }); +} +``` + +### 4.2 InboxApi + preload 확장 + +```ts +// shared/types.ts InboxApi +ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>; +onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; + +// preload/index.ts +ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'), +onOllamaStatus: (cb) => { + const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status); + ipcRenderer.on('ollama:status', listener); + return () => ipcRenderer.off('ollama:status', listener); +} +``` + +### 4.3 OllamaBanner 변경 + +`status.ok === false` 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래): + +```tsx + +``` + +기존 banner 스타일 유지 (warn variant). + +### 4.4 Tray 메뉴 + +기존 `createTray` 의 컨텍스트 메뉴에 항목 추가: + +```ts +{ + label: 'Ollama 재확인', + enabled: !health.lastStatus().ok, // dynamic — 정상이면 disabled + click: () => void deps.recheckOllama() +} +``` + +`createTray` 가 7 positional callbacks 받는 현 구조에 1 callback 추가 — v0.2.4 backlog #4 (TrayCallbacks object refactor) 와 정합. 본 cut 에서는 8번째 callback 추가 + backlog #4 의 trigger 만 강화. + +--- + +## 5. Telemetry + +### 5.1 신규 3 events + +| event | payload | 발화 | +|-------|---------|------| +| `ollama_unreachable` | `{ reason: string }` (max 500) | ok=true → ok=false 전이 (HealthChecker.onTelemetry) | +| `ollama_recovered` | `{ downtimeMs: number }` (≥0) | ok=false → ok=true 전이 | +| `ollama_recheck_manual` | `{}` (empty) | `inbox:ollamaRecheck` IPC handler | + +### 5.2 zod schemas + +```ts +// telemetryEvents.ts +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(); +``` + +`reason` 의 source 는 `LocalOllamaProvider.healthCheck()` 가 반환하는 generic message — `'connection refused'`, `'not installed'`, `'timeout'`, `'http 500'` 등 generic. 본문/PII 누출 0건. max 500 cap 으로 anomaly fence. + +### 5.3 stats.md 집계 + +신규 행 (휴지통 회수율 다음): + +``` +- Ollama unreachable 빈도: {count}건 +- 평균 downtimeMs (recovered): {avg} +- 수동 recheck 사용량: {count}건 +``` + +`DailyRow` 에 3 새 카운터 추가. + +--- + +## 6. 테스트 + +| 영역 | 단위 | 검증 | +|------|------|------| +| HealthChecker | `start()` idempotent | 2회 호출 → timer 1개. | +| HealthChecker | `start()` 즉시 1회 + 60s 마다 | `vi.useFakeTimers()` advance, runOnce 호출 횟수. | +| HealthChecker | `stop()` cleanup | clearInterval. timer null. | +| HealthChecker | ok=true → ok=false 전이 | onUpdate fire, onTelemetry `ollama_unreachable {reason}` 1회. | +| HealthChecker | ok=false → ok=true 전이 | onUpdate fire, onTelemetry `ollama_recovered {downtimeMs}` 1회. downtimeMs ≈ now-unreachableSince. | +| HealthChecker | reason 변경 (ok=false 유지) | onUpdate fire, onTelemetry 0건. | +| HealthChecker | ok=true → ok=true 변화 없음 | onUpdate 0건. | +| TelemetryEvents | zod 3 신규 parse | happy + extra field reject (privacy invariant 회귀). | +| TelemetryStats | 3 카운터 + downtime 평균 | aggregateStats 검증. | +| IPC handler | `inbox:ollamaRecheck` | runOnce + telemetry.emit recheck_manual + status 반환. | +| Store | `recheckOllama` action | inboxApi.ollamaRecheck → set ollamaStatus. | +| Store | onOllamaStatus subscriber | push 받으면 set ollamaStatus. | + +총 ≥ 12 단위. e2e 영향 없음. + +--- + +## 7. 작업 순서 (writing-plans 시 task 분할 가이드) + +T1. HealthChecker.start/stop + delta 전이 로직 + 단위 7개 (TDD) +T2. Telemetry 3 events (zod + EmitInput + stats.md 집계 + 단위 4개) +T3. main/index.ts wiring (`onUpdate` + `onTelemetry` + `start()` + `before-quit stop`) + 테스트는 T5 의 IPC 통해 +T4. IPC `inbox:ollamaRecheck` + `pushOllamaStatus` helper + `ollama:status` push + 단위 1개 +T5. shared/types InboxApi + preload + renderer onOllamaStatus subscriber + recheckOllama action + 단위 2개 +T6. OllamaBanner 재확인 버튼 + tray 메뉴 항목 (visual integration) +T7. closure (gates + roadmap mark + memory backlog) + +--- + +## 8. roadmap In/Out 일치 + +### 8.1 roadmap §3 #1 In 매핑 + +| roadmap | design | +|---------|--------| +| 60s polling, runOnce setInterval 자동 발화 | §2 ✓ | +| 회복 시 onUpdate → 구독 (renderer OllamaBanner) 자동 갱신 | §3.2 (push) + §4.1 (subscriber) ✓ | +| 실패 N회 후 polling 중단 정책 | Q2=A 절대 중단 안 함 | +| 수동 재확인 버튼 — OllamaBanner + 트레이 | §4.3 + §4.4 ✓ | +| IPC `inbox:ollamaRecheck` | §3.2 ✓ | +| Telemetry `ollama_unreachable {reason}`, `ollama_recovered {downtimeMs}`, `ollama_recheck_manual {}` | §5 ✓ | +| 단위 테스트 | §6 ≥ 12 | + +### 8.2 Out 유지 + +- 사용자 설정 가능 polling 주기 — Out (Q1=A 60s 고정). +- 회복 toast 알림 — Out (banner 자동 사라짐만). +- model 정상성 (tags 외) 체크 — Out (provider 의 healthCheck 만 사용). + +--- + +## 9. 위험 / 완화 + +| 위험 | 완화 | +|------|------| +| polling 이 app quit 시 leak | `app.on('before-quit')` 에서 `health.stop()`. 단위 테스트로 stop() 동작 가드. | +| onUpdate 가 status 매번 fire 되어 IPC 폭주 | delta only — last 와 비교 후만 fire. 단위 테스트로 ok=ok no-op 가드. | +| reason 문자열에 본문/PII 누출 | LocalOllamaProvider 가 generic message 만 반환. zod max length 500 cap. privacy invariant 단위 테스트. | +| recheck 가 polling 과 동시 발화 race | `runOnce()` async, sequential. provider.healthCheck() 가 자체적으로 동시 호출 안전 (HTTP GET). | +| reason 변경만으로 telemetry 폭주 (예: timeout ↔ refused 반복) | reason 변경 시 onUpdate fire 하지만 telemetry emit 안 함 — ratio 노이즈 회피. | + +--- + +## 10. 게이트 (PR 머지 조건, roadmap §3.1 일치) + +- `npm run typecheck` 0 에러 +- `npm test` — 327 + 12+ = 339+ +- `npm run test:e2e` 1/1 +- main 머지 + +머지 후: +- roadmap `### #1 Ollama 회복 (4번)` → `✓ 완료` +- `memory/project_v024_backlog.md` review deferred 항목 누적 + +--- + +## 11. 변경 이력 + +| 일자 | 변경 | +|------|------| +| 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (`ollama:status`) + manual recheck (banner + tray). | From 050e7f08f16d9f0500bc6fcd4c7fc99cb3df1eef Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:18:28 +0900 Subject: [PATCH 02/13] =?UTF-8?q?docs(spec):=20#1=20ollama=20=E2=80=94=20r?= =?UTF-8?q?unOnce({manual})=20+=20ollama=5Frecheck=5Fmanual=20via=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §2.1 / §3.2 / §11 보강 — IPC handler 가 직접 telemetry.emit 안 하고 HealthChecker.runOnce({ manual: true }) 호출 → onTelemetry hook 으로 ollama_recheck_manual 발화. 단위 테스트 가능 (HealthChecker 레이어). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-01-v023-ollama-recovery-design.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md b/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md index 5859436..f65199e 100644 --- a/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md +++ b/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md @@ -35,12 +35,17 @@ export interface HealthCheckerOptions { export type HealthTelemetryEvent = | { kind: 'ollama_unreachable'; reason: string } - | { kind: 'ollama_recovered'; downtimeMs: number }; + | { kind: 'ollama_recovered'; downtimeMs: number } + | { kind: 'ollama_recheck_manual' }; export class HealthChecker { constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {} - async runOnce(): Promise; // 기존 메서드 유지 (manual recheck + start 진입점) + /** + * @param opts.manual=true 일 때 결과와 무관하게 onTelemetry({kind:'ollama_recheck_manual'}) 1회 fire. + * IPC `inbox:ollamaRecheck` 가 호출 시 사용 — telemetry 가드를 service 레이어로 끌어 단위 테스트 가능. + */ + async runOnce(opts?: { manual?: boolean }): Promise; start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만) stop(): void; // clearInterval (idempotent) lastStatus(): HealthResult; @@ -112,12 +117,11 @@ export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: Hea } ``` -`inbox:ollamaRecheck` handler: +`inbox:ollamaRecheck` handler — telemetry emit 은 HealthChecker 의 onTelemetry hook 으로 위임 (testability): ```ts ipcMain.handle('inbox:ollamaRecheck', async () => { - await deps.health.runOnce(); // status 변경 시 onUpdate 자동 fire - void deps.telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {}); + await deps.health.runOnce({ manual: true }); // status 변경 시 onUpdate + ollama_recheck_manual onTelemetry fire return deps.health.lastStatus(); }); ``` @@ -320,3 +324,4 @@ T7. closure (gates + roadmap mark + memory backlog) | 일자 | 변경 | |------|------| | 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (`ollama:status`) + manual recheck (banner + tray). | +| 2026-05-01 | §2.1 / §3.2 보강 — `runOnce({ manual?: boolean })` 인자 추가, `ollama_recheck_manual` 도 onTelemetry hook 으로 통합 (IPC handler 가 직접 emit 안 함). 단위 테스트 가능. | From f299926f58a5517faab059dc6aaff13bdd8b4ba7 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:22:06 +0900 Subject: [PATCH 03/13] =?UTF-8?q?docs(plan):=20v0.2.3=20#1=20Ollama=20?= =?UTF-8?q?=ED=9A=8C=EB=B3=B5=20polling=20=EA=B5=AC=ED=98=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 task TDD 분할 + 단위 ≥ 17개 (spec §6 의 12개 충족 + 5 over): - T1 HealthChecker.start/stop + delta + onTelemetry hook - T2 telemetry 3 events + stats.md (downtime 평균 / unreachable 빈도 / recheck 사용량) - T3 main wiring — health.start + before-quit stop + onUpdate→push - T4 IPC inbox:ollamaRecheck + pushOllamaStatus helper - T5 InboxApi + preload + store recheckOllama + onOllamaStatus subscriber - T6 tray 'Ollama 재확인' 메뉴 + 8th callback - T7 OllamaBanner 재확인 button - T8 closure Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-01-v023-ollama-recovery.md | 1092 +++++++++++++++++ 1 file changed, 1092 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md diff --git a/docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md b/docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md new file mode 100644 index 0000000..3b91a3f --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md @@ -0,0 +1,1092 @@ +# #1 Ollama 회복 polling 구현 plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** v0.2.3 네 번째 항목 — `HealthChecker` 가 60s setInterval polling 으로 ollama 상태 자동 갱신. 회복 시 `onUpdate` → main process 가 renderer 에 `ollama:status` push → OllamaBanner 즉시 사라짐. 수동 "재확인" 버튼 (OllamaBanner + tray 메뉴). Telemetry 3 events (`ollama_unreachable` / `ollama_recovered` / `ollama_recheck_manual`). + +**Architecture:** HealthChecker 의 책임 확장 — 기존 `runOnce()` 1회 호출 + `lastStatus()` 만 → `start()` setInterval 60s 자동 polling + delta 전이 시점에 `onUpdate` callback + `onTelemetry` callback hook (telemetry emit 분리, 단위 테스트 가능). main 의 `runOnce` 호출은 `start()` 로 대체. IPC `inbox:ollamaRecheck` 가 `runOnce({manual:true})` 호출 → `ollama_recheck_manual` 도 hook 으로 발화. push 채널 `ollama:status` 가 renderer store 의 `ollamaStatus` 자동 갱신. + +**Tech Stack:** TypeScript / electron-vite / better-sqlite3 12.9 / zod 4.3.6 / vitest 4 / React 19 / zustand 5. 신규 dep 0. + +**선행 spec:** `docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md` +**선행 cut:** v0.2.3 #5 만료 추천 (commit `da7455b`) — telemetry hook 패턴 + push helper 패턴 (`pushNoteUpdated`) 위에서 동작. + +--- + +## File Structure + +| 경로 | 책임 | +|------|------| +| `src/main/services/HealthChecker.ts` (**modify**) | `start()` / `stop()` setInterval 60s + `runOnce({manual?})` 인자 추가 + `onUpdate` / `onTelemetry` callback hook + delta 전이 로직 (ok=true↔ok=false). | +| `src/main/services/telemetryEvents.ts` (**modify**) | zod `discriminatedUnion` 에 `ollama_unreachable` / `ollama_recovered` / `ollama_recheck_manual` 3 새 멤버, payload `.strict()`. | +| `src/main/services/TelemetryService.ts` (**modify**) | `EmitInput` union 에 3 추가 (TS 타입). | +| `src/main/services/telemetryStats.ts` (**modify**) | `DailyRow` 에 3 카운터 + 표 컬럼 + Ollama unreachable 빈도 / 평균 downtime / 수동 recheck 사용량 ratio. | +| `src/main/index.ts` (**modify**) | `health.runOnce()` 호출을 `health.start({ onUpdate, onTelemetry })` 로 교체. `app.on('before-quit')` 에 `health.stop()` 추가. tray 의 8번째 callback (Ollama 재확인) 추가. | +| `src/main/ipc/inboxApi.ts` (**modify**) | `pushOllamaStatus` helper 추가 (`pushNoteUpdated` 자매). `inbox:ollamaRecheck` handler 추가. | +| `src/main/tray.ts` (**modify**) | `createTray` 에 8번째 callback `runOllamaRecheck`. `buildMenu` 에 "Ollama 재확인" 항목 (status 기반 enabled). `refreshTray` 가 status 도 함께 받아 메뉴 갱신. | +| `src/preload/index.ts` (**modify**) | `ollamaRecheck` invoke + `onOllamaStatus` 이벤트 listener bridge. | +| `src/shared/types.ts` (**modify**) | `InboxApi` 에 `ollamaRecheck` + `onOllamaStatus`. | +| `src/renderer/inbox/store.ts` (**modify**) | `recheckOllama` action 추가. | +| `src/renderer/inbox/App.tsx` (**modify**) | `useEffect` 에 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 패턴 mirroring). | +| `src/renderer/inbox/components/OllamaBanner.tsx` (**modify**) | 재확인 버튼 — `recheckOllama` action 호출. | + +테스트: +- `tests/unit/HealthChecker.test.ts` (**new**) — 7 cases: start idempotent, polling fire (fakeTimers), ok→fail 전이, fail→ok 전이, reason 변경 telemetry skip, stop cleanup, manual recheck telemetry. +- `tests/unit/telemetryEvents.test.ts` (**modify**) — 3 신규 zod parse + 1 privacy invariant (`reason: 'leak'` 같은 extra field reject). +- `tests/unit/telemetryStats.test.ts` (**modify**) — 3 카운터 + downtime 평균 산출. +- `tests/unit/store.ollama.test.ts` (**new**) — 2 cases: `recheckOllama` 가 IPC 호출 + status 갱신, `onOllamaStatus` push 받으면 store 갱신. + +--- + +## Task 1: HealthChecker.start/stop + delta + onTelemetry callback + +**Files:** +- Modify: `src/main/services/HealthChecker.ts` +- Create: `tests/unit/HealthChecker.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +Create `tests/unit/HealthChecker.test.ts`: + +```typescript +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(); // 즉시 1회 (await pending Promise) + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + expect(provider['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); + // 1 초 동안 즉시 1 + 1초 후 1 = 최대 2회 — 2번째 start 가 timer 추가 안 했어야 + expect(provider['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['idx']; + hc.stop(); + await vi.advanceTimersByTimeAsync(5000); + expect(provider['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(); // ok=true (initial — no delta from {ok:true} default) + await hc.runOnce(); // ok=false transition + 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(); // ok=false (initial transition true→false), now()=1000 unreachableSince + await hc.runOnce(); // ok=true (transition), now()=2000 → downtimeMs=1000 + 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); // initial + reason change + expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]); + // timeout 전이는 telemetry 안 함 (ratio 노이즈 회피) + }); + + 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' }]); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/HealthChecker.test.ts` +Expected: FAIL — `start` / `stop` / `runOnce({manual})` / `onTelemetry` / `HealthTelemetryEvent` 미정의. + +- [ ] **Step 3: 구현** + +Replace `src/main/services/HealthChecker.ts` with: + +```typescript +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 }; + 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) { + // reason 변경만 — UI 갱신은 하되 telemetry emit 은 skip (ratio 노이즈 회피). + 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); + } + + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + } + + lastStatus(): HealthResult { return this.last; } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/HealthChecker.test.ts` +Expected: typecheck 0. 7 cases PASS. + +- [ ] **Step 5: 전체 단위 회귀 확인** + +Run: `npm test` +Expected: 327+7 = 334 PASS (또는 그 이상). 기존 main wiring 의 `health.runOnce()` 호출은 그대로 동작 (인자 optional). + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/services/HealthChecker.ts tests/unit/HealthChecker.test.ts +git commit -m "feat(ollama): HealthChecker.start/stop + delta + onTelemetry hook (#1 v0.2.3)" +``` + +--- + +## Task 2: Telemetry 3 events + stats.md + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` +- Modify: `src/main/services/TelemetryService.ts` +- Modify: `src/main/services/telemetryStats.ts` +- Modify: `tests/unit/telemetryEvents.test.ts` +- Modify: `tests/unit/telemetryStats.test.ts` + +- [ ] **Step 1: telemetryEvents 실패 테스트** + +Append to `tests/unit/telemetryEvents.test.ts` end of file (existing imports already present): + +```typescript +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(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: FAIL — discriminants 부재. + +- [ ] **Step 3: telemetryEvents.ts 확장** + +Add 3 new payload schemas (after existing ones): + +```typescript +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(); +``` + +Append to `TelemetryEventSchema` discriminatedUnion array: + +```typescript +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() +``` + +- [ ] **Step 4: TelemetryService.EmitInput 확장** + +In `src/main/services/TelemetryService.ts`, append 3 new union members at end of `EmitInput`: + +```typescript + | { kind: 'ollama_unreachable'; payload: { reason: string } } + | { kind: 'ollama_recovered'; payload: { downtimeMs: number } } + | { kind: 'ollama_recheck_manual'; payload: Record }; +``` + +- [ ] **Step 5: telemetryEvents 테스트 — PASS** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: 6 신규 + 기존 PASS. + +- [ ] **Step 6: telemetryStats 실패 테스트** + +Append to `tests/unit/telemetryStats.test.ts`: + +```typescript +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: {} } + ]; + 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 ms + expect(r.md).toMatch(/평균 downtimeMs.*90000/); + // 수동 recheck 사용량 + 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/); + }); +}); +``` + +- [ ] **Step 7: 실행 — FAIL** + +Run: `npm test -- tests/unit/telemetryStats.test.ts` +Expected: FAIL — counters/avg 부재. + +- [ ] **Step 8: telemetryStats.ts 확장** + +(a) Update `DailyRow` to add 3 new fields: + +```typescript +interface DailyRow { + date: string; + capture: number; + ai_succeeded: number; + ai_failed: number; + trash: number; + restore: number; + permanent_delete: number; + empty_trash: number; + expired_banner_shown: number; + expired_batch_trash: number; + ollama_unreachable: number; + ollama_recovered: number; + ollama_recheck_manual: number; +} +``` + +(b) Add accumulators near top of `aggregateStats`: + +```typescript +let ollamaDowntimeSum = 0; +let ollamaRecoveredCount = 0; +let ollamaRecheckManualCount = 0; +``` + +(c) Update new-row creation (any place that creates a fresh DailyRow): + +```typescript +row = { + 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, + ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0 +}; +``` + +(d) Append 3 branches to the if-else chain (after `expired_batch_trash`): + +```typescript +} 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; +} +``` + +(e) Update table headers to add 3 columns: + +```typescript +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('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|'); +``` + +(f) Update body row template: + +```typescript +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} |`); +``` + +(g) Compute downtime avg + add 3 ratio lines (after existing 만료 trash ratio): + +```typescript +const avgDowntime = ollamaRecoveredCount === 0 + ? 'N/A' + : `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`; +// ... +lines.push(`- Ollama unreachable 빈도: ${days.reduce((s, r) => s + r.ollama_unreachable, 0)}건`); +lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`); +lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`); +``` + +- [ ] **Step 9: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/telemetryEvents.test.ts tests/unit/telemetryStats.test.ts` +Expected: typecheck 0. 신규 + 기존 모두 PASS. + +Run also `npm test` 으로 전체 회귀 확인. TelemetryService.test.ts 의 discriminant narrowing 가드가 새 kind 들로 broken 가능성 — 만약 그렇다면 narrow `e.kind !== ...` 체인에 3 새 kind 추가 (m4 패턴 일치). + +- [ ] **Step 10: 커밋** + +```bash +git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts src/main/services/telemetryStats.ts tests/unit/telemetryEvents.test.ts tests/unit/telemetryStats.test.ts tests/unit/TelemetryService.test.ts +git commit -m "feat(ollama): telemetry 3 events — unreachable/recovered/recheck_manual (#1 v0.2.3)" +``` + +--- + +## Task 3: main wiring — health.start + before-quit + onTelemetry → telemetry.emit + +**Files:** +- Modify: `src/main/index.ts` + +이 task 는 wiring 만 — 단위 테스트 없음. typecheck + 기존 단위 회귀 가드. + +- [ ] **Step 1: 변경** + +In `src/main/index.ts`: + +(a) Replace the `health.runOnce()` block (around line 72-73): + +기존: +```typescript +const health = new HealthChecker(provider); +void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record)); +``` + +신규: +```typescript +const health = new HealthChecker(provider, { + onUpdate: (status) => { + logger.info('ai.health', { ...status } as Record); + pushOllamaStatus(getInboxWindow, status); + }, + 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(); +``` + +(b) Add `pushOllamaStatus` import alongside `pushNoteUpdated`: + +```typescript +import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; +``` + +(c) In the existing `app.on('before-quit', ...)` handler, before any other cleanup, add: + +```typescript +health.stop(); +``` + +Place it as the very first statement inside the handler so it always runs. + +(d) `createTray` 8th callback — placeholder for now (Task 6 에서 실제 wiring). 임시 처리: + +```typescript +createTray( + () => createInboxWindow(), + () => showQuickCapture(), + async () => { /* runBackup ... */ }, + async () => { /* runExport ... */ }, + async () => { /* runImport ... */ }, + async () => { /* runSync ... */ }, + async () => { /* runExportTelemetry ... */ }, + async () => { await health.runOnce({ manual: true }); } // 신규: Ollama 재확인 +); +``` + +(`createTray` 시그너처를 8 callbacks 로 확장하는 부분은 Task 6 에서 처리. Task 3 에서는 main 에 그 자리에 인자만 임시로 전달 — 컴파일 통과 위해.) + +근데 createTray 시그너처 변경 없이 8번째 인자만 추가하면 TypeScript 가 reject. 따라서 Task 6 의 tray.ts 변경이 Task 3 보다 먼저 들어가는 게 깔끔. **Task 6 → Task 3 순서로 swap.** + +→ Task 3 의 (d) 는 Task 6 머지 후 처리. 본 Task 3 에서는 (a), (b), (c) 만 진행. createTray 호출은 그대로. + +- [ ] **Step 2: typecheck + 단위** + +Run: `npm run typecheck && npm test` +Expected: typecheck 0. 단위 모두 PASS (333+ 또는 그 이상). + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/index.ts +git commit -m "feat(ollama): main wiring — health.start + before-quit stop (#1 v0.2.3)" +``` + +--- + +## Task 4: IPC `inbox:ollamaRecheck` + `pushOllamaStatus` helper + `ollama:status` push + +**Files:** +- Modify: `src/main/ipc/inboxApi.ts` + +이 task 는 main process IPC 만 — 단위 테스트는 Task 5 의 store level 에서 통합 검증. + +- [ ] **Step 1: pushOllamaStatus helper 추가** + +In `src/main/ipc/inboxApi.ts`, add at the bottom of the file (after `pushNoteUpdated`): + +```typescript +import type { HealthResult } from '../ai/InferenceProvider.js'; + +export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void { + const w = getWin(); + if (!w || w.isDestroyed()) return; + w.webContents.send('ollama:status', status); +} +``` + +(이미 BrowserWindow import 가 있을 것 — `pushNoteUpdated` 의 import 그대로 재사용.) + +- [ ] **Step 2: inbox:ollamaRecheck handler 추가** + +Inside `registerInboxApi`, append after `inbox:trashExpiredBatch`: + +```typescript +ipcMain.handle('inbox:ollamaRecheck', async () => { + await deps.health.runOnce({ manual: true }); + return deps.health.lastStatus(); +}); +``` + +- [ ] **Step 3: typecheck + 회귀** + +Run: `npm run typecheck && npm test` +Expected: typecheck 0. 단위 PASS. + +- [ ] **Step 4: 커밋** + +```bash +git add src/main/ipc/inboxApi.ts +git commit -m "feat(ollama): IPC inbox:ollamaRecheck + pushOllamaStatus helper (#1 v0.2.3)" +``` + +--- + +## Task 5: shared/types InboxApi + preload + store + onOllamaStatus subscriber + +**Files:** +- Modify: `src/shared/types.ts` +- Modify: `src/preload/index.ts` +- Modify: `src/renderer/inbox/store.ts` +- Modify: `src/renderer/inbox/App.tsx` +- Create: `tests/unit/store.ollama.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +Create `tests/unit/store.ollama.test.ts`: + +```typescript +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 () => ({ 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' }); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/store.ollama.test.ts` +Expected: FAIL — `recheckOllama` 미정의 + `ollamaRecheck` / `onOllamaStatus` API 미정의. + +- [ ] **Step 3: shared/types InboxApi 확장** + +In `src/shared/types.ts`, append to InboxApi (after `getTrashCount` group): + +```typescript +ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>; +onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; +``` + +- [ ] **Step 4: preload 확장** + +In `src/preload/index.ts`, append to `inbox` object: + +```typescript +ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'), +onOllamaStatus: (cb) => { + const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status); + ipcRenderer.on('ollama:status', listener); + return () => ipcRenderer.off('ollama:status', listener); +} +``` + +- [ ] **Step 5: store action 추가** + +In `src/renderer/inbox/store.ts`: + +(a) Extend `InboxState` interface: + +```typescript +recheckOllama: () => Promise; +``` + +(b) Add action implementation (after `snoozeExpired`): + +```typescript +async recheckOllama() { + const status = await inboxApi.ollamaRecheck(); + set({ ollamaStatus: status }); +} +``` + +- [ ] **Step 6: App.tsx 의 onOllamaStatus 구독** + +In `src/renderer/inbox/App.tsx`, modify the `useEffect`: + +기존: +```tsx +useEffect(() => { + void loadInitial(); + const unsub = inboxApi.onNoteUpdated((note) => { + upsertNote(note); + void refreshMeta(); + }); + const onFocus = () => { void refreshMeta(); }; + window.addEventListener('focus', onFocus); + return () => { unsub(); window.removeEventListener('focus', onFocus); }; +}, [loadInitial, refreshMeta, upsertNote]); +``` + +신규 (set 함수 가져오기 위해 store.setState 직접 사용 또는 store action 추가; 가장 단순한 패턴 — store 의 setState 노출 없이 useInbox 의 set 으로): + +```tsx +useEffect(() => { + void loadInitial(); + 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 () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; +}, [loadInitial, refreshMeta, upsertNote]); +``` + +(`useInbox` 가 이미 import 되어 있으므로 `useInbox.setState({ ollamaStatus: status })` 직접 호출. zustand 의 store 객체가 setState 노출.) + +- [ ] **Step 7: 테스트 실행 — PASS** + +Run: `npm run typecheck && npm test -- tests/unit/store.ollama.test.ts` +Expected: 2 신규 PASS. + +또한 `npm test` 전체 회귀 — 327 + 7 (Task 1) + 7 (Task 2) + 2 (Task 5) = 343 이상. + +- [ ] **Step 8: 커밋** + +```bash +git add src/shared/types.ts src/preload/index.ts src/renderer/inbox/store.ts src/renderer/inbox/App.tsx tests/unit/store.ollama.test.ts +git commit -m "feat(ollama): InboxApi + preload + store recheckOllama + onOllamaStatus subscriber (#1 v0.2.3)" +``` + +--- + +## Task 6: tray "Ollama 재확인" 메뉴 + main wiring 8th callback + +**Files:** +- Modify: `src/main/tray.ts` +- Modify: `src/main/index.ts` + +이 task 는 visual integration. typecheck + e2e gate. + +- [ ] **Step 1: tray.ts 변경** + +In `src/main/tray.ts`: + +(a) Add an 8th callback at module top: + +```typescript +let _runOllamaRecheck: () => void = () => {}; +``` + +(b) Track ollama status (for menu enabled state): + +```typescript +let _ollamaOk = true; +``` + +(c) Update `buildMenu` to add a menu item — place after `사용 로그 내보내기...`: + +```typescript +items.push({ + label: 'Ollama 재확인', + enabled: !_ollamaOk, + click: _runOllamaRecheck +}); +``` + +(d) Update `createTray` signature and assignment: + +```typescript +export function createTray( + showInbox: () => void, + showCapture: () => void, + runBackup: () => void, + runExport: () => void, + runImport: () => void, + runSync: () => void, + runExportTelemetry: () => void, + runOllamaRecheck: () => void +): TrayType { + _showInbox = showInbox; + _showCapture = showCapture; + _runBackup = runBackup; + _runExport = runExport; + _runImport = runImport; + _runSync = runSync; + _runExportTelemetry = runExportTelemetry; + _runOllamaRecheck = runOllamaRecheck; + // ... 이하 기존 그대로 ... +} +``` + +(e) Add a setter for ollama status (called from main on every push): + +```typescript +export function refreshTrayOllama(ok: boolean): void { + _ollamaOk = ok; + if (tray === null) return; + tray.setContextMenu(buildMenu()); +} +``` + +- [ ] **Step 2: main/index.ts 의 createTray 호출 확장** + +In `src/main/index.ts`, locate the `createTray(...)` call and append the 8th callback: + +```typescript +createTray( + () => createInboxWindow(), + () => showQuickCapture(), + async () => { /* runBackup ... 기존 그대로 */ }, + async () => { /* runExport ... 기존 그대로 */ }, + async () => { /* runImport ... 기존 그대로 */ }, + async () => { /* runSync ... 기존 그대로 */ }, + async () => { /* runExportTelemetry ... 기존 그대로 */ }, + () => { void health.runOnce({ manual: true }); } // 8th: Ollama 재확인 +); +``` + +(b) Update the `health` `onUpdate` to also call `refreshTrayOllama`: + +```typescript +import { createTray, refreshTray, refreshTrayOllama } from './tray.js'; +// ... +const health = new HealthChecker(provider, { + onUpdate: (status) => { + logger.info('ai.health', { ...status } as Record); + pushOllamaStatus(getInboxWindow, status); + refreshTrayOllama(status.ok); + }, + onTelemetry: ... +}); +``` + +- [ ] **Step 3: typecheck + 단위 + e2e** + +Run: `npm run typecheck && npm test && npm run test:e2e` +Expected: 모두 PASS. + +- [ ] **Step 4: 수동 검증 (개발 모드)** + +```bash +npm run dev +``` + +수동: +- ollama 끄고 60s 대기 → OllamaBanner 등장 + tray 메뉴 "Ollama 재확인" 활성. +- ollama 켜고 60s 대기 → OllamaBanner 사라짐 + tray 메뉴 비활성. +- OllamaBanner 의 "재확인" 버튼 클릭 → 즉시 status 갱신. +- tray "Ollama 재확인" 클릭 → 동일. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "feat(ollama): tray 'Ollama 재확인' 메뉴 + 8th callback (#1 v0.2.3)" +``` + +--- + +## Task 7: OllamaBanner 재확인 버튼 + +**Files:** +- Modify: `src/renderer/inbox/components/OllamaBanner.tsx` + +- [ ] **Step 1: OllamaBanner 변경** + +Replace `src/renderer/inbox/components/OllamaBanner.tsx`: + +```tsx +import React from 'react'; +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 + ? '`ollama pull gemma4:e4b` 실행 후 앱을 재시작해주세요.' + : 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.'; + return ( +
+
+ ⚠ {message} + +
+ {status.reason ? ( + + 진단: {status.reason} + + ) : null} +
+ ); +} +``` + +- [ ] **Step 2: typecheck + e2e** + +Run: `npm run typecheck && npm test && npm run test:e2e` +Expected: 모두 PASS. + +- [ ] **Step 3: 커밋** + +```bash +git add src/renderer/inbox/components/OllamaBanner.tsx +git commit -m "feat(ollama): OllamaBanner 재확인 button (#1 v0.2.3)" +``` + +--- + +## Task 8: Closure (gates + roadmap mark + memory backlog) + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` +- Modify: `memory/project_v024_backlog.md` (review 결과 반영) + +- [ ] **Step 1: 전체 게이트 검증** + +```bash +npm run typecheck # 0 errors +npm test # 단위 모두 PASS (327 + 신규 ≥ 12 = 339+) +npm run test:e2e # 1/1 +``` + +- [ ] **Step 2: roadmap §3 #1 ✓ 마커** + +In `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md`, change `### #1 Ollama 회복 (4번)` to `### #1 Ollama 회복 (4번) ✓ 완료`. + +- [ ] **Step 3: 후속 review 결과 메모리 backlog 갱신** + +After PR + review loop, add deferred items to `memory/project_v024_backlog.md` under a new heading. + +- [ ] **Step 4: closure 커밋** + +```bash +git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +git commit -m "chore(ollama): #1 closure — gates verified + roadmap mark complete" +``` + +- [ ] **Step 5: PR 작성 + 머지** + +PR title: `feat(ollama): #1 Ollama 회복 polling (v0.2.3 4/7)` +PR body: spec/plan/roadmap 링크 + 작업 요약 + 게이트 결과 + 단위 N개. + +머지 후: +- 로컬 main fast-forward +- `feat/v023-ollama` 브랜치 정리 (local + remote) +- v0.2.3 진행: 7항목 중 4/7 완료. 다음 #2 AI retry. + +--- + +## Self-Review (작성 후 점검) + +### Spec coverage 매트릭스 + +| spec §8.1 항목 | 본 plan task | +|----------------|------------| +| 60s polling, runOnce setInterval 자동 발화 | T1 (start/stop) | +| 회복 시 onUpdate → renderer OllamaBanner 자동 갱신 | T1 (delta) + T3 (main wiring) + T4 (push) + T5 (subscriber) | +| 실패 N회 후 polling 중단 정책 (Q2=A 절대 안 함) | T1 (구현 안 함 — 의도) | +| 수동 재확인 — OllamaBanner + 트레이 | T6 (tray) + T7 (banner) | +| IPC `inbox:ollamaRecheck` | T4 | +| Telemetry 3 events | T2 | +| 단위 테스트 ≥ 12 | T1(7) + T2(6 events + 2 stats) + T5(2) = 17 ≥ 12 | + +### 일관성 + +- T1 의 `HealthTelemetryEvent` 3 union → T2 의 zod 3 schema → T3 의 main `onTelemetry` if-else 분기 일관. +- T4 의 IPC handler 가 `health.runOnce({ manual: true })` 호출 → T1 의 `runOnce({ manual })` 시그너처 일치. +- T5 의 store `recheckOllama` → IPC `inbox:ollamaRecheck` → T4 handler 일관. +- T6 의 tray 8th callback → T3 의 `health.runOnce({ manual: true })` 호출 → T1 의 시그너처 일치. + +### Out 항목 일관 처리 + +- 사용자 설정 polling 주기 → 본 plan 어디에도 노출 안 함. ✓ (intervalMs option 은 `HealthCheckerOptions` 에만 — 외부 UI 없음). +- 회복 toast → notify 호출 없음. ✓ +- model 정상성 (tags 외) → provider.healthCheck() 만 사용. ✓ + +### Self-review 후 수정 (placeholder/contradiction/ambiguity) + +- T3 의 (d) 가 Task 6 dependency 명시함 (Task 3 에서는 createTray 호출 변경 안 함 — 8th callback 은 Task 6 머지 후). +- T6 의 main 변경이 (T6 안에서) onUpdate 에 `refreshTrayOllama` 추가 + createTray 호출에 8th callback. T3 와 T6 둘 다 main/index.ts 의 `health` 블록을 건드리므로 순서 의존: T3 → T6. From 12681e431c8017d4dd8ed9921c47f6e5d7ff9e56 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:25:26 +0900 Subject: [PATCH 04/13] feat(ollama): HealthChecker.start/stop + delta + onTelemetry hook (#1 v0.2.3) --- src/main/services/HealthChecker.ts | 66 +++++++++++++++- tests/unit/HealthChecker.test.ts | 117 +++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 tests/unit/HealthChecker.test.ts 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; - async runOnce(): Promise { - this.last = await this.provider.healthCheck(); - return this.last; + 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); + } + + stop(): void { + if (this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } } lastStatus(): HealthResult { return this.last; } 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' }]); + }); +}); From a68ffe0aeb28819ef1c3fc00425faad0c830b189 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:30:26 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat(ollama):=20telemetry=203=20events=20?= =?UTF-8?q?=E2=80=94=20unreachable/recovered/recheck=5Fmanual=20(#1=20v0.2?= =?UTF-8?q?.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/TelemetryService.ts | 5 ++- src/main/services/telemetryEvents.ts | 15 +++++++- src/main/services/telemetryStats.ts | 31 +++++++++++++-- tests/unit/TelemetryService.test.ts | 4 +- tests/unit/telemetryEvents.test.ts | 55 +++++++++++++++++++++++++++ tests/unit/telemetryStats.test.ts | 27 +++++++++++++ 6 files changed, 129 insertions(+), 8 deletions(-) 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/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/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/); + }); +}); From e30e436051f8bfddc3ae8080d40fa8ebe14a8fb5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:34:33 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat(ollama):=20main=20wiring=20=E2=80=94?= =?UTF-8?q?=20health.start=20+=20before-quit=20stop=20(#1=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 0abca87..c551b9a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -69,8 +69,22 @@ 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 추가는 Task 4 에서 — helper 부재 시점. + }, + 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 +142,7 @@ app.whenReady().then(async () => { let backupOnQuitDone = false; app.on('before-quit', (e) => { + health.stop(); if (backupOnQuitDone) return; e.preventDefault(); backup.runDaily() From 410a6f494bf5db51d83d66c293ade2825ec0e91a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:37:47 +0900 Subject: [PATCH 07/13] feat(ollama): IPC inbox:ollamaRecheck + pushOllamaStatus helper (#1 v0.2.3) --- src/main/ipc/inboxApi.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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); +} From c78f3af3a6ec17e0cfb110b07200458db16f557a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:41:04 +0900 Subject: [PATCH 08/13] feat(ollama): InboxApi + preload + store recheckOllama + onOllamaStatus subscriber (#1 v0.2.3) Co-Authored-By: Claude Sonnet 4.6 --- src/preload/index.ts | 6 ++++ src/renderer/inbox/App.tsx | 7 +++-- src/renderer/inbox/store.ts | 5 +++ src/shared/types.ts | 2 ++ tests/unit/store.ollama.test.ts | 55 +++++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 tests/unit/store.ollama.test.ts 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/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/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' }); + }); +}); From 557960ff5a237cb75b7f13c1d402deac1e680289 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:44:11 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(ollama):=20tray=20'Ollama=20?= =?UTF-8?q?=EC=9E=AC=ED=99=95=EC=9D=B8'=20=EB=A9=94=EB=89=B4=20+=208th=20c?= =?UTF-8?q?allback=20(#1=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/main/index.ts | 10 ++++++---- src/main/tray.ts | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index c551b9a..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'; @@ -72,7 +72,8 @@ app.whenReady().then(async () => { const health = new HealthChecker(provider, { onUpdate: (status) => { logger.info('ai.health', { ...status } as Record); - // pushOllamaStatus 추가는 Task 4 에서 — helper 부재 시점. + pushOllamaStatus(getInboxWindow, status); + refreshTrayOllama(status.ok); }, onTelemetry: (ev) => { if (ev.kind === 'ollama_unreachable') { @@ -335,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/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()); +} From cdf2e4bc47925f375906851299a617ac4f55fa32 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:46:18 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(ollama):=20OllamaBanner=20=EC=9E=AC?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20button=20(#1=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/renderer/inbox/components/OllamaBanner.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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} From d8f4ae5f6b4d78917be0799fd86df8ed65cd9a04 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:47:54 +0900 Subject: [PATCH 11/13] =?UTF-8?q?chore(ollama):=20#1=20closure=20=E2=80=94?= =?UTF-8?q?=20gates=20verified=20+=20roadmap=20mark=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - typecheck 0 errors - 단위 344/344 (T1~T7 누적 17 신규) - e2e 1/1 - roadmap §3 #1 ✓ 완료 마커 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-05-01-v023-feedback-roadmap-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 확정): From a94c7578b7693e577729c1298df0f50556d59d5a Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 02:04:25 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(ollama):=20review=20round=201=20?= =?UTF-8?q?=E2=80=94=20minor/nit=207=EA=B1=B4=20=EC=9D=BC=EA=B4=84=20(#1?= =?UTF-8?q?=20v0.2.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m1 — HealthChecker.last={ok:true} sentinel 의도 주석 (line 17). 첫 healthy=ok=true 면 transition 으로 인식 안 됨, ok=false 면 unreachable transition 으로 정상 인식. telemetry 누락 0. m2 — runOnce in-flight guard 추가. polling 첫 호출이 늦게 끝나는 동안 setInterval 가 두 번째 호출 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 race 안전하지만, 이중 onUpdate/telemetry emit 회피. m3 — main.ts before-quit 핸들러 통합. trayInterval cleanup 별도 핸들러 (line 349) 제거하고 health.stop() 핸들러 안에 흡수. 모든 cleanup 한 곳. n1 — OllamaBanner 재확인 button 의 onClick 에 .catch 추가. recheckOllama Promise rejection 시 console.warn (silent swallow 회피). n2 — App.tsx useEffect deps array 의도 주석 1줄. onOllamaStatus 콜백이 useInbox.setState 직접 호출 — store reference 안정적이라 deps 불필요. n3 — HealthChecker idempotent test 강화. <=2 → ===2 (정확). 두 timer 등록되면 4 (각 timer 마다 즉시+1s) 가 됨. n4 — runOnce 의 manual emit 이 healthCheck *전에* fire 인 의도 주석. provider 실패 시에도 manual 카운트 1:1 보장. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pr-16-diff.txt | 797 ++++++++++++++++++ .pr-16-review-r1.json | 48 ++ src/main/index.ts | 10 +- src/main/services/HealthChecker.ts | 15 + src/renderer/inbox/App.tsx | 2 + .../inbox/components/OllamaBanner.tsx | 7 +- tests/unit/HealthChecker.test.ts | 3 +- 7 files changed, 878 insertions(+), 4 deletions(-) create mode 100644 .pr-16-diff.txt create mode 100644 .pr-16-review-r1.json 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} -+
- {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') 으로 의도 문서화 권장." - } - ] -}