feat(ollama): #1 Ollama 회복 polling (v0.2.3 4/7) #16
1092
docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md
Normal file
@@ -124,7 +124,7 @@ v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]─────
|
||||
|
||||
**Out:** 시스템 알림 surface, 별 페이지, snooze 영속화, "안 옮김" 가중치 감소, 만료 임박 (D-7) 추천
|
||||
|
||||
### #1 Ollama 회복 (4번)
|
||||
### #1 Ollama 회복 (4번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- HealthChecker 주기 polling (기본 60s — mini-brainstorm 에서 주기/backoff 확정):
|
||||
|
||||
327
docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# 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 }
|
||||
| { kind: 'ollama_recheck_manual' };
|
||||
|
||||
export class HealthChecker {
|
||||
constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {}
|
||||
|
||||
/**
|
||||
* @param opts.manual=true 일 때 결과와 무관하게 onTelemetry({kind:'ollama_recheck_manual'}) 1회 fire.
|
||||
* IPC `inbox:ollamaRecheck` 가 호출 시 사용 — telemetry 가드를 service 레이어로 끌어 단위 테스트 가능.
|
||||
*/
|
||||
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult>;
|
||||
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 — telemetry emit 은 HealthChecker 의 onTelemetry hook 으로 위임 (testability):
|
||||
|
||||
```ts
|
||||
ipcMain.handle('inbox:ollamaRecheck', async () => {
|
||||
await deps.health.runOnce({ manual: true }); // status 변경 시 onUpdate + ollama_recheck_manual onTelemetry fire
|
||||
return deps.health.lastStatus();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. store + UI
|
||||
|
||||
### 4.1 store.ts 확장
|
||||
|
||||
`InboxState` 에 신규 action + push subscriber:
|
||||
|
||||
```ts
|
||||
recheckOllama: () => Promise<void>;
|
||||
```
|
||||
|
||||
`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
|
||||
<button onClick={() => void recheckOllama()}>재확인</button>
|
||||
```
|
||||
|
||||
기존 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). |
|
||||
| 2026-05-01 | §2.1 / §3.2 보강 — `runOnce({ manual?: boolean })` 인자 추가, `ollama_recheck_manual` 도 onTelemetry hook 으로 통합 (IPC handler 가 직접 emit 안 함). 단위 테스트 가능. |
|
||||
@@ -17,12 +17,12 @@ import { HealthChecker } from './services/HealthChecker.js';
|
||||
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
|
||||
import { AiWorker } from './ai/AiWorker.js';
|
||||
import { registerCaptureApi } from './ipc/captureApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
||||
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
||||
import {
|
||||
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
||||
} from './windows/quickCaptureWindow.js';
|
||||
import { createTray, refreshTray } from './tray.js';
|
||||
import { createTray, refreshTray, refreshTrayOllama } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
@@ -69,8 +69,23 @@ app.whenReady().then(async () => {
|
||||
fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
|
||||
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);
|
||||
refreshTrayOllama(status.ok);
|
||||
},
|
||||
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();
|
||||
|
||||
const worker = new AiWorker(repo, provider, {
|
||||
onUpdate: (note) => {
|
||||
@@ -127,7 +142,14 @@ app.whenReady().then(async () => {
|
||||
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
|
|
||||
app.on('before-quit', (e) => {
|
||||
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
||||
health.stop();
|
||||
if (trayInterval !== null) {
|
||||
clearInterval(trayInterval);
|
||||
trayInterval = null;
|
||||
}
|
||||
if (backupOnQuitDone) return;
|
||||
e.preventDefault();
|
||||
backup.runDaily()
|
||||
@@ -320,16 +342,17 @@ app.whenReady().then(async () => {
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
},
|
||||
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); }
|
||||
);
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
||||
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
|
||||
refreshTray(repo.countToday());
|
||||
const trayInterval = setInterval(() => {
|
||||
trayInterval = setInterval(() => {
|
||||
refreshTray(repo.countToday());
|
||||
}, 60_000);
|
||||
app.on('before-quit', () => { clearInterval(trayInterval); });
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { CaptureService } from '../services/CaptureService.js';
|
||||
import type { HealthChecker } from '../services/HealthChecker.js';
|
||||
import type { IntentService } from '../services/IntentService.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
|
||||
export interface InboxIpcDeps {
|
||||
repo: NoteRepository;
|
||||
@@ -127,6 +128,11 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
return { trashedCount: result.trashedCount, confirmed: true };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:ollamaRecheck', async () => {
|
||||
await deps.health.runOnce({ manual: true });
|
||||
return deps.health.lastStatus();
|
||||
});
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
@@ -134,3 +140,9 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
|
||||
if (!w || w.isDestroyed()) return;
|
||||
w.webContents.send('note:updated', note);
|
||||
}
|
||||
|
||||
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
|
||||
const w = getWin();
|
||||
if (!w || w.isDestroyed()) return;
|
||||
w.webContents.send('ollama:status', status);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,85 @@
|
||||
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
|
||||
|
||||
export class HealthChecker {
|
||||
private last: HealthResult = { ok: true };
|
||||
constructor(private provider: InferenceProvider) {}
|
||||
export type HealthTelemetryEvent =
|
||||
| { kind: 'ollama_unreachable'; reason: string }
|
||||
| { kind: 'ollama_recovered'; downtimeMs: number }
|
||||
| { kind: 'ollama_recheck_manual' };
|
||||
|
||||
async runOnce(): Promise<HealthResult> {
|
||||
this.last = await this.provider.healthCheck();
|
||||
return this.last;
|
||||
export interface HealthCheckerOptions {
|
||||
intervalMs?: number;
|
||||
onUpdate?: (status: HealthResult) => void;
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
|
||||
export class HealthChecker {
|
||||
// sentinel: 첫 healthCheck 가 ok=true 면 transition 으로 인식 안 됨 (no-op),
|
||||
|
claude-reviewer-01
commented
[minor] 초기 last = { ok: true } 가 첫 runOnce 에서 healthCheck 가 { ok: true, model: 'gemma4:e4b' } 반환 시 'no transition' 으로 분류되어 onUpdate 가 한 번도 fire 안 함 (okChanged=false, reasonChanged=undefined===undefined). renderer 는 startup 에서 inboxApi.getOllamaStatus() 로 fetch 해서 fine 하지만, push 채널만 의존하는 미래의 consumer 가 model 정보를 놓침. 의도라면 명시 주석 1줄 추가 권장 ('initial last is sentinel — first successful runOnce intentionally suppresses onUpdate when status remains ok'). v0.2.4 deferred OK. [minor] 초기 last = { ok: true } 가 첫 runOnce 에서 healthCheck 가 { ok: true, model: 'gemma4:e4b' } 반환 시 'no transition' 으로 분류되어 onUpdate 가 한 번도 fire 안 함 (okChanged=false, reasonChanged=undefined===undefined). renderer 는 startup 에서 inboxApi.getOllamaStatus() 로 fetch 해서 fine 하지만, push 채널만 의존하는 미래의 consumer 가 model 정보를 놓침. 의도라면 명시 주석 1줄 추가 권장 ('initial last is sentinel — first successful runOnce intentionally suppresses onUpdate when status remains ok'). v0.2.4 deferred OK.
|
||||
// ok=false 면 unreachable transition 으로 정상 인식. 즉 첫 호출이 healthy 면 telemetry 0.
|
||||
private last: HealthResult = { ok: true };
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private unreachableSince: number | null = null;
|
||||
// m2 fix: in-flight guard — 첫 runOnce 가 늦게 끝나는 동안 setInterval 이 두 번째
|
||||
// runOnce 를 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 안전 측면에선
|
||||
// 큰 문제 없지만, telemetry 이중 emit (false→true→false 동시 처리) 회피.
|
||||
private inFlight: Promise<HealthResult> | null = null;
|
||||
private intervalMs: number;
|
||||
private now: () => number;
|
||||
|
||||
constructor(
|
||||
private provider: InferenceProvider,
|
||||
private opts: HealthCheckerOptions = {}
|
||||
) {
|
||||
|
claude-reviewer-01
commented
[nit] manual flag 의 ollama_recheck_manual emit 이 healthCheck 호출 전에 fire — 의도적이지만 (provider 실패해도 manual 카운트 보장), 만약 healthCheck 가 throw 하면 (현재 LocalOllamaProvider impl 는 안 함) recheck_manual 은 emit 됐지만 onUpdate 는 fire 안 한 inconsistent state. 주석 1줄 ('emit BEFORE healthCheck — manual count must survive provider exception') 으로 의도 문서화 권장. [nit] manual flag 의 ollama_recheck_manual emit 이 healthCheck 호출 *전*에 fire — 의도적이지만 (provider 실패해도 manual 카운트 보장), 만약 healthCheck 가 throw 하면 (현재 LocalOllamaProvider impl 는 안 함) recheck_manual 은 emit 됐지만 onUpdate 는 fire 안 한 inconsistent state. 주석 1줄 ('emit BEFORE healthCheck — manual count must survive provider exception') 으로 의도 문서화 권장.
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
this.now = opts.now ?? Date.now;
|
||||
}
|
||||
|
||||
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
|
||||
// n4 의도: ollama_recheck_manual 은 healthCheck 호출 *전에* fire — provider 가 throw 하거나
|
||||
// 늦게 응답해도 manual 카운트는 누락 없음. user click → telemetry 1:1 보장.
|
||||
if (opts?.manual === true) {
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' });
|
||||
}
|
||||
if (this.inFlight !== null) return this.inFlight;
|
||||
this.inFlight = this.doRunOnce();
|
||||
try { return await this.inFlight; }
|
||||
finally { this.inFlight = null; }
|
||||
}
|
||||
|
||||
private async doRunOnce(): Promise<HealthResult> {
|
||||
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 {
|
||||
|
claude-reviewer-01
commented
[minor] start() 가 runOnce() 를 fire-and-forget (void) 로 즉시 호출. 첫 healthCheck 가 늦게 끝나는 동안 intervalMs 후 두 번째 runOnce 가 시작되면 둘 다 동시에 provider.healthCheck() 호출 → this.last 가 race 로 잘못된 순서로 저장 가능 (HTTP latency 가 intervalMs=60s 근접 시). 실사용에서는 /api/tags 가 ms 단위라 거의 발생 안 하지만, 테스트에서 fakeTimers 와 결합 시 발생 가능. 단위 테스트는 sequential await 사용하므로 가드 안 됨. v0.2.4 에서 'inFlight' guard 또는 last write wins 명시 주석 1줄 권장. [minor] start() 가 runOnce() 를 fire-and-forget (void) 로 즉시 호출. 첫 healthCheck 가 늦게 끝나는 동안 intervalMs 후 두 번째 runOnce 가 시작되면 둘 다 동시에 provider.healthCheck() 호출 → this.last 가 race 로 잘못된 순서로 저장 가능 (HTTP latency 가 intervalMs=60s 근접 시). 실사용에서는 /api/tags 가 ms 단위라 거의 발생 안 하지만, 테스트에서 fakeTimers 와 결합 시 발생 가능. 단위 테스트는 sequential await 사용하므로 가드 안 됨. v0.2.4 에서 'inFlight' guard 또는 last write wins 명시 주석 1줄 권장.
|
||||
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) {
|
||||
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; }
|
||||
|
||||
@@ -24,7 +24,10 @@ export type EmitInput =
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
| { kind: 'empty_trash'; payload: { count: number } }
|
||||
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
|
||||
| { kind: 'expired_batch_trash'; payload: { count: number } };
|
||||
| { kind: 'expired_batch_trash'; payload: { count: number } }
|
||||
| { kind: 'ollama_unreachable'; payload: { reason: string } }
|
||||
| { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
|
||||
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> };
|
||||
|
||||
export class TelemetryService {
|
||||
constructor(
|
||||
|
||||
@@ -36,6 +36,16 @@ const ExpiredBatchTrashPayload = z.object({
|
||||
count: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
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();
|
||||
|
||||
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
|
||||
@@ -45,7 +55,10 @@ export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict()
|
||||
z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(),
|
||||
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()
|
||||
]);
|
||||
|
||||
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
|
||||
|
||||
@@ -20,6 +20,9 @@ interface DailyRow {
|
||||
empty_trash: number;
|
||||
expired_banner_shown: number;
|
||||
expired_batch_trash: number;
|
||||
ollama_unreachable: number;
|
||||
ollama_recovered: number;
|
||||
ollama_recheck_manual: number;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
@@ -38,6 +41,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
let restoreCount = 0;
|
||||
let expiredBannerShownCandidatesSum = 0;
|
||||
let expiredBatchTrashCountSum = 0;
|
||||
let ollamaDowntimeSum = 0;
|
||||
let ollamaRecoveredCount = 0;
|
||||
let ollamaRecheckManualCount = 0;
|
||||
for (const ev of events) {
|
||||
const day = kstDate(ev.ts);
|
||||
let row = byDay.get(day);
|
||||
@@ -46,7 +52,8 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
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
|
||||
expired_banner_shown: 0, expired_batch_trash: 0,
|
||||
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0
|
||||
};
|
||||
byDay.set(day, row);
|
||||
}
|
||||
@@ -75,6 +82,15 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
} else if (ev.kind === 'expired_batch_trash') {
|
||||
row.expired_batch_trash += 1;
|
||||
expiredBatchTrashCountSum += ev.payload.count;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
@@ -87,6 +103,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
const expiredTrashRatio = expiredBannerShownCandidatesSum === 0
|
||||
? 'N/A'
|
||||
: `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`;
|
||||
const avgDowntime = ollamaRecoveredCount === 0
|
||||
? 'N/A'
|
||||
: `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`;
|
||||
const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0);
|
||||
const lines: string[] = [];
|
||||
lines.push('# Inkling Telemetry Stats');
|
||||
lines.push('');
|
||||
@@ -95,10 +115,10 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push('');
|
||||
lines.push('## 일자별 카운트');
|
||||
lines.push('');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|');
|
||||
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('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|');
|
||||
for (const row of days) {
|
||||
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} |`);
|
||||
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} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 핵심 ratio');
|
||||
@@ -107,6 +127,9 @@ export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): Sta
|
||||
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
|
||||
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
|
||||
lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`);
|
||||
lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}건`);
|
||||
lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`);
|
||||
lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`);
|
||||
lines.push('');
|
||||
return { md: lines.join('\n'), eventCount };
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ let _runExport: () => void = () => {};
|
||||
let _runImport: () => void = () => {};
|
||||
let _runSync: () => void = () => {};
|
||||
let _runExportTelemetry: () => void = () => {};
|
||||
let _runOllamaRecheck: () => void = () => {};
|
||||
let _ollamaOk = true;
|
||||
let _todayCount = 0;
|
||||
|
||||
function buildMenu() {
|
||||
@@ -27,6 +29,11 @@ function buildMenu() {
|
||||
items.push({ label: '백업에서 복원...', click: _runImport });
|
||||
items.push({ label: '지금 동기화', click: _runSync });
|
||||
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
|
||||
items.push({
|
||||
label: 'Ollama 재확인',
|
||||
enabled: !_ollamaOk,
|
||||
click: _runOllamaRecheck
|
||||
});
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
@@ -55,7 +62,8 @@ export function createTray(
|
||||
runExport: () => void,
|
||||
runImport: () => void,
|
||||
runSync: () => void,
|
||||
runExportTelemetry: () => void
|
||||
runExportTelemetry: () => void,
|
||||
runOllamaRecheck: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
@@ -64,6 +72,7 @@ export function createTray(
|
||||
_runImport = runImport;
|
||||
_runSync = runSync;
|
||||
_runExportTelemetry = runExportTelemetry;
|
||||
_runOllamaRecheck = runOllamaRecheck;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
@@ -82,3 +91,13 @@ export function refreshTray(todayCount: number): void {
|
||||
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출.
|
||||
* 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신.
|
||||
*/
|
||||
export function refreshTrayOllama(ok: boolean): void {
|
||||
_ollamaOk = ok;
|
||||
if (tray === null) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
@@ -27,10 +27,16 @@ const api: InklingApi = {
|
||||
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
|
||||
listExpired: () => ipcRenderer.invoke('inbox:listExpired'),
|
||||
trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }),
|
||||
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
|
||||
onNoteUpdated: (cb) => {
|
||||
const listener = (_e: unknown, note: Note) => cb(note);
|
||||
ipcRenderer.on('note:updated', listener);
|
||||
return () => ipcRenderer.off('note:updated', listener);
|
||||
},
|
||||
onOllamaStatus: (cb) => {
|
||||
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
|
||||
ipcRenderer.on('ollama:status', listener);
|
||||
return () => ipcRenderer.off('ollama:status', listener);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,13 +22,18 @@ export function App(): React.ReactElement {
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
const unsub = inboxApi.onNoteUpdated((note) => {
|
||||
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 () => { unsub(); window.removeEventListener('focus', onFocus); };
|
||||
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
|
||||
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
|
||||
|
claude-reviewer-01
commented
[nit] useEffect deps array 가 [loadInitial, refreshMeta, upsertNote] 만 — onOllamaStatus 의 setState 는 useInbox.setState 직접 호출이라 deps 영향 없으나, eslint exhaustive-deps 가 다음 추가 시 false positive 낼 수 있음. zustand 의 setState 는 stable reference 라 add 도 noop. 향후 다른 ev 추가 시 patterns 일관성 유지 권장. [nit] useEffect deps array 가 [loadInitial, refreshMeta, upsertNote] 만 — onOllamaStatus 의 setState 는 useInbox.setState 직접 호출이라 deps 영향 없으나, eslint exhaustive-deps 가 다음 추가 시 false positive 낼 수 있음. zustand 의 setState 는 stable reference 라 add 도 noop. 향후 다른 ev 추가 시 patterns 일관성 유지 권장.
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -10,7 +11,24 @@ export function OllamaBanner(): React.ReactElement | null {
|
||||
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
|
||||
return (
|
||||
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<span>⚠ {message}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<span style={{ flex: 1 }}>⚠ {message}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
|
claude-reviewer-01
commented
[nit] 재확인 button 의 onClick 이 [nit] 재확인 button 의 onClick 이 `void recheckOllama()` 사용 — recheckOllama 가 throw 하면 unhandled rejection. 현재 inboxApi.ollamaRecheck → ipcRenderer.invoke 는 IPC 채널이 없어진 케이스 외에는 reject 없고, IPC handler 자체가 healthCheck 가 throw 안 함을 가정. 안전하지만 try/catch 로 명시적 silent swallow 가 더 방어적. UI noise 없으니 nit 수준.
|
||||
recheckOllama().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('recheckOllama failed', e);
|
||||
});
|
||||
}}
|
||||
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}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface InboxState {
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
recheckOllama: () => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -167,5 +168,9 @@ export const useInbox = create<InboxState>((set, get) => ({
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
},
|
||||
async recheckOllama() {
|
||||
const status = await inboxApi.ollamaRecheck();
|
||||
set({ ollamaStatus: status });
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -79,7 +79,9 @@ export interface InboxApi {
|
||||
getTrashCount(): Promise<number>;
|
||||
listExpired(): Promise<Note[]>;
|
||||
trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>;
|
||||
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
|
||||
onNoteUpdated(cb: (note: Note) => void): () => void;
|
||||
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
118
tests/unit/HealthChecker.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).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();
|
||||
// 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBe(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 as any).idx;
|
||||
hc.stop();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect((provider as any).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();
|
||||
await hc.runOnce();
|
||||
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();
|
||||
|
claude-reviewer-01
commented
[nit] start() idempotent 테스트가 [nit] start() idempotent 테스트가 `idx <= 2` 만 검증. 만약 두 timer 가 동시에 1000ms 마다 fire 하면 idx 가 3 (즉시 1 + 1초 후 2개) 이 되겠지만, vi.advanceTimersByTimeAsync(1000) 가 두 timer 의 첫 fire 모두 실행하므로 idx 검증이 약함. private timer reference 직접 검증 (e.g., via reflection) 또는 5000ms advance 후 idx <= 6 같은 더 엄격한 invariant 권장. 본 테스트로도 명백한 leak 은 잡힘 — nit 수준.
|
||||
await hc.runOnce();
|
||||
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);
|
||||
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]);
|
||||
});
|
||||
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(3);
|
||||
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) =>
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash')
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual')
|
||||
? null
|
||||
: e.payload.noteId
|
||||
)).toEqual(['a', 'b', 'b']);
|
||||
@@ -164,7 +164,7 @@ describe('TelemetryService.readAllRecent', () => {
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.kind).toBe('capture');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash') expect(ev.payload.noteId).toBe('a');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual') expect(ev.payload.noteId).toBe('a');
|
||||
});
|
||||
|
||||
it('returns [] when dir missing', async () => {
|
||||
|
||||
55
tests/unit/store.ollama.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
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 (): Promise<{ ok: boolean; reason?: string }> => ({ 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' });
|
||||
});
|
||||
});
|
||||
@@ -195,3 +195,58 @@ describe('expired_banner_shown / expired_batch_trash events', () => {
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,3 +124,30 @@ describe('aggregateStats — expired_banner_shown / expired_batch_trash', () =>
|
||||
expect(r.md).toMatch(/만료 trash ratio.*N\/A/);
|
||||
});
|
||||
});
|
||||
|
||||
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: {} as Record<string, never> }
|
||||
];
|
||||
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
|
||||
expect(r.md).toMatch(/평균 downtimeMs.*90000/);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
[minor] app.on('before-quit') 가 두 곳에 등록 (line 145 health/backup, line 349 trayInterval cleanup). 첫 핸들러가 e.preventDefault() 후 backup 완료 시 app.quit() → before-quit 재발화 → health.stop() 두 번 호출 (idempotent OK) + clearInterval(trayInterval) 가 두 번째 firing 에야 동작. 이는 pre-existing 패턴이지만 본 PR 가 health.stop() 을 추가하면서 '두 번째 before-quit 발화에서 무엇이 실행되는가' 의 ���드 리딩 부담이 늘어남. v0.2.4 에서 quit cleanup 을 단일 함수로 모으는 refactor 후보.