From 9fef2edb6e9760ec95e79353a75de08b1e299a37 Mon Sep 17 00:00:00 2001 From: altair823 Date: Mon, 4 May 2026 23:32:20 +0900 Subject: [PATCH] feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProviderHolder: mutable holder + listeners, indirection layer - AiWorker: constructor InferenceProvider → ProviderHolder this.provider.x → this.holder.get().x 전환 - HealthChecker: 동일 패턴 - src/main/index.ts: provider 를 ProviderHolder 로 감싸서 생성 - 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap - 단위 +2 cases (ProviderHolder) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 8 ++--- src/main/ai/ProviderHolder.ts | 36 +++++++++++++++++++++ src/main/index.ts | 6 ++-- src/main/services/HealthChecker.ts | 7 ++-- tests/unit/AiWorker.test.ts | 51 +++++++++++++++--------------- tests/unit/HealthChecker.test.ts | 15 +++++---- tests/unit/ProviderHolder.test.ts | 30 ++++++++++++++++++ 7 files changed, 112 insertions(+), 41 deletions(-) create mode 100644 src/main/ai/ProviderHolder.ts create mode 100644 tests/unit/ProviderHolder.test.ts diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index d08b348..b8bd651 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -1,6 +1,6 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; -import type { InferenceProvider } from './InferenceProvider.js'; import type { Note } from '@shared/types'; +import { ProviderHolder } from './ProviderHolder.js'; import { parseAllCandidates } from '../services/dueDateParser.js'; import { ZodError } from 'zod'; @@ -66,7 +66,7 @@ export class AiWorker { constructor( private repo: NoteRepository, - private provider: InferenceProvider, + private holder: ProviderHolder, opts: AiWorkerOptions = {} ) { this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000]; @@ -135,7 +135,7 @@ export class AiWorker { const todayIso = todayKstAsIso(nowDate); const candidates = parseAllCandidates(note.rawText, todayDate); const vocab = this.repo.getTopUsedTags(20); - const res = await this.provider.generate({ + const res = await this.holder.get().generate({ text: note.rawText, todayKst: todayIso, dueDateCandidates: candidates, @@ -146,7 +146,7 @@ export class AiWorker { title: res.title, summary: res.summary, tags: res.tags, - provider: this.provider.name, + provider: this.holder.get().name, dueDate: res.dueDate ?? null }); this.unreachableBackoffStep = 0; // 성공 시 step reset diff --git a/src/main/ai/ProviderHolder.ts b/src/main/ai/ProviderHolder.ts new file mode 100644 index 0000000..7e3e28e --- /dev/null +++ b/src/main/ai/ProviderHolder.ts @@ -0,0 +1,36 @@ +import type { InferenceProvider } from './InferenceProvider.js'; + +/** + * v0.2.3.1 — Mutable provider holder. AiWorker / HealthChecker 가 endpoint 변경 시 + * 새 LocalOllamaProvider 인스턴스를 받도록 indirection layer. + * + * 사용 패턴: + * const holder = new ProviderHolder(initialProvider); + * aiWorker = new AiWorker(repo, holder, opts); + * health = new HealthChecker(holder, opts); + * + * // 사용자가 Settings 저장 시: + * holder.get().abort?.(); // in-flight 중단 (LocalOllamaProvider 전용) + * holder.replace(newProvider); // 모든 consumer 가 새 인스턴스 사용 + */ +export class ProviderHolder { + private current: InferenceProvider; + private listeners: Array<(p: InferenceProvider) => void> = []; + + constructor(initial: InferenceProvider) { + this.current = initial; + } + + get(): InferenceProvider { + return this.current; + } + + replace(next: InferenceProvider): void { + this.current = next; + for (const fn of this.listeners) fn(next); + } + + onReplace(fn: (p: InferenceProvider) => void): void { + this.listeners.push(fn); + } +} diff --git a/src/main/index.ts b/src/main/index.ts index 2f0e13a..a4ccea5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -15,6 +15,7 @@ import { HotkeyService } from './services/HotkeyService.js'; import { IntentService } from './services/IntentService.js'; import { HealthChecker } from './services/HealthChecker.js'; import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js'; +import { ProviderHolder } from './ai/ProviderHolder.js'; import { AiWorker } from './ai/AiWorker.js'; import { registerCaptureApi } from './ipc/captureApi.js'; import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js'; @@ -69,7 +70,8 @@ app.whenReady().then(async () => { fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined }); const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint }); - const health = new HealthChecker(provider, { + const providerHolder = new ProviderHolder(provider); + const health = new HealthChecker(providerHolder, { onUpdate: (status) => { logger.info('ai.health', { ...status } as Record); pushOllamaStatus(getInboxWindow, status); @@ -87,7 +89,7 @@ app.whenReady().then(async () => { }); health.start(); - const worker = new AiWorker(repo, provider, { + const worker = new AiWorker(repo, providerHolder, { onUpdate: (note) => { pushNoteUpdated(getInboxWindow, note); // F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신. diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts index 14b3df5..e70ba03 100644 --- a/src/main/services/HealthChecker.ts +++ b/src/main/services/HealthChecker.ts @@ -1,4 +1,5 @@ -import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js'; +import type { HealthResult } from '../ai/InferenceProvider.js'; +import { ProviderHolder } from '../ai/ProviderHolder.js'; export type HealthTelemetryEvent = | { kind: 'ollama_unreachable'; reason: string } @@ -28,7 +29,7 @@ export class HealthChecker { private now: () => number; constructor( - private provider: InferenceProvider, + private holder: ProviderHolder, private opts: HealthCheckerOptions = {} ) { this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS; @@ -48,7 +49,7 @@ export class HealthChecker { } private async doRunOnce(): Promise { - const next = await this.provider.healthCheck(); + const next = await this.holder.get().healthCheck(); const prev = this.last; const okChanged = prev.ok !== next.ok; const reasonChanged = prev.reason !== next.reason; diff --git a/tests/unit/AiWorker.test.ts b/tests/unit/AiWorker.test.ts index afc0bbe..81fbb5f 100644 --- a/tests/unit/AiWorker.test.ts +++ b/tests/unit/AiWorker.test.ts @@ -6,6 +6,7 @@ import { AiWorker } from '@main/ai/AiWorker.js'; import type { AiTelemetryEmitter } from '@main/ai/AiWorker.js'; import type { InferenceProvider } from '@main/ai/InferenceProvider.js'; import type { AiResponse } from '@main/ai/schema.js'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; type EmittedEvent = { kind: string; payload: unknown }; @@ -33,7 +34,7 @@ describe('AiWorker', () => { it('processes a pending job and marks done', async () => { const { id } = repo.create({ rawText: 'x' }); const updates: string[] = []; - const w = new AiWorker(repo, makeProvider(), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0], onUpdate: (note) => updates.push(note.aiStatus) }); @@ -48,7 +49,7 @@ describe('AiWorker', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('boom'); }) }); - const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); await w.enqueue(id); await w.drain(); const note = repo.findById(id)!; @@ -60,7 +61,7 @@ describe('AiWorker', () => { it('loadFromDb re-queues all pending', async () => { const a = repo.create({ rawText: 'a' }).id; const b = repo.create({ rawText: 'b' }).id; - const w = new AiWorker(repo, makeProvider(), { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0] }); await w.loadFromDb(); await w.drain(); expect(repo.findById(a)?.aiStatus).toBe('done'); @@ -79,7 +80,7 @@ describe('AiWorker', () => { return { title: '제목', summary: 'a\nb\nc', tags: [], dueDate: null }; }) }); - const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); for (const id of ids) await w.enqueue(id); await w.drain(); expect(max).toBe(1); @@ -96,7 +97,7 @@ describe('AiWorker', () => { }), healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -118,7 +119,7 @@ describe('AiWorker', () => { }), healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -140,7 +141,7 @@ describe('AiWorker', () => { }), healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -162,7 +163,7 @@ describe('AiWorker', () => { }, healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T15:00:00.000Z') // 04-27 00:00 KST }); @@ -184,7 +185,7 @@ describe('AiWorker', () => { }, healthCheck: async () => ({ ok: true }) } as any; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0], now: () => new Date('2026-04-26T00:00:00.000Z') }); @@ -216,7 +217,7 @@ describe('AiWorker telemetry emit', () => { it('emits ai_succeeded with durationMs/attempts on success', async () => { const { id } = repo.create({ rawText: '수요일 회의 메모' }); - const w = new AiWorker(repo, makeProvider(), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 0, 0], telemetry: collectingTelemetry }); @@ -236,7 +237,7 @@ describe('AiWorker telemetry emit', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10], telemetry: collectingTelemetry @@ -254,7 +255,7 @@ describe('AiWorker telemetry emit', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new ZodError([]); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: collectingTelemetry }); @@ -270,7 +271,7 @@ describe('AiWorker telemetry emit', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('mystery'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: collectingTelemetry }); @@ -300,7 +301,7 @@ describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => { db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`).run(id, '2026-05-01T12:00:00.000Z'); const generate = vi.fn(); const provider = makeProvider({ generate: generate as any }); - const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] }); + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0] }); await w.loadFromDb(); await w.drain(); expect(generate).not.toHaveBeenCalled(); @@ -322,7 +323,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); @@ -341,7 +342,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { const provider = makeProvider({ generate: vi.fn(async () => { throw new Error('Request timeout'); }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); @@ -360,7 +361,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { }) }); const events: any[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: async (e) => { events.push(e); } } }); @@ -379,7 +380,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { generate: vi.fn(async () => { throw new Error('something weird'); }) }); const events: any[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: async (e) => { events.push(e); } } }); @@ -392,7 +393,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { }); it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => { - const w = new AiWorker(repo, makeProvider(), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider()), { backoffsMs: [0, 30_000, 120_000], unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000] }); @@ -411,7 +412,7 @@ describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => { return { title: 't', summary: 's', tags: [], dueDate: null }; }) }); - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], unreachableBackoffsMs: [10, 10, 10, 10, 10, 10] }); @@ -443,7 +444,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { const generateMock = vi.fn(async () => ({ title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null })); - const w = new AiWorker(repo, makeProvider({ generate: generateMock }), { + const w = new AiWorker(repo, new ProviderHolder(makeProvider({ generate: generateMock })), { backoffsMs: [0, 0, 0] }); await w.enqueue(id); @@ -467,7 +468,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) @@ -497,7 +498,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); @@ -522,7 +523,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); @@ -546,7 +547,7 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => { })) }); const emits: EmittedEvent[] = []; - const w = new AiWorker(repo, provider, { + const w = new AiWorker(repo, new ProviderHolder(provider), { backoffsMs: [0, 0, 0], telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) } }); diff --git a/tests/unit/HealthChecker.test.ts b/tests/unit/HealthChecker.test.ts index b3f0b84..95c8251 100644 --- a/tests/unit/HealthChecker.test.ts +++ b/tests/unit/HealthChecker.test.ts @@ -2,6 +2,7 @@ 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'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; class FakeProvider implements InferenceProvider { readonly name = 'fake'; @@ -24,7 +25,7 @@ describe('HealthChecker — start/stop polling', () => { 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 }); + const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 }); hc.start(); await vi.runOnlyPendingTimersAsync(); await vi.advanceTimersByTimeAsync(1000); @@ -36,7 +37,7 @@ describe('HealthChecker — start/stop polling', () => { 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 }); + const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 }); hc.start(); hc.start(); // 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s). @@ -48,7 +49,7 @@ describe('HealthChecker — start/stop polling', () => { 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 }); + const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 }); hc.start(); await vi.runOnlyPendingTimersAsync(); const before = (provider as any).idx; @@ -64,7 +65,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }]; const updates: HealthResult[] = []; const events: HealthTelemetryEvent[] = []; - const hc = new HealthChecker(provider, { + const hc = new HealthChecker(new ProviderHolder(provider), { onUpdate: (s) => updates.push(s), onTelemetry: (e) => events.push(e) }); @@ -79,7 +80,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { provider.results = [{ ok: false, reason: 'refused' }, { ok: true }]; const events: HealthTelemetryEvent[] = []; let nowCounter = 0; - const hc = new HealthChecker(provider, { + const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e), now: () => { nowCounter += 1; return nowCounter * 1000; } }); @@ -97,7 +98,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { ]; const updates: HealthResult[] = []; const events: HealthTelemetryEvent[] = []; - const hc = new HealthChecker(provider, { + const hc = new HealthChecker(new ProviderHolder(provider), { onUpdate: (s) => updates.push(s), onTelemetry: (e) => events.push(e) }); @@ -111,7 +112,7 @@ describe('HealthChecker — delta transitions + telemetry', () => { const provider = new FakeProvider(); provider.results = [{ ok: true }]; const events: HealthTelemetryEvent[] = []; - const hc = new HealthChecker(provider, { onTelemetry: (e) => events.push(e) }); + const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e) }); await hc.runOnce({ manual: true }); expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]); }); diff --git a/tests/unit/ProviderHolder.test.ts b/tests/unit/ProviderHolder.test.ts new file mode 100644 index 0000000..83b77fe --- /dev/null +++ b/tests/unit/ProviderHolder.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ProviderHolder } from '@main/ai/ProviderHolder.js'; +import { LocalOllamaProvider } from '@main/ai/LocalOllamaProvider.js'; + +describe('ProviderHolder', () => { + it('replace() fires listener and get() returns new instance', () => { + const a = new LocalOllamaProvider({ endpoint: 'http://a:11434', model: 'm1' }); + const b = new LocalOllamaProvider({ endpoint: 'http://b:11434', model: 'm2' }); + const holder = new ProviderHolder(a); + const listener = vi.fn(); + holder.onReplace(listener); + expect(holder.get()).toBe(a); + holder.replace(b); + expect(holder.get()).toBe(b); + expect(listener).toHaveBeenCalledWith(b); + }); + + it('multiple listeners all fire on replace()', () => { + const a = new LocalOllamaProvider({ model: 'm1' }); + const b = new LocalOllamaProvider({ model: 'm2' }); + const holder = new ProviderHolder(a); + const l1 = vi.fn(); + const l2 = vi.fn(); + holder.onReplace(l1); + holder.onReplace(l2); + holder.replace(b); + expect(l1).toHaveBeenCalledWith(b); + expect(l2).toHaveBeenCalledWith(b); + }); +});