From 6e5f3703d7b78e2350c171f5eae3e1e7dc1d1734 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 2 May 2026 03:28:11 +0900 Subject: [PATCH] feat(retry): CaptureService.retryAllFailed + IPC 2 channels (#2 v0.2.3) Co-Authored-By: Claude Sonnet 4.6 --- src/main/ipc/inboxApi.ts | 3 ++ src/main/services/CaptureService.ts | 20 ++++++++++++ src/preload/index.ts | 4 ++- src/shared/types.ts | 2 ++ tests/unit/CaptureService.test.ts | 50 +++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index c5ea63a..3acb463 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -133,6 +133,9 @@ export function registerInboxApi(deps: InboxIpcDeps): void { await deps.health.runOnce({ manual: true }); return deps.health.lastStatus(); }); + + ipcMain.handle('inbox:retryAllFailed', async () => deps.capture.retryAllFailed()); + ipcMain.handle('inbox:failedCount', () => deps.repo.countFailed()); } export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void { diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index f79ddd7..1aa490b 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -11,6 +11,7 @@ export interface TelemetryEmitter { | { kind: 'empty_trash'; payload: { count: number } } | { kind: 'expired_banner_shown'; payload: { candidateCount: number } } | { kind: 'expired_batch_trash'; payload: { count: number } } + | { kind: 'ai_retry_manual'; payload: { failedCount: number } } ): Promise; } @@ -159,4 +160,23 @@ export class CaptureService { } return r; } + + /** + * 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset + worker.enqueue 재투입. + * 빈 결과는 telemetry emit 안 함 (failedCount ≥ 1 invariant). + * v0.2.3 #2 retry-all manual trigger. + */ + async retryAllFailed(): Promise<{ count: number }> { + const { ids } = this.repo.retryAllFailed(new Date().toISOString()); + for (const id of ids) { + await this.deps.enqueue(id); + } + if (ids.length > 0 && this.deps.telemetry) { + await this.deps.telemetry.emit({ + kind: 'ai_retry_manual', + payload: { failedCount: ids.length } + }).catch(() => {}); + } + return { count: ids.length }; + } } diff --git a/src/preload/index.ts b/src/preload/index.ts index f432856..2cb2a39 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -37,7 +37,9 @@ const api: InklingApi = { const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status); ipcRenderer.on('ollama:status', listener); return () => ipcRenderer.off('ollama:status', listener); - } + }, + retryAllFailed: () => ipcRenderer.invoke('inbox:retryAllFailed'), + getFailedCount: () => ipcRenderer.invoke('inbox:failedCount') } }; diff --git a/src/shared/types.ts b/src/shared/types.ts index 1b31125..726b63d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -82,6 +82,8 @@ export interface InboxApi { ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>; onNoteUpdated(cb: (note: Note) => void): () => void; onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void; + retryAllFailed(): Promise<{ count: number }>; + getFailedCount(): Promise; } export interface InklingApi { diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index 3760d53..39005f3 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -323,3 +323,53 @@ describe('CaptureService.trashExpiredBatch', () => { expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([]); }); }); + +describe('CaptureService.retryAllFailed', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let calls: Array<{ kind: string; payload: any }>; + let enqueued: string[]; + let svc: CaptureService; + + function makeFailed(rawText: string): string { + const { id } = repo.create({ rawText }); + db.prepare(`UPDATE notes SET ai_status='failed', ai_error='boom' WHERE id=?`).run(id); + db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id); + return id; + } + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-')); + store = new MediaStore(tmp); + calls = []; + enqueued = []; + svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {}, + telemetry: { emit: async (input) => { calls.push(input as any); } } + }); + }); + + it('retryAllFailed — enqueue per id + ai_retry_manual emit', async () => { + const a = makeFailed('a'); + const b = makeFailed('b'); + const r = await svc.retryAllFailed(); + expect(r.count).toBe(2); + expect(enqueued.sort()).toEqual([a, b].sort()); + expect(calls).toContainEqual( + expect.objectContaining({ kind: 'ai_retry_manual', payload: { failedCount: 2 } }) + ); + }); + + it('retryAllFailed empty — count=0, no emit', async () => { + const r = await svc.retryAllFailed(); + expect(r.count).toBe(0); + expect(enqueued).toEqual([]); + expect(calls.filter((c) => c.kind === 'ai_retry_manual')).toEqual([]); + }); +});