feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1)

- 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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-04 23:32:20 +09:00
parent c77c30be83
commit 9fef2edb6e
7 changed files with 112 additions and 41 deletions

View File

@@ -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); }) }
});

View File

@@ -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' }]);
});

View File

@@ -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);
});
});