Task 13 of the slice plan. Drives the pending → done/failed transitions: - enqueue() pushes a Job and kicks the loop; loadFromDb() rehydrates pending_jobs at startup so app restart resumes in-flight work. - drain() exposes a Promise for tests + graceful shutdown. - Concurrency 1: a single async loop awaits each provider call before the next, matching spec §2.2. - 3-attempt backoff (default [0, 30s, 120s]; tests inject [0,0,0]). Each failure logs ai.retry, increments pending_jobs.attempts, and on the final attempt calls markAiFailed and emits onUpdate. - emit() pushes the freshly-hydrated note to onUpdate (used by Task 30 to fan out IPC note:updated events). Verification: `npx vitest run tests/unit/AiWorker.test.ts` 4 passed. Suite total 41 / 41. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
2.9 KiB
TypeScript
85 lines
2.9 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import { runMigrations } from '@main/db/migrations/index.js';
|
|
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
|
import { AiWorker } from '@main/ai/AiWorker.js';
|
|
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
|
|
import type { AiResponse } from '@main/ai/schema.js';
|
|
|
|
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
|
|
return {
|
|
name: 'mock',
|
|
generate: vi.fn(async (): Promise<AiResponse> => ({
|
|
title: '제목', summary: 'a\nb\nc', tags: ['tag']
|
|
})),
|
|
healthCheck: vi.fn(async () => ({ ok: true })),
|
|
...overrides
|
|
} as InferenceProvider;
|
|
}
|
|
|
|
describe('AiWorker', () => {
|
|
let db: Database.Database;
|
|
let repo: NoteRepository;
|
|
|
|
beforeEach(() => {
|
|
db = new Database(':memory:');
|
|
runMigrations(db);
|
|
repo = new NoteRepository(db);
|
|
});
|
|
|
|
it('processes a pending job and marks done', async () => {
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
const updates: string[] = [];
|
|
const w = new AiWorker(repo, makeProvider(), {
|
|
backoffsMs: [0, 0, 0],
|
|
onUpdate: (note) => updates.push(note.aiStatus)
|
|
});
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
expect(repo.findById(id)?.aiStatus).toBe('done');
|
|
expect(updates).toContain('done');
|
|
});
|
|
|
|
it('retries 3 times then marks failed', async () => {
|
|
const { id } = repo.create({ rawText: 'x' });
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => { throw new Error('boom'); })
|
|
});
|
|
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
|
await w.enqueue(id);
|
|
await w.drain();
|
|
const note = repo.findById(id)!;
|
|
expect(note.aiStatus).toBe('failed');
|
|
expect(note.aiError).toContain('boom');
|
|
expect(provider.generate).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
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] });
|
|
await w.loadFromDb();
|
|
await w.drain();
|
|
expect(repo.findById(a)?.aiStatus).toBe('done');
|
|
expect(repo.findById(b)?.aiStatus).toBe('done');
|
|
});
|
|
|
|
it('processes sequentially (concurrency 1)', async () => {
|
|
const ids = [repo.create({ rawText: 'a' }).id, repo.create({ rawText: 'b' }).id];
|
|
let running = 0;
|
|
let max = 0;
|
|
const provider = makeProvider({
|
|
generate: vi.fn(async () => {
|
|
running++; max = Math.max(max, running);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
running--;
|
|
return { title: '제목', summary: 'a\nb\nc', tags: [] };
|
|
})
|
|
});
|
|
const w = new AiWorker(repo, provider, { backoffsMs: [0, 0, 0] });
|
|
for (const id of ids) await w.enqueue(id);
|
|
await w.drain();
|
|
expect(max).toBe(1);
|
|
});
|
|
});
|