# #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<HealthResult> {
    const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true };
    this.idx += 1;
    return r;
  }
  async generate(_input: GenerateInput): Promise<AiResponse> {
    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<HealthResult> {
    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<string, never> };
```

- [ ] **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<string, unknown>));
```

신규:
```typescript
const health = new HealthChecker(provider, {
  onUpdate: (status) => {
    logger.info('ai.health', { ...status } as Record<string, unknown>);
    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<void>;
```

(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<string, unknown>);
    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 (
    <div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
        <span style={{ flex: 1 }}>⚠ {message}</span>
        <button
          onClick={() => { void recheckOllama(); }}
          style={{
            background: 'transparent', color: '#946100',
            border: '1px solid #d99500', borderRadius: 4,
            padding: '2px 8px', fontSize: 12, cursor: 'pointer'
          }}
        >
          재확인
        </button>
      </div>
      {status.reason ? (
        <span style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
          진단: {status.reason}
        </span>
      ) : null}
    </div>
  );
}
```

- [ ] **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.
