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>
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: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 갱신,onOllamaStatuspush 받으면 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 의
HealthTelemetryEvent3 union → T2 의 zod 3 schema → T3 의 mainonTelemetryif-else 분기 일관. - T4 의 IPC handler 가
health.runOnce({ manual: true })호출 → T1 의runOnce({ manual })시그너처 일치. - T5 의 store
recheckOllama→ IPCinbox: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.