Files
inkling/docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md
altair823 f299926f58 docs(plan): v0.2.3 #1 Ollama 회복 polling 구현 계획
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) <noreply@anthropic.com>
2026-05-02 01:22:06 +09:00

39 KiB

#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:ollamaRecheckrunOnce({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 discriminatedUnionollama_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) InboxApiollamaRecheck + onOllamaStatus.
src/renderer/inbox/store.ts (modify) recheckOllama action 추가.
src/renderer/inbox/App.tsx (modify) useEffectinboxApi.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:

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:

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: 커밋
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):

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):

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:

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:

  | { 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:

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:

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:

let ollamaDowntimeSum = 0;
let ollamaRecoveredCount = 0;
let ollamaRecheckManualCount = 0;

(c) Update new-row creation (any place that creates a fresh DailyRow):

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):

} 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:

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:

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):

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: 커밋
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):

기존:

const health = new HealthChecker(provider);
void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record<string, unknown>));

신규:

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:

import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';

(c) In the existing app.on('before-quit', ...) handler, before any other cleanup, add:

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). 임시 처리:

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: 커밋
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):

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:

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: 커밋
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:

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):

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:

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:

recheckOllama: () => Promise<void>;

(b) Add action implementation (after snoozeExpired):

async recheckOllama() {
  const status = await inboxApi.ollamaRecheck();
  set({ ollamaStatus: status });
}
  • Step 6: App.tsx 의 onOllamaStatus 구독

In src/renderer/inbox/App.tsx, modify the useEffect:

기존:

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 으로):

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: 커밋
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:

let _runOllamaRecheck: () => void = () => {};

(b) Track ollama status (for menu enabled state):

let _ollamaOk = true;

(c) Update buildMenu to add a menu item — place after 사용 로그 내보내기...:

items.push({
  label: 'Ollama 재확인',
  enabled: !_ollamaOk,
  click: _runOllamaRecheck
});

(d) Update createTray signature and assignment:

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):

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:

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:

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: 수동 검증 (개발 모드)
npm run dev

수동:

  • ollama 끄고 60s 대기 → OllamaBanner 등장 + tray 메뉴 "Ollama 재확인" 활성.

  • ollama 켜고 60s 대기 → OllamaBanner 사라짐 + tray 메뉴 비활성.

  • OllamaBanner 의 "재확인" 버튼 클릭 → 즉시 status 갱신.

  • tray "Ollama 재확인" 클릭 → 동일.

  • Step 5: 커밋

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:

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: 커밋
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: 전체 게이트 검증

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 커밋
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.