From f299926f58a5517faab059dc6aaff13bdd8b4ba7 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:22:06 +0900 Subject: [PATCH] =?UTF-8?q?docs(plan):=20v0.2.3=20#1=20Ollama=20=ED=9A=8C?= =?UTF-8?q?=EB=B3=B5=20polling=20=EA=B5=AC=ED=98=84=20=EA=B3=84=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.