From f36b9ecb5b877159b6c7db839712a579186b26cd Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 01:16:14 +0900 Subject: [PATCH] =?UTF-8?q?docs(spec):=20v0.2.3=20#1=20Ollama=20=ED=9A=8C?= =?UTF-8?q?=EB=B3=B5=20polling=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mini-brainstorm 결과 3개 결정: - Q1=A polling 주기 60s - Q2=A 절대 중단 안 함 - Q3=A constant (no backoff) HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events (ollama_unreachable / ollama_recovered / ollama_recheck_manual) + main → renderer push (ollama:status) + manual recheck (banner + tray). T1-T7 작업 순서 + 단위 ≥ 12개. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-01-v023-ollama-recovery-design.md | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md diff --git a/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md b/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md new file mode 100644 index 0000000..5859436 --- /dev/null +++ b/docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md @@ -0,0 +1,322 @@ +# v0.2.3 #1 Ollama 회복 polling 설계 + +**작성일:** 2026-05-01 +**저자:** 김태현 (dlsrks0734@gmail.com) +**문서 성격:** v0.2.3 cut 7항목 중 4번째 항목 (#1 Ollama 회복) 의 mini-brainstorm 결정 + design. roadmap §3 #1 의 In/Out 위에서 §8 미결정 3항목 (polling 주기 / 실패 N회 중단 / backoff) 결정 + 추가 동작 사양 명시. + +**선행 문서:** +- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #1, §8 +- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15) + +--- + +## 1. 결정 요약 + +| Q | 결정 | 근거 | +|---|------|------| +| Q1 polling 주기 | **A 60s** | 회복 latency ≤ 1분 충분. `/api/tags` 호출 가벼워 부하 미미. dogfood 1인 사용 패턴 (분당 ~1 capture) 과 같은 톤. | +| Q2 실패 N회 후 중단 | **A 절대 중단 안 함** | 부하 무시 가능. 중단 시 사용자 마찰 (재확인 버튼 또는 재시작) 만 남김. | +| Q3 exponential backoff | **A constant 60s** | Q1 결론 + Q2 결론 연결 — backoff 효과 없고 회복 latency 만 늘어남. | + +--- + +## 2. HealthChecker 확장 + +### 2.1 시그너처 + +```ts +// src/main/services/HealthChecker.ts +export interface HealthCheckerOptions { + intervalMs?: number; // default 60_000 + onUpdate?: (status: HealthResult) => void; // delta only — status 가 변할 때만 fire + onTelemetry?: (event: HealthTelemetryEvent) => void; // emit hook (testability) + now?: () => number; // testability +} + +export type HealthTelemetryEvent = + | { kind: 'ollama_unreachable'; reason: string } + | { kind: 'ollama_recovered'; downtimeMs: number }; + +export class HealthChecker { + constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {} + + async runOnce(): Promise; // 기존 메서드 유지 (manual recheck + start 진입점) + start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만) + stop(): void; // clearInterval (idempotent) + lastStatus(): HealthResult; +} +``` + +### 2.2 상태 전이 로직 + +`runOnce()` 안에서 `result = await provider.healthCheck()` 후: + +| 전이 | 동작 | +|------|------| +| ok=true → ok=true (변화 없음) | no-op | +| ok=true → ok=false | `unreachableSince = now()`. `onUpdate(result)` 호출. `onTelemetry({kind:'ollama_unreachable', reason})` | +| ok=false → ok=true | `downtimeMs = now() - unreachableSince`. `onUpdate(result)`. `onTelemetry({kind:'ollama_recovered', downtimeMs})`. `unreachableSince = null` | +| ok=false → ok=false (reason 동일) | no-op | +| ok=false → ok=false (reason 다름) | `onUpdate(result)` (UI 갱신). telemetry emit **안 함** (ratio 노이즈 회피) | + +### 2.3 start/stop + +- `start()`: `runOnce()` 즉시 1회 + `setInterval(runOnce, intervalMs)` 등록. timer 이미 있으면 no-op. +- `stop()`: `clearInterval(timer)`. timer null 로 set. +- App quit hook (`app.on('before-quit')`) 에서 `health.stop()` — leak 방지. + +--- + +## 3. main wiring + IPC + +### 3.1 main/index.ts 변경 + +기존: +```ts +const health = new HealthChecker(provider); +void health.runOnce().then((h) => logger.info('ai.health', { ...h })); +``` + +신규: +```ts +const health = new HealthChecker(provider, { + onUpdate: (status) => pushOllamaStatus(getInboxWindow, status), + onTelemetry: (ev) => { + if (ev.kind === 'ollama_unreachable') void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {}); + else if (ev.kind === 'ollama_recovered') void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {}); + } +}); +health.start(); + +app.on('before-quit', () => health.stop()); +``` + +### 3.2 IPC 채널 + +| 채널 | 방향 | 용도 | +|------|------|------| +| `inbox:ollamaStatus` | renderer → main | 기존 — `health.lastStatus()` 반환. startup / refreshMeta 시 fetch. | +| `inbox:ollamaRecheck` | renderer → main → renderer | 신규 — main 이 `health.runOnce()` 호출, 결과 status push, telemetry `ollama_recheck_manual` emit. | +| `ollama:status` (push) | main → renderer | 신규 — onUpdate fire 시 main 이 webContents.send. (note:updated 패턴 mirroring) | + +`inbox:ollamaStatus` 는 변경 없음 (기존 IPC 호환). + +`pushOllamaStatus(getInboxWindow, status)` helper 추가 (`pushNoteUpdated` 의 자매): + +```ts +// src/main/ipc/inboxApi.ts +export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void { + const w = getWin(); + if (!w || w.isDestroyed()) return; + w.webContents.send('ollama:status', status); +} +``` + +`inbox:ollamaRecheck` handler: + +```ts +ipcMain.handle('inbox:ollamaRecheck', async () => { + await deps.health.runOnce(); // status 변경 시 onUpdate 자동 fire + void deps.telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {}); + return deps.health.lastStatus(); +}); +``` + +--- + +## 4. store + UI + +### 4.1 store.ts 확장 + +`InboxState` 에 신규 action + push subscriber: + +```ts +recheckOllama: () => Promise; +``` + +`loadInitial` 의 `useEffect` 에서 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 와 동일 패턴): + +```ts +// App.tsx useEffect +const unsubOllama = inboxApi.onOllamaStatus((status) => { + set({ ollamaStatus: status }); +}); +return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); }; +``` + +`recheckOllama` action: +```ts +async recheckOllama() { + const status = await inboxApi.ollamaRecheck(); + set({ ollamaStatus: status }); +} +``` + +### 4.2 InboxApi + preload 확장 + +```ts +// shared/types.ts InboxApi +ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>; +onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; + +// preload/index.ts +ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'), +onOllamaStatus: (cb) => { + const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status); + ipcRenderer.on('ollama:status', listener); + return () => ipcRenderer.off('ollama:status', listener); +} +``` + +### 4.3 OllamaBanner 변경 + +`status.ok === false` 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래): + +```tsx + +``` + +기존 banner 스타일 유지 (warn variant). + +### 4.4 Tray 메뉴 + +기존 `createTray` 의 컨텍스트 메뉴에 항목 추가: + +```ts +{ + label: 'Ollama 재확인', + enabled: !health.lastStatus().ok, // dynamic — 정상이면 disabled + click: () => void deps.recheckOllama() +} +``` + +`createTray` 가 7 positional callbacks 받는 현 구조에 1 callback 추가 — v0.2.4 backlog #4 (TrayCallbacks object refactor) 와 정합. 본 cut 에서는 8번째 callback 추가 + backlog #4 의 trigger 만 강화. + +--- + +## 5. Telemetry + +### 5.1 신규 3 events + +| event | payload | 발화 | +|-------|---------|------| +| `ollama_unreachable` | `{ reason: string }` (max 500) | ok=true → ok=false 전이 (HealthChecker.onTelemetry) | +| `ollama_recovered` | `{ downtimeMs: number }` (≥0) | ok=false → ok=true 전이 | +| `ollama_recheck_manual` | `{}` (empty) | `inbox:ollamaRecheck` IPC handler | + +### 5.2 zod schemas + +```ts +// telemetryEvents.ts +const OllamaUnreachablePayload = z.object({ + reason: z.string().min(1).max(500) +}).strict(); + +const OllamaRecoveredPayload = z.object({ + downtimeMs: z.number().nonnegative() +}).strict(); + +const EmptyPayload = z.object({}).strict(); +``` + +`reason` 의 source 는 `LocalOllamaProvider.healthCheck()` 가 반환하는 generic message — `'connection refused'`, `'not installed'`, `'timeout'`, `'http 500'` 등 generic. 본문/PII 누출 0건. max 500 cap 으로 anomaly fence. + +### 5.3 stats.md 집계 + +신규 행 (휴지통 회수율 다음): + +``` +- Ollama unreachable 빈도: {count}건 +- 평균 downtimeMs (recovered): {avg} +- 수동 recheck 사용량: {count}건 +``` + +`DailyRow` 에 3 새 카운터 추가. + +--- + +## 6. 테스트 + +| 영역 | 단위 | 검증 | +|------|------|------| +| HealthChecker | `start()` idempotent | 2회 호출 → timer 1개. | +| HealthChecker | `start()` 즉시 1회 + 60s 마다 | `vi.useFakeTimers()` advance, runOnce 호출 횟수. | +| HealthChecker | `stop()` cleanup | clearInterval. timer null. | +| HealthChecker | ok=true → ok=false 전이 | onUpdate fire, onTelemetry `ollama_unreachable {reason}` 1회. | +| HealthChecker | ok=false → ok=true 전이 | onUpdate fire, onTelemetry `ollama_recovered {downtimeMs}` 1회. downtimeMs ≈ now-unreachableSince. | +| HealthChecker | reason 변경 (ok=false 유지) | onUpdate fire, onTelemetry 0건. | +| HealthChecker | ok=true → ok=true 변화 없음 | onUpdate 0건. | +| TelemetryEvents | zod 3 신규 parse | happy + extra field reject (privacy invariant 회귀). | +| TelemetryStats | 3 카운터 + downtime 평균 | aggregateStats 검증. | +| IPC handler | `inbox:ollamaRecheck` | runOnce + telemetry.emit recheck_manual + status 반환. | +| Store | `recheckOllama` action | inboxApi.ollamaRecheck → set ollamaStatus. | +| Store | onOllamaStatus subscriber | push 받으면 set ollamaStatus. | + +총 ≥ 12 단위. e2e 영향 없음. + +--- + +## 7. 작업 순서 (writing-plans 시 task 분할 가이드) + +T1. HealthChecker.start/stop + delta 전이 로직 + 단위 7개 (TDD) +T2. Telemetry 3 events (zod + EmitInput + stats.md 집계 + 단위 4개) +T3. main/index.ts wiring (`onUpdate` + `onTelemetry` + `start()` + `before-quit stop`) + 테스트는 T5 의 IPC 통해 +T4. IPC `inbox:ollamaRecheck` + `pushOllamaStatus` helper + `ollama:status` push + 단위 1개 +T5. shared/types InboxApi + preload + renderer onOllamaStatus subscriber + recheckOllama action + 단위 2개 +T6. OllamaBanner 재확인 버튼 + tray 메뉴 항목 (visual integration) +T7. closure (gates + roadmap mark + memory backlog) + +--- + +## 8. roadmap In/Out 일치 + +### 8.1 roadmap §3 #1 In 매핑 + +| roadmap | design | +|---------|--------| +| 60s polling, runOnce setInterval 자동 발화 | §2 ✓ | +| 회복 시 onUpdate → 구독 (renderer OllamaBanner) 자동 갱신 | §3.2 (push) + §4.1 (subscriber) ✓ | +| 실패 N회 후 polling 중단 정책 | Q2=A 절대 중단 안 함 | +| 수동 재확인 버튼 — OllamaBanner + 트레이 | §4.3 + §4.4 ✓ | +| IPC `inbox:ollamaRecheck` | §3.2 ✓ | +| Telemetry `ollama_unreachable {reason}`, `ollama_recovered {downtimeMs}`, `ollama_recheck_manual {}` | §5 ✓ | +| 단위 테스트 | §6 ≥ 12 | + +### 8.2 Out 유지 + +- 사용자 설정 가능 polling 주기 — Out (Q1=A 60s 고정). +- 회복 toast 알림 — Out (banner 자동 사라짐만). +- model 정상성 (tags 외) 체크 — Out (provider 의 healthCheck 만 사용). + +--- + +## 9. 위험 / 완화 + +| 위험 | 완화 | +|------|------| +| polling 이 app quit 시 leak | `app.on('before-quit')` 에서 `health.stop()`. 단위 테스트로 stop() 동작 가드. | +| onUpdate 가 status 매번 fire 되어 IPC 폭주 | delta only — last 와 비교 후만 fire. 단위 테스트로 ok=ok no-op 가드. | +| reason 문자열에 본문/PII 누출 | LocalOllamaProvider 가 generic message 만 반환. zod max length 500 cap. privacy invariant 단위 테스트. | +| recheck 가 polling 과 동시 발화 race | `runOnce()` async, sequential. provider.healthCheck() 가 자체적으로 동시 호출 안전 (HTTP GET). | +| reason 변경만으로 telemetry 폭주 (예: timeout ↔ refused 반복) | reason 변경 시 onUpdate fire 하지만 telemetry emit 안 함 — ratio 노이즈 회피. | + +--- + +## 10. 게이트 (PR 머지 조건, roadmap §3.1 일치) + +- `npm run typecheck` 0 에러 +- `npm test` — 327 + 12+ = 339+ +- `npm run test:e2e` 1/1 +- main 머지 + +머지 후: +- roadmap `### #1 Ollama 회복 (4번)` → `✓ 완료` +- `memory/project_v024_backlog.md` review deferred 항목 누적 + +--- + +## 11. 변경 이력 + +| 일자 | 변경 | +|------|------| +| 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (`ollama:status`) + manual recheck (banner + tray). |