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:
@@ -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); }) }
|
||||
});
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
|
||||
30
tests/unit/ProviderHolder.test.ts
Normal file
30
tests/unit/ProviderHolder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user