|
|
|
|
@@ -0,0 +1,797 @@
|
|
|
|
|
diff --git a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
|
|
|
|
|
index e992cbd..24d59fe 100644
|
|
|
|
|
--- a/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
|
|
|
|
|
+++ b/docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
|
|
|
|
|
@@ -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 확정):
|
|
|
|
|
diff --git a/src/main/index.ts b/src/main/index.ts
|
|
|
|
|
index 0abca87..b4b7f3e 100644
|
|
|
|
|
--- a/src/main/index.ts
|
|
|
|
|
+++ b/src/main/index.ts
|
|
|
|
|
@@ -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) => {
|
|
|
|
|
@@ -128,6 +143,7 @@ app.whenReady().then(async () => {
|
|
|
|
|
|
|
|
|
|
let backupOnQuitDone = false;
|
|
|
|
|
app.on('before-quit', (e) => {
|
|
|
|
|
+ health.stop();
|
|
|
|
|
if (backupOnQuitDone) return;
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
backup.runDaily()
|
|
|
|
|
@@ -320,7 +336,8 @@ app.whenReady().then(async () => {
|
|
|
|
|
silent: true
|
|
|
|
|
}).show();
|
|
|
|
|
}
|
|
|
|
|
- }
|
|
|
|
|
+ },
|
|
|
|
|
+ /* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
|
|
|
|
diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts
|
|
|
|
|
index 475d38e..c5ea63a 100644
|
|
|
|
|
--- a/src/main/ipc/inboxApi.ts
|
|
|
|
|
+++ b/src/main/ipc/inboxApi.ts
|
|
|
|
|
@@ -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);
|
|
|
|
|
+}
|
|
|
|
|
diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts
|
|
|
|
|
index a8567a2..c10b9d2 100644
|
|
|
|
|
--- a/src/main/services/HealthChecker.ts
|
|
|
|
|
+++ b/src/main/services/HealthChecker.ts
|
|
|
|
|
@@ -1,12 +1,70 @@
|
|
|
|
|
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 };
|
|
|
|
|
- constructor(private provider: InferenceProvider) {}
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
|
|
- async runOnce(): Promise<HealthResult> {
|
|
|
|
|
- this.last = await this.provider.healthCheck();
|
|
|
|
|
- return this.last;
|
|
|
|
|
+ stop(): void {
|
|
|
|
|
+ if (this.timer !== null) {
|
|
|
|
|
+ clearInterval(this.timer);
|
|
|
|
|
+ this.timer = null;
|
|
|
|
|
+ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastStatus(): HealthResult { return this.last; }
|
|
|
|
|
diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts
|
|
|
|
|
index 6fb4600..0e3a00e 100644
|
|
|
|
|
--- a/src/main/services/TelemetryService.ts
|
|
|
|
|
+++ b/src/main/services/TelemetryService.ts
|
|
|
|
|
@@ -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(
|
|
|
|
|
diff --git a/src/main/services/telemetryEvents.ts b/src/main/services/telemetryEvents.ts
|
|
|
|
|
index 378f4ee..f608866 100644
|
|
|
|
|
--- a/src/main/services/telemetryEvents.ts
|
|
|
|
|
+++ b/src/main/services/telemetryEvents.ts
|
|
|
|
|
@@ -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>;
|
|
|
|
|
diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts
|
|
|
|
|
index c5dedc4..62f3173 100644
|
|
|
|
|
--- a/src/main/services/telemetryStats.ts
|
|
|
|
|
+++ b/src/main/services/telemetryStats.ts
|
|
|
|
|
@@ -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 };
|
|
|
|
|
}
|
|
|
|
|
diff --git a/src/main/tray.ts b/src/main/tray.ts
|
|
|
|
|
index 3a1b322..d905436 100644
|
|
|
|
|
--- a/src/main/tray.ts
|
|
|
|
|
+++ b/src/main/tray.ts
|
|
|
|
|
@@ -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());
|
|
|
|
|
+}
|
|
|
|
|
diff --git a/src/preload/index.ts b/src/preload/index.ts
|
|
|
|
|
index 7a35600..f432856 100644
|
|
|
|
|
--- a/src/preload/index.ts
|
|
|
|
|
+++ b/src/preload/index.ts
|
|
|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx
|
|
|
|
|
index ae1cdcb..37803bf 100644
|
|
|
|
|
--- a/src/renderer/inbox/App.tsx
|
|
|
|
|
+++ b/src/renderer/inbox/App.tsx
|
|
|
|
|
@@ -22,13 +22,16 @@ 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); };
|
|
|
|
|
}, [loadInitial, refreshMeta, upsertNote]);
|
|
|
|
|
|
|
|
|
|
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
|
|
|
|
diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx
|
|
|
|
|
index ff2c87f..4a61aba 100644
|
|
|
|
|
--- a/src/renderer/inbox/components/OllamaBanner.tsx
|
|
|
|
|
+++ b/src/renderer/inbox/components/OllamaBanner.tsx
|
|
|
|
|
@@ -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,19 @@ 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={() => { 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}
|
|
|
|
|
diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts
|
|
|
|
|
index c27ed76..45d9db3 100644
|
|
|
|
|
--- a/src/renderer/inbox/store.ts
|
|
|
|
|
+++ b/src/renderer/inbox/store.ts
|
|
|
|
|
@@ -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 });
|
|
|
|
|
}
|
|
|
|
|
}));
|
|
|
|
|
diff --git a/src/shared/types.ts b/src/shared/types.ts
|
|
|
|
|
index 33cca70..1b31125 100644
|
|
|
|
|
--- a/src/shared/types.ts
|
|
|
|
|
+++ b/src/shared/types.ts
|
|
|
|
|
@@ -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 {
|
|
|
|
|
diff --git a/tests/unit/HealthChecker.test.ts b/tests/unit/HealthChecker.test.ts
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 0000000..c8a0603
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/tests/unit/HealthChecker.test.ts
|
|
|
|
|
@@ -0,0 +1,117 @@
|
|
|
|
|
+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();
|
|
|
|
|
+ await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
|
+ expect((provider as any).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 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();
|
|
|
|
|
+ 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' }]);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/tests/unit/TelemetryService.test.ts b/tests/unit/TelemetryService.test.ts
|
|
|
|
|
index 8b7d1c8..183dadf 100644
|
|
|
|
|
--- a/tests/unit/TelemetryService.test.ts
|
|
|
|
|
+++ b/tests/unit/TelemetryService.test.ts
|
|
|
|
|
@@ -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 () => {
|
|
|
|
|
diff --git a/tests/unit/store.ollama.test.ts b/tests/unit/store.ollama.test.ts
|
|
|
|
|
new file mode 100644
|
|
|
|
|
index 0000000..c205d44
|
|
|
|
|
--- /dev/null
|
|
|
|
|
+++ b/tests/unit/store.ollama.test.ts
|
|
|
|
|
@@ -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' });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/tests/unit/telemetryEvents.test.ts b/tests/unit/telemetryEvents.test.ts
|
|
|
|
|
index 12020a6..1c74727 100644
|
|
|
|
|
--- a/tests/unit/telemetryEvents.test.ts
|
|
|
|
|
+++ b/tests/unit/telemetryEvents.test.ts
|
|
|
|
|
@@ -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();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|
|
|
|
|
diff --git a/tests/unit/telemetryStats.test.ts b/tests/unit/telemetryStats.test.ts
|
|
|
|
|
index 181b0d7..e3a2c91 100644
|
|
|
|
|
--- a/tests/unit/telemetryStats.test.ts
|
|
|
|
|
+++ b/tests/unit/telemetryStats.test.ts
|
|
|
|
|
@@ -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/);
|
|
|
|
|
+ });
|
|
|
|
|
+});
|