- 정리 보류 — 원문은 안전합니다
+
+
+ 정리 보류 — 원문은 안전합니다
+
+ {/* v0.3.9 — per-note 재시도 UI. FailedBanner 의 일괄 재시도와 별개. */}
+
)}
{/* v0.2.9 Cut B Task 13 — ai_status='disabled': raw_text 첫 줄 fallback title.
diff --git a/src/shared/types.ts b/src/shared/types.ts
index 848b14a..9d1df4f 100644
--- a/src/shared/types.ts
+++ b/src/shared/types.ts
@@ -149,6 +149,9 @@ export interface InboxApi {
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
retryAllFailed(): Promise<{ count: number }>;
getFailedCount(): Promise
;
+ // v0.3.9 — per-note retry/cancel. failed/pending 노트의 사용자 unblock path.
+ retryOneFailed(id: string): Promise<{ ok: boolean }>;
+ cancelPending(id: string): Promise<{ ok: boolean }>;
listRecallCandidate(): Promise;
markRecallOpened(id: string): Promise<{ note: Note }>;
dismissRecall(id: string): Promise<{ note: Note }>;
diff --git a/tests/unit/NoteRepository.test.ts b/tests/unit/NoteRepository.test.ts
index 2542e68..46df306 100644
--- a/tests/unit/NoteRepository.test.ts
+++ b/tests/unit/NoteRepository.test.ts
@@ -770,6 +770,41 @@ describe('NoteRepository — failed retry helpers', () => {
expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
});
+ it('v0.3.9 — retryOneFailed: failed → pending + pending_jobs INSERT', () => {
+ const a = makeFailed('a');
+ const b = makeFailed('b');
+ const r = repo.retryOneFailed(a, '2026-05-01T12:00:00.000Z');
+ expect(r).toEqual({ ok: true });
+ expect(repo.findById(a)!.aiStatus).toBe('pending');
+ expect(repo.findById(a)!.aiError).toBeNull();
+ expect(repo.findById(b)!.aiStatus).toBe('failed'); // 다른 노트 영향 없음
+ const jobs = repo.getAllPendingJobs();
+ expect(jobs.find((j) => j.noteId === a)).toBeDefined();
+ });
+
+ it('v0.3.9 — retryOneFailed: non-failed status 면 no-op', () => {
+ const { id } = repo.create({ rawText: 'pending note' });
+ const r = repo.retryOneFailed(id, '2026-05-01T12:00:00.000Z');
+ expect(r).toEqual({ ok: false });
+ });
+
+ it('v0.3.9 — cancelPending: pending → disabled + pending_jobs DELETE', () => {
+ const { id } = repo.create({ rawText: 'x' }); // ai_status=pending
+ expect(repo.findById(id)!.aiStatus).toBe('pending');
+ const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z');
+ expect(r).toEqual({ ok: true });
+ expect(repo.findById(id)!.aiStatus).toBe('disabled');
+ const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
+ expect(jobs).toHaveLength(0);
+ });
+
+ it('v0.3.9 — cancelPending: non-pending status 면 no-op', () => {
+ const id = makeFailed('a');
+ const r = repo.cancelPending(id, '2026-05-01T12:00:00.000Z');
+ expect(r).toEqual({ ok: false });
+ expect(repo.findById(id)!.aiStatus).toBe('failed'); // 변경 없음
+ });
+
it('setNextRunAt — attempts 변경 없이 next_run_at + last_error 갱신', () => {
const { id } = repo.create({ rawText: 'x' });
repo.incrementJobAttempt(id, '2026-05-01T11:00:00.000Z', 'first error');
diff --git a/tests/unit/ftsHelpers.test.ts b/tests/unit/ftsHelpers.test.ts
index daf1f9b..487e82a 100644
--- a/tests/unit/ftsHelpers.test.ts
+++ b/tests/unit/ftsHelpers.test.ts
@@ -15,6 +15,12 @@ describe('sanitizeFtsQuery', () => {
it('returns empty string for whitespace-only', () => {
expect(sanitizeFtsQuery(' ')).toBe('');
});
+ it('v0.3.9 — dash/caret/backtick 추가 sanitize', () => {
+ expect(sanitizeFtsQuery('key-value')).toBe('key value');
+ expect(sanitizeFtsQuery('^prefix')).toBe('prefix');
+ expect(sanitizeFtsQuery('back`tick')).toBe('back tick');
+ expect(sanitizeFtsQuery('-NOT')).toBe('NOT');
+ });
});
describe('computeCutoff', () => {