Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fc694c57b | ||
| 4e1f60cb7d | |||
|
|
8cdffb2143 | ||
|
|
5d0f87c5fb | ||
| cb29ef6f89 | |||
|
|
61b6fa6c1f | ||
|
|
348e9ee402 | ||
|
|
646fe7a7ab | ||
|
|
f4e1af83fe | ||
|
|
20394bf2a3 | ||
|
|
0c59ce3715 | ||
|
|
59cfb711cd | ||
|
|
b94e68238c | ||
|
|
0eb2e6282f | ||
|
|
746671059e | ||
|
|
e6494b8778 | ||
| 3c9326d6ec | |||
|
|
d8621d55e0 | ||
|
|
ff07738b02 | ||
|
|
727eeb1919 | ||
|
|
3e0f710c70 | ||
|
|
26f1db5626 | ||
|
|
973cb1d08d | ||
|
|
b81fc82621 | ||
|
|
daa8507364 | ||
|
|
896b374f56 | ||
|
|
134d59ddb4 | ||
|
|
e2b16d44d7 | ||
|
|
df8a53aec1 | ||
|
|
853ca39c0d | ||
|
|
8206462ee4 | ||
| dbbec38079 | |||
|
|
8f56814186 | ||
|
|
95bbe9cd22 | ||
|
|
e4a0be15ae | ||
|
|
406a5e61f0 | ||
|
|
3ebd3bc9a5 | ||
|
|
6e5f3703d7 | ||
|
|
12c267aabd | ||
|
|
449eb76683 | ||
|
|
2e3f0edffd | ||
|
|
821db4001d | ||
|
|
f50cabcc62 | ||
| 37292f1a53 | |||
|
|
b6c307148d | ||
|
|
a94c7578b7 | ||
|
|
d8f4ae5f6b | ||
|
|
cdf2e4bc47 | ||
|
|
557960ff5a | ||
|
|
c78f3af3a6 | ||
|
|
410a6f494b | ||
|
|
e30e436051 | ||
|
|
a68ffe0aeb | ||
|
|
12681e431c | ||
|
|
f299926f58 | ||
|
|
050e7f08f1 | ||
|
|
f36b9ecb5b | ||
| da7455b25f | |||
|
|
d672ec3afa | ||
|
|
8a96d5279d | ||
|
|
7cbbd4dc97 | ||
|
|
b7205597db | ||
|
|
749235f65d | ||
|
|
f76ca06d9e | ||
|
|
fec80361dd | ||
|
|
00423fb235 | ||
|
|
0a9dab4a7f | ||
|
|
a5e6859ac9 | ||
|
|
c45e613b31 | ||
|
|
4c2769fd82 | ||
| df60c5a5b2 | |||
|
|
87b6d71628 | ||
|
|
2ac4d648c1 | ||
|
|
03bca3ed59 | ||
|
|
df85b88424 | ||
|
|
99cdc346d2 | ||
|
|
3e4ad6ec91 | ||
|
|
dd74aec884 | ||
|
|
cdceb609e6 | ||
|
|
6f0d032ff1 | ||
|
|
a5f23b925e | ||
|
|
468ea90d6c | ||
|
|
b19ea6423a | ||
|
|
e6a945cad4 | ||
|
|
c5329f1ccc | ||
|
|
284bfcbdd1 | ||
|
|
78c10e8817 | ||
|
|
3c780a7464 | ||
|
|
2203bcf65b | ||
|
|
70a69f0ae3 | ||
|
|
11703b976e | ||
|
|
bf49b8351e | ||
|
|
13da554461 | ||
|
|
3797e6c4f3 | ||
|
|
5bcfd26bfd | ||
|
|
b93185edd5 | ||
|
|
61e277f36c | ||
| 6f8ae75ff7 | |||
|
|
7e8e2b598d | ||
|
|
5c97397cbe | ||
|
|
fe24ff577f | ||
|
|
dca6aed44e | ||
|
|
4213745dc7 | ||
|
|
01447ddaad | ||
|
|
f0cef95d3f | ||
|
|
36a5c67ed6 | ||
|
|
2036c687d2 | ||
|
|
9a066ed807 | ||
|
|
729a3f9c47 | ||
|
|
0501bd1762 | ||
|
|
50b6d05bcb | ||
|
|
93e278b241 | ||
|
|
0a0ef11327 | ||
|
|
358cada017 | ||
|
|
22a25cc622 |
1205
docs/superpowers/plans/2026-05-01-v023-ai-retry.md
Normal file
1205
docs/superpowers/plans/2026-05-01-v023-ai-retry.md
Normal file
File diff suppressed because it is too large
Load Diff
1477
docs/superpowers/plans/2026-05-01-v023-expiry.md
Normal file
1477
docs/superpowers/plans/2026-05-01-v023-expiry.md
Normal file
File diff suppressed because it is too large
Load Diff
1092
docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md
Normal file
1092
docs/superpowers/plans/2026-05-01-v023-ollama-recovery.md
Normal file
File diff suppressed because it is too large
Load Diff
1627
docs/superpowers/plans/2026-05-01-v023-telemetry.md
Normal file
1627
docs/superpowers/plans/2026-05-01-v023-telemetry.md
Normal file
File diff suppressed because it is too large
Load Diff
2276
docs/superpowers/plans/2026-05-01-v023-trash.md
Normal file
2276
docs/superpowers/plans/2026-05-01-v023-trash.md
Normal file
File diff suppressed because it is too large
Load Diff
1343
docs/superpowers/plans/2026-05-02-v023-recall-spike.md
Normal file
1343
docs/superpowers/plans/2026-05-02-v023-recall-spike.md
Normal file
File diff suppressed because it is too large
Load Diff
1091
docs/superpowers/plans/2026-05-02-v023-tag-vocab.md
Normal file
1091
docs/superpowers/plans/2026-05-02-v023-tag-vocab.md
Normal file
File diff suppressed because it is too large
Load Diff
352
docs/superpowers/specs/2026-05-01-v023-ai-retry-design.md
Normal file
352
docs/superpowers/specs/2026-05-01-v023-ai-retry-design.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# v0.2.3 #2 AI retry / 수동 trigger 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.3 cut 7항목 중 5번째 항목 (#2 AI retry) 의 mini-brainstorm 결정 + design. roadmap §3 #2 의 In/Out 위에서 §8 미결정 3항목 (unreachable backoff cap / reason 분류 정밀도 / per-note retry) 결정.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #2, §8
|
||||
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15), #1 ollama 회복 (PR #16)
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| Q | 결정 | 근거 |
|
||||
|---|------|------|
|
||||
| Q1 unreachable backoff cap | **A 15분 exponential** | 30s → 60s → 120s → 240s → 480s → 900s cap. 회복 latency 짧으면서 오래 꺼지면 부하 미미. |
|
||||
| Q2 timeout 분류 | **A unreachable 동일** (무한 retry) | timeout 99% 는 임시 (gemma cold start, 큰 입력). 영구 hang 케이스는 v0.2.4 dogfood 후 식별. roadmap §3 #2 In 과 deviation — 의식적. |
|
||||
| Q3 per-note retry | **A retry-all 만** | UI 노이즈 회피. NoteCard 단건 버튼은 v0.2.4 dogfood 마찰 발생 시 추가. |
|
||||
|
||||
---
|
||||
|
||||
## 2. AiWorker.processJob 정책 변경
|
||||
|
||||
### 2.1 분기 로직
|
||||
|
||||
`classifyReason(err)` 결과로 분기:
|
||||
|
||||
```ts
|
||||
const reason = classifyReason(err);
|
||||
if (reason === 'unreachable' || reason === 'timeout') {
|
||||
// 무한 retry 경로: attempts 증가 안 함, in-job loop 안에서 sleep + retry
|
||||
const sleepMs = nextBackoffMs(this.unreachableBackoffStep);
|
||||
this.unreachableBackoffStep = Math.min(this.unreachableBackoffStep + 1, 5);
|
||||
this.repo.setNextRunAt(job.noteId, new Date(Date.now() + sleepMs).toISOString(), msg);
|
||||
await this.sleep(sleepMs);
|
||||
// for 루프의 attempt 인덱스 그대로 — 다음 try 도 같은 attempt 번호로 재시도
|
||||
attempt -= 1; // for 루프의 attempt++ 상쇄
|
||||
continue;
|
||||
} else {
|
||||
// schema / other: 기존 max 3 retry 정책 그대로
|
||||
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
|
||||
if (isLast) {
|
||||
this.repo.markAiFailed(job.noteId, msg);
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({ kind: 'ai_failed', payload: { ... } }).catch(() => {});
|
||||
}
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
}
|
||||
await this.sleep(this.backoffsMs[attempt + 1] ?? 0);
|
||||
}
|
||||
```
|
||||
|
||||
성공 시 `unreachableBackoffStep = 0` 으로 reset.
|
||||
|
||||
### 2.2 backoff schedule
|
||||
|
||||
```ts
|
||||
private readonly UNREACHABLE_BACKOFFS_MS = [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
|
||||
private nextBackoffMs(step: number): number {
|
||||
return this.UNREACHABLE_BACKOFFS_MS[Math.min(step, 5)];
|
||||
}
|
||||
```
|
||||
|
||||
기존 `backoffsMs = [0, 30_000, 120_000]` 은 schema/other 전용 그대로.
|
||||
|
||||
### 2.3 invariants
|
||||
|
||||
- unreachable/timeout: `markAiFailed` 절대 호출 안 함. `ai_failed` telemetry emit 안 함.
|
||||
- schema/other: 기존 동작 (max 3 후 markAiFailed + emit).
|
||||
- 결과: `ai_failed.reason` 통계에는 schema/other 만 누적 (Q2 = A 의 자연 결과).
|
||||
|
||||
---
|
||||
|
||||
## 3. NoteRepository 확장
|
||||
|
||||
```ts
|
||||
// src/main/repository/NoteRepository.ts
|
||||
|
||||
findFailedIds(): string[];
|
||||
// SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC
|
||||
|
||||
countFailed(): number;
|
||||
// SELECT COUNT(*) FROM notes WHERE ai_status='failed' AND deleted_at IS NULL
|
||||
|
||||
retryAllFailed(now: string): { ids: string[] };
|
||||
// 단일 transaction 안에서:
|
||||
// UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=now WHERE id IN (...)
|
||||
// INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, now)
|
||||
// (이미 pending_jobs row 가 있으면 OR IGNORE — race 가드)
|
||||
|
||||
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void;
|
||||
// UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?
|
||||
// attempts 변경 없음 — unreachable/timeout 무한 retry 용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. CaptureService + IPC
|
||||
|
||||
### 4.1 CaptureService 메서드
|
||||
|
||||
```ts
|
||||
async retryAllFailed(): Promise<{ count: number }> {
|
||||
const { ids } = this.repo.retryAllFailed(new Date().toISOString());
|
||||
for (const id of ids) {
|
||||
await this.deps.enqueue(id);
|
||||
}
|
||||
if (this.deps.telemetry && ids.length > 0) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: ids.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { count: ids.length };
|
||||
}
|
||||
```
|
||||
|
||||
빈 배열 시 telemetry emit 안 함 — 사용자가 "재시도" 클릭해도 N=0 이면 noise.
|
||||
|
||||
### 4.2 IPC 채널 신규 2
|
||||
|
||||
| 채널 | 입력 | 출력 |
|
||||
|------|------|------|
|
||||
| `inbox:retryAllFailed` | (없음) | `{ count: number }` |
|
||||
| `inbox:failedCount` | (없음) | `number` |
|
||||
|
||||
confirm dialog 불필요 — destructive 아님 (단순 재처리 큐 등록, 데이터 손실 없음).
|
||||
|
||||
---
|
||||
|
||||
## 5. Tray + Banner UI
|
||||
|
||||
### 5.1 Tray 메뉴
|
||||
|
||||
기존 (#1 cut 후):
|
||||
```
|
||||
- 사용 로그 내보내기...
|
||||
- Ollama 재확인 (status.ok=false 시 enabled)
|
||||
```
|
||||
|
||||
신규 (본 cut):
|
||||
```
|
||||
- 사용 로그 내보내기...
|
||||
- Ollama 재확인 (status.ok=false 시 enabled)
|
||||
- 지금 AI 처리 (실패 N건) (failedCount > 0 시 enabled, label dynamic with N)
|
||||
```
|
||||
|
||||
`refreshTrayFailedCount(count: number)` setter — `refreshTrayOllama` 와 동일 패턴. `_failedCount` module-level state + 메뉴 rebuild.
|
||||
|
||||
`createTray` 의 9번째 callback `runRetryAllFailed`. 8 → 9 positional. v0.2.4 backlog #4 (TrayCallbacks object refactor) trigger 더 강화.
|
||||
|
||||
AiWorker.onUpdate 시점에 `refreshTrayFailedCount(repo.countFailed())` 호출.
|
||||
|
||||
### 5.2 FailedBanner
|
||||
|
||||
`src/renderer/inbox/components/FailedBanner.tsx` (신규):
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div className="banner warn" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button onClick={() => { retryAllFailed().catch((e) => console.warn('retryAllFailed failed', e)); }}>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
스타일: warn variant 색상은 PendingBanner 와 다른 차별 (#fff7e6 / #d99500 의 ExpiryBanner 와도 다름) — 본 banner 는 빨강 톤 (#fce4e4 / #a33). 사용자 주의 필요한 영구 실패 신호.
|
||||
|
||||
### 5.3 Inbox 상단 stack 갱신
|
||||
|
||||
```
|
||||
1. OllamaBanner (system - down)
|
||||
2. RecoveryToast (회복 toast)
|
||||
3. PendingBanner (AI 처리 N건 - 일시)
|
||||
4. FailedBanner (AI 실패 N건 - 영구 - 신규)
|
||||
5. ExpiryBanner (만료)
|
||||
6. tagFilter chip
|
||||
7. notes
|
||||
```
|
||||
|
||||
위치 근거: system status > 진행 (transient) > 영구 실패 (actionable) > 트리아지 (expiry, also actionable but lower urgency) > filter > content.
|
||||
|
||||
---
|
||||
|
||||
## 6. zustand store
|
||||
|
||||
```ts
|
||||
// InboxState 확장
|
||||
failedCount: number;
|
||||
retryAllFailed: () => Promise<void>;
|
||||
```
|
||||
|
||||
initial: `failedCount: 0`.
|
||||
|
||||
`loadInitial` / `refreshMeta` 의 `Promise.all` 에 `inboxApi.getFailedCount()` 합류 → `set({ failedCount })`.
|
||||
|
||||
`retryAllFailed` action:
|
||||
```ts
|
||||
async retryAllFailed() {
|
||||
const r = await inboxApi.retryAllFailed();
|
||||
// 낙관적 갱신: failedCount = 0 으로 reset (worker 처리 진행 중)
|
||||
// 실제 카운트는 AiWorker.onUpdate 트리거된 refreshMeta 에서 자연 동기.
|
||||
// PendingBanner 가 처리 중 N 건 노출.
|
||||
set({ failedCount: 0 });
|
||||
// r.count 는 telemetry/log 정보용
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Telemetry
|
||||
|
||||
### 7.1 신규 1 event
|
||||
|
||||
| event | payload | 발화 |
|
||||
|-------|---------|------|
|
||||
| `ai_retry_manual` | `{ failedCount: number }` (≥1) | retryAllFailed 시 ids.length>0 일 때만 |
|
||||
|
||||
빈 배열 시 emit 안 함 — sentinel.
|
||||
|
||||
### 7.2 zod schema
|
||||
|
||||
```ts
|
||||
const AiRetryManualPayload = z.object({
|
||||
failedCount: z.number().int().positive() // ≥1 enforced — 0 emit 자체가 invariant violation
|
||||
}).strict();
|
||||
```
|
||||
|
||||
### 7.3 stats.md 집계
|
||||
|
||||
신규 행 (수동 recheck 사용량 다음):
|
||||
- AI 수동 재시도 사용량: `count` 회 / 누적 `Σfailedcount` 건
|
||||
|
||||
`DailyRow` 에 1 카운터 + sum 누적기 추가.
|
||||
|
||||
### 7.4 기존 `ai_failed` 영향
|
||||
|
||||
변경 없음. unreachable/timeout 가 markAiFailed 안 부르므로 자연히 reason 분포에서 제외. 결과: `ai_failed.reason` 분포 = schema + other 만. dogfood 통계 의미 명확화.
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
| 영역 | 케이스 | 검증 |
|
||||
|------|--------|------|
|
||||
| AiWorker | unreachable 무한 retry | attempts 증가 안 함, markAiFailed 안 호출, ai_failed emit 안 함 |
|
||||
| AiWorker | timeout 무한 retry (Q2=A) | unreachable 와 동일 경로 |
|
||||
| AiWorker | schema fail max 3 | attempts 증가, 마지막에 markAiFailed + ai_failed emit |
|
||||
| AiWorker | other fail max 3 | schema 와 동일 |
|
||||
| AiWorker | unreachable backoff step | 1차 30s, 2차 60s, ..., 6차 900s cap |
|
||||
| AiWorker | success 시 unreachableBackoffStep reset | 다음 unreachable 발생 시 30s 부터 |
|
||||
| Repo | findFailedIds — failed + active 만 | trashed 또는 pending/done 제외 |
|
||||
| Repo | countFailed | 정확 |
|
||||
| Repo | retryAllFailed atomic | ai_status reset + pending_jobs 재투입 |
|
||||
| Repo | retryAllFailed empty | `{ ids: [] }` |
|
||||
| Repo | retryAllFailed pending_jobs 이미 존재 | OR IGNORE — race 안전 |
|
||||
| Repo | setNextRunAt | attempts 변경 없이 next_run_at + last_error 만 |
|
||||
| CaptureService | retryAllFailed — telemetry emit + worker.enqueue 호출 | per-id enqueue + ai_retry_manual emit |
|
||||
| CaptureService | retryAllFailed 빈 결과 emit 없음 | count=0 sentinel |
|
||||
| TelemetryEvents | zod parse `ai_retry_manual` | happy + extra field reject + 0 reject (≥1 invariant) |
|
||||
| TelemetryStats | AI 수동 재시도 집계 | count + sum |
|
||||
| Store | retryAllFailed action — failedCount=0 reset | 낙관적 갱신 |
|
||||
|
||||
총 ≥ 17 단위.
|
||||
|
||||
---
|
||||
|
||||
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
|
||||
|
||||
T1. Repo: findFailedIds + countFailed + retryAllFailed + setNextRunAt + 단위 5개
|
||||
T2. AiWorker: unreachable/timeout 무한 retry 로직 + 단위 6개
|
||||
T3. Telemetry: ai_retry_manual 1 event + stats + 단위 3개
|
||||
T4. CaptureService.retryAllFailed + IPC 2 채널 + preload + 단위 2개
|
||||
T5. shared/types InboxApi + store retryAllFailed + failedCount + 단위 1개
|
||||
T6. FailedBanner 컴포넌트 + App.tsx mount
|
||||
T7. Tray "지금 AI 처리 (실패 N건)" 메뉴 + 9th callback + refreshTrayFailedCount + main wiring
|
||||
T8. closure (gates + roadmap mark + memory backlog)
|
||||
|
||||
---
|
||||
|
||||
## 10. roadmap In/Out 일치
|
||||
|
||||
### 10.1 roadmap §3 #2 In 매핑
|
||||
|
||||
| roadmap | design |
|
||||
|---------|--------|
|
||||
| AiWorker unreachable 무한 retry, attempts 증가 안 함 | §2 ✓ |
|
||||
| schema fail / invalid response / timeout 만 attempts 증가 (max 3 유지) | §2 — **timeout 은 deviation (Q2=A)**, schema/other 만 attempts 증가 |
|
||||
| markAiFailed 한 노트 수동 re-enqueue | §3 retryAllFailed |
|
||||
| 트레이 + Inbox "지금 AI 처리 (실패 N건)" | §5 ✓ |
|
||||
| FailedBanner | §5.2 ✓ |
|
||||
| IPC `inbox:retryAllFailed`, `inbox:failedCount` | §4 ✓ |
|
||||
| Telemetry `ai_retry_manual {failedCount}` | §7 ✓ |
|
||||
| 단위 테스트 | §8 ≥ 17 |
|
||||
|
||||
### 10.2 Out 유지
|
||||
|
||||
- per-note retry 버튼 (Q3=A) — Out
|
||||
- failed reason 별 차등 정책 — Out (모두 동일 max 3, telemetry 통계만 분리)
|
||||
- retry progress UI — Out (PendingBanner 가 자연 표현)
|
||||
- retry rate-limit — Out
|
||||
|
||||
### 10.3 roadmap deviation
|
||||
|
||||
§3 #2 In 의 "timeout 만 attempts 증가" 와 본 design 의 Q2=A "timeout 무한 retry" 가 충돌. 의식적 변경 — `ai_failed.reason='timeout'` 통계가 부족할 수 있음. dogfood 데이터로 검증 후 v0.2.4 에서 hang 케이스 분리 가능.
|
||||
|
||||
---
|
||||
|
||||
## 11. 위험 / 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| unreachable 무한 retry 큐 폭주 | sleep await sequential. 같은 job 안 in-place loop, 새 job 추가 0. cap 15분. |
|
||||
| retryAllFailed 가 큰 N (예: 100+) | enqueue in-memory queue push. AiWorker 가 sequential 처리 — provider 호출 1개씩. 폭주 0. |
|
||||
| timeout 분류 잘못 — 영구 hang 노트가 무한 retry | telemetry markAiFailed 시점만 emit → timeout 무한 retry 노트 stats 안 보임. v0.2.4 dogfood 시 ai_status='pending' 의 attempts 분포로 영구 hang 식별. 필요 시 timeout cap 도입. |
|
||||
| unreachableBackoffStep 이 process restart 시 reset | 의도. next_run_at 가 미래면 sleep, 과거면 즉시 retry — 자연. |
|
||||
| schema 후 unreachable 발생 — backoff step 이 unreachableBackoffStep 와 별개 인덱스 | unreachableBackoffStep 은 unreachable/timeout 전용. schema 의 attempts 와 독립. 단위 테스트 회귀 가드. |
|
||||
| retryAllFailed 와 AiWorker 큐의 race (이미 처리 중인 노트 재투입) | retryAllFailed SQL 이 ai_status='failed' 만 → 처리 중 ('pending') 노트는 자연 제외. atomic transaction. |
|
||||
| pending_jobs 재투입 시 이미 pending_jobs row 존재 (예: race) | INSERT OR IGNORE — duplicate ignored. attempts/next_run_at 그대로 유지. 안전. |
|
||||
|
||||
---
|
||||
|
||||
## 12. 게이트 (PR 머지 조건)
|
||||
|
||||
- `npm run typecheck` 0 errors
|
||||
- `npm test` — 344 + 17 = 361+
|
||||
- `npm run test:e2e` 1/1
|
||||
- main 머지
|
||||
|
||||
머지 후:
|
||||
- roadmap §3 #2 ✓ 완료 마커
|
||||
- v0.2.4 backlog 누적
|
||||
|
||||
---
|
||||
|
||||
## 13. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — Q1=A (15분 cap), Q2=A (timeout=unreachable), Q3=A (retry-all only). AiWorker unreachable/timeout 무한 retry + retryAllFailed atomic + FailedBanner + tray "지금 AI 처리" + ai_retry_manual telemetry. roadmap §3 #2 deviation 1건 (timeout) 의식적. |
|
||||
294
docs/superpowers/specs/2026-05-01-v023-expiry-design.md
Normal file
294
docs/superpowers/specs/2026-05-01-v023-expiry-design.md
Normal file
@@ -0,0 +1,294 @@
|
||||
# v0.2.3 #5 만료 추천 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.3 cut 7항목 중 3번째 항목 (#5 만료 추천) 의 mini-brainstorm 결정 + design. roadmap §3 #5 의 In/Out 위에서 §8 의 미결정 3항목 + UI 위치/0건 처리 추가 결정.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #5 (In/Out), §8 (미결정 항목)
|
||||
- `docs/superpowers/specs/2026-05-01-v023-trash-design.md` (#4 trash, deleted_at 인프라)
|
||||
- `docs/superpowers/plans/2026-04-26-f7-ai-primary-due-date.md` (due_date 컬럼 + AI 추출 흐름)
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| Q | 결정 | 근거 |
|
||||
|---|------|------|
|
||||
| Q1 `due_date_edited_by_user` 필터 | **B 필터 없음** — AI 자동 + 사용자 수동 모두 후보 | 의도와 무관하게 "지나간 due_date" 는 트리아지 대상. AI 자동 가중치 차등은 v0.2.4 로. |
|
||||
| Q2 만료 임박 (D-7) | **A 만료만** (`due_date < today`) | roadmap §3 #5 Out 명시. 임박은 의미 (주의 환기 vs trash) 가 달라 분리 surface 필요. v0.2.4. |
|
||||
| Q3 멀티선택 default | **C unchecked default + "전체 선택" 토글 버튼** | 데이터 안전 우선 (v0.2.1 패턴). 일괄도 토글 한 번. |
|
||||
| Q4 배너 위치 | **B PendingBanner 아래** | system(Ollama) → progress(Pending) → actionable(Expired) → filter(tagFilter) 순. |
|
||||
| Q5 후보 0건 / snooze | **A collapse** (렌더링 생략) | PendingBanner `pendingCount===0` → null 패턴 일치. 빈 카피는 노이즈. |
|
||||
|
||||
---
|
||||
|
||||
## 2. 데이터 / 쿼리
|
||||
|
||||
### 2.1 NoteRepository 확장
|
||||
|
||||
```ts
|
||||
// src/main/repository/NoteRepository.ts
|
||||
|
||||
findExpiredCandidates({ today }: { today: string }): Note[];
|
||||
trashBatch(ids: string[], deletedAt: string): { trashedCount: number };
|
||||
```
|
||||
|
||||
- `today`: `'YYYY-MM-DD'` 문자열 (KST 자정 기준 오늘 날짜). caller 가 KST 기준 계산 후 주입 (테스트에서 clock injection 용이).
|
||||
- `due_date` 도 `'YYYY-MM-DD'` 저장 (slice §F1 invariant 일치).
|
||||
- `findExpiredCandidates` SQL:
|
||||
```sql
|
||||
SELECT <note columns + JOIN tags + media> FROM notes
|
||||
WHERE due_date IS NOT NULL
|
||||
AND due_date < ?
|
||||
AND deleted_at IS NULL
|
||||
AND ai_status = 'done'
|
||||
ORDER BY created_at DESC
|
||||
```
|
||||
- `trashBatch` 는 단일 `db.transaction()` 안에서 `repo.trash(id, deletedAt)` 반복. 이미 trash 된 id 는 silent skip (UPDATE 가 deleted_at 이 이미 set 인 row 에 영향 0건). 반환 `trashedCount` 는 실제 transition (active → trash) 발생 건수. pending_jobs 정리는 `trash()` 가 이미 처리.
|
||||
|
||||
### 2.2 KST 자정 today 계산
|
||||
|
||||
```ts
|
||||
// src/main/util/kstDate.ts (재사용 또는 신설)
|
||||
export function todayInKst(now: Date): string {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const kst = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return kst.toISOString().slice(0, 10); // 'YYYY-MM-DD'
|
||||
}
|
||||
```
|
||||
|
||||
- ContinuityService 의 KST_OFFSET_MS 패턴 재사용. 신규 util 또는 ContinuityService 의 helper 추출.
|
||||
- 단위 테스트: UTC 23:30 (KST 다음날 08:30) 케이스 검증.
|
||||
|
||||
---
|
||||
|
||||
## 3. IPC
|
||||
|
||||
| 채널 | 입력 | 출력 | 설명 |
|
||||
|------|------|------|------|
|
||||
| `inbox:listExpired` | (없음) | `Note[]` | candidates 조회. 빈 배열 가능. |
|
||||
| `inbox:trashExpiredBatch` | `{ ids: string[] }` | `{ trashedCount: number; confirmed: boolean }` | atomic batch trash + native confirm. ids 빈 배열 시 즉시 `{ trashedCount: 0, confirmed: false }`. |
|
||||
|
||||
CaptureService 가 진입점. `today` 는 main 에서 `todayInKst(new Date())` 로 계산.
|
||||
|
||||
---
|
||||
|
||||
## 4. 상태 관리 (zustand)
|
||||
|
||||
```ts
|
||||
// src/renderer/inbox/store.ts
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null; // KST 자정 epoch ms
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
```
|
||||
|
||||
### 4.1 동작 사양
|
||||
|
||||
- `loadExpired()`: IPC `inbox:listExpired` 호출 → `expiredCandidates` 갱신.
|
||||
- `loadInitial()` + `refreshMeta()` 의 `Promise.all` 에 `inboxApi.listExpired()` 합류.
|
||||
- `trashExpiredBatch(ids)`: IPC `inbox:trashBatch` 호출 → 성공 시 `expiredCandidates` 에서 ids 제거 + `trashCount` 증가 + `notes` 에서도 제거 (낙관적 갱신, restore 와 동일 패턴 — main 은 push 안 함).
|
||||
- `snoozeExpired()`: KST 자정 epoch ms 계산해 `expiredSnoozeUntilMs` 에 set. 컴포넌트에서 `Date.now() < snoozeUntil` 체크.
|
||||
- in-memory only. 앱 재시작 시 다시 노출 (roadmap §3 #5 In 의 명시 사양).
|
||||
|
||||
### 4.2 KST 자정 epoch 계산
|
||||
|
||||
```ts
|
||||
function nextKstMidnightMs(now: number): number {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnight = Math.ceil(kstNow / 86_400_000) * 86_400_000;
|
||||
return kstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
```
|
||||
|
||||
단위 테스트: KST 23:00 호출 시 다음날 00:00 KST 반환 (1시간 후), KST 00:01 호출 시 같은 날 자정 24시간 후 (23h59m 후).
|
||||
|
||||
---
|
||||
|
||||
## 5. UI — `ExpiryBanner`
|
||||
|
||||
위치: `<App.tsx>` 의 `<PendingBanner />` 아래 (showTrash=false 분기 안).
|
||||
|
||||
### 5.1 collapse 조건 (렌더 null)
|
||||
|
||||
```
|
||||
expiredCandidates.length === 0
|
||||
|| (expiredSnoozeUntilMs !== null && Date.now() < expiredSnoozeUntilMs)
|
||||
```
|
||||
|
||||
### 5.2 unfolded 구조
|
||||
|
||||
```
|
||||
┌─ ⏰ 오늘 기준 만료 N개 [▼ 펼침/▲ 접힘] [오늘 그만] ─┐
|
||||
│ │
|
||||
│ ☐ [전체 선택] │
|
||||
│ ☐ 노트 제목 1 · due 2026-04-20 · #회의 │
|
||||
│ ☐ 노트 제목 2 · due 2026-04-18 · #학습 │
|
||||
│ ... │
|
||||
│ │
|
||||
│ [선택 휴지통 (M개)] (M=0 시 disabled) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**헤더 (1줄)**
|
||||
- "⏰ 오늘 기준 만료 {N}개"
|
||||
- 펼침 토글 버튼 (▼/▲)
|
||||
- "오늘 그만" 버튼 — 클릭 시 `snoozeExpired()` → 배너 즉시 collapse
|
||||
|
||||
**펼침 영역**
|
||||
- 첫 노출 시 default = **펼침** (사용자가 만료 N건 + 어떤 노트인지 동시 노출).
|
||||
- 한 번 접으면 component-local useState 로 세션 동안 접힘 유지. reload 시 다시 펼침.
|
||||
- "전체 선택" 체크박스 — 모든 row 동시 toggle. partial 선택 시 indeterminate 상태 표시.
|
||||
- 노트 row: 체크박스 + 제목(truncate, max 1 line) + due_date + 태그 chip (1개, 없으면 생략).
|
||||
- row 전체 clickable — 클릭 시 체크박스 toggle (편집/펼침 액션 없음, read-only triage 모드).
|
||||
- "선택 휴지통 ({M}개)": M=0 시 disabled. 클릭 시 native confirm dialog ("선택한 {M}개를 휴지통으로 옮깁니다.\n\n복구는 휴지통 탭에서 가능합니다.") → 확인 시 `trashExpiredBatch(selectedIds)`.
|
||||
|
||||
### 5.3 confirm dialog
|
||||
|
||||
`#4` 패턴 재사용 — main 의 `dialog.showMessageBox` 동기 IPC. type='question', `buttons=['옮기기','취소'], defaultId=1, cancelId=1` (project 의 `inbox:permanentDelete` / `inbox:emptyTrash` 와 일관 — 위험 액션은 default focus = 취소). response 0 만 confirm 으로 처리.
|
||||
|
||||
---
|
||||
|
||||
## 6. Telemetry
|
||||
|
||||
### 6.1 신규 events
|
||||
|
||||
| event | payload | 발화 |
|
||||
|-------|---------|------|
|
||||
| `expired_banner_shown` | `{ candidateCount: number }` | `loadExpired()` 결과 `candidates.length > 0` 시. 같은 세션에 동일 후보 set 중복 emit 회피 (last shown signature 비교). |
|
||||
| `expired_batch_trash` | `{ count: number }` | `trashBatch` 성공 직후 (count = trashedCount). |
|
||||
|
||||
### 6.2 중복 emit 회피 — signature
|
||||
|
||||
`signature = candidateCount + ':' + first-3-ids.join('-')` (ids 는 §2.1 의 ORDER BY created_at DESC 정렬 결과의 처음 3개). main 의 `CaptureService.listExpired()` 안에서 `lastExpiredShownSig: string | null` field 와 비교 → 같으면 emit skip, 다르면 emit + sig 갱신. renderer 는 dedup 미관여 (단순 fetch). 결과: IPC 채널 2개 유지 (`inbox:listExpired` 가 자체 dedup-emit 통합).
|
||||
|
||||
### 6.3 zod 스키마
|
||||
|
||||
```ts
|
||||
// src/main/services/TelemetryService.ts (TelemetryEvent discriminatedUnion 확장)
|
||||
z.object({
|
||||
kind: z.literal('expired_banner_shown'),
|
||||
payload: z.object({ candidateCount: z.number().int().nonnegative() }).strict()
|
||||
}).strict(),
|
||||
z.object({
|
||||
kind: z.literal('expired_batch_trash'),
|
||||
payload: z.object({ count: z.number().int().nonnegative() }).strict()
|
||||
}).strict(),
|
||||
```
|
||||
|
||||
### 6.4 stats.md 집계 추가
|
||||
|
||||
| 행 | 산식 |
|
||||
|----|------|
|
||||
| 만료 배너 노출 | `expired_banner_shown` count |
|
||||
| 만료 일괄 trash | `expired_batch_trash` total `count` 합 |
|
||||
| 만료 trash ratio | `sum(expired_batch_trash.count) / sum(expired_banner_shown.candidateCount)` |
|
||||
|
||||
---
|
||||
|
||||
## 7. F5 export / F6 import / 백업
|
||||
|
||||
영향 0건. #4 가 이미 `deleted_at IS NULL` 을 export/active query 에 적용. 만료 후보는 active 노트의 부분집합이므로 별도 정책 불필요.
|
||||
|
||||
**Regression guard**: 단위 테스트로 "사용자 수동 due_date 도 만료 후보" + "trash 된 만료 노트는 후보 제외" 회귀 가드 추가.
|
||||
|
||||
---
|
||||
|
||||
## 8. 테스트
|
||||
|
||||
| 영역 | 단위 | 검증 |
|
||||
|------|------|------|
|
||||
| Repo | `findExpiredCandidates` happy path | due_date < today 만 반환, ORDER BY created_at DESC |
|
||||
| Repo | `findExpiredCandidates` AI + 수동 mix | Q1=B 회귀 가드 — 둘 다 포함 |
|
||||
| Repo | `findExpiredCandidates` deleted_at | trash 노트 제외 (#4 invariant 회귀 가드) |
|
||||
| Repo | `findExpiredCandidates` ai_status | pending/failed 제외 |
|
||||
| Repo | `findExpiredCandidates` due_date NULL | NULL 노트 제외 (NULL < string 평가 가드) |
|
||||
| Repo | `trashBatch` atomic happy | N개 모두 trash, count=N |
|
||||
| Repo | `trashBatch` 빈 배열 | count=0, no-op |
|
||||
| Repo | `trashBatch` 일부 invalid id | valid 만 trash, count = valid 수 |
|
||||
| Repo | `trashBatch` 이미 trash | 재호출 시 count=0 (idempotent) |
|
||||
| util | `todayInKst` UTC vs KST 경계 | 23:30 UTC → 다음날 KST 날짜 |
|
||||
| Service | `nextKstMidnightMs` | 자정 KST 정확 계산 |
|
||||
| Telemetry | zod parse `expired_banner_shown` | candidateCount int ≥ 0 |
|
||||
| Telemetry | zod parse `expired_batch_trash` | count int ≥ 0 |
|
||||
| Telemetry | privacy invariant | payload 에 raw_text/title 포함 시 거부 (기존 invariant 회귀 가드) |
|
||||
| Store | `loadExpired` integration | candidates set + count |
|
||||
| Store | `trashExpiredBatch` 낙관적 갱신 | candidates 제거 + trashCount 증가 + notes 제거 |
|
||||
| Store | `snoozeExpired` | snoozeUntilMs = 다음 KST 자정 epoch |
|
||||
|
||||
총 단위 ≥ 16개. e2e smoke 영향 없음 (만료 노트 fixture 추가 없이 기존 1/1 e2e 보존).
|
||||
|
||||
---
|
||||
|
||||
## 9. 작업 순서 (writing-plans 시 task 분할 가이드)
|
||||
|
||||
T1. `findExpiredCandidates` repo + 단위 5개 (TDD)
|
||||
T2. `trashBatch` repo + 단위 4개 (TDD)
|
||||
T3. `todayInKst` util + `nextKstMidnightMs` 계산 + 단위 2개
|
||||
T4. Telemetry 2 events 추가 (zod + stats.md 집계 + 단위 3개)
|
||||
T5. CaptureService 메소드 + IPC 2 채널 + 단위
|
||||
T6. zustand store 확장 + 단위 3개
|
||||
T7. `ExpiryBanner` 컴포넌트 (펼침/접힘/체크박스/전체선택/오늘그만)
|
||||
T8. App.tsx 통합 (PendingBanner 아래 mount)
|
||||
T9. confirm dialog + trashBatch 호출 path 통합
|
||||
T10. typecheck + 전체 단위 + e2e + roadmap §3 #5 ✓ 마커 + closure
|
||||
|
||||
---
|
||||
|
||||
## 10. roadmap In/Out 일치
|
||||
|
||||
### 10.1 roadmap §3 #5 In 처리 매트릭스
|
||||
|
||||
| roadmap 항목 | 본 design |
|
||||
|-------------|----------|
|
||||
| `findExpiredCandidates({today})` | §2.1 ✓ |
|
||||
| Inbox 상단 만료 배너 + 펼침 + 멀티선택 + 선택 휴지통 + 오늘 그만 | §5 ✓ |
|
||||
| IPC `inbox:listExpired`, `inbox:trashBatch` | §3 ✓ |
|
||||
| Telemetry `expired_banner_shown` `{candidateCount}` | §6.1 ✓ |
|
||||
| Telemetry `expired_batch_trash` `{count}` | §6.1 ✓ |
|
||||
| 단위 테스트 | §8 ✓ (16개) |
|
||||
|
||||
### 10.2 roadmap §3 #5 Out 유지
|
||||
|
||||
- 시스템 알림 surface — Out
|
||||
- 별 페이지 — Out
|
||||
- snooze 영속화 — Out (in-memory + 자정 KST 리셋)
|
||||
- "안 옮김" 가중치 감소 — Out
|
||||
- 만료 임박 (D-7) 추천 — Out (Q2 confirmed)
|
||||
|
||||
---
|
||||
|
||||
## 11. 위험 / 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| `due_date IS NOT NULL` 누락 시 NULL < string 평가 (SQLite 의 NULL 비교 결과 NULL → falsy) | 명시적 `WHERE due_date IS NOT NULL` + 단위 테스트 회귀 가드 |
|
||||
| 사용자가 "오늘 그만" 후 다른 만료 노트 추가 시 배너 안 뜸 (자정까지) | 의도된 동작. 자정 KST 리셋 시 다시 노출. roadmap §3 #5 In 명시. |
|
||||
| 같은 세션에 candidates 가 자주 바뀌면 (capture 등) `expired_banner_shown` 이 과다 emit | signature 비교 (§6.2) 로 회피 |
|
||||
| `trashBatch` 의 `today` 가 caller 마다 다른 시점이면 race | main 단일 진입점 (CaptureService) 에서 호출 시점 1회 계산. renderer 가 today 주입 안 함. |
|
||||
| ExpiryBanner 가 PendingBanner 사이에 끼어 layout shift | 양쪽 다 collapse 조건 명확 (count=0 → null) — shift 는 사용자 액션 결과 (예측 가능) |
|
||||
|
||||
---
|
||||
|
||||
## 12. 게이트 (PR 머지 조건, roadmap §3.1 일치)
|
||||
|
||||
- `npm run typecheck` 0 에러
|
||||
- `npm test` — 기존 295/295 + 신규 16개 = 311/311 (또는 그 이상)
|
||||
- `npm run test:e2e` 1/1 통과
|
||||
- main 머지
|
||||
|
||||
머지 후:
|
||||
- roadmap `§3 #5 만료 추천 (3번)` 다음 `✓ 완료` 마커
|
||||
- `memory/project_v024_backlog.md` 에 deferred 항목 기록 (review 결과)
|
||||
|
||||
---
|
||||
|
||||
## 13. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — Q1=B (필터 없음), Q2=A (만료만), Q3=C (unchecked default + 전체선택 토글), Q4=B (PendingBanner 아래), Q5=A (0건 collapse). |
|
||||
| 2026-05-01 | §6.2 dedup 위치를 renderer → main (CaptureService) 로 변경. IPC 채널 수 2개 유지. plan 단계 단순화. |
|
||||
@@ -0,0 +1,342 @@
|
||||
# v0.2.2 Dogfood 피드백 로드맵 (#7→#6 → v0.2.3) 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.2 dogfood 중 발견된 7개 항목 (`memory/project_v022_feedback.md` #1~#6 + 본 brainstorm 에서 추가된 #7 telemetry) 의 순차 작업 로드맵. 본 문서는 **순서·범위·게이트** 만 정의하며, 각 항목 내부 설계는 항목별 mini-brainstorm + writing-plans 에서 결정.
|
||||
|
||||
**선행 문서:**
|
||||
- `memory/project_v022_feedback.md` (raw 피드백 6건)
|
||||
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` (v0.2.1 로드맵, 본 문서의 패턴 원형)
|
||||
- `docs/superpowers/specs/2026-04-24-inkling-vertical-slice-design.md` (slice §1.1 invariant 4 — 본문 미기록)
|
||||
- `docs/superpowers/strategy/strategy.md` (#6 에서 §2.3·§4.3·§8 동반 갱신 대상)
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| 결정 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| Cut 패턴 | **단일 cut v0.2.3** (7항목 한 묶음) | v0.2.1 패턴 반복. 항목 간 결합도 (특히 #7 → #4~#6 emit) 분리 시 회전 비용. |
|
||||
| 우선순위 기준 | **데이터 안전 우선** (v0.2.1 패턴) | 측정 인프라 (#7) → schema migration v3 (#4) → 안전망 위에서 기능 진행. |
|
||||
| 첫 항목 | **#7 Telemetry skeleton** | 다른 6 항목이 emit hook 만 추가. 측정 없는 기능 출시는 다음 cut 까지 1주 본인 라벨링으로 후퇴. |
|
||||
| 항목당 게이트 | **머지 + 테스트 통과** (typecheck + 205+ 단위 + e2e smoke) | v0.2.2 기준선. |
|
||||
| 다음 빌드 | **v0.2.3** (7항목 모두 머지 후 단일 cut) | slice §7 strict-pin patch 증분. |
|
||||
| 신규 dependency | **0 목표** | 모두 stdlib + 기존 better-sqlite3 / electron 으로 충분. |
|
||||
| Schema 변경 | **migration v3** — 3 컬럼 한 번에: `deleted_at TEXT NULL`, `last_recalled_at TEXT NULL`, `recall_dismissed_at TEXT NULL` | #4 휴지통 + #6 회상 메타 한 묶음. 별 migration 두 번 회피. |
|
||||
| Trash 와 export/backup | **B 정책** — F6-L1 backup 포함 (byte-for-byte), F5 export 제외, F6-L3 import 시 `deleted_at IS NOT NULL` 우선 (삭제 보존) | 백업은 회복 용도, export 는 외부 노출 형식. |
|
||||
| Decision-pending 처리 | **항목별 mini-brainstorm** | 본 문서는 순서·In/Out 만, 항목 내부는 per-item. |
|
||||
|
||||
---
|
||||
|
||||
## 2. 순차 작업 순서
|
||||
|
||||
```
|
||||
v0.2.2 ────────[ dogfood 동결, 병렬 진행 ]────────
|
||||
│
|
||||
개발 트랙 (main 직접 머지 또는 PR): │
|
||||
① #7 Telemetry skeleton [작음, 인프라 1번] │
|
||||
② #4 휴지통 + migration v3 [중, schema + 정책] │
|
||||
③ #5 만료 추천 [작음, #4 destination]│
|
||||
④ #1 Ollama 회복 polling [작음, 독립] │
|
||||
⑤ #2 AI retry / 수동 trigger [중, AiWorker 정책] │
|
||||
⑥ #3 태그 vocab 주입 [작음, 독립] │
|
||||
⑦ #6 리마인드 1 spike [중, strategy 갱신] │
|
||||
│
|
||||
┌──────────┘
|
||||
▼
|
||||
v0.2.3 cut (단일)
|
||||
│
|
||||
▼
|
||||
dogfood 재설치 + ≥ 1주 soak
|
||||
│
|
||||
▼
|
||||
telemetry export → 분석 →
|
||||
v0.2.4 brainstorm
|
||||
```
|
||||
|
||||
### 2.1 순서 결정 근거
|
||||
|
||||
1. **#7 (1번)** — 측정 인프라. 다른 항목이 emit hook 추가만 하도록 skeleton 먼저. Cross-cutting privacy invariant 강제도 1번에서 단위 테스트로 고정.
|
||||
2. **#4 (2번)** — schema migration v3 가 #6 회상 메타 컬럼 동반. 휴지통 invariant (`deleted_at IS NULL` 모든 active 쿼리) 가 다른 항목에 영향. 회복 안전망 (pre-v3 snapshot, v0.2.1 메커니즘) 위에서 진행.
|
||||
3. **#5 (3번)** — #4 휴지통 destination 직접 소비. 같은 영역 (Inbox 상단 배너).
|
||||
4. **#1 (4번)** — #2 의 reliable health 의존성. polling 인프라 먼저.
|
||||
5. **#2 (5번)** — #1 health 위에서 retry/manual trigger 정책 변경. AiWorker 의 unreachable infinite retry 로 #1 polling 결과 활용.
|
||||
6. **#3 (6번)** — 독립 prompt 변경. PROMPT_VERSION 4. AI 영역 마지막에 묶어서 AiWorker 회귀 위험 격리.
|
||||
7. **#6 (7번)** — strategy.md 갱신 + RecallBanner 1 spike. last_recalled_at / recall_dismissed_at 사용. 가장 마지막에 두는 이유: 다른 항목 telemetry hook 이 모두 박혀야 #6 측정 가치가 살아남.
|
||||
|
||||
---
|
||||
|
||||
## 3. 항목당 In (PR 범위) / Out (deferred)
|
||||
|
||||
각 항목 PR 범위 라인. 세부 결정 (decision-pending) 은 항목 시작 시 mini-brainstorm.
|
||||
|
||||
### #7 Telemetry skeleton (1번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `TelemetryService` (`src/main/services/TelemetryService.ts`):
|
||||
- `emit(kind, payload)` → 비동기 append to `<profileDir>/telemetry/events-YYYY-MM-DD.jsonl`
|
||||
- 일자별 rotation (KST 자정), 14일 후 rolling 삭제
|
||||
- write 실패 시 silent log only (앱 동작 영향 없음)
|
||||
- 이벤트 zod schema: `{ ts: ISO string, kind: enum, payload: object }`. payload shape 는 kind 별 fixed.
|
||||
- **Privacy invariant** (slice §1.1 invariant 4 강화): payload 에 `raw_text` / `ai_title` / `ai_summary` / `user_intent` / 태그 name 포함 시 zod parser 거부. 단위 테스트로 고정.
|
||||
- 기본 emit hook 박기:
|
||||
- `capture` (CaptureService.submit 후): `{ noteId, rawTextLength, hasMedia }`
|
||||
- `ai_succeeded` (AiWorker.processJob 성공): `{ noteId, durationMs, attempts }`
|
||||
- `ai_failed` (AiWorker.processJob 실패): `{ noteId, reason: "unreachable"|"schema"|"timeout"|"other", attempts }`
|
||||
- 트레이 메뉴 "사용 로그 내보내기...":
|
||||
- 폴더 다이얼로그 → `events.jsonl` (최근 14일 concat) + `stats.md` (집계 마크다운) zip
|
||||
- `stats.md` 내용: 항목별 일자별 카운트 표 + 핵심 ratio (AI 성공률, ollama uptime%, recall opened/shown, expired batched/shown 등)
|
||||
- 트레이 콜백 (main 내부 — 별도 IPC 채널 불필요)
|
||||
- 단위 테스트: emit, rotation, privacy invariant 거부, stats 집계, export zip
|
||||
|
||||
**Out:** 자동 업로드 / 원격 telemetry (모두 로컬), 실시간 대시보드 UI, opt-out 토글 (로컬이라 불필요), 14일 보존 기간 사용자 설정
|
||||
|
||||
### #4 휴지통 (2번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- migration v3: `notes.deleted_at TEXT NULL` + `notes.last_recalled_at TEXT NULL` + `notes.recall_dismissed_at TEXT NULL` (3 컬럼 한 번)
|
||||
- `NoteRepository`: `trash(id)` (`deleted_at = now()`), `restore(id)` (`deleted_at = NULL`), `emptyTrash()` (hard delete + media 정리). 기존 `delete()` 는 deprecate 후 `emptyTrash` 내부에서만 호출.
|
||||
- **Active 쿼리 일괄 `WHERE deleted_at IS NULL` 추가**: `listNotes`, `countToday`, `findByTag`, search, F5 export, AiWorker `loop()` 진입 시 deleted_at 체크
|
||||
- 휴지통 UI: Inbox 상단 탭 ("Inbox · 휴지통(N)") — 정밀 위치는 mini-brainstorm
|
||||
- 휴지통 비우기 confirm dialog ("N개 영구 삭제. 되돌릴 수 없음.")
|
||||
- F5 export 가 `deleted_at IS NOT NULL` 제외
|
||||
- F6-L3 import 충돌 정책 추가: source 와 dest 중 `deleted_at IS NOT NULL` 우선 (삭제 보존)
|
||||
- IPC: `inbox:trash` / `inbox:restore` / `inbox:emptyTrash` / `inbox:listTrash`
|
||||
- Telemetry emit: `trash` / `restore` / `emptyTrash`
|
||||
- 단위 테스트: trash/restore/emptyTrash, active query 제외, AiWorker skip, F5 export 제외, F6-L3 import 머지
|
||||
|
||||
**Out:** 자동 비우기 정책 (사용자 트리거만), 휴지통 검색, trash 안 노트 편집, 휴지통 UI 정밀 위치 (mini-brainstorm), per-note 영속 보호 플래그
|
||||
|
||||
### #5 만료 추천 (3번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `NoteRepository.findExpiredCandidates({today})`:
|
||||
- `WHERE due_date < today AND deleted_at IS NULL AND ai_status = 'done'`
|
||||
- ORDER BY `created_at DESC`
|
||||
- Inbox 상단 **만료 배너** (펼침 가능):
|
||||
- "오늘 기준 만료 N개" 헤더
|
||||
- 펼치면 노트 카드 리스트 + 체크박스 멀티선택
|
||||
- "선택 휴지통" 버튼 → 일괄 trash + telemetry emit
|
||||
- "오늘 그만" → in-memory snooze (자정 KST 리셋)
|
||||
- IPC: `inbox:listExpired`, `inbox:trashBatch`
|
||||
- Telemetry emit: `expired_banner_shown` (`{ candidateCount }`), `expired_batch_trash` (`{ count }`)
|
||||
- 단위 테스트: 후보 query, 멀티선택 batch trash, snooze 동작, deleted_at 제외 확인
|
||||
|
||||
**Out:** 시스템 알림 surface, 별 페이지, snooze 영속화, "안 옮김" 가중치 감소, 만료 임박 (D-7) 추천
|
||||
|
||||
### #1 Ollama 회복 (4번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- HealthChecker 주기 polling (기본 60s — mini-brainstorm 에서 주기/backoff 확정):
|
||||
- `runOnce()` 가 setInterval 로 자동 발화
|
||||
- 회복 시 `onUpdate` fire → 구독 (renderer OllamaBanner) 자동 갱신
|
||||
- 실패 N회 후 polling 중단 정책 — mini-brainstorm
|
||||
- 수동 "재확인" 버튼: `OllamaBanner` + 트레이 컨텍스트 메뉴
|
||||
- IPC: `inbox:ollamaRecheck`
|
||||
- Telemetry emit: `ollama_unreachable` (`{ reason }`), `ollama_recovered` (`{ downtimeMs }`), `ollama_recheck_manual` (`{}`)
|
||||
- 단위 테스트: polling fire, manual recheck, 회복 status 전이 + telemetry emit
|
||||
|
||||
**Out:** 사용자 설정 가능 polling 주기, 회복 toast 알림, 모델 정상성 (tags 외) 체크
|
||||
|
||||
### #2 AI retry / 수동 trigger (5번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `AiWorker.processJob()` 정책 변경:
|
||||
- **ollama unreachable** 일 때 `attempts` 증가 안 하고 `next_run_at` 만 backoff (무한 retry while unreachable)
|
||||
- schema fail / invalid response / timeout 만 `attempts` 증가 (기존 max 3 유지)
|
||||
- reason 분류는 `LocalOllamaProvider` 결과 + zod 결과로 결정
|
||||
- `markAiFailed` 한 노트 수동 re-enqueue 가능 (hard fail 도 회수 경로)
|
||||
- 트레이 + Inbox 메뉴 **"지금 AI 처리 (실패 N건)"** → 모든 ai_status='failed' → pending_jobs 재투입
|
||||
- `FailedBanner` (PendingBanner 형제, 실패 N건 + retry 버튼)
|
||||
- IPC: `inbox:retryAllFailed`, `inbox:failedCount`
|
||||
- Telemetry emit: `ai_failed` (#7 의 기본 hook 에 reason 분류 추가), `ai_retry_manual` (`{ failedCount }`)
|
||||
- 단위 테스트: unreachable infinite retry, retry-all trigger, unreachable vs schema fail 구분, attempts 증가 정책
|
||||
|
||||
**Out:** per-note retry 버튼 (NoteCard), failed reason 별 차등 정책, retry progress UI, retry rate-limit
|
||||
|
||||
### #3 태그 vocab (6번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `NoteRepository.getTopUsedTags(N=20)`:
|
||||
- `SELECT t.name, COUNT(*) c FROM tags t JOIN note_tags nt ON nt.tag_id=t.id JOIN notes n ON n.id=nt.note_id WHERE n.deleted_at IS NULL GROUP BY t.id ORDER BY c DESC LIMIT 20`
|
||||
- `buildPrompt()` 에 vocab 주입 라인:
|
||||
- "기존 태그를 우선 재사용. 새 태그는 vocab 에 없는 의미일 때만 만들기:" + kebab-case 리스트
|
||||
- vocab 빈 케이스 (신규 사용자) → 라인 자체 생략
|
||||
- `PROMPT_VERSION` 3 → **4**
|
||||
- AI 응답 후 vocab hit/miss 분류 → telemetry emit
|
||||
- Telemetry emit: `tag_vocab_hit` (`{ tagId, vocabSize }`), `tag_vocab_miss` (`{ vocabSize }`)
|
||||
- 단위 테스트: vocab 합성, 빈 vocab, 길이 cap, prompt version bump, hit/miss 분류
|
||||
|
||||
**Out:** 임베딩 유사도 dedup, 사용자 controlled vocabulary 화이트리스트, 자동 normalize ("회의" ↔ "미팅"), top-N 튜닝, vocab cache invalidation 정책
|
||||
|
||||
### #6 리마인드 1 spike (7번) ✓ 완료
|
||||
|
||||
**In:**
|
||||
- `strategy.md` §2.3 / §4.3 / §8 갱신: Capitalize 본격 진입, "오늘 회상" surface 정의, F4-A/B/D deferred 항목의 측정 인프라 마련 명시
|
||||
- Inbox 상단 **`RecallBanner`** — "오늘 회상해볼 노트" 1건 추천:
|
||||
- algo: `WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','-7 day')) AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','-30 day')) AND ai_status='done' AND deleted_at IS NULL AND (due_date IS NULL OR due_date >= today) ORDER BY created_at ASC LIMIT 1`
|
||||
- 사용자 액션 3개:
|
||||
- "열어보기" → 노트 카드 스크롤 + `last_recalled_at = now()`
|
||||
- "다음에" → in-memory snooze 1일 (영속화 X)
|
||||
- "더 이상" → `recall_dismissed_at = now()`
|
||||
- IPC: `inbox:listRecallCandidate`, `inbox:markRecallOpened`, `inbox:dismissRecall`
|
||||
- Telemetry emit: `recall_shown` (`{ noteId, ageDays }`), `recall_opened`, `recall_dismissed`, `recall_snoozed`
|
||||
- 단위 테스트: algo selection, dismiss 만료 (30일 후 재추천), last_recalled 갱신, deleted_at 제외, 후보 0건 케이스
|
||||
|
||||
**Out:** 잠금해제 hook (F4-A), 무작위 토스트 (F4-D), ambient if-then (F4-B), 임베딩 유사도 추천 (#3 vocab 후속), spaced repetition (Leitner/SM-2), 다중 후보 추천
|
||||
|
||||
### 3.1 공통 게이트 (모든 항목)
|
||||
|
||||
각 항목 머지 전 필수:
|
||||
|
||||
- `npm run typecheck` 통과 (현재 0 에러)
|
||||
- `npm test` 통과 (현재 205/205, 항목 신규 단위 추가)
|
||||
- `npm run test:e2e` 통과 (현재 1/1)
|
||||
- 항목 신규 단위 테스트 ≥ 1개 (TDD)
|
||||
- main 머지
|
||||
|
||||
---
|
||||
|
||||
## 4. 항목당 작업 흐름 + Cross-cutting
|
||||
|
||||
```
|
||||
[항목 N 시작]
|
||||
│
|
||||
├─ mini-brainstorm ← decision-pending 답변
|
||||
│ - 본 문서 §3 의 "Out" 후보 일부가 In 으로 승격 가능
|
||||
│ - per-item spec doc → docs/superpowers/specs/2026-MM-DD-v023-<slug>.md
|
||||
│
|
||||
├─ writing-plans ← TDD 구현 계획
|
||||
│
|
||||
├─ 구현 (executing-plans 또는 직접)
|
||||
│ - 브랜치: feat/v023-<slug> (예: feat/v023-trash, feat/v023-recall)
|
||||
│ - 게이트 통과 후 main 머지
|
||||
│
|
||||
└─ 다음 항목 시작
|
||||
```
|
||||
|
||||
### 4.1 Cross-cutting 정책
|
||||
|
||||
| 영역 | 정책 |
|
||||
|------|------|
|
||||
| **버전 관리** | 7개 모두 머지될 때까지 `package.json` `0.2.2` 유지. v0.2.3 cut 은 7번 후 단일. |
|
||||
| **CHANGELOG** | 기존 `CHANGELOG.md` 에 `[0.2.3]` section append (v0.2.2 에서 확립한 패턴). v0.2.3 cut 직전 한 번만 수정. |
|
||||
| **브랜치 전략** | `feat/v023-<slug>` 단명. main 머지 후 삭제. 작은 항목 (#1, #3, #6 strategy 갱신) 은 main 직접 push 도 허용. |
|
||||
| **테스트 추가 정책** | 항목당 최소 단위 1개. e2e smoke 영향 시 단언 동기화. AiWorker 변경 (#1, #2, #3) 은 integration (Ollama) 영향 시 검토. |
|
||||
| **Slice invariant 위반 시** | 본 로드맵 결과로 invariant 변경 — slice §1.1 §7 도 PR 안에 동봉 수정. |
|
||||
| **신규 dependency** | slice §7 strict-pin 그대로. 0 신규 dep 목표 — 위반 시 PR 안에 §7.2 갱신 + 합리화 동봉. |
|
||||
| **로깅 정책** | slice §1.1 invariant 4 **강화**: telemetry payload 에 raw_text/title/summary/intent/tag name 포함 절대 금지. 위반 시 silent invariant 위반. #7 단위 테스트로 zod parser 가 거부. |
|
||||
| **Strategy.md 동반 갱신** | #6 항목 (7번) 에서만. 다른 항목은 strategy.md 미수정. |
|
||||
| **Schema invariant 추가** | `deleted_at IS NOT NULL` 노트는 모든 active 쿼리 (Inbox 리스트 / 카운트 / 검색 / 태그 필터 / AiWorker 처리 / F5 export) 에서 제외. F6-L1 backup 만 예외. 위반 시 dogfood-feedback 재오픈. |
|
||||
|
||||
---
|
||||
|
||||
## 5. v0.2.3 Cut 단계
|
||||
|
||||
7번 항목 머지 후:
|
||||
|
||||
```
|
||||
[v0.2.2 dogfood 환경에서]
|
||||
1. 트레이 → "지금 백업" 1회 클릭 ← F6-L1 첫 실증
|
||||
2. 트레이 → "내보내기..." 1회 ← F5 schema-agnostic 백업
|
||||
3. 트레이 → "사용 로그 내보내기..." 1회 ← #7 의 첫 실증 (없으면 v0.2.2 raw 데이터 손실)
|
||||
4. Inkling 종료
|
||||
|
||||
[빌드 머신에서]
|
||||
5. package.json version: 0.2.2 → 0.2.3
|
||||
6. CHANGELOG.md 에 [0.2.3] section append
|
||||
7. npm run dist
|
||||
8. dist/Inkling Setup 0.2.3.exe 검증
|
||||
|
||||
[dogfood 머신에서]
|
||||
9. Setup 0.2.3.exe 실행 → 같은 폴더에 설치
|
||||
10. 첫 실행 → migration v3 자동 적용 (deleted_at + last_recalled_at + recall_dismissed_at)
|
||||
11. 트레이 메뉴 "사용 로그 내보내기..." 항목 존재 확인
|
||||
12. ≥ 1주 soak 시작
|
||||
```
|
||||
|
||||
### 5.1 업그레이드 안전망
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| migration v3 결함으로 DB 손상 | 2가지 복원 경로 (v0.2.1 부터): (a) `<dbFile>.pre-v3.bak` 자동 snapshot 으로 SQLite 복원 (v0.2.2 인스톨러 재설치 필요), (b) F5 export → v0.2.3 의 F6-L3 import 로 schema-agnostic 복원 (더 빠름) |
|
||||
| `deleted_at IS NULL` 누락 — 휴지통 노트가 active 쿼리에 새는 회귀 | 단위 테스트로 모든 active 쿼리 확인. 실수 시 dogfood-feedback 즉시 재오픈. |
|
||||
| Telemetry payload 에 본문 누출 | `TelemetryService.emit` 의 zod parser 가 거부. CI 단위 테스트로 고정. |
|
||||
| AiWorker unreachable infinite retry 가 큐 폭주 | next_run_at 의 backoff cap (15분) — mini-brainstorm 에서 확정. |
|
||||
| 자동시작 토글 / 데이터 디렉터리 손실 | v0.2.1 동일 — `HKCU\...\Run` + `<profileDir>` 보존됨 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 측정
|
||||
|
||||
### 6.1 로드맵 측정
|
||||
|
||||
| 메트릭 | 임계값 | 측정 방법 |
|
||||
|--------|--------|----------|
|
||||
| 항목 평균 PR 사이즈 | < 800 lines diff | git log 통계 |
|
||||
| 항목 평균 머지 간격 | < 5일 | git log 시간차 |
|
||||
| 회귀 테스트 추가 | 항목당 ≥ 1개 단위 | `tests/unit` 카운트 |
|
||||
| v0.2.3 cut 후 1주 데이터 손실 | 0회 | telemetry + 본인 라벨링 보강 |
|
||||
| typecheck/test 회귀 | 0회 | CI · 로컬 |
|
||||
| Telemetry 본문 누출 | 0건 | events.jsonl grep + zod parser |
|
||||
|
||||
### 6.2 dogfood soak 측정 (#7 의 본격 사용처)
|
||||
|
||||
`stats.md` 가 다음을 답해야 함:
|
||||
|
||||
| 질문 | 데이터 |
|
||||
|------|--------|
|
||||
| AI 가 실제로 동작 중인가? | `ai_succeeded / (ai_succeeded + ai_failed)` ratio 일자별 |
|
||||
| Ollama unreachable 빈도? | `ollama_unreachable` count + 평균 `downtimeMs` |
|
||||
| 수동 trigger 가 쓰이고 있나? | `ai_retry_manual` / `ollama_recheck_manual` count |
|
||||
| 휴지통이 회수 도구로 동작? | `restore / trash` ratio |
|
||||
| 만료 추천이 nudging 으로 동작? | `expired_batch_trash / expired_banner_shown` ratio |
|
||||
| 회상 spike 가 의미 있나? | `recall_opened / recall_shown` ratio + `recall_dismissed` count |
|
||||
| Tag vocab 재사용? | `tag_vocab_hit / (hit + miss)` ratio (목표: 시간 흐름에 따라 상승) |
|
||||
|
||||
### 6.3 silent invariant 후보
|
||||
|
||||
본 로드맵 결과로 slice §1.3 종료 조건에 추가 권장:
|
||||
|
||||
> **"Telemetry 본문 누출 0회"** — events.jsonl 의 어떤 payload 에도 raw_text/title/summary/intent/tag name 미포함. 발생 시 즉시 silent invariant 위반.
|
||||
|
||||
> **"`deleted_at IS NULL` 망각 0회"** — active 쿼리 회귀 시 즉시 dogfood-feedback 재오픈.
|
||||
|
||||
이 추가는 #7 / #4 항목 머지 시 slice §1.3 동봉 수정.
|
||||
|
||||
---
|
||||
|
||||
## 7. 본 로드맵의 종료 조건
|
||||
|
||||
**모두 만족해야 종결**:
|
||||
|
||||
1. #7·#4·#5·#1·#2·#3·#6 7개 항목 모두 main 머지
|
||||
2. `CHANGELOG.md [0.2.3]` section + `package.json` 0.2.3 + slice §1.3 silent invariant 2개 추가 동봉 갱신 완료
|
||||
3. v0.2.3 cut → dogfood 머신 재설치 → migration v3 적용 확인 → 첫 실행 정상 + 트레이 메뉴 신규 항목 ("사용 로그 내보내기...") 동작 확인
|
||||
4. ≥ 1주 dogfood soak 완료 (데이터 손실 0회 + telemetry 본문 누출 0건 확인)
|
||||
5. `events.jsonl` + `stats.md` export → 분석 → v0.2.4 brainstorm 진입
|
||||
|
||||
5 가 끝나면 본 로드맵 종결.
|
||||
|
||||
---
|
||||
|
||||
## 8. 미결정 항목 (각 항목 mini-brainstorm 에서 답변)
|
||||
|
||||
본 로드맵은 순서·In/Out 만 정의. 다음 결정들은 빨리 마주치게 됨:
|
||||
|
||||
- **#7**: events.jsonl rotation 주기 (자정 KST 확정), stats.md 집계 ratio 의 정확한 컬럼 list, payload schema 별 zod 파서 합성 정책, write 실패 시 백오프
|
||||
- **#4**: 휴지통 UI 정밀 위치 (Inbox 탭 vs 트레이 별 윈도우 vs 별 페이지), 휴지통 비우기 confirm 카피, F5 export 의 trash 옵션 (제외 강제 vs 사용자 토글)
|
||||
- **#5**: 후보 `due_date_edited_by_user` 필터 여부 (수동 입력만 vs AI 자동 추출 포함), 만료 임박 (D-7) 포함 여부, 멀티선택 default 상태 (전체 선택 vs 비선택)
|
||||
- **#1**: polling 주기 (10/30/60s), 실패 N회 후 polling 중단, exponential backoff 적용
|
||||
- **#2**: unreachable backoff cap (15분 후보), reason 분류 정밀도 (timeout vs unreachable 구분), per-note retry 승격 여부
|
||||
- **#3**: top-N 값 (20 후보), vocab cache invalidation 정책 (write-through vs 매 prompt 시 fresh), 빈 vocab 임계값
|
||||
- **#6**: dismiss 만료 30일 vs 14일 vs 60일, 후보 0건 시 RecallBanner 숨김 vs 빈 상태 카피
|
||||
- **#5+#6 coexistence**: 둘 다 Inbox 상단 배너 noting. stack 순서 (만료 위 → 회상 아래 가 자연 — 시간 민감도 우선), 동시 N건 시 우선 표시 정책, 빈 상태 시 영역 collapse 여부. #5 → #6 순서 머지라 #6 mini-brainstorm 에서 #5 와 통합 layout 결정.
|
||||
|
||||
---
|
||||
|
||||
## 9. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — v0.2.2 dogfood 7항목 (#7 telemetry 신설 포함) 단일 cut 로드맵, 데이터 안전 우선 (C 채택), schema migration v3 3컬럼 한 묶음 (B 채택), trash↔backup/export B 정책, #6 = 1 spike 흡수 (B 채택). |
|
||||
327
docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md
Normal file
327
docs/superpowers/specs/2026-05-01-v023-ollama-recovery-design.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# v0.2.3 #1 Ollama 회복 polling 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.3 cut 7항목 중 4번째 항목 (#1 Ollama 회복) 의 mini-brainstorm 결정 + design. roadmap §3 #1 의 In/Out 위에서 §8 미결정 3항목 (polling 주기 / 실패 N회 중단 / backoff) 결정 + 추가 동작 사양 명시.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #1, §8
|
||||
- 선행 cut: #7 telemetry (PR #13), #4 trash (PR #14), #5 expiry (PR #15)
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| Q | 결정 | 근거 |
|
||||
|---|------|------|
|
||||
| Q1 polling 주기 | **A 60s** | 회복 latency ≤ 1분 충분. `/api/tags` 호출 가벼워 부하 미미. dogfood 1인 사용 패턴 (분당 ~1 capture) 과 같은 톤. |
|
||||
| Q2 실패 N회 후 중단 | **A 절대 중단 안 함** | 부하 무시 가능. 중단 시 사용자 마찰 (재확인 버튼 또는 재시작) 만 남김. |
|
||||
| Q3 exponential backoff | **A constant 60s** | Q1 결론 + Q2 결론 연결 — backoff 효과 없고 회복 latency 만 늘어남. |
|
||||
|
||||
---
|
||||
|
||||
## 2. HealthChecker 확장
|
||||
|
||||
### 2.1 시그너처
|
||||
|
||||
```ts
|
||||
// src/main/services/HealthChecker.ts
|
||||
export interface HealthCheckerOptions {
|
||||
intervalMs?: number; // default 60_000
|
||||
onUpdate?: (status: HealthResult) => void; // delta only — status 가 변할 때만 fire
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void; // emit hook (testability)
|
||||
now?: () => number; // testability
|
||||
}
|
||||
|
||||
export type HealthTelemetryEvent =
|
||||
| { kind: 'ollama_unreachable'; reason: string }
|
||||
| { kind: 'ollama_recovered'; downtimeMs: number }
|
||||
| { kind: 'ollama_recheck_manual' };
|
||||
|
||||
export class HealthChecker {
|
||||
constructor(private provider: InferenceProvider, private opts: HealthCheckerOptions = {}) {}
|
||||
|
||||
/**
|
||||
* @param opts.manual=true 일 때 결과와 무관하게 onTelemetry({kind:'ollama_recheck_manual'}) 1회 fire.
|
||||
* IPC `inbox:ollamaRecheck` 가 호출 시 사용 — telemetry 가드를 service 레이어로 끌어 단위 테스트 가능.
|
||||
*/
|
||||
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult>;
|
||||
start(): void; // setInterval 시작 (idempotent — 2회 호출 시 1번만)
|
||||
stop(): void; // clearInterval (idempotent)
|
||||
lastStatus(): HealthResult;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 상태 전이 로직
|
||||
|
||||
`runOnce()` 안에서 `result = await provider.healthCheck()` 후:
|
||||
|
||||
| 전이 | 동작 |
|
||||
|------|------|
|
||||
| ok=true → ok=true (변화 없음) | no-op |
|
||||
| ok=true → ok=false | `unreachableSince = now()`. `onUpdate(result)` 호출. `onTelemetry({kind:'ollama_unreachable', reason})` |
|
||||
| ok=false → ok=true | `downtimeMs = now() - unreachableSince`. `onUpdate(result)`. `onTelemetry({kind:'ollama_recovered', downtimeMs})`. `unreachableSince = null` |
|
||||
| ok=false → ok=false (reason 동일) | no-op |
|
||||
| ok=false → ok=false (reason 다름) | `onUpdate(result)` (UI 갱신). telemetry emit **안 함** (ratio 노이즈 회피) |
|
||||
|
||||
### 2.3 start/stop
|
||||
|
||||
- `start()`: `runOnce()` 즉시 1회 + `setInterval(runOnce, intervalMs)` 등록. timer 이미 있으면 no-op.
|
||||
- `stop()`: `clearInterval(timer)`. timer null 로 set.
|
||||
- App quit hook (`app.on('before-quit')`) 에서 `health.stop()` — leak 방지.
|
||||
|
||||
---
|
||||
|
||||
## 3. main wiring + IPC
|
||||
|
||||
### 3.1 main/index.ts 변경
|
||||
|
||||
기존:
|
||||
```ts
|
||||
const health = new HealthChecker(provider);
|
||||
void health.runOnce().then((h) => logger.info('ai.health', { ...h }));
|
||||
```
|
||||
|
||||
신규:
|
||||
```ts
|
||||
const health = new HealthChecker(provider, {
|
||||
onUpdate: (status) => pushOllamaStatus(getInboxWindow, status),
|
||||
onTelemetry: (ev) => {
|
||||
if (ev.kind === 'ollama_unreachable') void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
|
||||
else if (ev.kind === 'ollama_recovered') void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
|
||||
}
|
||||
});
|
||||
health.start();
|
||||
|
||||
app.on('before-quit', () => health.stop());
|
||||
```
|
||||
|
||||
### 3.2 IPC 채널
|
||||
|
||||
| 채널 | 방향 | 용도 |
|
||||
|------|------|------|
|
||||
| `inbox:ollamaStatus` | renderer → main | 기존 — `health.lastStatus()` 반환. startup / refreshMeta 시 fetch. |
|
||||
| `inbox:ollamaRecheck` | renderer → main → renderer | 신규 — main 이 `health.runOnce()` 호출, 결과 status push, telemetry `ollama_recheck_manual` emit. |
|
||||
| `ollama:status` (push) | main → renderer | 신규 — onUpdate fire 시 main 이 webContents.send. (note:updated 패턴 mirroring) |
|
||||
|
||||
`inbox:ollamaStatus` 는 변경 없음 (기존 IPC 호환).
|
||||
|
||||
`pushOllamaStatus(getInboxWindow, status)` helper 추가 (`pushNoteUpdated` 의 자매):
|
||||
|
||||
```ts
|
||||
// src/main/ipc/inboxApi.ts
|
||||
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
|
||||
const w = getWin();
|
||||
if (!w || w.isDestroyed()) return;
|
||||
w.webContents.send('ollama:status', status);
|
||||
}
|
||||
```
|
||||
|
||||
`inbox:ollamaRecheck` handler — telemetry emit 은 HealthChecker 의 onTelemetry hook 으로 위임 (testability):
|
||||
|
||||
```ts
|
||||
ipcMain.handle('inbox:ollamaRecheck', async () => {
|
||||
await deps.health.runOnce({ manual: true }); // status 변경 시 onUpdate + ollama_recheck_manual onTelemetry fire
|
||||
return deps.health.lastStatus();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. store + UI
|
||||
|
||||
### 4.1 store.ts 확장
|
||||
|
||||
`InboxState` 에 신규 action + push subscriber:
|
||||
|
||||
```ts
|
||||
recheckOllama: () => Promise<void>;
|
||||
```
|
||||
|
||||
`loadInitial` 의 `useEffect` 에서 `inboxApi.onOllamaStatus(cb)` 구독 (note:updated 와 동일 패턴):
|
||||
|
||||
```ts
|
||||
// App.tsx useEffect
|
||||
const unsubOllama = inboxApi.onOllamaStatus((status) => {
|
||||
set({ ollamaStatus: status });
|
||||
});
|
||||
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
|
||||
```
|
||||
|
||||
`recheckOllama` action:
|
||||
```ts
|
||||
async recheckOllama() {
|
||||
const status = await inboxApi.ollamaRecheck();
|
||||
set({ ollamaStatus: status });
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 InboxApi + preload 확장
|
||||
|
||||
```ts
|
||||
// shared/types.ts InboxApi
|
||||
ollamaRecheck(): Promise<{ ok: boolean; reason?: string }>;
|
||||
onOllamaStatus(cb: (status: { ok: boolean; reason?: string }) => void): () => void;
|
||||
|
||||
// preload/index.ts
|
||||
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
|
||||
onOllamaStatus: (cb) => {
|
||||
const listener = (_e: unknown, status: { ok: boolean; reason?: string }) => cb(status);
|
||||
ipcRenderer.on('ollama:status', listener);
|
||||
return () => ipcRenderer.off('ollama:status', listener);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 OllamaBanner 변경
|
||||
|
||||
`status.ok === false` 시 "재확인" 버튼 추가 (기존 메시지 + 진단 줄 옆 또는 아래):
|
||||
|
||||
```tsx
|
||||
<button onClick={() => void recheckOllama()}>재확인</button>
|
||||
```
|
||||
|
||||
기존 banner 스타일 유지 (warn variant).
|
||||
|
||||
### 4.4 Tray 메뉴
|
||||
|
||||
기존 `createTray` 의 컨텍스트 메뉴에 항목 추가:
|
||||
|
||||
```ts
|
||||
{
|
||||
label: 'Ollama 재확인',
|
||||
enabled: !health.lastStatus().ok, // dynamic — 정상이면 disabled
|
||||
click: () => void deps.recheckOllama()
|
||||
}
|
||||
```
|
||||
|
||||
`createTray` 가 7 positional callbacks 받는 현 구조에 1 callback 추가 — v0.2.4 backlog #4 (TrayCallbacks object refactor) 와 정합. 본 cut 에서는 8번째 callback 추가 + backlog #4 의 trigger 만 강화.
|
||||
|
||||
---
|
||||
|
||||
## 5. Telemetry
|
||||
|
||||
### 5.1 신규 3 events
|
||||
|
||||
| event | payload | 발화 |
|
||||
|-------|---------|------|
|
||||
| `ollama_unreachable` | `{ reason: string }` (max 500) | ok=true → ok=false 전이 (HealthChecker.onTelemetry) |
|
||||
| `ollama_recovered` | `{ downtimeMs: number }` (≥0) | ok=false → ok=true 전이 |
|
||||
| `ollama_recheck_manual` | `{}` (empty) | `inbox:ollamaRecheck` IPC handler |
|
||||
|
||||
### 5.2 zod schemas
|
||||
|
||||
```ts
|
||||
// telemetryEvents.ts
|
||||
const OllamaUnreachablePayload = z.object({
|
||||
reason: z.string().min(1).max(500)
|
||||
}).strict();
|
||||
|
||||
const OllamaRecoveredPayload = z.object({
|
||||
downtimeMs: z.number().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const EmptyPayload = z.object({}).strict();
|
||||
```
|
||||
|
||||
`reason` 의 source 는 `LocalOllamaProvider.healthCheck()` 가 반환하는 generic message — `'connection refused'`, `'not installed'`, `'timeout'`, `'http 500'` 등 generic. 본문/PII 누출 0건. max 500 cap 으로 anomaly fence.
|
||||
|
||||
### 5.3 stats.md 집계
|
||||
|
||||
신규 행 (휴지통 회수율 다음):
|
||||
|
||||
```
|
||||
- Ollama unreachable 빈도: {count}건
|
||||
- 평균 downtimeMs (recovered): {avg}
|
||||
- 수동 recheck 사용량: {count}건
|
||||
```
|
||||
|
||||
`DailyRow` 에 3 새 카운터 추가.
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트
|
||||
|
||||
| 영역 | 단위 | 검증 |
|
||||
|------|------|------|
|
||||
| HealthChecker | `start()` idempotent | 2회 호출 → timer 1개. |
|
||||
| HealthChecker | `start()` 즉시 1회 + 60s 마다 | `vi.useFakeTimers()` advance, runOnce 호출 횟수. |
|
||||
| HealthChecker | `stop()` cleanup | clearInterval. timer null. |
|
||||
| HealthChecker | ok=true → ok=false 전이 | onUpdate fire, onTelemetry `ollama_unreachable {reason}` 1회. |
|
||||
| HealthChecker | ok=false → ok=true 전이 | onUpdate fire, onTelemetry `ollama_recovered {downtimeMs}` 1회. downtimeMs ≈ now-unreachableSince. |
|
||||
| HealthChecker | reason 변경 (ok=false 유지) | onUpdate fire, onTelemetry 0건. |
|
||||
| HealthChecker | ok=true → ok=true 변화 없음 | onUpdate 0건. |
|
||||
| TelemetryEvents | zod 3 신규 parse | happy + extra field reject (privacy invariant 회귀). |
|
||||
| TelemetryStats | 3 카운터 + downtime 평균 | aggregateStats 검증. |
|
||||
| IPC handler | `inbox:ollamaRecheck` | runOnce + telemetry.emit recheck_manual + status 반환. |
|
||||
| Store | `recheckOllama` action | inboxApi.ollamaRecheck → set ollamaStatus. |
|
||||
| Store | onOllamaStatus subscriber | push 받으면 set ollamaStatus. |
|
||||
|
||||
총 ≥ 12 단위. e2e 영향 없음.
|
||||
|
||||
---
|
||||
|
||||
## 7. 작업 순서 (writing-plans 시 task 분할 가이드)
|
||||
|
||||
T1. HealthChecker.start/stop + delta 전이 로직 + 단위 7개 (TDD)
|
||||
T2. Telemetry 3 events (zod + EmitInput + stats.md 집계 + 단위 4개)
|
||||
T3. main/index.ts wiring (`onUpdate` + `onTelemetry` + `start()` + `before-quit stop`) + 테스트는 T5 의 IPC 통해
|
||||
T4. IPC `inbox:ollamaRecheck` + `pushOllamaStatus` helper + `ollama:status` push + 단위 1개
|
||||
T5. shared/types InboxApi + preload + renderer onOllamaStatus subscriber + recheckOllama action + 단위 2개
|
||||
T6. OllamaBanner 재확인 버튼 + tray 메뉴 항목 (visual integration)
|
||||
T7. closure (gates + roadmap mark + memory backlog)
|
||||
|
||||
---
|
||||
|
||||
## 8. roadmap In/Out 일치
|
||||
|
||||
### 8.1 roadmap §3 #1 In 매핑
|
||||
|
||||
| roadmap | design |
|
||||
|---------|--------|
|
||||
| 60s polling, runOnce setInterval 자동 발화 | §2 ✓ |
|
||||
| 회복 시 onUpdate → 구독 (renderer OllamaBanner) 자동 갱신 | §3.2 (push) + §4.1 (subscriber) ✓ |
|
||||
| 실패 N회 후 polling 중단 정책 | Q2=A 절대 중단 안 함 |
|
||||
| 수동 재확인 버튼 — OllamaBanner + 트레이 | §4.3 + §4.4 ✓ |
|
||||
| IPC `inbox:ollamaRecheck` | §3.2 ✓ |
|
||||
| Telemetry `ollama_unreachable {reason}`, `ollama_recovered {downtimeMs}`, `ollama_recheck_manual {}` | §5 ✓ |
|
||||
| 단위 테스트 | §6 ≥ 12 |
|
||||
|
||||
### 8.2 Out 유지
|
||||
|
||||
- 사용자 설정 가능 polling 주기 — Out (Q1=A 60s 고정).
|
||||
- 회복 toast 알림 — Out (banner 자동 사라짐만).
|
||||
- model 정상성 (tags 외) 체크 — Out (provider 의 healthCheck 만 사용).
|
||||
|
||||
---
|
||||
|
||||
## 9. 위험 / 완화
|
||||
|
||||
| 위험 | 완화 |
|
||||
|------|------|
|
||||
| polling 이 app quit 시 leak | `app.on('before-quit')` 에서 `health.stop()`. 단위 테스트로 stop() 동작 가드. |
|
||||
| onUpdate 가 status 매번 fire 되어 IPC 폭주 | delta only — last 와 비교 후만 fire. 단위 테스트로 ok=ok no-op 가드. |
|
||||
| reason 문자열에 본문/PII 누출 | LocalOllamaProvider 가 generic message 만 반환. zod max length 500 cap. privacy invariant 단위 테스트. |
|
||||
| recheck 가 polling 과 동시 발화 race | `runOnce()` async, sequential. provider.healthCheck() 가 자체적으로 동시 호출 안전 (HTTP GET). |
|
||||
| reason 변경만으로 telemetry 폭주 (예: timeout ↔ refused 반복) | reason 변경 시 onUpdate fire 하지만 telemetry emit 안 함 — ratio 노이즈 회피. |
|
||||
|
||||
---
|
||||
|
||||
## 10. 게이트 (PR 머지 조건, roadmap §3.1 일치)
|
||||
|
||||
- `npm run typecheck` 0 에러
|
||||
- `npm test` — 327 + 12+ = 339+
|
||||
- `npm run test:e2e` 1/1
|
||||
- main 머지
|
||||
|
||||
머지 후:
|
||||
- roadmap `### #1 Ollama 회복 (4번)` → `✓ 완료`
|
||||
- `memory/project_v024_backlog.md` review deferred 항목 누적
|
||||
|
||||
---
|
||||
|
||||
## 11. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — Q1=A (60s), Q2=A (절대 중단 안 함), Q3=A (constant). HealthChecker.start/stop + delta-only onUpdate + 3 telemetry events + main → renderer push (`ollama:status`) + manual recheck (banner + tray). |
|
||||
| 2026-05-01 | §2.1 / §3.2 보강 — `runOnce({ manual?: boolean })` 인자 추가, `ollama_recheck_manual` 도 onTelemetry hook 으로 통합 (IPC handler 가 직접 emit 안 함). 단위 테스트 가능. |
|
||||
385
docs/superpowers/specs/2026-05-01-v023-trash-design.md
Normal file
385
docs/superpowers/specs/2026-05-01-v023-trash-design.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# #4 휴지통 (soft delete + migration v3) 설계
|
||||
|
||||
**작성일:** 2026-05-01
|
||||
**저자:** 김태현 (dlsrks0734@gmail.com)
|
||||
**문서 성격:** v0.2.3 로드맵의 두 번째 항목. mini-brainstorm 결과를 잠그고 구현 계획 (`writing-plans`) 으로 넘기는 분기 spec. 본 문서는 **데이터 모델·외부 API·UI 결정** 만 정의. 세부 코드 토폴로지는 plan 단계에서.
|
||||
|
||||
**선행 문서:**
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §3 #4 — 본 항목의 In/Out 라인 + cross-cutting 정책
|
||||
- `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1 — schema migration v3, trash↔backup/export B 정책
|
||||
- `docs/superpowers/specs/2026-04-26-feedback-roadmap-design.md` §5.1 — pre-v<N>.bak snapshot 메커니즘 (v0.2.1 도입)
|
||||
- v0.2.3 #7 telemetry skeleton (merged at `6f8ae75`) — 본 항목이 emit hook 대상
|
||||
|
||||
---
|
||||
|
||||
## 1. 결정 요약
|
||||
|
||||
| 영역 | 값 | 근거 |
|
||||
|------|-----|------|
|
||||
| Schema | **migration v3** — `deleted_at TEXT NULL` + `last_recalled_at TEXT NULL` + `recall_dismissed_at TEXT NULL` (#6 도 같이) | 한 migration 으로 #4+#6 cover. 별 v4 회피. |
|
||||
| UI 위치 | **Inbox 상단 탭 toggle** (`Inbox(N) · 휴지통(M)`) | 현재 router 없음, single-page 구조 일관. v0.2.1 F2 태그 필터 패턴 (`tagFilter` zustand) 동일 흐름. |
|
||||
| 쿼리 필터 전략 | **명시적 WHERE** — 모든 active query 에 `WHERE deleted_at IS NULL` 직접 박음 | 기존 SQL prepare 패턴 일관. grep audit 가능. C (silent at hydration) 의 AiWorker race window 회피. |
|
||||
| AiWorker race | **C — pending_jobs cleanup + processJob 가드** (둘 다) | atomic + 이미 dequeue 한 race window 도 가드. result 적용 직전 재체크는 의도적 skip — restore 시 AI 결과 보존이 UX 유리. |
|
||||
| 휴지통 액션 | **per-card 복구 + per-card 영구 삭제 + bulk 휴지통 비우기** | per-card 영구 삭제는 fine-grained 삭제 욕구 대응. roadmap §3 #4 의 4채널 → 5채널 (`permanentDelete` 추가) 으로 확장. |
|
||||
| Confirm UX | **Electron `dialog.showMessageBox`** — F5/F6 패턴 일관 | 신규 React 모달 회피. native = 운영체제 톤. |
|
||||
| 정렬 | **`deleted_at DESC`** | 회수 의도 매칭 (최근 삭제 먼저). |
|
||||
| Card 차이 | **휴지통 카드 = read-only** — edit 액션 hidden, raw text 토글은 보존 | roadmap §3 #4 Out (`trash 안 노트 편집`) 일관. |
|
||||
| F5 export | **`deleted_at IS NOT NULL` 제외** | trash B 정책 (roadmap §1). |
|
||||
| F6-L1 backup | **byte-for-byte 자동 포함** | SQLite copy. 무수정. |
|
||||
| F6-L3 import | **`deleted_at` source/dest 중 IS NOT NULL 우선** | 삭제 보존 invariant. |
|
||||
| Restore 시 AI 결과 | **그대로 살아있음** (race window self-healing) | trash 도중 AI 결과 박힌 경우 restore 시 노트가 결과까지 함께 회수. UX positive. |
|
||||
|
||||
### 1.1 v0.2.3 #4 roadmap 와의 차이
|
||||
|
||||
| 항목 | roadmap §3 #4 | 본 spec |
|
||||
|------|---------------|---------|
|
||||
| 휴지통 액션 | 복구 + bulk emptyTrash (4 IPC 채널) | + per-card 영구 삭제 (5 IPC 채널) |
|
||||
| Telemetry kinds | `trash` / `restore` / `emptyTrash` (3) | + `permanent_delete` (4) |
|
||||
|
||||
**근거:** mini-brainstorm 에서 사용자 결정 (B 옵션 — fine-grained 영구 삭제 추가). 본 spec 의 결정이 roadmap 보다 우선.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data model
|
||||
|
||||
### 2.1 Migration v3 — `m003_soft_delete.ts`
|
||||
|
||||
```sql
|
||||
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
|
||||
CREATE INDEX idx_notes_deleted_at ON notes(deleted_at);
|
||||
```
|
||||
|
||||
- `deleted_at`: ISO timestamp (UTC). `NULL` = active, IS NOT NULL = trashed.
|
||||
- `last_recalled_at`: #6 가 사용. v3 에서 컬럼만 추가, `Note` type 노출 + 사용은 #6.
|
||||
- `recall_dismissed_at`: #6 가 사용. 위와 동일.
|
||||
- `idx_notes_deleted_at`: `WHERE deleted_at IS NULL` 쿼리 다수, partial index 효과 기대. SQLite 가 NULL 스파스 인덱스 효율 잘 처리.
|
||||
|
||||
m001/m002 와 같이 `version = 3` export 후 `migrations/index.ts` 의 array 에 등록. transaction 내 실행. 실패 시 트랜잭션 롤백 + 사용자에게 보고.
|
||||
|
||||
**pre-v3 snapshot:** `<dbFile>.pre-v3.bak` 자동 생성 (v0.2.1 메커니즘 그대로). v0.2.2 → v0.2.3 첫 실행 시 한 번.
|
||||
|
||||
### 2.2 `Note` 타입 (`@shared/types`) 확장
|
||||
|
||||
```typescript
|
||||
export interface Note {
|
||||
// ... 기존 필드 ...
|
||||
deletedAt: string | null; // #4 가 사용
|
||||
lastRecalledAt: string | null; // #6 가 사용 (v0.2.3 #4 단계엔 항상 null 으로 hydrate)
|
||||
recallDismissedAt: string | null; // #6 가 사용 (위와 동일)
|
||||
}
|
||||
```
|
||||
|
||||
세 필드 모두 v3 부터 schema 에 존재하므로 hydration 코드는 한 번에 추가. 사용은 단계별.
|
||||
|
||||
### 2.3 Schema invariant 추가
|
||||
|
||||
slice §1.3 silent invariant 후보 (roadmap §6.3 에서 #4 머지 시 동봉 갱신):
|
||||
|
||||
> **`deleted_at IS NULL` 망각 0회** — 모든 active query (Inbox / countToday / findByTag / search / F5 export / AiWorker 처리) 가 `WHERE deleted_at IS NULL` 을 빠뜨리지 않는다. 위반 시 dogfood-feedback 즉시 재오픈.
|
||||
|
||||
---
|
||||
|
||||
## 3. NoteRepository 변경
|
||||
|
||||
### 3.1 신규 메서드
|
||||
|
||||
| 메서드 | SQL | 부수 효과 |
|
||||
|--------|-----|-----------|
|
||||
| `trash(id, deletedAt: string): void` | `UPDATE notes SET deleted_at=?, updated_at=? WHERE id=?` + `DELETE FROM pending_jobs WHERE note_id=?` (한 transaction) | AI 큐 깨끗. atomic. |
|
||||
| `restore(id): void` | `UPDATE notes SET deleted_at=NULL, updated_at=? WHERE id=?` | 노트 active 복귀. AI 결과 보존됨. |
|
||||
| `permanentDelete(id): void` | `DELETE FROM notes WHERE id=?` | cascade FK (`note_tags` / `media` / `pending_jobs`) 자동 정리. media 파일 정리는 caller (`CaptureService`) 책임. |
|
||||
| `emptyTrash(): { noteIds: string[] }` | `SELECT id FROM notes WHERE deleted_at IS NOT NULL` → 각 id `permanentDelete` (한 transaction). 반환된 `noteIds` 로 caller 가 media 정리. |
|
||||
| `listTrashed(opts: {limit, cursor?}): Note[]` | `WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` | cursor = `deleted_at` 값 기준. |
|
||||
|
||||
### 3.2 기존 메서드 변경
|
||||
|
||||
`delete(id)` 는 **deprecate** (호출 site 0건 보장). hard delete 는 `permanentDelete()` 로만. 단계적 cleanup — `delete()` 를 즉시 제거하지 않고 `@deprecated` 로 표시 후 v0.2.4 cut 시 삭제.
|
||||
|
||||
### 3.3 Active query 일괄 변경 (`WHERE deleted_at IS NULL` 추가)
|
||||
|
||||
| 메서드 | 현재 | 변경 후 |
|
||||
|--------|------|---------|
|
||||
| `list(opts)` | `ORDER BY created_at DESC LIMIT ?` | `WHERE deleted_at IS NULL ORDER BY ... LIMIT ?` |
|
||||
| `listAll()` | `ORDER BY created_at ASC` | `WHERE deleted_at IS NULL ORDER BY ...` |
|
||||
| `countToday(now?)` | KST today filter | `WHERE deleted_at IS NULL AND ...` |
|
||||
| `getAllPendingJobs()` | `pending_jobs` 직접 select | **변경 없음** — `trash()` 가 atomic 하게 `pending_jobs` row 정리하는 invariant 가 자연 보장. AiWorker `processJob` 의 `deletedAt` 가드는 이미 dequeue 한 race 만 처리. |
|
||||
| `findById(id)` | **변경 없음** — 휴지통 카드도 같은 메서드 사용. `Note.deletedAt` 으로 호출자가 분기. |
|
||||
|
||||
NoteRepository 에는 현재 `findByTag` / search 메서드가 없다 — 태그 필터링은 renderer 의 `selectFilteredNotes` 에서 client-side 로 수행 (zustand `tagFilter` state). 따라서 active query 변경은 위 표의 3 메서드 (`list`, `listAll`, `countToday`) + AiWorker 가드 + `getAllPendingJobs` 의 invariant 보존이 전부.
|
||||
|
||||
---
|
||||
|
||||
## 4. CaptureService 변경
|
||||
|
||||
### 4.1 메서드 변경
|
||||
|
||||
```typescript
|
||||
async deleteNote(noteId: string): Promise<void> {
|
||||
this.repo.trash(noteId, new Date().toISOString());
|
||||
// media 는 그대로 둔다 (restore 시 필요)
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 신규 메서드
|
||||
|
||||
```typescript
|
||||
async restoreNote(noteId: string): Promise<void> {
|
||||
this.repo.restore(noteId);
|
||||
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
|
||||
async permanentDeleteNote(noteId: string): Promise<void> {
|
||||
this.repo.permanentDelete(noteId);
|
||||
await this.store.deleteNoteDirectory(noteId);
|
||||
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
|
||||
async emptyTrash(): Promise<{ count: number }> {
|
||||
const { noteIds } = this.repo.emptyTrash();
|
||||
for (const id of noteIds) {
|
||||
try { await this.store.deleteNoteDirectory(id); }
|
||||
catch (e) { /* best-effort */ }
|
||||
}
|
||||
if (this.deps.telemetry) await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
|
||||
return { count: noteIds.length };
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Telemetry interface 확장
|
||||
|
||||
`CaptureService.ts` 의 `TelemetryEmitter` 인터페이스에 4 union 멤버 추가:
|
||||
|
||||
```typescript
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
| { kind: 'empty_trash'; payload: { count: number } }
|
||||
): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`TelemetryService.ts` 의 `EmitInput` union 도 같은 4 추가. `telemetryEvents.ts` 의 zod `discriminatedUnion` 에도 4 새 멤버, 각 payload `.strict()`. **Privacy invariant** 그대로 — payload 에 `noteId` / `count` 만, raw text/title/summary/intent/tag name 절대 미포함. zod 가 거부.
|
||||
|
||||
`stats.md` 집계 (`telemetryStats.aggregateStats`) 도 4 신규 카운트 컬럼 추가:
|
||||
|
||||
```
|
||||
| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash |
|
||||
```
|
||||
|
||||
핵심 ratio:
|
||||
- `restore / trash` — 휴지통이 회수 도구로 동작?
|
||||
|
||||
---
|
||||
|
||||
## 5. AiWorker 가드
|
||||
|
||||
`processJob` 진입 시 deletedAt 체크 추가:
|
||||
|
||||
```typescript
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
|
||||
```
|
||||
|
||||
`pending_jobs` 정리는 `trash()` 가 atomic 하게 처리하므로 정상 흐름에서 dead row 미발생. AiWorker 가 이미 dequeue 한 후 trash 된 race 만 본 가드가 cover.
|
||||
|
||||
result 적용 (`updateAiResult`) 직전 재체크는 의도적으로 skip — restore 시 AI 결과 살아있어 UX 유리.
|
||||
|
||||
---
|
||||
|
||||
## 6. IPC
|
||||
|
||||
### 6.1 신규 채널 (5개)
|
||||
|
||||
`src/main/ipc/inboxApi.ts` 의 `registerInboxApi` 에 추가:
|
||||
|
||||
| 채널 | 핸들러 | 응답 |
|
||||
|------|--------|------|
|
||||
| `inbox:trash` | `(_, id: string) => capture.deleteNote(id)` | `void` |
|
||||
| `inbox:restore` | `(_, id: string) => capture.restoreNote(id)` | `void` |
|
||||
| `inbox:permanentDelete` | `(_, id: string) => capture.permanentDeleteNote(id)` | `void` |
|
||||
| `inbox:emptyTrash` | `() => capture.emptyTrash()` | `{ count: number }` |
|
||||
| `inbox:listTrash` | `(_, opts) => repo.listTrashed(opts)` | `Note[]` |
|
||||
|
||||
confirm dialog (per-card 영구 삭제 / bulk emptyTrash) 는 main 프로세스에서 `dialog.showMessageBox` 호출 (트레이 export/import 와 동일 패턴). 사용자 confirm 후에야 IPC 가 실제 작업 수행.
|
||||
|
||||
### 6.2 기존 `inbox:delete` 처리
|
||||
|
||||
기존 `inbox:delete` 는 그대로 유지하되 내부적으로 `capture.deleteNote(id)` 가 trash 호출 (변경된 동작). 채널 이름은 유지 — renderer 에서 `inboxApi.deleteNote` 호출하던 곳 (`NoteCard` 의 "🗑 삭제" 버튼) 이 그대로 동작 (의미만 hard → soft 로 변경). 단계적 마이그레이션 — v0.2.4 에서 `inbox:trash` 로 rename 검토.
|
||||
|
||||
---
|
||||
|
||||
## 7. Renderer (Inbox)
|
||||
|
||||
### 7.1 zustand store 확장
|
||||
|
||||
```typescript
|
||||
interface InboxState {
|
||||
// 기존 ...
|
||||
showTrash: boolean; // false = Inbox view, true = 휴지통 view
|
||||
trashNotes: Note[]; // 휴지통 노트 cache
|
||||
trashCount: number; // 헤더 탭 라벨 (`휴지통(M)`)
|
||||
|
||||
toggleShowTrash(): void;
|
||||
loadTrash(): Promise<void>;
|
||||
restoreNote(id: string): Promise<void>;
|
||||
permanentDeleteNote(id: string): Promise<void>;
|
||||
confirmEmptyTrash(): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`toggleShowTrash` 가 `showTrash` 토글 + 진입 시 `loadTrash()` 호출.
|
||||
|
||||
`confirmEmptyTrash` 는 IPC `inbox:emptyTrash` 호출 (main 이 dialog 띄움). 사용자 cancel 시 `count: 0` 반환.
|
||||
|
||||
`upsertNote(note)` / `removeNote(id)` 가 `notes` 와 `trashNotes` 양쪽 다 갱신 — note 의 `deletedAt` 값으로 어느 list 에 들어갈지 결정.
|
||||
|
||||
### 7.2 UI 추가
|
||||
|
||||
`App.tsx` 헤더 영역 (h1 + ContinuityBadge 옆):
|
||||
|
||||
```tsx
|
||||
<button onClick={() => setShowTrash(false)} aria-pressed={!showTrash}>
|
||||
Inbox({notes.length})
|
||||
</button>
|
||||
<button onClick={() => setShowTrash(true)} aria-pressed={showTrash}>
|
||||
휴지통({trashCount})
|
||||
</button>
|
||||
```
|
||||
|
||||
`showTrash === true` 시:
|
||||
- 상단에 "휴지통 비우기 (M개)" 버튼 (M=0 이면 disabled). 클릭 → `confirmEmptyTrash()`.
|
||||
- `trashNotes.map(n => <NoteCard note={n} mode="trash" />)`
|
||||
|
||||
### 7.3 NoteCard prop `mode`
|
||||
|
||||
```tsx
|
||||
type NoteCardProps = { note: Note; mode?: 'inbox' | 'trash' };
|
||||
```
|
||||
|
||||
`mode === 'trash'` 시:
|
||||
- DueDateBadge: read-only (날짜 텍스트만 표시, 클릭 무반응)
|
||||
- IntentBanner: hidden
|
||||
- 태그 chip: ✕ 버튼 hidden, 클릭 시 필터링 동작 X
|
||||
- "🗑 삭제" 버튼 → "🔄 복구" + "🗑 영구 삭제" 두 버튼으로 교체
|
||||
- raw text 토글 (`▸ 원문 보기`): 보존 (read-only 도 본문 확인 필요)
|
||||
- EditableField (title / summary): read-only 모드 (input 비활성)
|
||||
|
||||
빈 휴지통 상태 (`trashNotes.length === 0` AND `showTrash`):
|
||||
> "휴지통이 비어있습니다."
|
||||
|
||||
### 7.4 Confirm dialog 카피
|
||||
|
||||
`dialog.showMessageBox` 옵션:
|
||||
|
||||
**bulk emptyTrash:**
|
||||
- type: `question`
|
||||
- buttons: `['휴지통 비우기', '취소']`
|
||||
- defaultId: 1, cancelId: 1
|
||||
- title: `Inkling`
|
||||
- message: `휴지통의 노트 ${count}개를 영구 삭제합니다`
|
||||
- detail: `이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.`
|
||||
|
||||
**per-card 영구 삭제:**
|
||||
- buttons: `['영구 삭제', '취소']`
|
||||
- message: `이 노트를 영구 삭제합니다`
|
||||
- detail: 위와 동일
|
||||
|
||||
---
|
||||
|
||||
## 8. F5 export / F6-L3 import / F6-L1 backup
|
||||
|
||||
### 8.1 ExportService
|
||||
|
||||
`repo.listAll()` 자체에 `WHERE deleted_at IS NULL` 추가 (active query exclusion 의 일환, §3.3 표 그대로). ExportService 코드는 무수정 — `repo.listAll()` 호출이 자동으로 trash 제외하게 됨. 휴지통 export 는 본 cut 범위 외 (Out, §10).
|
||||
|
||||
### 8.2 ImportService
|
||||
|
||||
`ImportNoteInput` interface 에 `deletedAt?: string | null` 추가. INSERT statement 에 컬럼 + 값 추가. fork 케이스 (raw_text 다름) 에서도 `deletedAt` 보존.
|
||||
|
||||
충돌 해결 — id 동일 + raw_text 동일 (skip) 또는 raw_text 상이 (fork) 가 기존 정책. `deletedAt` 머지는 그 위에 추가:
|
||||
|
||||
- **id 동일 + raw_text 동일** (skip 케이스): source 가 `deletedAt IS NOT NULL` 이고 dest 가 `IS NULL` 이면 dest 의 `deleted_at` 을 source 값으로 **갱신** (삭제 보존). 그 외는 그대로 skip.
|
||||
- **id 동일 + raw_text 상이** (fork 케이스): source 의 `deletedAt` 을 새 fork 노트에 그대로 넣음 (raw_text invariant 보존이 우선이라 fork 자체는 기존대로).
|
||||
- **id 신규** (insert 케이스): source 의 `deletedAt` 을 그대로 INSERT.
|
||||
- **양쪽 IS NOT NULL** (skip 케이스 의 corner case): 단순화 — dest 값 유지 (skip). roadmap §1 의 "IS NOT NULL 우선" 은 한쪽이 NULL 일 때만 결정 영향, 양쪽 IS NOT NULL 시엔 dest 가 이미 trash 라 "삭제 보존" 자체는 만족.
|
||||
|
||||
### 8.3 BackupService
|
||||
|
||||
무수정. SQLite `db.backup()` 가 byte-for-byte 카피 — `deleted_at IS NOT NULL` 노트도 자동 포함.
|
||||
|
||||
---
|
||||
|
||||
## 9. 단위 테스트 (TDD 가이드)
|
||||
|
||||
### 9.1 Migration v3
|
||||
- 빈 DB v0 → v3 migrate 후 `deleted_at` / `last_recalled_at` / `recall_dismissed_at` 컬럼 + `idx_notes_deleted_at` 존재 확인
|
||||
- v2 DB → v3 migrate 시 기존 노트의 새 컬럼 모두 NULL
|
||||
- migrate idempotent (이미 v3 인 DB 재실행 시 변경 없음)
|
||||
- pre-v3.bak snapshot 자동 생성 (한 번만)
|
||||
|
||||
### 9.2 NoteRepository
|
||||
- `trash(id, deletedAt)` 가 `deleted_at` 설정 + `pending_jobs` row 정리 (atomic — 한 transaction 내 두 쿼리)
|
||||
- `restore(id)` 가 `deleted_at` NULL 복원
|
||||
- `permanentDelete(id)` 가 cascade FK 통해 `note_tags` / `media` / `pending_jobs` 정리
|
||||
- `emptyTrash()` 가 IS NOT NULL 노트 모두 hard delete + 반환된 noteIds 정확
|
||||
- `listTrashed()` 가 `deleted_at DESC` 정렬, IS NOT NULL 만 반환
|
||||
- `list()` / `listAll()` / `countToday()` 가 `deleted_at IS NULL` 만 반환 (active query exclusion)
|
||||
- `findById()` 는 휴지통 노트도 반환 (모든 노트)
|
||||
- `getAllPendingJobs()` 가 trash 노트 미반환 (join 또는 trash cleanup invariant)
|
||||
|
||||
### 9.3 AiWorker
|
||||
- `processJob` 가 `deletedAt IS NOT NULL` 노트 즉시 return (provider.generate 미호출)
|
||||
- 정상 노트는 그대로 처리 (회귀 없음)
|
||||
|
||||
### 9.4 CaptureService
|
||||
- `deleteNote` 가 trash 호출 + telemetry `trash` emit (media 미삭제)
|
||||
- `restoreNote` 가 restore 호출 + telemetry `restore` emit
|
||||
- `permanentDeleteNote` 가 hard delete + media 디렉터리 정리 + telemetry `permanent_delete` emit
|
||||
- `emptyTrash` 가 모든 trash 노트 hard delete + 각 media 정리 + telemetry `empty_trash` emit (count 정확)
|
||||
|
||||
### 9.5 ExportService (F5)
|
||||
- 활성 노트만 export, trash 노트 제외 (frontmatter 마크다운 파일 미생성)
|
||||
- `index.jsonl` 도 trash 미포함
|
||||
|
||||
### 9.6 ImportService (F6-L3)
|
||||
- source 의 `deletedAt` 값이 import 후 보존
|
||||
- 충돌 해결 — source/dest 중 IS NOT NULL 우선 4가지 조합 모두
|
||||
|
||||
### 9.7 Telemetry events
|
||||
- 4 신규 kind (`trash` / `restore` / `permanent_delete` / `empty_trash`) zod 검증 통과
|
||||
- payload `.strict()` 가 `rawText` / `title` / `summary` / `userIntent` / `tagNames` 포함 시 거부 (기존 invariant 유지)
|
||||
- `aggregateStats` 가 4 신규 컬럼 카운트 정확, `restore / trash` ratio 계산
|
||||
|
||||
### 9.8 e2e smoke (Playwright)
|
||||
- 노트 캡처 → trash 클릭 → Inbox 에서 사라지고 휴지통 탭(1) 표시
|
||||
- 휴지통 탭 진입 → 노트 보임, 복구 클릭 → 다시 Inbox
|
||||
- per-card 영구 삭제 confirm → 노트 영구 사라짐, media 디렉터리 정리
|
||||
|
||||
---
|
||||
|
||||
## 10. Out (deferred to v0.2.4+)
|
||||
|
||||
- 자동 비우기 정책 (사용자 트리거만 — 30일 자동 비우기 등은 차후)
|
||||
- 휴지통 검색 (full-text 또는 태그 필터)
|
||||
- trash 안 노트 편집 (read-only invariant 깨지면 회귀)
|
||||
- per-note 영속 보호 플래그 (lock 같은 것)
|
||||
- restore 시 AI 결과 보존 invariant 명시 — 본 spec 에 짧게 언급, 별 spec 화는 v0.2.4 reason 분포 본 후
|
||||
- `inbox:delete` 채널 → `inbox:trash` 로 rename (단계적 마이그레이션)
|
||||
- 휴지통에서 다중 선택 (멀티 복구 / 멀티 영구 삭제)
|
||||
- `last_recalled_at` / `recall_dismissed_at` 활용 — #6 가 사용
|
||||
|
||||
---
|
||||
|
||||
## 11. 변경 이력
|
||||
|
||||
| 일자 | 변경 |
|
||||
|------|------|
|
||||
| 2026-05-01 | 초안 — UI=A (Inbox 탭), 필터=A (명시적 WHERE), AiWorker race=C (cleanup+가드), 액션=B (per-card 영구 삭제 추가, 5 IPC 채널), confirm/정렬/카드차이 모두 A. roadmap §3 #4 의 4채널 → 5채널 확장 명시. |
|
||||
321
docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
Normal file
321
docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# v0.2.3 #6 리마인드 1 spike — Design Spec
|
||||
|
||||
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #6 (7번째 / 마지막 cut)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (`RecallBanner`) 도입. 7일 이상 안 본 노트 중 가장 오래된 1건을 제시하여 사용자가 자기 기록을 재방문할 기회 제공. 4종 telemetry (`recall_shown` / `recall_opened` / `recall_dismissed` / `recall_snoozed`) 로 효과 측정 인프라 마련.
|
||||
|
||||
## 2. Decisions (mini-brainstorm 합의)
|
||||
|
||||
| # | 질문 | 선택 | 이유 |
|
||||
|---|---|---|---|
|
||||
| Q1 | 다음에 snooze 영속화 | **A** in-memory | `expiredSnoozeUntilMs` 패턴 일관, schema migration v4 회피, dogfood telemetry 보고 v0.2.4 영속화 결정 |
|
||||
| Q2 | `ageDays` 의미 | **B** `last_recalled_at ?? created_at` 기준 | algo 의 "7일 안 본 노트" trigger 와 동일 axis, 재추천 분포 측정 가치 |
|
||||
|
||||
자명 결정 (질문 없이 패턴 따름):
|
||||
- Banner 위치: `ExpiryBanner` 다음 (stack 끝, 시간 민감도 가장 낮음)
|
||||
- 0건 시: `null` return (`ExpiryBanner` 패턴)
|
||||
- Snooze duration: KST 다음 자정 (`snoozeExpired` 패턴)
|
||||
- "열어보기" 동작: `scrollIntoView` (NoteCard 항상 expanded — expand 동작 X)
|
||||
|
||||
## 3. Architecture & data flow
|
||||
|
||||
```
|
||||
Inbox 마운트 시:
|
||||
loadInitial() → recallCandidate fetch (별도 fetch, 단일 노트 또는 null)
|
||||
|
||||
RecallBanner render (recallCandidate !== null && !snoozed):
|
||||
┌─ "오늘 회상해볼 노트" + 노트 제목 + (N일 전)
|
||||
├─ [열어보기] → scrollIntoView(noteCardRef) + markRecallOpened(id)
|
||||
│ → telemetry: recall_opened
|
||||
├─ [다음에] → store.snoozeRecall() (KST 다음 자정까지 in-memory)
|
||||
│ → telemetry: recall_snoozed
|
||||
└─ [더 이상] → dismissRecall(id) (DB: recall_dismissed_at = now)
|
||||
→ telemetry: recall_dismissed
|
||||
|
||||
Banner 첫 렌더 시 자동 emit: recall_shown { noteId, ageDays }
|
||||
|
||||
다음 fetch 트리거:
|
||||
- markRecallOpened / dismissRecall 후 store 가 자동 다음 후보 fetch
|
||||
- refreshMeta (focus / inbox:noteUpdated) 도 fetch
|
||||
```
|
||||
|
||||
### 3.1 Invariants
|
||||
|
||||
1. **단일 후보 fetch** — `LIMIT 1` + `ORDER BY created_at ASC` (가장 오래된 1건)
|
||||
2. **KST 보정** — SQL 의 `date('now')` 자리 모두 `date('now','+9 hours')`
|
||||
3. **마감 임박 노트 제외** — `due_date < today` 인 노트는 ExpiryBanner 영역 (#5) 이라 회상 후보에서 빠짐
|
||||
4. **Snooze in-memory** — `recallSnoozeUntilMs` store 변수, KST 다음 자정 (ExpiryBanner 패턴)
|
||||
5. **emit 순서** — `recall_shown` (banner 첫 렌더) → `recall_opened/dismissed/snoozed` (사용자 액션)
|
||||
6. **Snooze 시 `recall_shown` 1회만** — 같은 후보가 다시 보여도 `recall_shown` 재emit 안 함 (notes 1건당 session 1 shown — `recallShownIds: Set<string>` in-memory)
|
||||
|
||||
## 4. Components
|
||||
|
||||
### 4.1 `NoteRepository`
|
||||
|
||||
#### `findRecallCandidate(): Note | null`
|
||||
|
||||
```sql
|
||||
SELECT * FROM notes
|
||||
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
|
||||
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
|
||||
AND ai_status = 'done'
|
||||
AND deleted_at IS NULL
|
||||
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
```
|
||||
|
||||
기존 `hydrate(row)` 사용 (이미 `last_recalled_at` / `recall_dismissed_at` 매핑 있음).
|
||||
|
||||
#### `markRecallOpened(id: string, now: string): void`
|
||||
|
||||
```sql
|
||||
UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
#### `dismissRecall(id: string, now: string): void`
|
||||
|
||||
```sql
|
||||
UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?
|
||||
```
|
||||
|
||||
### 4.2 `CaptureService` (5 신규 메서드)
|
||||
|
||||
```typescript
|
||||
async listRecallCandidate(): Promise<Note | null> {
|
||||
return this.repo.findRecallCandidate();
|
||||
}
|
||||
|
||||
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) throw new Error(`note not found: ${noteId}`);
|
||||
this.repo.markRecallOpened(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_opened',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
async dismissRecall(noteId: string): Promise<{ note: Note }> {
|
||||
this.repo.dismissRecall(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_dismissed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
async emitRecallShown(noteId: string): Promise<void> {
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) return;
|
||||
const ageDays = this.computeAgeDays(note);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId, ageDays }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async emitRecallSnoozed(noteId: string): Promise<void> {
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_snoozed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private computeAgeDays(note: Note): number {
|
||||
const ref = note.lastRecalledAt ?? note.createdAt;
|
||||
const refMs = new Date(ref).getTime();
|
||||
const nowMs = Date.now();
|
||||
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 IPC (5 신규 channels)
|
||||
|
||||
```typescript
|
||||
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
|
||||
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
```
|
||||
|
||||
### 4.4 Preload + InboxApi shared type
|
||||
|
||||
```typescript
|
||||
// preload/index.ts
|
||||
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
|
||||
```
|
||||
|
||||
```typescript
|
||||
// shared/types.ts InboxApi
|
||||
listRecallCandidate(): Promise<Note | null>;
|
||||
markRecallOpened(id: string): Promise<{ note: Note }>;
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
emitRecallShown(id: string): Promise<void>;
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
```
|
||||
|
||||
### 4.5 `telemetryEvents.ts` zod
|
||||
|
||||
```typescript
|
||||
const RecallShownPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
ageDays: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
// recall_opened / recall_dismissed / recall_snoozed → 기존 NoteIdPayload 재사용
|
||||
```
|
||||
|
||||
union 15 → **19** (recall_shown + recall_opened + recall_dismissed + recall_snoozed).
|
||||
|
||||
### 4.6 `telemetryStats.ts`
|
||||
|
||||
- DailyRow +4 cols (`recall_shown`, `recall_opened`, `recall_dismissed`, `recall_snoozed`)
|
||||
- accumulators: `recallShownCount`, `recallOpenedCount`, `recallDismissedCount`, `recallSnoozedCount`, `recallAgeDaysSum`
|
||||
- summary lines:
|
||||
```
|
||||
- 회상 추천: shown {N} / opened {O} / dismissed {D} / snoozed {S} (열림율 {O/N}%)
|
||||
- 회상 평균 ageDays: {avg}
|
||||
```
|
||||
N=0 시 `(데이터 없음)`
|
||||
|
||||
### 4.7 `TelemetryService.EmitInput` union 15 → 19
|
||||
|
||||
### 4.8 Renderer store (`src/renderer/inbox/store.ts`)
|
||||
|
||||
```typescript
|
||||
interface InboxState {
|
||||
// ... existing ...
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
recallShownIds: Set<string>; // session-local, "1 shown per note per session"
|
||||
loadRecallCandidate: () => Promise<void>;
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>; // store action 명, IPC 와 구별
|
||||
snoozeRecall: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
`refreshMeta` + `loadInitial` 가 `loadRecallCandidate` 도 호출.
|
||||
|
||||
`openRecall(id)`:
|
||||
- `inboxApi.markRecallOpened(id)` → DB 갱신
|
||||
- `loadRecallCandidate()` → 다음 후보 fetch
|
||||
- (스크롤은 RecallBanner 컴포넌트가 자체 처리)
|
||||
|
||||
`dismissRecallNote(id)`:
|
||||
- `inboxApi.dismissRecall(id)` → DB 갱신
|
||||
- `loadRecallCandidate()` → 다음 후보 fetch
|
||||
|
||||
`snoozeRecall()`:
|
||||
- `recallSnoozeUntilMs = nextKstMidnight()` (`snoozeExpired` 패턴)
|
||||
- 현재 candidate noteId 기준 `inboxApi.emitRecallSnoozed(id)`
|
||||
|
||||
### 4.9 RecallBanner 컴포넌트
|
||||
|
||||
**파일**: `src/renderer/inbox/components/RecallBanner.tsx` (신규)
|
||||
|
||||
- 위치: `<ExpiryBanner />` 다음 (App.tsx)
|
||||
- 첫 렌더 시 `useEffect` 가 `recallShownIds` 체크 후 미emit 시 `inboxApi.emitRecallShown(id)` 호출 + Set 에 추가
|
||||
- Banner UI: 노트 제목 + ageDays + 3개 버튼 (열어보기 / 다음에 / 더 이상)
|
||||
- `null` return: candidate=null OR snoozed (Date.now < snoozeUntilMs)
|
||||
- snoozeUntilMs 만료 시 setTimeout re-render 트리거 (ExpiryBanner 패턴)
|
||||
|
||||
### 4.10 NoteCard ref 시스템 (scroll target)
|
||||
|
||||
App.tsx 가 `noteRefs: Map<noteId, HTMLDivElement | null>` ref store 보유 + RecallBanner 가 store 의 ref 를 lookup 후 `scrollIntoView({ behavior: 'smooth', block: 'center' })` 호출.
|
||||
|
||||
구체 구현:
|
||||
- `App.tsx` 가 `useRef<Map<string, HTMLDivElement | null>>(new Map())` 보유
|
||||
- 각 `<NoteCard>` 에 `ref={(el) => { noteRefs.current.set(note.id, el); }}` 전달 (NoteCard 가 ref forwardRef 지원 필요)
|
||||
- RecallBanner 가 `noteRefs` prop 으로 받아 사용
|
||||
|
||||
**대안 (단순)**: `document.getElementById(\`note-${id}\`)` — App.tsx 의 NoteCard 가 `id={\`note-${note.id}\`}` 만 추가하면 됨. **이 spike 에선 이 단순 방식 채택** (ref 시스템 복잡도 회피).
|
||||
|
||||
## 5. Privacy invariant
|
||||
|
||||
- `recall_shown.payload`: `{ noteId, ageDays }` — noteId 기존 패턴, ageDays 정수
|
||||
- `recall_opened/dismissed/snoozed.payload`: `{ noteId }` — `NoteIdPayload` 재사용
|
||||
- `.strict()` zod 가드 + extra field 거부 테스트
|
||||
|
||||
## 6. Tests (≥17개)
|
||||
|
||||
### NoteRepository.test.ts (5)
|
||||
1. 빈 db → null
|
||||
2. last_recalled_at 5일 전 노트 제외 (7일 이내)
|
||||
3. last_recalled_at 8일 전 노트 후보 (7일 초과)
|
||||
4. recall_dismissed_at 25일 전 제외, 35일 전 후보
|
||||
5. deleted_at / ai_status='pending' / due_date < today 모두 제외
|
||||
|
||||
### CaptureService.test.ts (4)
|
||||
6. listRecallCandidate → repo.findRecallCandidate
|
||||
7. markRecallOpened → repo + recall_opened emit + last_recalled_at 갱신 검증
|
||||
8. dismissRecall → repo + recall_dismissed emit + recall_dismissed_at 갱신 검증
|
||||
9. emitRecallShown → ageDays 정확 (last_recalled NULL 시 createdAt 기준)
|
||||
|
||||
### telemetryEvents.test.ts (3)
|
||||
10. recall_shown valid parse (noteId + ageDays)
|
||||
11. recall_shown extra field 거부 (privacy)
|
||||
12. recall_opened/dismissed/snoozed valid parse (noteId only)
|
||||
|
||||
### telemetryStats.test.ts (2)
|
||||
13. shown/opened/dismissed/snoozed 누적 + 열림율 계산
|
||||
14. 평균 ageDays 계산
|
||||
|
||||
### store.recall.test.ts (신규, 3)
|
||||
15. snoozeRecall → snoozeUntilMs KST 다음 자정 + emitRecallSnoozed 호출
|
||||
16. openRecall → API 호출 + recall_shown 한 번만 emit (recallShownIds set)
|
||||
17. dismissRecallNote → 후보 다시 fetch
|
||||
|
||||
총 신규 단위 **17개**. 기존 단위 386 + 17 = **403** 예상.
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
(roadmap §3 #6 + 본 cut 결정)
|
||||
|
||||
- 잠금해제 hook (F4-A, strategy.md)
|
||||
- 무작위 토스트 (F4-D)
|
||||
- ambient if-then (F4-B)
|
||||
- 임베딩 유사도 추천 (#3 vocab 후속)
|
||||
- spaced repetition (Leitner / SM-2)
|
||||
- 다중 후보 추천 (현재 `LIMIT 1` only)
|
||||
- snooze 영속화 (Q1=A in-memory)
|
||||
- 사용자 정의 회상 주기 (7일 hardcoded)
|
||||
- "회상 history" 보기 (last_recalled_at 만 저장, 이전 history X)
|
||||
- RecallBanner 컴포넌트 단위 테스트 (Inkling 패턴: store 단위만 테스트)
|
||||
|
||||
## 8. Gates (roadmap §3.1)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 386 → 403 (+17), 모두 통과
|
||||
- e2e 1/1
|
||||
- 새 SQL: 복합 조건 — `idx_notes_ai_status` + `idx_notes_created_at` 활용. 별도 인덱스 불필요.
|
||||
|
||||
## 9. `strategy.md` 갱신 (별도 task)
|
||||
|
||||
roadmap §3 #6 In 절: §2.3 / §4.3 / §8 갱신:
|
||||
- Capitalize 본격 진입 (회상 surface 도입)
|
||||
- "오늘 회상" surface 정의
|
||||
- F4-A/B/D deferred 항목의 측정 인프라 마련 명시 (recall_* telemetry 가 그 기반)
|
||||
|
||||
## 10. Roadmap relation
|
||||
|
||||
- v0.2.3 dogfood feedback #6 (7번째 / 마지막 cut)
|
||||
- 머지 후 v0.2.3 cut 7/7 완료 → v0.2.3 binary 빌드 + 핸드오프
|
||||
- v0.2.4 후속: dogfood telemetry 분석 (열림율, 평균 ageDays), F4-A/B/D 본격 진행, snooze 영속화 결정
|
||||
255
docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md
Normal file
255
docs/superpowers/specs/2026-05-02-v023-tag-vocab-design.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# v0.2.3 #3 태그 vocab — Design Spec
|
||||
|
||||
> 작성: 2026-05-02 · v0.2.3 dogfood feedback roadmap §3 #3 (6번째 cut)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
기존 `tags` 테이블의 자주 쓰인 태그들을 AI prompt 에 vocabulary 로 주입해, AI 가 의미 일치 시 새 태그 생성 대신 기존 태그를 재사용하도록 유도. 효과는 `tag_vocab_hit` / `tag_vocab_miss` telemetry 로 측정.
|
||||
|
||||
## 2. Decisions (mini-brainstorm 합의)
|
||||
|
||||
| # | 질문 | 선택 | 이유 |
|
||||
|---|---|---|---|
|
||||
| Q1 | vocab pool 범위 | **C** AI+user 통합 + kebab-case 필터 | 사용자가 형식 맞춰 단 태그도 재사용 가치 있음, 단 형식 안 맞는 한글/공백 태그는 prompt 오염 |
|
||||
| Q2 | telemetry emit 단위 | **A** 태그별 (per-tag hit/miss) | roadmap §3 #3 합의 시그니처 + 기존 누적 카운터 통계 모델과 정합 |
|
||||
| Q3 | prompt 강제력 강도 | **B** "Prefer" (우선) | "MUST" 는 semantic mismatch 시 false hit, "For reference" 는 효과 미미; "Prefer" 는 우선순위 신호 + escape hatch 보장 |
|
||||
| Q4 | 기존 노트 재처리 | **A** 자연 진화 (X) | invariant (user-edited 결과 보호) 와 합치, 새 노트만으로 hit/miss 충분 수집, B 는 사용자 결과 변경 |
|
||||
|
||||
## 3. Architecture & data flow
|
||||
|
||||
```
|
||||
AiWorker.processJob()
|
||||
├─ const vocab = repo.getTopUsedTags(20) ← SQL fetch (kebab-case 필터)
|
||||
├─ provider.generate({ ..., vocab }) ← 새 input 필드
|
||||
│ └─ LocalOllamaProvider.generate()
|
||||
│ └─ buildPrompt(rawText, todayKst, candidates, vocab)
|
||||
│ └─ vocab.length > 0 시 prompt 라인 추가
|
||||
├─ AI response (tags: ['design', 'meeting', ...])
|
||||
├─ repo.updateAiResult(...) ← 기존 흐름, tag insert
|
||||
└─ for tag of res.tags: ← per-tag hit/miss 분류
|
||||
if vocabSet.has(tag):
|
||||
tagId = repo.getTagIdByName(tag) ← insert 후 보장
|
||||
emit tag_vocab_hit { tagId, vocabSize }
|
||||
else:
|
||||
emit tag_vocab_miss { vocabSize }
|
||||
```
|
||||
|
||||
### 3.1 Invariants
|
||||
|
||||
1. **매 generate 마다 SQL fetch** — vocab 캐싱/invalidation 안 함 (out of scope)
|
||||
2. **vocab 빈 케이스 (N=0)** → prompt 라인 자체 생략, AI 자유롭게 새 태그 생성
|
||||
3. **tagId** 는 hit 시 db tag id (`getTagIdByName` lookup, `updateAiResult` 후 호출이라 insert 보장)
|
||||
4. **PROMPT_VERSION 3 → 4** (marker only, retry 트리거 X)
|
||||
5. **vocab snapshot 동결** — 같은 generate call 의 `vocab` 배열로 hit/miss 판정. 처리 중 다른 노트가 새 태그 추가해도 이번 노트 분류엔 영향 X
|
||||
6. **emit 순서** — `updateAiResult` 후 emit (tagId 확보 보장)
|
||||
|
||||
## 4. Components
|
||||
|
||||
### 4.1 `NoteRepository`
|
||||
|
||||
#### `getTopUsedTags(limit = 20): string[]`
|
||||
|
||||
```sql
|
||||
SELECT t.name, COUNT(*) c
|
||||
FROM tags t
|
||||
JOIN note_tags nt ON nt.tag_id = t.id
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
GROUP BY t.id
|
||||
ORDER BY c DESC, t.id ASC
|
||||
LIMIT ?
|
||||
```
|
||||
|
||||
JS-side 후처리:
|
||||
```typescript
|
||||
return rows
|
||||
.map((r) => r.name)
|
||||
.filter((n) => /^[a-z0-9-]+$/.test(n));
|
||||
```
|
||||
|
||||
- `source` 무시 (AI+user 통합 — Q1=C)
|
||||
- `t.id ASC` tiebreaker (deterministic)
|
||||
- regex 필터로 한글/공백/대문자 태그 제외
|
||||
|
||||
#### `getTagIdByName(name: string): number | null`
|
||||
|
||||
```sql
|
||||
SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1
|
||||
```
|
||||
|
||||
대소문자 무시 (tag table `name COLLATE NOCASE` 와 정합).
|
||||
|
||||
### 4.2 `prompt.ts`
|
||||
|
||||
```typescript
|
||||
export const PROMPT_VERSION = 4; // bump from 3
|
||||
|
||||
export function buildPrompt(
|
||||
rawText: string,
|
||||
todayKst: string,
|
||||
candidates: ParseResult[] = [],
|
||||
vocab: string[] = []
|
||||
): string {
|
||||
const candidateBlock = ...; // 기존 로직 유지
|
||||
const vocabBlock = vocab.length > 0
|
||||
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
|
||||
: '';
|
||||
return `... ${candidateBlock} ${vocabBlock} ...`;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 `InferenceProvider` + `LocalOllamaProvider`
|
||||
|
||||
```typescript
|
||||
export interface GenerateInput {
|
||||
text: string;
|
||||
todayKst: string;
|
||||
dueDateCandidates: ParseResult[];
|
||||
vocab?: string[]; // optional, 미전달 시 buildPrompt 가 빈 배열 처리
|
||||
}
|
||||
```
|
||||
|
||||
`LocalOllamaProvider.generate()` 가 `buildPrompt(text, todayKst, candidates, input.vocab ?? [])` 호출.
|
||||
|
||||
### 4.4 `AiWorker.processJob`
|
||||
|
||||
generate 호출 직전:
|
||||
```typescript
|
||||
const vocab = this.repo.getTopUsedTags(20);
|
||||
const res = await this.provider.generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
dueDateCandidates: candidates,
|
||||
vocab
|
||||
});
|
||||
```
|
||||
|
||||
`updateAiResult` 후 emit 루프:
|
||||
```typescript
|
||||
const vocabSet = new Set(vocab);
|
||||
for (const tagName of res.tags) {
|
||||
if (vocabSet.has(tagName)) {
|
||||
const tagId = this.repo.getTagIdByName(tagName);
|
||||
if (tagId !== null && this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId, vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 `telemetryEvents.ts` — zod schema
|
||||
|
||||
```typescript
|
||||
const TagVocabHitPayload = z.object({
|
||||
tagId: z.number().int().positive(),
|
||||
vocabSize: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const TagVocabMissPayload = z.object({
|
||||
vocabSize: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
```
|
||||
|
||||
`TelemetryEventSchema` discriminatedUnion 13 → **15** entries.
|
||||
|
||||
### 4.6 `telemetryStats.ts` — 누적
|
||||
|
||||
- `DailyRow` 에 `tag_vocab_hit: number`, `tag_vocab_miss: number` 추가
|
||||
- accumulator 분기 2개
|
||||
- table 컬럼 2개 추가
|
||||
- summary 라인:
|
||||
```
|
||||
- 태그 vocab: hit/miss = {N}/{M} (적중률 {X}%)
|
||||
```
|
||||
N+M=0 시 `(데이터 없음)` 표기
|
||||
|
||||
### 4.7 `TelemetryService.EmitInput` union 확장 (15 entries)
|
||||
|
||||
### 4.8 `AiWorker.AiTelemetryEmitter` interface 확장
|
||||
|
||||
```typescript
|
||||
export interface AiTelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'ai_succeeded'; payload: ... }
|
||||
| { kind: 'ai_failed'; payload: ... }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Privacy invariant
|
||||
|
||||
- `tag_vocab_hit.payload.tagId` — 숫자 id 만, 태그 이름 X
|
||||
- `tag_vocab_miss.payload` — `vocabSize` 만 (tagId 없음)
|
||||
- prompt 본문에 vocab 이름 들어가지만 **prompt 는 telemetry 가 아님** (모델 컨텍스트, local Ollama 머신 내부에서만 처리)
|
||||
- `.strict()` zod 가드 + extra field 거부 테스트로 invariant 보호
|
||||
|
||||
## 6. Tests (≥19개)
|
||||
|
||||
### NoteRepository.test.ts (7)
|
||||
1. 빈 db → `[]`
|
||||
2. 정렬 (count desc, id asc tiebreaker)
|
||||
3. kebab-case 필터 — 한글/공백/대문자 태그 제외
|
||||
4. AI+user source 통합 카운트
|
||||
5. `deleted_at IS NULL` 필터
|
||||
6. LIMIT 적용 (>20 시 잘림)
|
||||
7. `getTagIdByName` — 존재 시 id, 없으면 null
|
||||
|
||||
### prompt.test.ts (4)
|
||||
8. `PROMPT_VERSION === 4`
|
||||
9. vocab=[] → 라인 자체 생략
|
||||
10. vocab 1+ → "Prefer reusing..." 문구 + comma-separated 리스트
|
||||
11. vocab 라인 위치 (candidate block 뒤, JSON rules 앞)
|
||||
|
||||
### AiWorker.test.ts (4)
|
||||
12. vocab fetch + provider.generate 에 vocab 전달 + hit emit
|
||||
13. miss emit (vocab 밖의 tag), vocabSize 정확
|
||||
14. vocab=[] 시 모든 응답 태그 miss
|
||||
15. 응답 태그 3개 → 3개 emit (per-tag 검증)
|
||||
|
||||
### telemetryEvents.test.ts (3)
|
||||
16. `tag_vocab_hit` valid parse
|
||||
17. `tag_vocab_hit` extra field 거부 (privacy)
|
||||
18. `tag_vocab_miss` valid parse, tagId 필드 없음
|
||||
|
||||
### telemetryStats.test.ts (1)
|
||||
19. hit 5 + miss 3 → daily row + summary "적중률 62.5%"
|
||||
|
||||
기존 단위 363 + **19** = **382** 예상. Q3 phrasing 변경으로 LocalOllamaProvider 기존 테스트 일부 string assertion 수정 가능 (±5).
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
(roadmap §3 #3 + 본 cut 결정)
|
||||
|
||||
- 임베딩 유사도 dedup ("회의" ↔ "meeting" semantic 매핑)
|
||||
- 사용자 controlled vocabulary 화이트리스트
|
||||
- 자동 normalize ("회의" ↔ "미팅")
|
||||
- top-N 튜닝 (N=20 hardcoded)
|
||||
- vocab cache invalidation 정책 (매번 SQL fetch)
|
||||
- vocab 시간 범위 필터 (최근 N일 → 전체 사용)
|
||||
- 기존 `ai_status='done'` 노트 일괄 재처리 (Q4=A 자연 진화)
|
||||
- 명시적 "AI 결과 재처리" trigger UI (v0.2.4 backlog)
|
||||
- `promptVersion` 을 telemetry payload 에 포함 (v0.2.4 검토 — 단일 버전 cut 에선 무의미)
|
||||
- `idx_note_tags_tag_id` 인덱스 추가 (현재 dogfood 규모에선 불필요, v0.2.4 검토)
|
||||
|
||||
## 8. Gates (roadmap §3.1 공통)
|
||||
|
||||
- typecheck 0
|
||||
- 단위 363 → 382 (+19), 모두 통과
|
||||
- e2e 1/1
|
||||
- 새 SQL: `getTopUsedTags` (3-table JOIN) + `getTagIdByName` (single-table) — 인덱스 영향 dogfood 규모에서 무시
|
||||
|
||||
## 9. Roadmap relation
|
||||
|
||||
- v0.2.3 dogfood feedback #3 (6번째 cut)
|
||||
- 다음 cut: #6 리마인드 1 spike (7번째, 마지막)
|
||||
- v0.2.4 후속: top-N 튜닝, controlled vocabulary, normalize, embeddings dedup
|
||||
@@ -53,6 +53,8 @@ AI가 제목, 요약, 태그, 프로젝트 후보를 생성합니다. 다만 사
|
||||
|
||||
하루 또는 주간 리뷰에서 AI가 메모를 업무 산출물로 바꿔줍니다.
|
||||
|
||||
오늘 회상 (RecallBanner, v0.2.3 #6): Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 가장 오래된 순으로 제시합니다. 사용자는 "열어보기"(노트 카드 스크롤 + last_recalled_at 갱신), "다음에"(KST 자정까지 in-memory snooze), "더 이상"(recall_dismissed_at 갱신, 30일 후 재추천) 중 선택합니다. 본 surface 가 Capitalize 단계의 첫 본격 진입점입니다.
|
||||
|
||||
예:
|
||||
|
||||
“이번 주 결정 근거”
|
||||
@@ -140,6 +142,8 @@ Confluence 공유 후보 추천
|
||||
|
||||
직장에서의 동기와 몰입은 의미 있는 일에서 진전이 보일 때 강해집니다. Amabile와 Kramer의 “Progress Principle”은 지식 근로자의 감정·동기·창의성에 작은 진전 경험이 중요하다는 점을 강조합니다. Inkling의 주간 리포트는 “기록 수”보다 업무 진전의 증거를 보여줘야 합니다.
|
||||
|
||||
측정 인프라 (v0.2.3 #6): recall_shown / recall_opened / recall_dismissed / recall_snoozed 4종 telemetry 가 본 cut 으로 자리잡았습니다. 향후 F4-A (잠금해제 hook), F4-B (ambient if-then), F4-D (무작위 토스트) 항목 진입 시 본 telemetry 가 효과 측정 기반으로 확장됩니다.
|
||||
|
||||
5. 스트릭은 처벌이 아니라 회복 친화적으로 설계한다
|
||||
|
||||
기획서에 스트릭과 뱃지가 포함되어 있는데, 이 장치는 조심해서 써야 합니다. 게임화 연구는 전반적으로 긍정적 효과를 보이지만, 효과 크기와 안정성은 맥락에 따라 다르고, 특히 동기·행동 효과는 고품질 연구만 보면 덜 안정적일 수 있습니다. 따라서 Inkling은 경쟁·압박형 게임화가 아니라 자기효능감 회복형 게임화가 맞습니다.
|
||||
@@ -280,6 +284,8 @@ AI 자동 정리는 Inkling의 핵심 강점입니다. 다만 사용자가 완
|
||||
|
||||
8. 관계성 보상: “내 메모가 동료의 시간을 아껴준다”
|
||||
|
||||
Inbox surface stack (v0.2.3 기준): Ollama 회복 → Pending 진행 → Failed 실패 → Expiry 마감 임박 → Recall 회상 추천. 시간 민감도 순으로 위에서 아래. RecallBanner 가 가장 가벼운 surface 로 stack 끝에 놓입니다.
|
||||
|
||||
기록 습관은 개인 생산성뿐 아니라 팀 학습과도 연결됩니다. Edmondson의 심리적 안전감 연구는 팀원이 대인관계 위험을 감수하고 질문·실수·학습 행동을 할 수 있는 분위기가 팀 학습과 관련된다는 점을 제시합니다. 업무 메모를 팀 지식으로 공유하게 만들려면 “감시받는다”가 아니라 동료를 돕는다는 감각이 필요합니다.
|
||||
|
||||
따라서 Confluence 내보내기 UX는 이렇게 설계합니다.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
@@ -22,13 +22,18 @@
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"predist": "npm run rebuild:electron && npm run build",
|
||||
"dist": "electron-builder --win --x64",
|
||||
"dist": "electron-builder",
|
||||
"predist:dir": "npm run rebuild:electron && npm run build",
|
||||
"dist:dir": "electron-builder --dir --win --x64"
|
||||
"dist:dir": "electron-builder --dir",
|
||||
"predist:win": "npm run rebuild:electron && npm run build",
|
||||
"dist:win": "electron-builder --win --x64",
|
||||
"predist:mac": "npm run rebuild:electron && npm run build",
|
||||
"dist:mac": "electron-builder --mac --arm64"
|
||||
},
|
||||
"build": {
|
||||
"appId": "xyz.altair823.inkling",
|
||||
"productName": "Inkling",
|
||||
"publish": null,
|
||||
"files": [
|
||||
"out/**/*",
|
||||
"package.json"
|
||||
@@ -47,6 +52,13 @@
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"shortcutName": "Inkling"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
{ "target": "dmg", "arch": ["arm64"] }
|
||||
],
|
||||
"category": "public.app-category.productivity",
|
||||
"identity": null
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { InferenceProvider } from './InferenceProvider.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import { parseAllCandidates } from '../services/dueDateParser.js';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
@@ -15,8 +16,30 @@ function todayKstAsIso(now: Date): string {
|
||||
return todayKstAsDate(now).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' {
|
||||
if (err instanceof ZodError) return 'schema';
|
||||
const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase();
|
||||
if (msg.includes('econnrefused') || msg.includes('enotfound') || msg.includes('fetch failed') || msg.includes('econnreset') || msg.includes('unreachable')) {
|
||||
return 'unreachable';
|
||||
}
|
||||
if (msg.includes('timeout') || msg.includes('timedout') || msg.includes('aborted')) {
|
||||
return 'timeout';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export interface AiTelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface AiWorkerOptions {
|
||||
backoffsMs?: number[];
|
||||
unreachableBackoffsMs?: number[];
|
||||
onUpdate?: (note: Note) => void;
|
||||
logger?: {
|
||||
info: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
@@ -24,6 +47,7 @@ export interface AiWorkerOptions {
|
||||
error: (msg: string, meta?: Record<string, unknown>) => void;
|
||||
};
|
||||
now?: () => Date;
|
||||
telemetry?: AiTelemetryEmitter;
|
||||
}
|
||||
|
||||
interface Job { noteId: string; attempts: number; }
|
||||
@@ -33,9 +57,12 @@ export class AiWorker {
|
||||
private running = false;
|
||||
private drainResolvers: Array<() => void> = [];
|
||||
private backoffsMs: number[];
|
||||
private unreachableBackoffsMs: number[];
|
||||
private unreachableBackoffStep = 0;
|
||||
private onUpdate?: (note: Note) => void;
|
||||
private logger: NonNullable<AiWorkerOptions['logger']>;
|
||||
private now: () => Date;
|
||||
private telemetry?: AiTelemetryEmitter;
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
@@ -43,9 +70,11 @@ export class AiWorker {
|
||||
opts: AiWorkerOptions = {}
|
||||
) {
|
||||
this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_000];
|
||||
this.unreachableBackoffsMs = opts.unreachableBackoffsMs ?? [30_000, 60_000, 120_000, 240_000, 480_000, 900_000];
|
||||
this.onUpdate = opts.onUpdate;
|
||||
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
this.telemetry = opts.telemetry;
|
||||
}
|
||||
|
||||
async enqueue(noteId: string): Promise<void> {
|
||||
@@ -93,19 +122,24 @@ export class AiWorker {
|
||||
}
|
||||
|
||||
private async processJob(job: Job): Promise<void> {
|
||||
// `max` 는 schema/other 분기 (attempts 증가) 의 cap 이다.
|
||||
// unreachable/timeout 분기는 `attempt -= 1; continue` 로 인덱스 stay — max 와 무관 무한 retry.
|
||||
const max = this.backoffsMs.length;
|
||||
for (let attempt = job.attempts; attempt < max; attempt++) {
|
||||
const startMs = this.now().getTime();
|
||||
try {
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || note.aiStatus !== 'pending') return;
|
||||
if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return;
|
||||
const nowDate = this.now();
|
||||
const todayDate = todayKstAsDate(nowDate);
|
||||
const todayIso = todayKstAsIso(nowDate);
|
||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||
const vocab = this.repo.getTopUsedTags(20);
|
||||
const res = await this.provider.generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
dueDateCandidates: candidates
|
||||
dueDateCandidates: candidates,
|
||||
vocab
|
||||
});
|
||||
// AI primary: AI's dueDate is final (no rule merge)
|
||||
this.repo.updateAiResult(job.noteId, {
|
||||
@@ -115,23 +149,80 @@ export class AiWorker {
|
||||
provider: this.provider.name,
|
||||
dueDate: res.dueDate ?? null
|
||||
});
|
||||
this.unreachableBackoffStep = 0; // 성공 시 step reset
|
||||
this.logger.info('ai.done', {
|
||||
noteId: job.noteId,
|
||||
attempt,
|
||||
dueDateSource: res.dueDate !== null ? 'ai' : 'none',
|
||||
candidatesCount: candidates.length
|
||||
});
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'ai_succeeded',
|
||||
payload: {
|
||||
noteId: job.noteId,
|
||||
durationMs: this.now().getTime() - startMs,
|
||||
attempts: attempt + 1
|
||||
}
|
||||
}).catch(() => {});
|
||||
// v0.2.3 #3 — per-tag vocab hit/miss 분류 (updateAiResult 후 → tagId 보장)
|
||||
// dedup: AI 응답에 같은 태그 중복 가능 — INSERT OR IGNORE 와 정합한 1-emit/태그 보장
|
||||
const vocabSet = new Set(vocab);
|
||||
for (const tagName of new Set(res.tags)) {
|
||||
if (vocabSet.has(tagName)) {
|
||||
const tagId = this.repo.getTagIdByName(tagName);
|
||||
if (tagId !== null) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId, vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
await this.telemetry.emit({
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: vocab.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
} catch (err) {
|
||||
const isLast = attempt === max - 1;
|
||||
const reason = classifyReason(err);
|
||||
const msg = (err as Error).message;
|
||||
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg });
|
||||
this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg, reason });
|
||||
if (reason === 'unreachable' || reason === 'timeout') {
|
||||
// 무한 retry: attempts 증가 안 함, in-place loop + sleep.
|
||||
// markAiFailed / ai_failed emit 안 함 — ratio 통계는 schema/other 만 누적.
|
||||
const sleepMs = this.nextBackoffMs(this.unreachableBackoffStep);
|
||||
// step 이 cap 도달 후엔 인덱스 stay — increment 는 무의미하지만 안전한 no-op.
|
||||
// (Math.min 가드: cap 넘어가도 length-1 로 묶임.)
|
||||
if (this.unreachableBackoffStep < this.unreachableBackoffsMs.length - 1) {
|
||||
this.unreachableBackoffStep += 1;
|
||||
}
|
||||
const nextRunAt = new Date(Date.now() + sleepMs).toISOString();
|
||||
this.repo.setNextRunAt(job.noteId, nextRunAt, msg);
|
||||
await this.sleep(sleepMs);
|
||||
attempt -= 1; // for 루프 attempt++ 상쇄 — 같은 attempt 인덱스로 재시도
|
||||
continue;
|
||||
}
|
||||
// schema / other: 기존 max 3 retry 정책
|
||||
const isLast = attempt === max - 1;
|
||||
const nextRunAt = new Date(Date.now() + (this.backoffsMs[attempt + 1] ?? 0)).toISOString();
|
||||
this.repo.incrementJobAttempt(job.noteId, nextRunAt, msg);
|
||||
if (isLast) {
|
||||
this.repo.markAiFailed(job.noteId, msg);
|
||||
this.logger.error('ai.failed', { noteId: job.noteId, err: msg });
|
||||
if (this.telemetry) {
|
||||
await this.telemetry.emit({
|
||||
kind: 'ai_failed',
|
||||
payload: {
|
||||
noteId: job.noteId,
|
||||
reason,
|
||||
attempts: attempt + 1
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
this.emit(job.noteId);
|
||||
return;
|
||||
}
|
||||
@@ -140,6 +231,11 @@ export class AiWorker {
|
||||
}
|
||||
}
|
||||
|
||||
private nextBackoffMs(step: number): number {
|
||||
const idx = Math.min(step, this.unreachableBackoffsMs.length - 1);
|
||||
return this.unreachableBackoffsMs[idx]!;
|
||||
}
|
||||
|
||||
private emit(noteId: string): void {
|
||||
if (!this.onUpdate) return;
|
||||
const note = this.repo.findById(noteId);
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface GenerateInput {
|
||||
text: string;
|
||||
todayKst: string; // ISO YYYY-MM-DD in KST
|
||||
dueDateCandidates: ParseResult[];
|
||||
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
|
||||
}
|
||||
|
||||
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
|
||||
|
||||
@@ -37,7 +37,7 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates),
|
||||
prompt: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []),
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import type { ParseResult } from '../services/dueDateParser.js';
|
||||
|
||||
export const PROMPT_VERSION = 3;
|
||||
export const PROMPT_VERSION = 4;
|
||||
|
||||
export function buildPrompt(
|
||||
rawText: string,
|
||||
todayKst: string,
|
||||
candidates: ParseResult[] = []
|
||||
candidates: ParseResult[] = [],
|
||||
vocab: string[] = []
|
||||
): string {
|
||||
const candidateBlock = candidates.length > 0
|
||||
? `\nDate candidates extracted by a Korean rule parser (these are HINTS — you decide which is correct, or pick null):
|
||||
${candidates.map((c, i) => ` ${i + 1}. ${c.iso ?? '(ambiguous)'} — matched token: "${c.matchedToken ?? '?'}" (confidence: ${c.confidence ?? 'low'})`).join('\n')}\n`
|
||||
: '';
|
||||
|
||||
const vocabBlock = vocab.length > 0
|
||||
? `\nExisting vocabulary tags (most-used first): ${vocab.join(', ')}\nPrefer reusing a vocabulary tag when the meaning matches; create new tags only when the meaning is genuinely new.\n`
|
||||
: '';
|
||||
|
||||
// candidateBlock & vocabBlock are self-delimited with leading/trailing \n
|
||||
return `You organize raw personal notes into structured metadata.
|
||||
|
||||
Today's date in Korea Standard Time (KST): ${todayKst}
|
||||
${candidateBlock}
|
||||
${candidateBlock}${vocabBlock}
|
||||
Input note (raw text, may be fragmented, any language):
|
||||
---
|
||||
${rawText}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import * as m001 from './m001_initial.js';
|
||||
import * as m002 from './m002_due_date.js';
|
||||
import * as m003 from './m003_soft_delete.js';
|
||||
|
||||
const migrations = [m001, m002];
|
||||
const migrations = [m001, m002, m003];
|
||||
|
||||
export function latestVersion(): number {
|
||||
return migrations[migrations.length - 1]!.version;
|
||||
|
||||
15
src/main/db/migrations/m003_soft_delete.ts
Normal file
15
src/main/db/migrations/m003_soft_delete.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// v3: soft delete (#4) introduces deleted_at.
|
||||
// last_recalled_at + recall_dismissed_at are pre-allocated for #6 (recall) —
|
||||
// dormant until then to avoid a v4 migration round-trip.
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export const version = 3;
|
||||
|
||||
export function up(db: Database.Database): void {
|
||||
db.exec(`
|
||||
ALTER TABLE notes ADD COLUMN deleted_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN last_recalled_at TEXT;
|
||||
ALTER TABLE notes ADD COLUMN recall_dismissed_at TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at);
|
||||
`);
|
||||
}
|
||||
@@ -17,17 +17,18 @@ import { HealthChecker } from './services/HealthChecker.js';
|
||||
import { LocalOllamaProvider } from './ai/LocalOllamaProvider.js';
|
||||
import { AiWorker } from './ai/AiWorker.js';
|
||||
import { registerCaptureApi } from './ipc/captureApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated } from './ipc/inboxApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
||||
import { createInboxWindow, getInboxWindow } from './windows/inboxWindow.js';
|
||||
import {
|
||||
createQuickCaptureWindow, showQuickCapture, getQuickCaptureWindow
|
||||
} from './windows/quickCaptureWindow.js';
|
||||
import { createTray, refreshTray } from './tray.js';
|
||||
import { createTray, refreshTray, refreshTrayOllama, refreshTrayFailedCount } from './tray.js';
|
||||
import { MediaGc } from './services/MediaGc.js';
|
||||
import { BackupService } from './services/BackupService.js';
|
||||
import { ExportService } from './services/ExportService.js';
|
||||
import { ImportService } from './services/ImportService.js';
|
||||
import { SyncService } from './services/SyncService.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
|
||||
const HIDDEN_ARG = '--hidden';
|
||||
const startedHidden = process.argv.includes(HIDDEN_ARG);
|
||||
@@ -43,6 +44,11 @@ app.whenReady().then(async () => {
|
||||
|
||||
const paths = resolveProfilePaths('default');
|
||||
|
||||
const telemetry = new TelemetryService(join(paths.profileDir, 'telemetry'), () => new Date(), 14, { silent: true });
|
||||
void telemetry.cleanupOldFiles()
|
||||
.then((r) => logger.info('telemetry.cleanup', { removed: r.removed.length }))
|
||||
.catch((e) => logger.warn('telemetry.cleanup.failed', { reason: String(e) }));
|
||||
|
||||
if (app.isPackaged && process.platform === 'win32') {
|
||||
const initFlag = join(paths.profileDir, '.autostart-init');
|
||||
if (!existsSync(initFlag)) {
|
||||
@@ -63,16 +69,33 @@ app.whenReady().then(async () => {
|
||||
fromEnv: process.env.INKLING_OLLAMA_ENDPOINT !== undefined
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint });
|
||||
const health = new HealthChecker(provider);
|
||||
void health.runOnce().then((h) => logger.info('ai.health', { ...h } as Record<string, unknown>));
|
||||
const health = new HealthChecker(provider, {
|
||||
onUpdate: (status) => {
|
||||
logger.info('ai.health', { ...status } as Record<string, unknown>);
|
||||
pushOllamaStatus(getInboxWindow, status);
|
||||
refreshTrayOllama(status.ok);
|
||||
},
|
||||
onTelemetry: (ev) => {
|
||||
if (ev.kind === 'ollama_unreachable') {
|
||||
void telemetry.emit({ kind: 'ollama_unreachable', payload: { reason: ev.reason } }).catch(() => {});
|
||||
} else if (ev.kind === 'ollama_recovered') {
|
||||
void telemetry.emit({ kind: 'ollama_recovered', payload: { downtimeMs: ev.downtimeMs } }).catch(() => {});
|
||||
} else if (ev.kind === 'ollama_recheck_manual') {
|
||||
void telemetry.emit({ kind: 'ollama_recheck_manual', payload: {} }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
health.start();
|
||||
|
||||
const worker = new AiWorker(repo, provider, {
|
||||
onUpdate: (note) => {
|
||||
pushNoteUpdated(getInboxWindow, note);
|
||||
// F4-C: AI 처리 완료 = 새 캡처가 inbox 에 합류한 시점, tray 도 즉시 갱신.
|
||||
refreshTray(repo.countToday());
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
},
|
||||
logger
|
||||
logger,
|
||||
telemetry
|
||||
});
|
||||
|
||||
const notify = new NotificationService({
|
||||
@@ -84,7 +107,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
const capture = new CaptureService(repo, store, {
|
||||
enqueue: (id) => worker.enqueue(id),
|
||||
celebrate: (id) => notify.celebrate(id)
|
||||
celebrate: (id) => notify.celebrate(id),
|
||||
telemetry
|
||||
});
|
||||
|
||||
registerCaptureApi(capture, getQuickCaptureWindow);
|
||||
@@ -119,7 +143,14 @@ app.whenReady().then(async () => {
|
||||
.catch((e) => logger.warn('backup.daily.failed', { reason: String(e) }));
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
app.on('before-quit', (e) => {
|
||||
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
||||
health.stop();
|
||||
if (trayInterval !== null) {
|
||||
clearInterval(trayInterval);
|
||||
trayInterval = null;
|
||||
}
|
||||
if (backupOnQuitDone) return;
|
||||
e.preventDefault();
|
||||
backup.runDaily()
|
||||
@@ -283,16 +314,48 @@ app.whenReady().then(async () => {
|
||||
logger.warn('sync.exception', { reason: String(e) });
|
||||
new Notification({ title: 'Inkling', body: '동기화를 완료하지 못했습니다.', silent: true }).show();
|
||||
}
|
||||
}
|
||||
},
|
||||
/* runExportTelemetry */ async () => {
|
||||
const win = getInboxWindow();
|
||||
const dialogOpts: Electron.OpenDialogOptions = {
|
||||
title: '사용 로그를 내보낼 폴더 선택',
|
||||
message: '선택한 폴더에 events.jsonl + stats.md 가 생성됩니다. raw_text/요약/제목/태그 이름은 미포함입니다.',
|
||||
buttonLabel: '여기로 내보내기',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
};
|
||||
const result = win
|
||||
? await dialog.showOpenDialog(win, dialogOpts)
|
||||
: await dialog.showOpenDialog(dialogOpts);
|
||||
if (result.canceled || result.filePaths.length === 0) return;
|
||||
try {
|
||||
const r = await telemetry.exportTo(result.filePaths[0]!);
|
||||
logger.info('telemetry.export', { eventCount: r.eventCount, outDir: result.filePaths[0] });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: `사용 로그 내보내기 완료 — ${r.eventCount}개 이벤트`,
|
||||
silent: true
|
||||
}).show();
|
||||
} catch (e) {
|
||||
logger.warn('telemetry.export.failed', { reason: String(e) });
|
||||
new Notification({
|
||||
title: 'Inkling',
|
||||
body: '사용 로그 내보내기를 완료하지 못했습니다.',
|
||||
silent: true
|
||||
}).show();
|
||||
}
|
||||
},
|
||||
/* runOllamaRecheck */ () => { void health.runOnce({ manual: true }); },
|
||||
/* runRetryAllFailed */ () => { void capture.retryAllFailed(); }
|
||||
);
|
||||
|
||||
// F4-C 환경 앵커 — tray tooltip + 메뉴 첫 항목을 오늘 KST 캡처 수로 갱신.
|
||||
// 초기 1회 + 60s interval. AiWorker.onUpdate 도 별도 갱신 트리거.
|
||||
// cleanup 은 위 통합 before-quit 핸들러에서 처리.
|
||||
refreshTray(repo.countToday());
|
||||
const trayInterval = setInterval(() => {
|
||||
refreshTrayFailedCount(repo.countFailed());
|
||||
trayInterval = setInterval(() => {
|
||||
refreshTray(repo.countToday());
|
||||
}, 60_000);
|
||||
app.on('before-quit', () => { clearInterval(trayInterval); });
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createInboxWindow();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import electron from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
const { ipcMain } = electron;
|
||||
const { ipcMain, dialog } = electron;
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { ContinuityService } from '../services/ContinuityService.js';
|
||||
import type { CaptureService } from '../services/CaptureService.js';
|
||||
import type { HealthChecker } from '../services/HealthChecker.js';
|
||||
import type { IntentService } from '../services/IntentService.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { HealthResult } from '../ai/InferenceProvider.js';
|
||||
|
||||
export interface InboxIpcDeps {
|
||||
repo: NoteRepository;
|
||||
@@ -52,6 +53,95 @@ export function registerInboxApi(deps: InboxIpcDeps): void {
|
||||
ipcMain.handle('inbox:pendingCount', () => deps.repo.getPendingCount());
|
||||
ipcMain.handle('inbox:ollamaStatus', () => deps.health.lastStatus());
|
||||
ipcMain.handle('inbox:todayCount', () => deps.repo.countToday());
|
||||
|
||||
ipcMain.handle('inbox:restore', async (_e, noteId: string) => {
|
||||
await deps.capture.restoreNote(noteId);
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:permanentDelete', async (_e, noteId: string) => {
|
||||
const win = deps.getInboxWindow();
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['영구 삭제', '취소'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: 'Inkling',
|
||||
message: '이 노트를 영구 삭제합니다',
|
||||
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
|
||||
};
|
||||
const r = win
|
||||
? await dialog.showMessageBox(win, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (r.response !== 0) return { confirmed: false };
|
||||
await deps.capture.permanentDeleteNote(noteId);
|
||||
return { confirmed: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:emptyTrash', async () => {
|
||||
const fullCount = deps.repo.countTrashed();
|
||||
if (fullCount === 0) return { confirmed: true, count: 0 };
|
||||
const win = deps.getInboxWindow();
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['휴지통 비우기', '취소'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: 'Inkling',
|
||||
message: `휴지통의 노트 ${fullCount}개를 영구 삭제합니다`,
|
||||
detail: '이 작업은 되돌릴 수 없습니다. 첨부된 이미지도 함께 삭제됩니다.'
|
||||
};
|
||||
const r = win
|
||||
? await dialog.showMessageBox(win, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (r.response !== 0) return { confirmed: false, count: 0 };
|
||||
const result = await deps.capture.emptyTrash();
|
||||
return { confirmed: true, count: result.count };
|
||||
});
|
||||
|
||||
ipcMain.handle('inbox:listTrash', (_e, opts: { limit: number }) =>
|
||||
deps.repo.listTrashed(opts)
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed());
|
||||
|
||||
ipcMain.handle('inbox:listExpired', async () => deps.capture.listExpired());
|
||||
|
||||
ipcMain.handle(
|
||||
'inbox:trashExpiredBatch',
|
||||
async (_e, payload: { ids: string[] }) => {
|
||||
if (payload.ids.length === 0) return { trashedCount: 0, confirmed: false };
|
||||
const win = deps.getInboxWindow();
|
||||
const opts: Electron.MessageBoxOptions = {
|
||||
type: 'question',
|
||||
buttons: ['옮기기', '취소'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
title: 'Inkling',
|
||||
message: `선택한 노트 ${payload.ids.length}개를 휴지통으로 옮깁니다`,
|
||||
detail: '복구는 휴지통 탭에서 가능합니다.'
|
||||
};
|
||||
const r = win
|
||||
? await dialog.showMessageBox(win, opts)
|
||||
: await dialog.showMessageBox(opts);
|
||||
if (r.response !== 0) return { trashedCount: 0, confirmed: false };
|
||||
const result = await deps.capture.trashExpiredBatch(payload.ids);
|
||||
return { trashedCount: result.trashedCount, confirmed: true };
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle('inbox:ollamaRecheck', async () => {
|
||||
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());
|
||||
|
||||
ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
|
||||
ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
|
||||
ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
|
||||
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
|
||||
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
|
||||
}
|
||||
|
||||
export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): void {
|
||||
@@ -59,3 +149,9 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note):
|
||||
if (!w || w.isDestroyed()) return;
|
||||
w.webContents.send('note:updated', note);
|
||||
}
|
||||
|
||||
export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void {
|
||||
const w = getWin();
|
||||
if (!w || w.isDestroyed()) return;
|
||||
w.webContents.send('ollama:status', status);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import { v7 as uuidv7, v4 as uuidv4 } from 'uuid';
|
||||
import type { Note, NoteMedia, NoteTag } from '@shared/types';
|
||||
import { todayInKstString } from '../util/kstDate.js';
|
||||
|
||||
export interface CreateNoteInput { rawText: string; }
|
||||
|
||||
@@ -28,6 +29,7 @@ export interface ImportNoteInput {
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
tags: { name: string; source: 'ai' | 'user' }[];
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
export type ImportNoteStatus = 'inserted' | 'skipped' | 'forked';
|
||||
@@ -38,6 +40,8 @@ export interface ImportNoteResult {
|
||||
status: ImportNoteStatus;
|
||||
}
|
||||
|
||||
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export class NoteRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
@@ -83,17 +87,25 @@ export class NoteRepository {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = opts.cursor
|
||||
? (this.db
|
||||
.prepare(`SELECT * FROM notes WHERE created_at < ? ORDER BY created_at DESC, id DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL AND created_at < ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(opts.cursor, limit) as any[])
|
||||
: (this.db
|
||||
.prepare(`SELECT * FROM notes ORDER BY created_at DESC, id DESC LIMIT ?`)
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?`
|
||||
)
|
||||
.all(limit) as any[]);
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
listAll(): Note[] {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes ORDER BY created_at ASC, id ASC`)
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC, id ASC`)
|
||||
.all() as any[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
@@ -146,6 +158,140 @@ export class NoteRepository {
|
||||
tx();
|
||||
}
|
||||
|
||||
findFailedIds(): string[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL ORDER BY updated_at DESC, id DESC`
|
||||
)
|
||||
.all() as Array<{ id: string }>;
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
countFailed(): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`
|
||||
)
|
||||
.get() as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 ai_status='failed' (active) 노트를 'pending' 으로 reset 하고 pending_jobs 재투입.
|
||||
* 단일 transaction. v0.2.3 #2 retryAllFailed.
|
||||
*
|
||||
* INSERT OR IGNORE 로 race 안전 (이미 pending_jobs row 존재 시 skip).
|
||||
*/
|
||||
retryAllFailed(now: string): { ids: string[] } {
|
||||
const ids: string[] = [];
|
||||
const tx = this.db.transaction(() => {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT id FROM notes WHERE ai_status='failed' AND deleted_at IS NULL`)
|
||||
.all() as Array<{ id: string }>;
|
||||
if (rows.length === 0) return;
|
||||
const reset = this.db.prepare(
|
||||
`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`
|
||||
);
|
||||
const insert = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`
|
||||
);
|
||||
for (const r of rows) {
|
||||
reset.run(now, r.id);
|
||||
insert.run(r.id, now);
|
||||
ids.push(r.id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { ids };
|
||||
}
|
||||
|
||||
/**
|
||||
* pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음.
|
||||
* v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로).
|
||||
*/
|
||||
setNextRunAt(noteId: string, nextRunAt: string, lastError: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE pending_jobs SET next_run_at=?, last_error=? WHERE note_id=?`
|
||||
)
|
||||
.run(nextRunAt, lastError.slice(0, 500), noteId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선.
|
||||
* - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전)
|
||||
* - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트
|
||||
* - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today)
|
||||
* KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가.
|
||||
*/
|
||||
findRecallCandidate(): Note | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
|
||||
AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
|
||||
AND ai_status = 'done'
|
||||
AND deleted_at IS NULL
|
||||
AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get() as Record<string, unknown> | undefined;
|
||||
return row ? this.hydrate(row) : null;
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */
|
||||
markRecallOpened(id: string, now: string): void {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(now, now, id);
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */
|
||||
dismissRecall(id: string, now: string): void {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(now, now, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #3 — AI prompt 의 vocabulary 후보. 사용 빈도 높은 태그 top-N.
|
||||
* source 무시 (AI+user 통합), kebab-case 통과한 것만 (한글/공백/대문자 제외).
|
||||
* deleted_at IS NULL 만 (휴지통 노트 태그 제외).
|
||||
*
|
||||
* Note: LIMIT 가 SQL 단계에서 먼저 적용된 후 regex 필터링이 후처리 됨.
|
||||
* 따라서 반환 배열 length 가 limit 보다 작을 수 있음 (top-N 안에 비-kebab-case
|
||||
* 태그가 섞여 있을 때). v0.2.3 dogfood 규모에서는 실용적 영향 없음.
|
||||
*/
|
||||
getTopUsedTags(limit = 20): string[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT t.name, COUNT(*) AS c
|
||||
FROM tags t
|
||||
JOIN note_tags nt ON nt.tag_id = t.id
|
||||
JOIN notes n ON n.id = nt.note_id
|
||||
WHERE n.deleted_at IS NULL
|
||||
GROUP BY t.id
|
||||
ORDER BY c DESC, t.id ASC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limit) as Array<{ name: string; c: number }>;
|
||||
return rows
|
||||
.map((r) => r.name)
|
||||
.filter((n) => KEBAB_CASE_RE.test(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #3 — vocab hit telemetry 의 tagId 확보용. updateAiResult 후 호출 보장.
|
||||
* tags.name COLLATE NOCASE 라 case-insensitive lookup.
|
||||
*/
|
||||
getTagIdByName(name: string): number | null {
|
||||
const row = this.db
|
||||
.prepare(`SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1`)
|
||||
.get(name) as { id: number } | undefined;
|
||||
return row ? row.id : null;
|
||||
}
|
||||
|
||||
updateUserAiFields(
|
||||
id: string,
|
||||
fields: { title?: string; summary?: string; tags?: string[] }
|
||||
@@ -221,6 +367,84 @@ export class NoteRepository {
|
||||
.run(date, now, id);
|
||||
}
|
||||
|
||||
trash(id: string, deletedAt: string): void {
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET deleted_at = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(deletedAt, deletedAt, id);
|
||||
this.db.prepare(`DELETE FROM pending_jobs WHERE note_id = ?`).run(id);
|
||||
});
|
||||
tx();
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically transition a batch of notes from active → trash.
|
||||
* Returns the number of notes that actually transitioned (i.e. were active
|
||||
* before the call). Already-trashed and unknown ids are silent skips —
|
||||
* counting them would inflate `expired_batch_trash` telemetry.
|
||||
*
|
||||
* Reuses `trash(id, deletedAt)` per row to inherit pending_jobs cleanup
|
||||
* invariant (§9.2 of #4 spec).
|
||||
*/
|
||||
trashBatch(ids: string[], deletedAt: string): { trashedCount: number } {
|
||||
if (ids.length === 0) return { trashedCount: 0 };
|
||||
let trashedCount = 0;
|
||||
const tx = this.db.transaction((batch: string[]) => {
|
||||
for (const id of batch) {
|
||||
const row = this.db
|
||||
.prepare(`SELECT deleted_at FROM notes WHERE id = ?`)
|
||||
.get(id) as { deleted_at: string | null } | undefined;
|
||||
if (!row || row.deleted_at !== null) continue;
|
||||
this.trash(id, deletedAt);
|
||||
trashedCount += 1;
|
||||
}
|
||||
});
|
||||
tx(ids);
|
||||
return { trashedCount };
|
||||
}
|
||||
|
||||
restore(id: string): void {
|
||||
const now = new Date().toISOString();
|
||||
this.db
|
||||
.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`)
|
||||
.run(now, id);
|
||||
}
|
||||
|
||||
permanentDelete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
|
||||
emptyTrash(): { noteIds: string[] } {
|
||||
// Single DELETE ... RETURNING is atomic by itself (no explicit transaction needed)
|
||||
// and avoids per-row prepare overhead. RETURNING is house-style elsewhere
|
||||
// (updateAiResult/updateUserAiFields/getAllPendingJobs).
|
||||
const rows = this.db
|
||||
.prepare('DELETE FROM notes WHERE deleted_at IS NOT NULL RETURNING id')
|
||||
.all() as Array<{ id: string }>;
|
||||
return { noteIds: rows.map((r) => r.id) };
|
||||
}
|
||||
|
||||
listTrashed(opts: { limit: number }): Note[] {
|
||||
const limit = Math.max(1, Math.min(200, opts.limit));
|
||||
const rows = this.db
|
||||
.prepare(`SELECT * FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC, id DESC LIMIT ?`)
|
||||
.all(limit) as any[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap COUNT for trash UI badge / bulk-empty dialog. Does not hydrate
|
||||
* tags/media — used in hot paths (loadInitial / refreshMeta / upsertNote
|
||||
* follow-ups) where listTrashed() is wasteful.
|
||||
*/
|
||||
countTrashed(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`)
|
||||
.get() as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/** @deprecated v0.2.3 #4 부터 hard delete 는 permanentDelete() 사용. soft delete 는 trash(). 본 메서드는 v0.2.4 에서 제거 예정. */
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM notes WHERE id=?').run(id);
|
||||
}
|
||||
@@ -239,6 +463,10 @@ export class NoteRepository {
|
||||
* - id present + raw_text identical → no-op (status: 'skipped')
|
||||
* - id present + raw_text differs → INSERT under fresh uuidv7
|
||||
* to preserve the raw_text-immutable invariant (status: 'forked')
|
||||
*
|
||||
* deletedAt merge (v0.2.3 #4, spec §8.2): source/dest 중 IS NOT NULL 우선
|
||||
* (삭제 보존). skip 케이스에서 source NN + dest NULL 일 때만 dest 갱신.
|
||||
* insert/fork 는 source 의 deletedAt 그대로 보존.
|
||||
*/
|
||||
importNote(input: ImportNoteInput): ImportNoteResult {
|
||||
const existing = this.findRawTextById(input.id);
|
||||
@@ -246,6 +474,16 @@ export class NoteRepository {
|
||||
let status: ImportNoteStatus = 'inserted';
|
||||
if (existing !== null) {
|
||||
if (existing === input.rawText) {
|
||||
// skip — source 가 deletedAt IS NOT NULL 이고 dest 가 NULL 이면 dest 갱신 (삭제 보존).
|
||||
// trash() 를 재사용해 pending_jobs cleanup invariant (§9.2) 도 동시에 만족.
|
||||
if (input.deletedAt != null) {
|
||||
const destRow = this.db
|
||||
.prepare('SELECT deleted_at FROM notes WHERE id=?')
|
||||
.get(input.id) as { deleted_at: string | null } | undefined;
|
||||
if (destRow && destRow.deleted_at === null) {
|
||||
this.trash(input.id, input.deletedAt);
|
||||
}
|
||||
}
|
||||
return { id: input.id, status: 'skipped' };
|
||||
}
|
||||
finalId = uuidv7();
|
||||
@@ -257,8 +495,8 @@ export class NoteRepository {
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user,
|
||||
user_intent, intent_prompted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
user_intent, intent_prompted_at, deleted_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
finalId,
|
||||
@@ -271,6 +509,7 @@ export class NoteRepository {
|
||||
input.summaryEditedByUser ? 1 : 0,
|
||||
input.userIntent,
|
||||
input.intentPromptedAt,
|
||||
input.deletedAt ?? null,
|
||||
input.createdAt,
|
||||
input.updatedAt
|
||||
);
|
||||
@@ -297,7 +536,9 @@ export class NoteRepository {
|
||||
|
||||
getPendingCount(): number {
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending'`)
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes WHERE ai_status='pending' AND deleted_at IS NULL`
|
||||
)
|
||||
.get() as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
@@ -319,11 +560,36 @@ export class NoteRepository {
|
||||
const startIso = new Date(kstMidnightUtc).toISOString();
|
||||
const endIso = new Date(nextKstMidnightUtc).toISOString();
|
||||
const row = this.db
|
||||
.prepare(`SELECT COUNT(*) AS c FROM notes WHERE created_at >= ? AND created_at < ?`)
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS c FROM notes
|
||||
WHERE deleted_at IS NULL AND created_at >= ? AND created_at < ?`
|
||||
)
|
||||
.get(startIso, endIso) as { c: number };
|
||||
return row.c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notes whose due_date is strictly before today (KST calendar) and that are
|
||||
* still active (not trashed) and AI-processed. Includes both AI-extracted and
|
||||
* user-edited due_date (v0.2.3 #5 spec §1 Q1=B).
|
||||
*
|
||||
* Caller may inject `now` for testability; defaults to `new Date()`.
|
||||
*/
|
||||
findExpiredCandidates(now: Date = new Date()): Note[] {
|
||||
const today = todayInKstString(now);
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM notes
|
||||
WHERE due_date IS NOT NULL
|
||||
AND due_date < ?
|
||||
AND deleted_at IS NULL
|
||||
AND ai_status = 'done'
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
)
|
||||
.all(today) as any[];
|
||||
return rows.map((r) => this.hydrate(r));
|
||||
}
|
||||
|
||||
getAllPendingJobs(): Array<{ noteId: string; attempts: number; nextRunAt: string }> {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT note_id, attempts, next_run_at FROM pending_jobs`)
|
||||
@@ -375,6 +641,9 @@ export class NoteRepository {
|
||||
intentPromptedAt: row.intent_prompted_at,
|
||||
dueDate: row.due_date ?? null,
|
||||
dueDateEditedByUser: row.due_date_edited_by_user === 1,
|
||||
deletedAt: row.deleted_at ?? null,
|
||||
lastRecalledAt: row.last_recalled_at ?? null,
|
||||
recallDismissedAt: row.recall_dismissed_at ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
tags: tags as NoteTag[],
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { MediaStore } from './MediaStore.js';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
export interface TelemetryEmitter {
|
||||
emit(input:
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
| { 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 } }
|
||||
| { kind: 'recall_opened'; payload: { noteId: string } }
|
||||
| { kind: 'recall_dismissed'; payload: { noteId: string } }
|
||||
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
|
||||
| { kind: 'recall_snoozed'; payload: { noteId: string } }
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CaptureDeps {
|
||||
enqueue: (noteId: string) => Promise<void>;
|
||||
celebrate: (noteId: string) => void;
|
||||
telemetry?: TelemetryEmitter;
|
||||
}
|
||||
|
||||
export interface SubmitInput {
|
||||
@@ -12,6 +31,8 @@ export interface SubmitInput {
|
||||
}
|
||||
|
||||
export class CaptureService {
|
||||
private lastExpiredShownSig: string | null = null;
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
private store: MediaStore,
|
||||
@@ -39,13 +60,188 @@ export class CaptureService {
|
||||
}
|
||||
this.repo.insertMedia(rows);
|
||||
}
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'capture',
|
||||
payload: {
|
||||
noteId: id,
|
||||
rawTextLength: input.text.length,
|
||||
hasMedia: input.images.length > 0
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
await this.deps.enqueue(id);
|
||||
this.deps.celebrate(id);
|
||||
return { noteId: id };
|
||||
}
|
||||
|
||||
async deleteNote(noteId: string): Promise<void> {
|
||||
this.repo.delete(noteId);
|
||||
await this.store.deleteNoteDirectory(noteId);
|
||||
// v0.2.3 #4: hard delete → soft delete. media 보존 (restore 시 필요).
|
||||
// 이미 trash 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note || note.deletedAt !== null) return;
|
||||
this.repo.trash(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'trash', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async restoreNote(noteId: string): Promise<void> {
|
||||
// 이미 active 인 노트는 telemetry emit skip — restore/trash ratio 오염 방지.
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note || note.deletedAt === null) return;
|
||||
this.repo.restore(noteId);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'restore', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async permanentDeleteNote(noteId: string): Promise<void> {
|
||||
// 존재하지 않는 노트는 emit skip — 메트릭 오염 방지.
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) return;
|
||||
this.repo.permanentDelete(noteId);
|
||||
// best-effort media cleanup — disk 실패해도 telemetry/IPC 흐름은 그대로 (orphan dir
|
||||
// 은 future janitor 가 정리). emptyTrash 와 동일 패턴.
|
||||
try { await this.store.deleteNoteDirectory(noteId); }
|
||||
catch { /* best-effort */ }
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'permanent_delete', payload: { noteId } }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async emptyTrash(): Promise<{ count: number }> {
|
||||
const { noteIds } = this.repo.emptyTrash();
|
||||
for (const id of noteIds) {
|
||||
try { await this.store.deleteNoteDirectory(id); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({ kind: 'empty_trash', payload: { count: noteIds.length } }).catch(() => {});
|
||||
}
|
||||
return { count: noteIds.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 후보 (due_date < today KST, active, ai_status=done) 조회.
|
||||
* candidates 가 비지 않고 signature 가 직전과 다르면 expired_banner_shown 자동 emit.
|
||||
* v0.2.3 #5 spec §6.2 — dedup 위치 main 통합.
|
||||
*/
|
||||
async listExpired(now: Date = new Date()): Promise<Note[]> {
|
||||
const candidates = this.repo.findExpiredCandidates(now);
|
||||
if (candidates.length === 0) {
|
||||
// empty → reset sig 으로 의도적: 다시 후보가 차오르면 동일 set 이라도 1회 emit.
|
||||
// (사용자가 "오늘 그만" 후 새 만료 노트 들어와도 셀렉션 변화로 재인식)
|
||||
this.lastExpiredShownSig = null;
|
||||
return candidates;
|
||||
}
|
||||
const sig = `${candidates.length}:${candidates.slice(0, 3).map((n) => n.id).join('-')}`;
|
||||
if (sig !== this.lastExpiredShownSig) {
|
||||
this.lastExpiredShownSig = sig;
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'expired_banner_shown',
|
||||
payload: { candidateCount: candidates.length }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* 만료 후보 일괄 trash. 빈 배열은 즉시 no-op.
|
||||
* 성공 시 expired_batch_trash 1회 emit (per-id trash emit 은 별도 발화 안 함 —
|
||||
* stats.md 에서 `trash` (단건) vs `expired_batch_trash` (배치) 분리 통계).
|
||||
*/
|
||||
async trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number }> {
|
||||
if (ids.length === 0) return { trashedCount: 0 };
|
||||
const r = this.repo.trashBatch(ids, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'expired_batch_trash',
|
||||
payload: { count: r.trashedCount }
|
||||
}).catch(() => {});
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 후보 1건 fetch. */
|
||||
async listRecallCandidate(): Promise<Note | null> {
|
||||
return this.repo.findRecallCandidate();
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */
|
||||
async markRecallOpened(noteId: string): Promise<{ note: Note }> {
|
||||
if (!this.repo.findById(noteId)) throw new Error(`note not found: ${noteId}`);
|
||||
this.repo.markRecallOpened(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_opened',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */
|
||||
async dismissRecall(noteId: string): Promise<{ note: Note }> {
|
||||
this.repo.dismissRecall(noteId, new Date().toISOString());
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_dismissed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
return { note: this.repo.findById(noteId)! };
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */
|
||||
async emitRecallShown(noteId: string): Promise<void> {
|
||||
const note = this.repo.findById(noteId);
|
||||
if (!note) return;
|
||||
const ageDays = this.computeAgeDays(note);
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId, ageDays }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */
|
||||
async emitRecallSnoozed(noteId: string): Promise<void> {
|
||||
if (this.deps.telemetry) {
|
||||
await this.deps.telemetry.emit({
|
||||
kind: 'recall_snoozed',
|
||||
payload: { noteId }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */
|
||||
private computeAgeDays(note: Note): number {
|
||||
const ref = note.lastRecalledAt ?? note.createdAt;
|
||||
const refMs = new Date(ref).getTime();
|
||||
const nowMs = Date.now();
|
||||
return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ export class ContinuityService {
|
||||
|
||||
get(): WeeklyContinuity {
|
||||
const rows = this.db
|
||||
.prepare(`SELECT created_at FROM notes ORDER BY created_at ASC`)
|
||||
.prepare(
|
||||
`SELECT created_at FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC`
|
||||
)
|
||||
.all() as Array<{ created_at: string }>;
|
||||
const dates = rows.map((r) => new Date(r.created_at));
|
||||
if (dates.length === 0) {
|
||||
|
||||
@@ -1,12 +1,85 @@
|
||||
import type { InferenceProvider, HealthResult } from '../ai/InferenceProvider.js';
|
||||
|
||||
export class HealthChecker {
|
||||
private last: HealthResult = { ok: true };
|
||||
constructor(private provider: InferenceProvider) {}
|
||||
export type HealthTelemetryEvent =
|
||||
| { kind: 'ollama_unreachable'; reason: string }
|
||||
| { kind: 'ollama_recovered'; downtimeMs: number }
|
||||
| { kind: 'ollama_recheck_manual' };
|
||||
|
||||
async runOnce(): Promise<HealthResult> {
|
||||
this.last = await this.provider.healthCheck();
|
||||
return this.last;
|
||||
export interface HealthCheckerOptions {
|
||||
intervalMs?: number;
|
||||
onUpdate?: (status: HealthResult) => void;
|
||||
onTelemetry?: (event: HealthTelemetryEvent) => void;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 60_000;
|
||||
|
||||
export class HealthChecker {
|
||||
// sentinel: 첫 healthCheck 가 ok=true 면 transition 으로 인식 안 됨 (no-op),
|
||||
// ok=false 면 unreachable transition 으로 정상 인식. 즉 첫 호출이 healthy 면 telemetry 0.
|
||||
private last: HealthResult = { ok: true };
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private unreachableSince: number | null = null;
|
||||
// m2 fix: in-flight guard — 첫 runOnce 가 늦게 끝나는 동안 setInterval 이 두 번째
|
||||
// runOnce 를 시작하면 같은 promise 반환. healthCheck 가 idempotent HTTP 라 안전 측면에선
|
||||
// 큰 문제 없지만, telemetry 이중 emit (false→true→false 동시 처리) 회피.
|
||||
private inFlight: Promise<HealthResult> | null = null;
|
||||
private intervalMs: number;
|
||||
private now: () => number;
|
||||
|
||||
constructor(
|
||||
private provider: InferenceProvider,
|
||||
private opts: HealthCheckerOptions = {}
|
||||
) {
|
||||
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
this.now = opts.now ?? Date.now;
|
||||
}
|
||||
|
||||
async runOnce(opts?: { manual?: boolean }): Promise<HealthResult> {
|
||||
// n4 의도: ollama_recheck_manual 은 healthCheck 호출 *전에* fire — provider 가 throw 하거나
|
||||
// 늦게 응답해도 manual 카운트는 누락 없음. user click → telemetry 1:1 보장.
|
||||
if (opts?.manual === true) {
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_recheck_manual' });
|
||||
}
|
||||
if (this.inFlight !== null) return this.inFlight;
|
||||
this.inFlight = this.doRunOnce();
|
||||
try { return await this.inFlight; }
|
||||
finally { this.inFlight = null; }
|
||||
}
|
||||
|
||||
private async doRunOnce(): Promise<HealthResult> {
|
||||
const next = await this.provider.healthCheck();
|
||||
const prev = this.last;
|
||||
const okChanged = prev.ok !== next.ok;
|
||||
const reasonChanged = prev.reason !== next.reason;
|
||||
if (okChanged) {
|
||||
if (next.ok === false) {
|
||||
this.unreachableSince = this.now();
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_unreachable', reason: next.reason ?? 'unknown' });
|
||||
} else {
|
||||
const downtimeMs = this.unreachableSince !== null ? this.now() - this.unreachableSince : 0;
|
||||
this.unreachableSince = null;
|
||||
this.opts.onTelemetry?.({ kind: 'ollama_recovered', downtimeMs });
|
||||
}
|
||||
this.opts.onUpdate?.(next);
|
||||
} else if (reasonChanged) {
|
||||
this.opts.onUpdate?.(next);
|
||||
}
|
||||
this.last = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer !== null) return;
|
||||
void this.runOnce();
|
||||
this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
lastStatus(): HealthResult { return this.last; }
|
||||
|
||||
@@ -39,7 +39,8 @@ function parsedToInput(parsed: ParsedNote): ImportNoteInput {
|
||||
aiGeneratedAt: parsed.aiGeneratedAt,
|
||||
userIntent: parsed.userIntent,
|
||||
intentPromptedAt: parsed.intentPromptedAt,
|
||||
tags: parsed.tags
|
||||
tags: parsed.tags,
|
||||
deletedAt: parsed.deletedAt
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ export class MediaGc {
|
||||
|
||||
async run(): Promise<{ removed: number }> {
|
||||
const dirs = await this.store.listNoteDirs();
|
||||
// Intentionally does NOT filter `deleted_at IS NULL` — trashed notes still own
|
||||
// their media until permanentDelete/emptyTrash. Removing dirs of soft-deleted
|
||||
// notes here would defeat restore.
|
||||
const rows = this.db.prepare('SELECT id FROM notes').all() as Array<{ id: string }>;
|
||||
const known = new Set(rows.map((r) => r.id));
|
||||
let removed = 0;
|
||||
|
||||
143
src/main/services/TelemetryService.ts
Normal file
143
src/main/services/TelemetryService.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { validateEvent, TelemetryEvent } from './telemetryEvents.js';
|
||||
import { aggregateStats } from './telemetryStats.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
function todayKstIso(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export interface TelemetryServiceOptions {
|
||||
silent?: boolean;
|
||||
}
|
||||
|
||||
export type EmitInput =
|
||||
| { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }
|
||||
| { kind: 'ai_succeeded'; payload: { noteId: string; durationMs: number; attempts: number } }
|
||||
| { kind: 'ai_failed'; payload: { noteId: string; reason: 'unreachable' | 'schema' | 'timeout' | 'other'; attempts: number } }
|
||||
| { kind: 'trash'; payload: { noteId: string } }
|
||||
| { kind: 'restore'; payload: { noteId: string } }
|
||||
| { kind: 'permanent_delete'; payload: { noteId: string } }
|
||||
| { kind: 'empty_trash'; payload: { count: number } }
|
||||
| { kind: 'expired_banner_shown'; payload: { candidateCount: number } }
|
||||
| { kind: 'expired_batch_trash'; payload: { count: number } }
|
||||
| { kind: 'ollama_unreachable'; payload: { reason: string } }
|
||||
| { kind: 'ollama_recovered'; payload: { downtimeMs: number } }
|
||||
| { kind: 'ollama_recheck_manual'; payload: Record<string, never> }
|
||||
| { kind: 'ai_retry_manual'; payload: { failedCount: number } }
|
||||
| { kind: 'tag_vocab_hit'; payload: { tagId: number; vocabSize: number } }
|
||||
| { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
|
||||
| { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
|
||||
| { kind: 'recall_opened'; payload: { noteId: string } }
|
||||
| { kind: 'recall_dismissed'; payload: { noteId: string } }
|
||||
| { kind: 'recall_snoozed'; payload: { noteId: string } };
|
||||
|
||||
export class TelemetryService {
|
||||
constructor(
|
||||
private dir: string,
|
||||
private now: () => Date = () => new Date(),
|
||||
private retentionDays: number = 14,
|
||||
private opts: TelemetryServiceOptions = {}
|
||||
) {}
|
||||
|
||||
async cleanupOldFiles(): Promise<{ removed: string[] }> {
|
||||
const removed: string[] = [];
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(this.dir);
|
||||
} catch {
|
||||
return { removed };
|
||||
}
|
||||
const cutoff = new Date(this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000);
|
||||
const cutoffIso = todayKstIso(cutoff); // KST 일자 비교
|
||||
for (const name of entries) {
|
||||
const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name);
|
||||
if (!m) continue;
|
||||
const fileDate = m[1]!;
|
||||
if (fileDate < cutoffIso) {
|
||||
try {
|
||||
await unlink(join(this.dir, name));
|
||||
removed.push(name);
|
||||
} catch {
|
||||
// ignore — best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
return { removed };
|
||||
}
|
||||
|
||||
async emit(input: EmitInput): Promise<void> {
|
||||
// 회차 1 review (PR #13) — `now()` 한 번만 호출. KST 자정 경계에서 ts 와 파일명 일자가
|
||||
// 어긋나는 것을 방지.
|
||||
const nowDate = this.now();
|
||||
const ts = nowDate.toISOString();
|
||||
const event = validateEvent({ ts, kind: input.kind, payload: input.payload });
|
||||
const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`);
|
||||
try {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8');
|
||||
} catch (err) {
|
||||
if (this.opts.silent) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async readAllRecent(): Promise<TelemetryEvent[]> {
|
||||
const events: TelemetryEvent[] = [];
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(this.dir);
|
||||
} catch {
|
||||
return events;
|
||||
}
|
||||
const cutoffMs = this.now().getTime() - this.retentionDays * 24 * 60 * 60 * 1000;
|
||||
const cutoffIso = todayKstIso(new Date(cutoffMs));
|
||||
// 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로
|
||||
// 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨.
|
||||
const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
||||
const fileNames = entries
|
||||
.filter((n) => {
|
||||
const m = datePattern.exec(n);
|
||||
return m !== null && m[1]! >= cutoffIso;
|
||||
})
|
||||
.sort();
|
||||
for (const name of fileNames) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(join(this.dir, name), 'utf8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
events.push(validateEvent(parsed));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async exportTo(outDir: string): Promise<{ eventCount: number }> {
|
||||
const events = await this.readAllRecent();
|
||||
await mkdir(outDir, { recursive: true });
|
||||
const eventsContent = events.map((e) => JSON.stringify(e)).join('\n') + (events.length > 0 ? '\n' : '');
|
||||
await writeFile(join(outDir, 'events.jsonl'), eventsContent, 'utf8');
|
||||
const stats = aggregateStats(events, this.now());
|
||||
await writeFile(join(outDir, 'stats.md'), stats.md, 'utf8');
|
||||
return { eventCount: stats.eventCount };
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface ParsedNote {
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
deletedAt: string | null; // 신규 v0.2.3 #4
|
||||
tags: ParsedNoteTag[];
|
||||
images: ParsedNoteImage[];
|
||||
exportVersion: number;
|
||||
@@ -347,6 +348,7 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
aiGeneratedAt: get('ai_generated_at'),
|
||||
userIntent: get('user_intent'),
|
||||
intentPromptedAt: get('intent_prompted_at'),
|
||||
deletedAt: get('deleted_at'),
|
||||
tags: fm.tags,
|
||||
images: fm.images,
|
||||
exportVersion
|
||||
|
||||
94
src/main/services/telemetryEvents.ts
Normal file
94
src/main/services/telemetryEvents.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const CapturePayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
rawTextLength: z.number().int().nonnegative(),
|
||||
hasMedia: z.boolean()
|
||||
}).strict();
|
||||
|
||||
const AiSucceededPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
durationMs: z.number().nonnegative(),
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const AiFailedReason = z.enum(['unreachable', 'schema', 'timeout', 'other']);
|
||||
|
||||
const AiFailedPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
reason: AiFailedReason,
|
||||
attempts: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const NoteIdPayload = z.object({
|
||||
noteId: z.string().min(1)
|
||||
}).strict();
|
||||
|
||||
const EmptyTrashPayload = z.object({
|
||||
count: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const ExpiredBannerShownPayload = z.object({
|
||||
candidateCount: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const ExpiredBatchTrashPayload = z.object({
|
||||
count: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const OllamaUnreachablePayload = z.object({
|
||||
reason: z.string().min(1).max(500)
|
||||
}).strict();
|
||||
|
||||
const OllamaRecoveredPayload = z.object({
|
||||
downtimeMs: z.number().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const EmptyPayload = z.object({}).strict();
|
||||
|
||||
const AiRetryManualPayload = z.object({
|
||||
failedCount: z.number().int().positive()
|
||||
}).strict();
|
||||
|
||||
const TagVocabHitPayload = z.object({
|
||||
tagId: z.number().int().positive(),
|
||||
vocabSize: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const TagVocabMissPayload = z.object({
|
||||
vocabSize: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
const RecallShownPayload = z.object({
|
||||
noteId: z.string().min(1),
|
||||
ageDays: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
|
||||
export const TelemetryEventSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ ts: z.string(), kind: z.literal('capture'), payload: CapturePayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_succeeded'), payload: AiSucceededPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_failed'), payload: AiFailedPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('trash'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('restore'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('permanent_delete'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('empty_trash'), payload: EmptyTrashPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('expired_banner_shown'), payload: ExpiredBannerShownPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('expired_batch_trash'), payload: ExpiredBatchTrashPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_unreachable'), payload: OllamaUnreachablePayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_recovered'), payload: OllamaRecoveredPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ollama_recheck_manual'), payload: EmptyPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('ai_retry_manual'), payload: AiRetryManualPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('tag_vocab_hit'), payload: TagVocabHitPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(),
|
||||
z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict()
|
||||
]);
|
||||
|
||||
export type TelemetryEvent = z.infer<typeof TelemetryEventSchema>;
|
||||
export type TelemetryKind = TelemetryEvent['kind'];
|
||||
|
||||
export function validateEvent(raw: unknown): TelemetryEvent {
|
||||
return TelemetryEventSchema.parse(raw);
|
||||
}
|
||||
191
src/main/services/telemetryStats.ts
Normal file
191
src/main/services/telemetryStats.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { TelemetryEvent } from './telemetryEvents.js';
|
||||
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
function kstDate(ts: string): string {
|
||||
const d = new Date(ts);
|
||||
const k = new Date(d.getTime() + KST_OFFSET_MS);
|
||||
return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()))
|
||||
.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
interface DailyRow {
|
||||
date: string;
|
||||
capture: number;
|
||||
ai_succeeded: number;
|
||||
ai_failed: number;
|
||||
trash: number;
|
||||
restore: number;
|
||||
permanent_delete: number;
|
||||
empty_trash: number;
|
||||
expired_banner_shown: number;
|
||||
expired_batch_trash: number;
|
||||
ollama_unreachable: number;
|
||||
ollama_recovered: number;
|
||||
ollama_recheck_manual: number;
|
||||
ai_retry_manual: number;
|
||||
tag_vocab_hit: number;
|
||||
tag_vocab_miss: number;
|
||||
recall_shown: number;
|
||||
recall_opened: number;
|
||||
recall_dismissed: number;
|
||||
recall_snoozed: number;
|
||||
}
|
||||
|
||||
export interface StatsResult {
|
||||
md: string;
|
||||
eventCount: number;
|
||||
}
|
||||
|
||||
export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult {
|
||||
const eventCount = events.length;
|
||||
const byDay = new Map<string, DailyRow>();
|
||||
let aiSucceeded = 0;
|
||||
let aiFailed = 0;
|
||||
let durationSum = 0;
|
||||
let durationN = 0;
|
||||
let trashCount = 0;
|
||||
let restoreCount = 0;
|
||||
let expiredBannerShownCandidatesSum = 0;
|
||||
let expiredBatchTrashCountSum = 0;
|
||||
let ollamaDowntimeSum = 0;
|
||||
let ollamaRecoveredCount = 0;
|
||||
let ollamaRecheckManualCount = 0;
|
||||
let aiRetryManualCount = 0;
|
||||
let aiRetryManualFailedSum = 0;
|
||||
let tagVocabHitCount = 0;
|
||||
let tagVocabMissCount = 0;
|
||||
let recallShownCount = 0;
|
||||
let recallOpenedCount = 0;
|
||||
let recallDismissedCount = 0;
|
||||
let recallSnoozedCount = 0;
|
||||
let recallAgeDaysSum = 0;
|
||||
for (const ev of events) {
|
||||
const day = kstDate(ev.ts);
|
||||
let row = byDay.get(day);
|
||||
if (!row) {
|
||||
row = {
|
||||
date: day,
|
||||
capture: 0, ai_succeeded: 0, ai_failed: 0,
|
||||
trash: 0, restore: 0, permanent_delete: 0, empty_trash: 0,
|
||||
expired_banner_shown: 0, expired_batch_trash: 0,
|
||||
ollama_unreachable: 0, ollama_recovered: 0, ollama_recheck_manual: 0,
|
||||
ai_retry_manual: 0,
|
||||
tag_vocab_hit: 0, tag_vocab_miss: 0,
|
||||
recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0
|
||||
};
|
||||
byDay.set(day, row);
|
||||
}
|
||||
if (ev.kind === 'capture') row.capture += 1;
|
||||
else if (ev.kind === 'ai_succeeded') {
|
||||
row.ai_succeeded += 1;
|
||||
aiSucceeded += 1;
|
||||
durationSum += ev.payload.durationMs;
|
||||
durationN += 1;
|
||||
} else if (ev.kind === 'ai_failed') {
|
||||
row.ai_failed += 1;
|
||||
aiFailed += 1;
|
||||
} else if (ev.kind === 'trash') {
|
||||
row.trash += 1;
|
||||
trashCount += 1;
|
||||
} else if (ev.kind === 'restore') {
|
||||
row.restore += 1;
|
||||
restoreCount += 1;
|
||||
} else if (ev.kind === 'permanent_delete') {
|
||||
row.permanent_delete += 1;
|
||||
} else if (ev.kind === 'empty_trash') {
|
||||
row.empty_trash += 1;
|
||||
} else if (ev.kind === 'expired_banner_shown') {
|
||||
row.expired_banner_shown += 1;
|
||||
expiredBannerShownCandidatesSum += ev.payload.candidateCount;
|
||||
} else if (ev.kind === 'expired_batch_trash') {
|
||||
row.expired_batch_trash += 1;
|
||||
expiredBatchTrashCountSum += ev.payload.count;
|
||||
} else if (ev.kind === 'ollama_unreachable') {
|
||||
row.ollama_unreachable += 1;
|
||||
} else if (ev.kind === 'ollama_recovered') {
|
||||
row.ollama_recovered += 1;
|
||||
ollamaDowntimeSum += ev.payload.downtimeMs;
|
||||
ollamaRecoveredCount += 1;
|
||||
} else if (ev.kind === 'ollama_recheck_manual') {
|
||||
row.ollama_recheck_manual += 1;
|
||||
ollamaRecheckManualCount += 1;
|
||||
} else if (ev.kind === 'ai_retry_manual') {
|
||||
row.ai_retry_manual += 1;
|
||||
aiRetryManualCount += 1;
|
||||
aiRetryManualFailedSum += ev.payload.failedCount;
|
||||
} else if (ev.kind === 'tag_vocab_hit') {
|
||||
row.tag_vocab_hit += 1;
|
||||
tagVocabHitCount += 1;
|
||||
} else if (ev.kind === 'tag_vocab_miss') {
|
||||
row.tag_vocab_miss += 1;
|
||||
tagVocabMissCount += 1;
|
||||
} else if (ev.kind === 'recall_shown') {
|
||||
row.recall_shown += 1;
|
||||
recallShownCount += 1;
|
||||
recallAgeDaysSum += ev.payload.ageDays;
|
||||
} else if (ev.kind === 'recall_opened') {
|
||||
row.recall_opened += 1;
|
||||
recallOpenedCount += 1;
|
||||
} else if (ev.kind === 'recall_dismissed') {
|
||||
row.recall_dismissed += 1;
|
||||
recallDismissedCount += 1;
|
||||
} else if (ev.kind === 'recall_snoozed') {
|
||||
row.recall_snoozed += 1;
|
||||
recallSnoozedCount += 1;
|
||||
}
|
||||
}
|
||||
const days = Array.from(byDay.values()).sort((a, b) => a.date.localeCompare(b.date));
|
||||
const aiTotal = aiSucceeded + aiFailed;
|
||||
const successRate = aiTotal === 0 ? 'N/A' : `${(aiSucceeded / aiTotal * 100).toFixed(1)}% (${aiSucceeded}/${aiTotal})`;
|
||||
const avgDuration = durationN === 0 ? 'N/A' : `${Math.round(durationSum / durationN)}`;
|
||||
const trashRecoveryRate = trashCount === 0
|
||||
? 'N/A'
|
||||
: `${(restoreCount / trashCount * 100).toFixed(1)}% (${restoreCount}/${trashCount})`;
|
||||
const expiredTrashRatio = expiredBannerShownCandidatesSum === 0
|
||||
? 'N/A'
|
||||
: `${(expiredBatchTrashCountSum / expiredBannerShownCandidatesSum * 100).toFixed(1)}% (${expiredBatchTrashCountSum}/${expiredBannerShownCandidatesSum})`;
|
||||
const avgDowntime = ollamaRecoveredCount === 0
|
||||
? 'N/A'
|
||||
: `${Math.round(ollamaDowntimeSum / ollamaRecoveredCount)}`;
|
||||
const totalUnreachable = days.reduce((s, r) => s + r.ollama_unreachable, 0);
|
||||
const tagVocabTotal = tagVocabHitCount + tagVocabMissCount;
|
||||
const tagVocabSummary = tagVocabTotal === 0
|
||||
? '(데이터 없음)'
|
||||
: `hit/miss = ${tagVocabHitCount}/${tagVocabMissCount} (적중률 ${(tagVocabHitCount / tagVocabTotal * 100).toFixed(1)}%)`;
|
||||
const recallSummary = recallShownCount === 0
|
||||
? '(데이터 없음)'
|
||||
: `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`;
|
||||
const recallAvgAge = recallShownCount === 0
|
||||
? '(데이터 없음)'
|
||||
: `${Math.round(recallAgeDaysSum / recallShownCount)}`;
|
||||
const lines: string[] = [];
|
||||
lines.push('# Inkling Telemetry Stats');
|
||||
lines.push('');
|
||||
lines.push(`생성: ${generatedAt.toISOString()}`);
|
||||
lines.push(`총 이벤트: ${eventCount}`);
|
||||
lines.push('');
|
||||
lines.push('## 일자별 카운트');
|
||||
lines.push('');
|
||||
lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |');
|
||||
lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|--------------|---------------|------------------|----------------|');
|
||||
for (const row of days) {
|
||||
lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 핵심 ratio');
|
||||
lines.push('');
|
||||
lines.push(`- AI 성공률: ${successRate}`);
|
||||
lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`);
|
||||
lines.push(`- 휴지통 회수율: ${trashRecoveryRate}`);
|
||||
lines.push(`- 만료 trash ratio: ${expiredTrashRatio}`);
|
||||
lines.push(`- Ollama unreachable 빈도: ${totalUnreachable}건`);
|
||||
lines.push(`- 평균 downtimeMs (recovered): ${avgDowntime}`);
|
||||
lines.push(`- 수동 recheck 사용량: ${ollamaRecheckManualCount}건`);
|
||||
lines.push(`- AI 수동 재시도: ${aiRetryManualCount}회 / 누적 ${aiRetryManualFailedSum}건`);
|
||||
lines.push(`- 태그 vocab: ${tagVocabSummary}`);
|
||||
lines.push(`- 회상 추천: ${recallSummary}`);
|
||||
lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`);
|
||||
lines.push('');
|
||||
return { md: lines.join('\n'), eventCount };
|
||||
}
|
||||
@@ -9,7 +9,12 @@ let _runBackup: () => void = () => {};
|
||||
let _runExport: () => void = () => {};
|
||||
let _runImport: () => void = () => {};
|
||||
let _runSync: () => void = () => {};
|
||||
let _runExportTelemetry: () => void = () => {};
|
||||
let _runOllamaRecheck: () => void = () => {};
|
||||
let _ollamaOk = true;
|
||||
let _todayCount = 0;
|
||||
let _runRetryAllFailed: () => void = () => {};
|
||||
let _failedCount = 0;
|
||||
|
||||
function buildMenu() {
|
||||
const items: MenuItemConstructorOptions[] = [];
|
||||
@@ -25,6 +30,17 @@ function buildMenu() {
|
||||
items.push({ label: '내보내기...', click: _runExport });
|
||||
items.push({ label: '백업에서 복원...', click: _runImport });
|
||||
items.push({ label: '지금 동기화', click: _runSync });
|
||||
items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry });
|
||||
items.push({
|
||||
label: 'Ollama 재확인',
|
||||
enabled: !_ollamaOk,
|
||||
click: _runOllamaRecheck
|
||||
});
|
||||
items.push({
|
||||
label: `지금 AI 처리 (실패 ${_failedCount}건)`,
|
||||
enabled: _failedCount > 0,
|
||||
click: _runRetryAllFailed
|
||||
});
|
||||
if (app.isPackaged) {
|
||||
const { openAtLogin } = app.getLoginItemSettings();
|
||||
items.push({
|
||||
@@ -52,7 +68,10 @@ export function createTray(
|
||||
runBackup: () => void,
|
||||
runExport: () => void,
|
||||
runImport: () => void,
|
||||
runSync: () => void
|
||||
runSync: () => void,
|
||||
runExportTelemetry: () => void,
|
||||
runOllamaRecheck: () => void,
|
||||
runRetryAllFailed: () => void
|
||||
): TrayType {
|
||||
_showInbox = showInbox;
|
||||
_showCapture = showCapture;
|
||||
@@ -60,6 +79,9 @@ export function createTray(
|
||||
_runExport = runExport;
|
||||
_runImport = runImport;
|
||||
_runSync = runSync;
|
||||
_runExportTelemetry = runExportTelemetry;
|
||||
_runOllamaRecheck = runOllamaRecheck;
|
||||
_runRetryAllFailed = runRetryAllFailed;
|
||||
const icon = nativeImage.createEmpty();
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(`Inkling — 오늘 ${_todayCount}`);
|
||||
@@ -78,3 +100,22 @@ export function refreshTray(todayCount: number): void {
|
||||
tray.setToolTip(`Inkling — 오늘 ${todayCount}`);
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #1 — Ollama 상태가 변할 때 main 의 health.onUpdate 가 호출.
|
||||
* 메뉴의 "Ollama 재확인" 활성/비활성 상태 갱신.
|
||||
*/
|
||||
export function refreshTrayOllama(ok: boolean): void {
|
||||
_ollamaOk = ok;
|
||||
if (tray === null) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.2.3 #2 — AiWorker.onUpdate 시 실패 카운트 변하면 메뉴 라벨 + enabled 갱신.
|
||||
*/
|
||||
export function refreshTrayFailedCount(count: number): void {
|
||||
_failedCount = count;
|
||||
if (tray === null) return;
|
||||
tray.setContextMenu(buildMenu());
|
||||
}
|
||||
|
||||
28
src/main/util/kstDate.ts
Normal file
28
src/main/util/kstDate.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant.
|
||||
*
|
||||
* v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against
|
||||
* notes.due_date (also stored as YYYY-MM-DD per slice §F1).
|
||||
*/
|
||||
export function todayInKstString(now: Date): string {
|
||||
const k = new Date(now.getTime() + KST_OFFSET_MS);
|
||||
return new Date(
|
||||
Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())
|
||||
).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch ms of the next 00:00 KST strictly after `now`.
|
||||
*
|
||||
* v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze
|
||||
* deadline ("오늘 그만").
|
||||
*/
|
||||
export function nextKstMidnightMs(now: number): number {
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
// Floor to KST midnight, then add one day.
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
return nextKstMidnight - KST_OFFSET_MS;
|
||||
}
|
||||
@@ -19,11 +19,32 @@ const api: InklingApi = {
|
||||
getPendingCount: () => ipcRenderer.invoke('inbox:pendingCount'),
|
||||
getOllamaStatus: () => ipcRenderer.invoke('inbox:ollamaStatus'),
|
||||
getTodayCount: () => ipcRenderer.invoke('inbox:todayCount'),
|
||||
// 신규 v0.2.3 #4:
|
||||
restoreNote: (noteId) => ipcRenderer.invoke('inbox:restore', noteId),
|
||||
permanentDeleteNote: (noteId) => ipcRenderer.invoke('inbox:permanentDelete', noteId),
|
||||
emptyTrash: () => ipcRenderer.invoke('inbox:emptyTrash'),
|
||||
listTrash: (opts) => ipcRenderer.invoke('inbox:listTrash', opts),
|
||||
getTrashCount: () => ipcRenderer.invoke('inbox:trashCount'),
|
||||
listExpired: () => ipcRenderer.invoke('inbox:listExpired'),
|
||||
trashExpiredBatch: (ids) => ipcRenderer.invoke('inbox:trashExpiredBatch', { ids }),
|
||||
ollamaRecheck: () => ipcRenderer.invoke('inbox:ollamaRecheck'),
|
||||
onNoteUpdated: (cb) => {
|
||||
const listener = (_e: unknown, note: Note) => cb(note);
|
||||
ipcRenderer.on('note:updated', listener);
|
||||
return () => ipcRenderer.off('note:updated', listener);
|
||||
}
|
||||
},
|
||||
onOllamaStatus: (cb) => {
|
||||
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'),
|
||||
listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
|
||||
markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
|
||||
dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
|
||||
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
|
||||
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,93 +9,149 @@ import { PendingBanner } from './components/PendingBanner.js';
|
||||
import { OllamaBanner } from './components/OllamaBanner.js';
|
||||
import { RecoveryToast } from './components/RecoveryToast.js';
|
||||
import { TagUndoToast } from './components/TagUndoToast.js';
|
||||
import { ExpiryBanner } from './components/ExpiryBanner.js';
|
||||
import { FailedBanner } from './components/FailedBanner.js';
|
||||
import { RecallBanner } from './components/RecallBanner.js';
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const {
|
||||
notes,
|
||||
loading,
|
||||
loadInitial,
|
||||
refreshMeta,
|
||||
upsertNote,
|
||||
removeNote,
|
||||
continuity,
|
||||
tagFilter,
|
||||
setTagFilter
|
||||
notes, trashNotes, trashCount, showTrash,
|
||||
loading, loadInitial, refreshMeta, upsertNote, removeNote,
|
||||
continuity, tagFilter, setTagFilter,
|
||||
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
|
||||
} = useInbox();
|
||||
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
const unsub = inboxApi.onNoteUpdated((note) => {
|
||||
const unsubNote = inboxApi.onNoteUpdated((note) => {
|
||||
upsertNote(note);
|
||||
void refreshMeta();
|
||||
});
|
||||
const unsubOllama = inboxApi.onOllamaStatus((status) => {
|
||||
useInbox.setState({ ollamaStatus: status });
|
||||
});
|
||||
const onFocus = () => { void refreshMeta(); };
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => { unsub(); window.removeEventListener('focus', onFocus); };
|
||||
return () => { unsubNote(); unsubOllama(); window.removeEventListener('focus', onFocus); };
|
||||
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
|
||||
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
|
||||
}, [loadInitial, refreshMeta, upsertNote]);
|
||||
|
||||
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
|
||||
const filtered = selectFilteredNotes({ notes, tagFilter });
|
||||
|
||||
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
|
||||
background: active ? '#0a4b80' : 'transparent',
|
||||
color: active ? '#fff' : '#0a4b80',
|
||||
border: '1px solid #0a4b80',
|
||||
borderRadius: 4,
|
||||
padding: '4px 10px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2 }}>
|
||||
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
|
||||
<button
|
||||
onClick={() => { if (showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={!showTrash}
|
||||
style={tabBtnStyle(!showTrash)}
|
||||
>
|
||||
Inbox({notes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (!showTrash) void toggleShowTrash(); }}
|
||||
aria-pressed={showTrash}
|
||||
style={tabBtnStyle(showTrash)}
|
||||
>
|
||||
휴지통({trashCount})
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
|
||||
<ContinuityBadge />
|
||||
<IdentityCounter />
|
||||
</div>
|
||||
</div>
|
||||
<main className="main">
|
||||
<OllamaBanner />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
{tagFilter !== null && (
|
||||
<div
|
||||
style={{
|
||||
background: '#eaf3ff',
|
||||
color: '#0a4b80',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 6,
|
||||
margin: '8px 0',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}
|
||||
>
|
||||
<span>🔎 필터: <strong>#{tagFilter}</strong></span>
|
||||
<span style={{ color: '#666' }}>({filtered.length}개)</span>
|
||||
<button
|
||||
onClick={() => setTagFilter(null)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#0a4b80',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12
|
||||
}}
|
||||
title="필터 해제"
|
||||
>
|
||||
✕ 해제
|
||||
</button>
|
||||
</div>
|
||||
{!showTrash && (
|
||||
<>
|
||||
<OllamaBanner />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
<FailedBanner />
|
||||
<ExpiryBanner />
|
||||
<RecallBanner />
|
||||
{tagFilter !== null && (
|
||||
<div style={{
|
||||
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
|
||||
borderRadius: 6, margin: '8px 0', fontSize: 12,
|
||||
display: 'flex', alignItems: 'center', gap: 8
|
||||
}}>
|
||||
<span>🔎 필터: <strong>#{tagFilter}</strong></span>
|
||||
<span style={{ color: '#666' }}>({filtered.length}개)</span>
|
||||
<button
|
||||
onClick={() => setTagFilter(null)}
|
||||
style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
|
||||
title="필터 해제"
|
||||
>
|
||||
✕ 해제
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="inbox"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
onUpdated={(u) => upsertNote(u)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">머릿속에 떠다니는 한 줄을 적어보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty">이 태그의 노트가 없습니다.</div>
|
||||
) : (
|
||||
filtered.map((n) => (
|
||||
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
|
||||
))
|
||||
{showTrash && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
|
||||
<div style={{ fontSize: 13, color: '#666' }}>
|
||||
{trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void emptyTrash()}
|
||||
disabled={trashCount === 0}
|
||||
style={{
|
||||
background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4, padding: '4px 10px',
|
||||
fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
휴지통 비우기 ({trashCount}개)
|
||||
</button>
|
||||
</div>
|
||||
{trashNotes.length === 0 ? null : (
|
||||
trashNotes.map((n) => (
|
||||
<NoteCard
|
||||
key={n.id} note={n} mode="trash"
|
||||
onDeleted={() => removeNote(n.id)}
|
||||
onUpdated={(u) => upsertNote(u)}
|
||||
onRestore={() => void restoreNote(n.id)}
|
||||
onPermanentDelete={() => void permanentDeleteNote(n.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<TagUndoToast />
|
||||
|
||||
157
src/renderer/inbox/components/ExpiryBanner.tsx
Normal file
157
src/renderer/inbox/components/ExpiryBanner.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { Note } from '@shared/types';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function ExpiryBanner(): React.ReactElement | null {
|
||||
const candidates = useInbox((s) => s.expiredCandidates);
|
||||
const snoozeUntilMs = useInbox((s) => s.expiredSnoozeUntilMs);
|
||||
const trashExpiredBatch = useInbox((s) => s.trashExpiredBatch);
|
||||
const snoozeExpired = useInbox((s) => s.snoozeExpired);
|
||||
// n1 fix — snoozeUntilMs 가 set 되어 있고 아직 미래면 그 시점에 force re-render 트리거.
|
||||
// 24h+ 켜둔 상태에서 자정 KST 넘어 자동 collapse 해제 보장.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (snoozeUntilMs === null) return;
|
||||
const remaining = snoozeUntilMs - Date.now();
|
||||
if (remaining <= 0) return;
|
||||
const t = setTimeout(() => setTick((n) => n + 1), remaining);
|
||||
return () => clearTimeout(t);
|
||||
}, [snoozeUntilMs]);
|
||||
|
||||
// Q5=A: 0건 / snooze 활성 시 collapse
|
||||
if (candidates.length === 0) return null;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
|
||||
|
||||
return <ExpiryBannerInner
|
||||
candidates={candidates}
|
||||
onTrash={(ids) => {
|
||||
trashExpiredBatch(ids).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('trashExpiredBatch failed', e);
|
||||
});
|
||||
}}
|
||||
onSnooze={() => snoozeExpired()}
|
||||
/>;
|
||||
}
|
||||
|
||||
interface InnerProps {
|
||||
candidates: Note[];
|
||||
onTrash: (ids: string[]) => void;
|
||||
onSnooze: () => void;
|
||||
}
|
||||
|
||||
function ExpiryBannerInner({ candidates, onTrash, onSnooze }: InnerProps): React.ReactElement {
|
||||
const [expanded, setExpanded] = useState<boolean>(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// candidates 가 변하면 selected 의 stale id 정리
|
||||
useEffect(() => {
|
||||
const valid = new Set(candidates.map((c) => c.id));
|
||||
setSelected((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const id of prev) if (valid.has(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, [candidates]);
|
||||
|
||||
const allSelected = candidates.length > 0 && candidates.every((c) => selected.has(c.id));
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
|
||||
function toggleAll() {
|
||||
if (allSelected) setSelected(new Set());
|
||||
else setSelected(new Set(candidates.map((c) => c.id)));
|
||||
}
|
||||
|
||||
function toggle(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff7e6', border: '1px solid #d99500', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>⏰ <b>오늘 기준 만료 {candidates.length}개</b></span>
|
||||
<button
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#946100' }}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? '▲ 접기' : '▼ 펼치기'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSnooze}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent', color: '#946100',
|
||||
border: '1px solid #d99500', borderRadius: 4,
|
||||
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
오늘 그만
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '8px 0 4px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
ref={(el) => { if (el) el.indeterminate = someSelected; }}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<span style={{ color: '#666' }}>전체 선택 ({selected.size}/{candidates.length})</span>
|
||||
</label>
|
||||
<div>
|
||||
{candidates.map((n) => (
|
||||
<label
|
||||
key={n.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '4px 0', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(n.id)}
|
||||
onChange={() => toggle(n.id)}
|
||||
/>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{n.aiTitle ?? n.rawText.slice(0, 60)}
|
||||
</span>
|
||||
<span style={{ color: '#946100', fontSize: 12 }}>due {n.dueDate}</span>
|
||||
{n.tags[0] && (
|
||||
<span style={{
|
||||
background: '#fce8b2', color: '#946100', padding: '0 6px',
|
||||
borderRadius: 10, fontSize: 11
|
||||
}}>
|
||||
#{n.tags[0].name}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onTrash(Array.from(selected))}
|
||||
disabled={selected.size === 0}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
background: selected.size === 0 ? '#999' : '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12,
|
||||
cursor: selected.size === 0 ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
선택 휴지통 ({selected.size}개)
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/renderer/inbox/components/FailedBanner.tsx
Normal file
32
src/renderer/inbox/components/FailedBanner.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
|
||||
export function FailedBanner(): React.ReactElement | null {
|
||||
const count = useInbox((s) => s.failedCount);
|
||||
const retryAllFailed = useInbox((s) => s.retryAllFailed);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fce4e4', border: '1px solid #a33', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13,
|
||||
display: 'flex', alignItems: 'center', gap: 8
|
||||
}}>
|
||||
<span style={{ flex: 1 }}>❌ AI 처리 실패 <b>{count}</b>건</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
retryAllFailed().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('retryAllFailed failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: '#a33', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ interface Props {
|
||||
note: Note;
|
||||
onDeleted: () => void;
|
||||
onUpdated: (n: Note) => void;
|
||||
mode?: 'inbox' | 'trash'; // default 'inbox'
|
||||
onRestore?: () => void;
|
||||
onPermanentDelete?: () => void;
|
||||
}
|
||||
|
||||
const aiBadgeStyle: React.CSSProperties = {
|
||||
@@ -104,7 +107,8 @@ function DueDateBadge({
|
||||
);
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElement {
|
||||
export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore, onPermanentDelete }: Props): React.ReactElement {
|
||||
const isTrash = mode === 'trash';
|
||||
const [rawOpen, setRawOpen] = useState(note.aiStatus !== 'done');
|
||||
const [local, setLocal] = useState(note);
|
||||
|
||||
@@ -180,10 +184,11 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
const showIntentBanner = local.aiStatus === 'done' && local.intentPromptedAt === null;
|
||||
|
||||
return (
|
||||
<div style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
// id load-bearing — RecallBanner 의 scrollIntoView target (#6 v0.2.3)
|
||||
<div id={`note-${note.id}`} style={{ background: 'white', padding: 16, marginBottom: 12, borderRadius: 10, boxShadow: '0 1px 2px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ fontSize: 11, color: '#888' }}>{formatted}</div>
|
||||
|
||||
{showIntentBanner && (
|
||||
{!isTrash && showIntentBanner && (
|
||||
<IntentBanner
|
||||
noteId={note.id}
|
||||
onResolved={(intentText) => {
|
||||
@@ -206,86 +211,122 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
)}
|
||||
{local.aiStatus === 'done' && (
|
||||
<>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<EditableField
|
||||
value={local.aiTitle ?? ''}
|
||||
onSave={saveTitle}
|
||||
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
|
||||
singleLine
|
||||
/>
|
||||
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<EditableField
|
||||
value={local.aiSummary ?? ''}
|
||||
onSave={saveSummary}
|
||||
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
|
||||
singleLine={false}
|
||||
/>
|
||||
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<DueDateBadge
|
||||
value={local.dueDate}
|
||||
isEdited={local.dueDateEditedByUser}
|
||||
today={todayKstIso()}
|
||||
onSave={saveDueDate}
|
||||
/>
|
||||
</div>
|
||||
{local.tags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{local.tags.map((t) => (
|
||||
<span
|
||||
key={t.name}
|
||||
style={{
|
||||
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
|
||||
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
|
||||
padding: '2px 4px 2px 8px',
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => filterByTag(t.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={`#${t.name} 노트만 보기`}
|
||||
>
|
||||
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 14,
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
opacity: 0.6
|
||||
}}
|
||||
title="태그 제거"
|
||||
aria-label={`${t.name} 태그 제거`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{local.userIntent !== null && (
|
||||
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
|
||||
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
|
||||
<EditableField
|
||||
value={local.userIntent}
|
||||
onSave={saveIntent}
|
||||
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
|
||||
singleLine
|
||||
/>
|
||||
</div>
|
||||
{isTrash ? (
|
||||
<>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>{local.aiTitle ?? '(제목 없음)'}</h3>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}>
|
||||
{local.aiSummary ?? '(요약 없음)'}
|
||||
</div>
|
||||
{local.dueDate !== null && (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 11, color: '#666' }}>📅 {local.dueDate}</span>
|
||||
</div>
|
||||
)}
|
||||
{local.tags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{local.tags.map((t) => (
|
||||
<span
|
||||
key={t.name}
|
||||
style={{
|
||||
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
|
||||
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 12,
|
||||
fontSize: 12
|
||||
}}
|
||||
>
|
||||
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<EditableField
|
||||
value={local.aiTitle ?? ''}
|
||||
onSave={saveTitle}
|
||||
style={{ display: 'inline-block', fontSize: 16, fontWeight: 600 }}
|
||||
singleLine
|
||||
/>
|
||||
{!local.titleEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<EditableField
|
||||
value={local.aiSummary ?? ''}
|
||||
onSave={saveSummary}
|
||||
style={{ fontSize: 13, color: '#333', whiteSpace: 'pre-wrap' }}
|
||||
singleLine={false}
|
||||
/>
|
||||
{!local.summaryEditedByUser && <span style={aiBadgeStyle} title="AI 제안">AI</span>}
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<DueDateBadge
|
||||
value={local.dueDate}
|
||||
isEdited={local.dueDateEditedByUser}
|
||||
today={todayKstIso()}
|
||||
onSave={saveDueDate}
|
||||
/>
|
||||
</div>
|
||||
{local.tags.length > 0 && (
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{local.tags.map((t) => (
|
||||
<span
|
||||
key={t.name}
|
||||
style={{
|
||||
background: t.source === 'ai' ? '#eaf3ff' : '#e9f9e4',
|
||||
color: t.source === 'ai' ? '#0a4b80' : '#236b1a',
|
||||
padding: '2px 4px 2px 8px',
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => filterByTag(t.name)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title={`#${t.name} 노트만 보기`}
|
||||
>
|
||||
{t.name}{t.source === 'ai' && <sub style={{ marginLeft: 3, fontSize: 9 }}>AI</sub>}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); void removeTag(t.name); }}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 14,
|
||||
padding: '0 2px',
|
||||
lineHeight: 1,
|
||||
opacity: 0.6
|
||||
}}
|
||||
title="태그 제거"
|
||||
aria-label={`${t.name} 태그 제거`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{local.userIntent !== null && (
|
||||
<div style={{ marginTop: 10, padding: 8, background: '#fffbe9', borderRadius: 6 }}>
|
||||
<span style={{ fontSize: 12, color: '#7a5a00', marginRight: 6 }}>💡</span>
|
||||
<EditableField
|
||||
value={local.userIntent}
|
||||
onSave={saveIntent}
|
||||
style={{ display: 'inline-block', fontSize: 13, color: '#444' }}
|
||||
singleLine
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -310,9 +351,32 @@ export function NoteCard({ note, onDeleted, onUpdated }: Props): React.ReactElem
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 10, textAlign: 'right' }}>
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
</button>
|
||||
{isTrash ? (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onRestore}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #0a4b80', color: '#0a4b80',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🔄 복구
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
style={{
|
||||
background: 'none', border: '1px solid #c93030', color: '#c93030',
|
||||
cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
}}
|
||||
>
|
||||
🗑 영구 삭제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => void handleDelete()} style={{ background: 'none', border: 'none', color: '#c93030', cursor: 'pointer', fontSize: 12 }}>
|
||||
🗑 삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useInbox } from '../store.js';
|
||||
|
||||
export function OllamaBanner(): React.ReactElement | null {
|
||||
const status = useInbox((s) => s.ollamaStatus);
|
||||
const recheckOllama = useInbox((s) => s.recheckOllama);
|
||||
if (status.ok) return null;
|
||||
const isMissing = status.reason?.includes('not installed');
|
||||
const message = isMissing
|
||||
@@ -10,7 +11,24 @@ export function OllamaBanner(): React.ReactElement | null {
|
||||
: 'Inkling 정리가 잠시 멈췄습니다. Ollama를 실행해주세요.';
|
||||
return (
|
||||
<div className="banner warn" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<span>⚠ {message}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
|
||||
<span style={{ flex: 1 }}>⚠ {message}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
recheckOllama().catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('recheckOllama failed', e);
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent', color: '#946100',
|
||||
border: '1px solid #d99500', borderRadius: 4,
|
||||
padding: '2px 8px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
재확인
|
||||
</button>
|
||||
</div>
|
||||
{status.reason ? (
|
||||
<span style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
|
||||
진단: {status.reason}
|
||||
|
||||
100
src/renderer/inbox/components/RecallBanner.tsx
Normal file
100
src/renderer/inbox/components/RecallBanner.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
export function RecallBanner(): React.ReactElement | null {
|
||||
const candidate = useInbox((s) => s.recallCandidate);
|
||||
const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
|
||||
const openRecall = useInbox((s) => s.openRecall);
|
||||
const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
|
||||
const snoozeRecall = useInbox((s) => s.snoozeRecall);
|
||||
|
||||
// i1 fix — shownIds 를 useRef 로 관리해 race 차단 (setState 트리거 X)
|
||||
// 같은 RecallBanner 컴포넌트 인스턴스 동안 per-noteId 1회 emit 보장.
|
||||
// 컴포넌트 언마운트/리마운트 시 reset (session-local 의도).
|
||||
const shownIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// ExpiryBanner 패턴 — snoozeUntilMs 만료 시 force re-render
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
if (snoozeUntilMs === null) return;
|
||||
const remaining = snoozeUntilMs - Date.now();
|
||||
if (remaining <= 0) return;
|
||||
const t = setTimeout(() => setTick((n) => n + 1), remaining);
|
||||
return () => clearTimeout(t);
|
||||
}, [snoozeUntilMs]);
|
||||
|
||||
// first-render emit recall_shown (per-banner-lifetime 1회 per note)
|
||||
useEffect(() => {
|
||||
if (!candidate) return;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
|
||||
if (shownIdsRef.current.has(candidate.id)) return;
|
||||
void inboxApi.emitRecallShown(candidate.id);
|
||||
shownIdsRef.current.add(candidate.id);
|
||||
}, [candidate, snoozeUntilMs]);
|
||||
|
||||
if (candidate === null) return null;
|
||||
if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;
|
||||
|
||||
const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
|
||||
// m4 fix — rawText 와 aiTitle 모두 비었을 때 빈 제목 방지
|
||||
const title = candidate.aiTitle?.trim() || candidate.rawText.trim().slice(0, 60) || '(제목 없음)';
|
||||
|
||||
function onOpen() {
|
||||
void openRecall(candidate!.id);
|
||||
const el = document.getElementById(`note-${candidate!.id}`);
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
|
||||
padding: '8px 12px', margin: '8px 0', fontSize: 13
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>💭 <b>오늘 회상해볼 노트</b></span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
|
||||
{title}
|
||||
</span>
|
||||
<span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays}일 전</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
background: '#4a7ec0', color: '#fff',
|
||||
border: 'none', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
열어보기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void snoozeRecall()}
|
||||
style={{
|
||||
background: 'transparent', color: '#4a7ec0',
|
||||
border: '1px solid #4a7ec0', borderRadius: 4,
|
||||
padding: '4px 12px', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
다음에
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void dismissRecallNote(candidate.id)}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
background: 'transparent', color: '#888',
|
||||
border: 'none', fontSize: 12, cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
더 이상
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function computeAgeDays(refIso: string): number {
|
||||
const refMs = new Date(refIso).getTime();
|
||||
return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
|
||||
}
|
||||
@@ -6,17 +6,39 @@ export { selectFilteredNotes } from './selectFilteredNotes.js';
|
||||
|
||||
interface InboxState {
|
||||
notes: Note[];
|
||||
trashNotes: Note[];
|
||||
trashCount: number;
|
||||
showTrash: boolean;
|
||||
continuity: WeeklyContinuity;
|
||||
pendingCount: number;
|
||||
ollamaStatus: { ok: boolean; reason?: string };
|
||||
todayCount: number;
|
||||
loading: boolean;
|
||||
tagFilter: string | null;
|
||||
expiredCandidates: Note[];
|
||||
expiredSnoozeUntilMs: number | null;
|
||||
failedCount: number;
|
||||
recallCandidate: Note | null;
|
||||
recallSnoozeUntilMs: number | null;
|
||||
loadInitial: () => Promise<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
upsertNote: (note: Note) => void;
|
||||
removeNote: (id: string) => void;
|
||||
setTagFilter: (tag: string | null) => void;
|
||||
toggleShowTrash: () => Promise<void>;
|
||||
loadTrash: () => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
loadExpired: () => Promise<void>;
|
||||
trashExpiredBatch: (ids: string[]) => Promise<void>;
|
||||
snoozeExpired: () => void;
|
||||
recheckOllama: () => Promise<void>;
|
||||
retryAllFailed: () => Promise<void>;
|
||||
loadRecallCandidate: () => Promise<void>;
|
||||
openRecall: (id: string) => Promise<void>;
|
||||
dismissRecallNote: (id: string) => Promise<void>;
|
||||
snoozeRecall: () => Promise<void>;
|
||||
}
|
||||
|
||||
const emptyContinuity: WeeklyContinuity = {
|
||||
@@ -26,46 +48,181 @@ const emptyContinuity: WeeklyContinuity = {
|
||||
|
||||
export const useInbox = create<InboxState>((set, get) => ({
|
||||
notes: [],
|
||||
trashNotes: [],
|
||||
trashCount: 0,
|
||||
showTrash: false,
|
||||
continuity: emptyContinuity,
|
||||
pendingCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
todayCount: 0,
|
||||
loading: false,
|
||||
tagFilter: null,
|
||||
expiredCandidates: [],
|
||||
expiredSnoozeUntilMs: null,
|
||||
failedCount: 0,
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
async loadInitial() {
|
||||
set({ loading: true });
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
|
||||
const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
inboxApi.listNotes({ limit: 50 }),
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount()
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
]);
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, loading: false });
|
||||
set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
|
||||
},
|
||||
async refreshMeta() {
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount] = await Promise.all([
|
||||
const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
|
||||
inboxApi.getContinuity(),
|
||||
inboxApi.getPendingCount(),
|
||||
inboxApi.getOllamaStatus(),
|
||||
inboxApi.getTodayCount()
|
||||
inboxApi.getTodayCount(),
|
||||
inboxApi.getTrashCount(),
|
||||
inboxApi.listExpired(),
|
||||
inboxApi.getFailedCount(),
|
||||
inboxApi.listRecallCandidate()
|
||||
]);
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount });
|
||||
set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
|
||||
},
|
||||
upsertNote(note) {
|
||||
const i = get().notes.findIndex((n) => n.id === note.id);
|
||||
if (i >= 0) {
|
||||
const next = get().notes.slice();
|
||||
next[i] = note;
|
||||
set({ notes: next });
|
||||
// trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일
|
||||
// 때만 trashCount 를 local recompute. 그 외엔 server 값 (refreshMeta) 보존.
|
||||
const showTrash = get().showTrash;
|
||||
if (note.deletedAt !== null) {
|
||||
// trash 노트: notes 에서 제거 + trashNotes 에 upsert
|
||||
const cleanNotes = get().notes.filter((n) => n.id !== note.id);
|
||||
const ti = get().trashNotes.findIndex((n) => n.id === note.id);
|
||||
const nextTrash = get().trashNotes.slice();
|
||||
if (ti >= 0) nextTrash[ti] = note;
|
||||
else nextTrash.unshift(note);
|
||||
set({
|
||||
notes: cleanNotes,
|
||||
trashNotes: nextTrash,
|
||||
...(showTrash ? { trashCount: nextTrash.length } : {})
|
||||
});
|
||||
} else {
|
||||
set({ notes: [note, ...get().notes] });
|
||||
// active 노트: trashNotes 에서 제거 + notes 에 upsert (restore 케이스 포함)
|
||||
const cleanTrash = get().trashNotes.filter((n) => n.id !== note.id);
|
||||
const i = get().notes.findIndex((n) => n.id === note.id);
|
||||
const nextNotes = get().notes.slice();
|
||||
if (i >= 0) nextNotes[i] = note;
|
||||
else nextNotes.unshift(note);
|
||||
set({
|
||||
notes: nextNotes,
|
||||
trashNotes: cleanTrash,
|
||||
...(showTrash ? { trashCount: cleanTrash.length } : {})
|
||||
});
|
||||
}
|
||||
},
|
||||
removeNote(id) {
|
||||
set({ notes: get().notes.filter((n) => n.id !== id) });
|
||||
const cleanNotes = get().notes.filter((n) => n.id !== id);
|
||||
const cleanTrash = get().trashNotes.filter((n) => n.id !== id);
|
||||
const showTrash = get().showTrash;
|
||||
set({
|
||||
notes: cleanNotes,
|
||||
trashNotes: cleanTrash,
|
||||
...(showTrash ? { trashCount: cleanTrash.length } : {})
|
||||
});
|
||||
},
|
||||
setTagFilter(tag) {
|
||||
set({ tagFilter: tag });
|
||||
},
|
||||
async toggleShowTrash() {
|
||||
const next = !get().showTrash;
|
||||
set({ showTrash: next });
|
||||
if (next) await get().loadTrash();
|
||||
},
|
||||
async loadTrash() {
|
||||
const trashNotes = await inboxApi.listTrash({ limit: 200 });
|
||||
set({ trashNotes, trashCount: trashNotes.length });
|
||||
},
|
||||
async restoreNote(id) {
|
||||
await inboxApi.restoreNote(id);
|
||||
// 낙관적 갱신: main 은 trash/restore 시 pushNoteUpdated 를 보내지 않음
|
||||
// (현재 AiWorker.onUpdate 만 push). 자가 반영이 primary 메커니즘.
|
||||
// 전제: 호출 시점에 trashNotes 에 노트가 존재 (T14 trash view 한정 호출).
|
||||
const note = get().trashNotes.find((n) => n.id === id);
|
||||
if (note) {
|
||||
get().upsertNote({ ...note, deletedAt: null });
|
||||
}
|
||||
},
|
||||
async permanentDeleteNote(id) {
|
||||
const r = await inboxApi.permanentDeleteNote(id);
|
||||
if (r.confirmed) get().removeNote(id);
|
||||
},
|
||||
async emptyTrash() {
|
||||
const r = await inboxApi.emptyTrash();
|
||||
if (r.confirmed) {
|
||||
set({ trashNotes: [], trashCount: 0 });
|
||||
}
|
||||
},
|
||||
async loadExpired() {
|
||||
const expiredCandidates = await inboxApi.listExpired();
|
||||
set({ expiredCandidates });
|
||||
},
|
||||
async trashExpiredBatch(ids: string[]) {
|
||||
const r = await inboxApi.trashExpiredBatch(ids);
|
||||
if (!r.confirmed) return;
|
||||
const idSet = new Set(ids);
|
||||
set({
|
||||
expiredCandidates: get().expiredCandidates.filter((n) => !idSet.has(n.id)),
|
||||
notes: get().notes.filter((n) => !idSet.has(n.id)),
|
||||
trashCount: get().trashCount + r.trashedCount
|
||||
});
|
||||
},
|
||||
snoozeExpired() {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
},
|
||||
async recheckOllama() {
|
||||
const status = await inboxApi.ollamaRecheck();
|
||||
set({ ollamaStatus: status });
|
||||
},
|
||||
async retryAllFailed() {
|
||||
await inboxApi.retryAllFailed();
|
||||
// 낙관적 갱신: failedCount = 0. AiWorker 처리 진행 중에 PendingBanner 가 N건 노출.
|
||||
// refreshMeta 가 트리거되면 자연 동기 (worker.onUpdate → main → renderer).
|
||||
// 반환된 r.count 는 의도적으로 무시 — 단일 process 환경 (Electron) 이라 race 무관,
|
||||
// 모든 ai_status='failed' 가 retry 대상이므로 사용자 시점 카운트는 0 으로 reset 가 정확.
|
||||
set({ failedCount: 0 });
|
||||
},
|
||||
async loadRecallCandidate() {
|
||||
const recallCandidate = await inboxApi.listRecallCandidate();
|
||||
set({ recallCandidate });
|
||||
},
|
||||
async openRecall(id) {
|
||||
await inboxApi.markRecallOpened(id);
|
||||
const recallCandidate = await inboxApi.listRecallCandidate();
|
||||
set({ recallCandidate });
|
||||
},
|
||||
async dismissRecallNote(id) {
|
||||
await inboxApi.dismissRecall(id);
|
||||
const recallCandidate = await inboxApi.listRecallCandidate();
|
||||
// m2 fix — dismiss 후 새 candidate 가 들어와도 이전 snooze 가 적용되지 않도록 clear
|
||||
set({ recallCandidate, recallSnoozeUntilMs: null });
|
||||
},
|
||||
async snoozeRecall() {
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
const kstNow = now + KST_OFFSET_MS;
|
||||
const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
|
||||
const nextKstMidnight = kstMidnightFloor + 86_400_000;
|
||||
set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
|
||||
// m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시
|
||||
// snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적).
|
||||
const candidate = get().recallCandidate;
|
||||
if (candidate) {
|
||||
await inboxApi.emitRecallSnoozed(candidate.id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface Note {
|
||||
intentPromptedAt: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
// 신규 v3:
|
||||
deletedAt: string | null;
|
||||
lastRecalledAt: string | null;
|
||||
recallDismissedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: NoteTag[];
|
||||
@@ -67,7 +71,24 @@ export interface InboxApi {
|
||||
getPendingCount(): Promise<number>;
|
||||
getOllamaStatus(): Promise<{ ok: boolean; reason?: string }>;
|
||||
getTodayCount(): Promise<number>;
|
||||
// 신규 v0.2.3 #4:
|
||||
restoreNote(noteId: string): Promise<void>;
|
||||
permanentDeleteNote(noteId: string): Promise<{ confirmed: boolean }>;
|
||||
emptyTrash(): Promise<{ confirmed: boolean; count: number }>;
|
||||
listTrash(opts: { limit: number }): Promise<Note[]>;
|
||||
getTrashCount(): Promise<number>;
|
||||
listExpired(): Promise<Note[]>;
|
||||
trashExpiredBatch(ids: string[]): Promise<{ trashedCount: number; confirmed: boolean }>;
|
||||
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<number>;
|
||||
listRecallCandidate(): Promise<Note | null>;
|
||||
markRecallOpened(id: string): Promise<{ note: Note }>;
|
||||
dismissRecall(id: string): Promise<{ note: Note }>;
|
||||
emitRecallShown(id: string): Promise<void>;
|
||||
emitRecallSnoozed(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -3,9 +3,12 @@ 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 { AiTelemetryEmitter } from '@main/ai/AiWorker.js';
|
||||
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
|
||||
import type { AiResponse } from '@main/ai/schema.js';
|
||||
|
||||
type EmittedEvent = { kind: string; payload: unknown };
|
||||
|
||||
function makeProvider(overrides: Partial<InferenceProvider> = {}): InferenceProvider {
|
||||
return {
|
||||
name: 'mock',
|
||||
@@ -193,3 +196,365 @@ describe('AiWorker', () => {
|
||||
expect(captured.dueDateCandidates.length).toBe(2); // 내일 + 모레
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker telemetry emit', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let events: Array<{ kind: string; payload: { noteId?: string; durationMs?: number; reason?: string; attempts?: number; tagId?: number; vocabSize?: number } }>;
|
||||
const collectingTelemetry: AiTelemetryEmitter = {
|
||||
emit: async (ev) => {
|
||||
events.push(ev as typeof events[number]);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
events = [];
|
||||
});
|
||||
|
||||
it('emits ai_succeeded with durationMs/attempts on success', async () => {
|
||||
const { id } = repo.create({ rawText: '수요일 회의 메모' });
|
||||
const w = new AiWorker(repo, makeProvider(), {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const succeeded = events.find((e) => e.kind === 'ai_succeeded');
|
||||
expect(succeeded).toBeDefined();
|
||||
expect(succeeded!.payload.noteId).toBe(id);
|
||||
// attempts = 시도한 횟수 (count, 1-based). 첫 시도 성공이므로 1.
|
||||
// 회차 1 review (PR #13) 의 비대칭 의미 통일 결과 — 실패 경로의 `attempt + 1` 과 동일 의미.
|
||||
expect(succeeded!.payload.attempts).toBe(1);
|
||||
expect(succeeded!.payload.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('unreachable error — ai_failed NOT emitted (infinite retry, no markAiFailed)', async () => {
|
||||
const { id } = repo.create({ rawText: '메모' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const failed = events.find((e) => e.kind === 'ai_failed');
|
||||
expect(failed).toBeUndefined();
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
});
|
||||
|
||||
it('emits ai_failed with reason=schema on zod failure', async () => {
|
||||
const { id } = repo.create({ rawText: '메모' });
|
||||
const { ZodError } = await import('zod');
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new ZodError([]); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const failed = events.find((e) => e.kind === 'ai_failed');
|
||||
expect(failed).toBeDefined();
|
||||
expect(failed!.payload.reason).toBe('schema');
|
||||
});
|
||||
|
||||
it('emits ai_failed with reason=other on unrecognized error', async () => {
|
||||
const { id } = repo.create({ rawText: '메모' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('mystery'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: collectingTelemetry
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const failed = events.find((e) => e.kind === 'ai_failed');
|
||||
expect(failed).toBeDefined();
|
||||
expect(failed!.payload.reason).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker — deletedAt guard (v0.2.3 #4)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('skips notes with deleted_at IS NOT NULL — provider.generate not called', async () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
// 먼저 trash — pending_jobs cleanup 됨
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
// 강제로 pending_jobs row 다시 삽입 (race 시뮬레이션 — AiWorker 가 이미 dequeue 한 상태 흉내)
|
||||
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] });
|
||||
await w.loadFromDb();
|
||||
await w.drain();
|
||||
expect(generate).not.toHaveBeenCalled();
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker — unreachable/timeout infinite retry (v0.2.3 #2)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('unreachable — markAiFailed 안 호출, attempts 증가 안 함', async () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('ECONNREFUSED'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 30_000, 120_000],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
|
||||
});
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
await w.enqueue(id);
|
||||
// 무한 retry — drain() 은 끝나지 않음. 짧게 대기 후 검증.
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
expect(provider.generate).toHaveBeenCalled();
|
||||
expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
|
||||
expect(job.attempts).toBe(0);
|
||||
});
|
||||
|
||||
it('timeout — unreachable 동일 (Q2=A)', async () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('Request timeout'); })
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 30_000, 120_000],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
|
||||
});
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
await w.enqueue(id);
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(repo.findById(id)!.aiStatus).toBe('pending');
|
||||
expect((provider.generate as any).mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('schema fail max 3 — markAiFailed + ai_failed emit (reason=schema)', async () => {
|
||||
const { ZodError } = await import('zod');
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => {
|
||||
throw new ZodError([{ code: 'custom', message: 'bad', path: [] } as any]);
|
||||
})
|
||||
});
|
||||
const events: any[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: async (e) => { events.push(e); } }
|
||||
});
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(repo.findById(id)!.aiStatus).toBe('failed');
|
||||
expect((provider.generate as any).mock.calls.length).toBe(3);
|
||||
const failed = events.find((e) => e.kind === 'ai_failed');
|
||||
expect(failed).toBeDefined();
|
||||
expect(failed.payload.reason).toBe('schema');
|
||||
});
|
||||
|
||||
it('other fail max 3 — markAiFailed + ai_failed emit (reason=other)', async () => {
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => { throw new Error('something weird'); })
|
||||
});
|
||||
const events: any[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: async (e) => { events.push(e); } }
|
||||
});
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(repo.findById(id)!.aiStatus).toBe('failed');
|
||||
const failed = events.find((e) => e.kind === 'ai_failed');
|
||||
expect(failed.payload.reason).toBe('other');
|
||||
});
|
||||
|
||||
it('unreachable backoff schedule — nextBackoffMs(step) cap at index 5 (15분)', async () => {
|
||||
const w = new AiWorker(repo, makeProvider(), {
|
||||
backoffsMs: [0, 30_000, 120_000],
|
||||
unreachableBackoffsMs: [30_000, 60_000, 120_000, 240_000, 480_000, 900_000]
|
||||
});
|
||||
expect((w as any).nextBackoffMs(0)).toBe(30_000);
|
||||
expect((w as any).nextBackoffMs(2)).toBe(120_000);
|
||||
expect((w as any).nextBackoffMs(5)).toBe(900_000);
|
||||
expect((w as any).nextBackoffMs(10)).toBe(900_000); // cap
|
||||
});
|
||||
|
||||
it('success 후 unreachableBackoffStep reset', async () => {
|
||||
let callCount = 0;
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async (): Promise<AiResponse> => {
|
||||
callCount += 1;
|
||||
if (callCount <= 2) throw new Error('ECONNREFUSED');
|
||||
return { title: 't', summary: 's', tags: [], dueDate: null };
|
||||
})
|
||||
});
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
unreachableBackoffsMs: [10, 10, 10, 10, 10, 10]
|
||||
});
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(repo.findById(id)!.aiStatus).toBe('done');
|
||||
expect(callCount).toBe(3);
|
||||
expect((w as any).unreachableBackoffStep).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('fetches vocab and passes to provider.generate', async () => {
|
||||
// Pre-seed 1 note with tag 'design' so vocab non-empty
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const generateMock = vi.fn(async () => ({
|
||||
title: '제목', summary: 'a\nb\nc', tags: ['design'], dueDate: null
|
||||
}));
|
||||
const w = new AiWorker(repo, makeProvider({ generate: generateMock }), {
|
||||
backoffsMs: [0, 0, 0]
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
vocab: expect.arrayContaining(['design'])
|
||||
}));
|
||||
});
|
||||
|
||||
it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
|
||||
// Pre-seed: 'design' in vocab
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'newtag'], // 1 hit + 1 miss
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: {
|
||||
emit: vi.fn(async (input) => { emits.push(input); })
|
||||
}
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
|
||||
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
|
||||
expect(hit).toHaveLength(1);
|
||||
expect(miss).toHaveLength(1);
|
||||
const hitPayload = hit[0]!.payload as { tagId: number; vocabSize: number };
|
||||
const missPayload = miss[0]!.payload as { vocabSize: number };
|
||||
expect(hitPayload.tagId).toBeGreaterThan(0);
|
||||
expect(hitPayload.vocabSize).toBe(1);
|
||||
expect(missPayload.vocabSize).toBe(1);
|
||||
});
|
||||
|
||||
it('all tags miss when vocab is empty', async () => {
|
||||
// No seed → vocab=[]
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'meeting', 'qa'],
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
|
||||
expect(miss).toHaveLength(3);
|
||||
expect(emits.filter((e) => e.kind === 'tag_vocab_hit')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('emits one event per tag (3 tags → 3 events)', async () => {
|
||||
// Pre-seed: all 3 in vocab
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'meeting', 'qa'],
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const hits = emits.filter((e) => e.kind === 'tag_vocab_hit');
|
||||
expect(hits).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('dedupes duplicate tags in AI response (one emit per unique tag)', async () => {
|
||||
// Pre-seed: 'design' in vocab
|
||||
const seed = repo.create({ rawText: 'seed' }).id;
|
||||
repo.updateAiResult(seed, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
|
||||
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
const provider = makeProvider({
|
||||
generate: vi.fn(async () => ({
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: ['design', 'design', 'meeting'], // 중복 'design' 의도적
|
||||
dueDate: null
|
||||
}))
|
||||
});
|
||||
const emits: EmittedEvent[] = [];
|
||||
const w = new AiWorker(repo, provider, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
telemetry: { emit: vi.fn(async (input) => { emits.push(input); }) }
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
const hit = emits.filter((e) => e.kind === 'tag_vocab_hit');
|
||||
const miss = emits.filter((e) => e.kind === 'tag_vocab_miss');
|
||||
expect(hit).toHaveLength(1); // 'design' 중복 → 1 hit (dedup)
|
||||
expect(miss).toHaveLength(1); // 'meeting' 1 miss
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { mkdtempSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -51,10 +51,390 @@ describe('CaptureService', () => {
|
||||
expect(celebrated).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleteNote removes db row + media dir', async () => {
|
||||
it('deleteNote soft-deletes (sets deletedAt, preserves row)', async () => {
|
||||
const img = new Uint8Array([0, 1, 2, 3]).buffer;
|
||||
const { noteId } = await svc.submit({ text: 't', images: [img] });
|
||||
await svc.deleteNote(noteId);
|
||||
expect(repo.findById(noteId)).toBeNull();
|
||||
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService telemetry emit', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let events: Array<{ kind: string; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } }>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
||||
store = new MediaStore(tmp);
|
||||
events = [];
|
||||
});
|
||||
|
||||
it('emits capture event with noteId/rawTextLength/hasMedia', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
|
||||
});
|
||||
await svc.submit({ text: '안녕하세요', images: [] });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('capture');
|
||||
expect(events[0]!.payload.rawTextLength).toBe('안녕하세요'.length);
|
||||
expect(events[0]!.payload.hasMedia).toBe(false);
|
||||
expect(typeof events[0]!.payload.noteId).toBe('string');
|
||||
});
|
||||
|
||||
it('emits hasMedia=true when images present', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev as typeof events[number]); } }
|
||||
});
|
||||
const img = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]).buffer;
|
||||
await svc.submit({ text: '이미지 메모', images: [img] });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.payload.hasMedia).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT emit when telemetry dep absent (backward compat)', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {}
|
||||
});
|
||||
const result = await svc.submit({ text: 'no telem', images: [] });
|
||||
expect(typeof result.noteId).toBe('string');
|
||||
expect(events).toHaveLength(0); // events array stays empty since no telemetry was wired
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService trash flow (v0.2.3 #4)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let events: Array<{ kind: string; payload: any }>;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-trash-'));
|
||||
store = new MediaStore(tmp);
|
||||
events = [];
|
||||
});
|
||||
|
||||
it('deleteNote sets deleted_at and emits trash event (no media cleanup)', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
|
||||
events.length = 0; // clear capture event
|
||||
await svc.deleteNote(noteId);
|
||||
expect(repo.findById(noteId)!.deletedAt).not.toBeNull();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('trash');
|
||||
expect(events[0]!.payload.noteId).toBe(noteId);
|
||||
// media 디렉터리 보존 확인 (restore 시 필요)
|
||||
expect(existsSync(join(tmp, 'media', noteId))).toBe(true);
|
||||
});
|
||||
|
||||
it('restoreNote clears deleted_at and emits restore event', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'hi', images: [] });
|
||||
events.length = 0;
|
||||
await svc.deleteNote(noteId);
|
||||
events.length = 0;
|
||||
await svc.restoreNote(noteId);
|
||||
expect(repo.findById(noteId)!.deletedAt).toBeNull();
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('restore');
|
||||
});
|
||||
|
||||
it('permanentDeleteNote hard-deletes + cleans media + emits permanent_delete', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const { noteId } = await svc.submit({ text: 'hi', images: [new ArrayBuffer(8)] });
|
||||
events.length = 0;
|
||||
await svc.permanentDeleteNote(noteId);
|
||||
expect(repo.findById(noteId)).toBeNull();
|
||||
expect(existsSync(join(tmp, 'media', noteId))).toBe(false);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]!.kind).toBe('permanent_delete');
|
||||
});
|
||||
|
||||
it('emptyTrash deletes all trashed + cleans each media + emits empty_trash with count', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const a = (await svc.submit({ text: 'a', images: [new ArrayBuffer(8)] })).noteId;
|
||||
const b = (await svc.submit({ text: 'b', images: [new ArrayBuffer(8)] })).noteId;
|
||||
await svc.submit({ text: 'c (active)', images: [] });
|
||||
await svc.deleteNote(a);
|
||||
await svc.deleteNote(b);
|
||||
events.length = 0;
|
||||
const r = await svc.emptyTrash();
|
||||
expect(r.count).toBe(2);
|
||||
expect(repo.findById(a)).toBeNull();
|
||||
expect(repo.findById(b)).toBeNull();
|
||||
expect(existsSync(join(tmp, 'media', a))).toBe(false);
|
||||
expect(existsSync(join(tmp, 'media', b))).toBe(false);
|
||||
const empty = events.find((e) => e.kind === 'empty_trash')!;
|
||||
expect(empty.payload.count).toBe(2);
|
||||
});
|
||||
|
||||
it('emptyTrash returns count=0 when trash empty', async () => {
|
||||
const svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { events.push(ev); } }
|
||||
});
|
||||
const r = await svc.emptyTrash();
|
||||
expect(r.count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.listExpired (dedup signature)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let calls: Array<{ kind: string; payload: any }>;
|
||||
let svc: CaptureService;
|
||||
|
||||
function addExpired(id: string, dueDate: string, createdAt: string = '2026-04-30T10:00:00Z'): void {
|
||||
db.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_status, due_date, created_at, updated_at)
|
||||
VALUES (?, ?, 'done', ?, ?, ?)`
|
||||
).run(id, id, dueDate, createdAt, createdAt);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
||||
store = new MediaStore(tmp);
|
||||
calls = [];
|
||||
svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
||||
});
|
||||
});
|
||||
|
||||
it('emits expired_banner_shown on first call when candidates > 0', async () => {
|
||||
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
||||
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
||||
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r).toHaveLength(2);
|
||||
expect(calls).toContainEqual(
|
||||
expect.objectContaining({ kind: 'expired_banner_shown', payload: { candidateCount: 2 } })
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT re-emit on second call with identical candidate set (dedup)', async () => {
|
||||
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
||||
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
|
||||
expect(showns).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('re-emits when candidate set changes (count or first-3-ids)', async () => {
|
||||
addExpired('n1', '2026-04-20', '2026-04-30T10:00:00Z');
|
||||
addExpired('n2', '2026-04-22', '2026-04-30T11:00:00Z');
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
addExpired('n3', '2026-04-23', '2026-04-30T12:00:00Z');
|
||||
await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
const showns = calls.filter((c) => c.kind === 'expired_banner_shown');
|
||||
expect(showns).toHaveLength(2);
|
||||
expect(showns[1]!.payload).toMatchObject({ candidateCount: 3 });
|
||||
});
|
||||
|
||||
it('does NOT emit when candidates is empty', async () => {
|
||||
const r = await svc.listExpired(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r).toEqual([]);
|
||||
expect(calls.filter((c) => c.kind === 'expired_banner_shown')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService.trashExpiredBatch', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let calls: Array<{ kind: string; payload: any }>;
|
||||
let svc: CaptureService;
|
||||
|
||||
function addExpired(id: string, dueDate: string): void {
|
||||
db.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_status, due_date, created_at, updated_at)
|
||||
VALUES (?, ?, 'done', ?, ?, ?)`
|
||||
).run(id, id, dueDate, '2026-04-30T10:00:00Z', '2026-04-30T10:00:00Z');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-capture-'));
|
||||
store = new MediaStore(tmp);
|
||||
calls = [];
|
||||
svc = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (input) => { calls.push(input as any); } }
|
||||
});
|
||||
});
|
||||
|
||||
it('emits expired_batch_trash with trashedCount + no per-id trash emit', async () => {
|
||||
addExpired('n1', '2026-04-20');
|
||||
addExpired('n2', '2026-04-22');
|
||||
const r = await svc.trashExpiredBatch(['n1', 'n2']);
|
||||
expect(r.trashedCount).toBe(2);
|
||||
expect(calls.filter((c) => c.kind === 'expired_batch_trash')).toEqual([
|
||||
expect.objectContaining({ kind: 'expired_batch_trash', payload: { count: 2 } })
|
||||
]);
|
||||
expect(calls.filter((c) => c.kind === 'trash')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns trashedCount=0 for empty array (no emit)', async () => {
|
||||
const r = await svc.trashExpiredBatch([]);
|
||||
expect(r.trashedCount).toBe(0);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CaptureService recall methods (v0.2.3 #6)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let store: MediaStore;
|
||||
let tmp: string;
|
||||
let emits: Array<{ kind: string; payload: any }>;
|
||||
let service: CaptureService;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
tmp = mkdtempSync(join(tmpdir(), 'inkling-recall-'));
|
||||
store = new MediaStore(tmp);
|
||||
emits = [];
|
||||
service = new CaptureService(repo, store, {
|
||||
enqueue: async () => {},
|
||||
celebrate: () => {},
|
||||
telemetry: { emit: async (ev) => { emits.push(ev as any); } }
|
||||
});
|
||||
});
|
||||
|
||||
it('listRecallCandidate delegates to repo.findRecallCandidate', async () => {
|
||||
const id = repo.create({ rawText: 'old' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
// No last_recalled_at → eligible immediately
|
||||
const candidate = await service.listRecallCandidate();
|
||||
expect(candidate?.id).toBe(id);
|
||||
});
|
||||
|
||||
it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
const before = repo.findById(id)!.lastRecalledAt;
|
||||
expect(before).toBeNull();
|
||||
await service.markRecallOpened(id);
|
||||
expect(repo.findById(id)!.lastRecalledAt).not.toBeNull();
|
||||
expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined();
|
||||
});
|
||||
|
||||
it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
expect(repo.findById(id)!.recallDismissedAt).toBeNull();
|
||||
await service.dismissRecall(id);
|
||||
expect(repo.findById(id)!.recallDismissedAt).not.toBeNull();
|
||||
expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined();
|
||||
});
|
||||
|
||||
it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
// Backdate created_at to 14 days ago
|
||||
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`)
|
||||
.run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id);
|
||||
await service.emitRecallShown(id);
|
||||
const shown = emits.find((e) => e.kind === 'recall_shown');
|
||||
expect(shown).toBeDefined();
|
||||
const payload = shown!.payload as { noteId: string; ageDays: number };
|
||||
expect(payload.noteId).toBe(id);
|
||||
expect(payload.ageDays).toBeGreaterThanOrEqual(13);
|
||||
expect(payload.ageDays).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,4 +88,18 @@ describe('ContinuityService', () => {
|
||||
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
|
||||
expect(svc.get().showRecoveryToast).toBe(false);
|
||||
});
|
||||
|
||||
it('excludes trashed notes from streak/recovery math (v0.2.3 #4)', () => {
|
||||
const db = dbWithDates([
|
||||
'2026-04-22T10:00:00+09:00',
|
||||
'2026-04-25T11:00:00+09:00'
|
||||
]);
|
||||
// 22일 노트를 trash → 25일이 마지막. 22일 미만이라 weekCount 1 이지만 lastNoteAt
|
||||
// 은 25일 (마지막 active) 이어야 함. trashed 노트가 무시되어야 함.
|
||||
db.prepare(`UPDATE notes SET deleted_at='2026-04-26T00:00:00Z' WHERE id='n0'`).run();
|
||||
const svc = new ContinuityService(db, () => new Date('2026-04-25T12:00:00+09:00'));
|
||||
const r = svc.get();
|
||||
expect(r.weekCount).toBe(1);
|
||||
expect(r.lastNoteAt).toBe('2026-04-25T02:00:00.000Z'); // KST 11:00 = UTC 02:00
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,4 +138,18 @@ describe('ExportService', () => {
|
||||
expect(readme).toContain('RAG');
|
||||
expect(readme).toContain('inkling_export_version');
|
||||
});
|
||||
|
||||
it('does NOT export trashed notes (listAll filter — v0.2.3 #4 회귀 가드)', async () => {
|
||||
const a = repo.create({ rawText: 'active note' }).id;
|
||||
const t = repo.create({ rawText: 'trashed note' }).id;
|
||||
repo.updateAiResult(a, { title: '활성', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
|
||||
repo.updateAiResult(t, { title: '버려짐', summary: 'a\nb\nc', tags: [], provider: 'p', dueDate: null });
|
||||
repo.trash(t, '2026-05-01T00:00:00.000Z');
|
||||
const r = await svc.export(outDir, { includeMedia: false });
|
||||
expect(r.noteCount).toBe(1);
|
||||
// index.jsonl 도 trash 미포함
|
||||
const indexPath = join(outDir, 'index.jsonl');
|
||||
const lines = readFileSync(indexPath, 'utf8').trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
118
tests/unit/HealthChecker.test.ts
Normal file
118
tests/unit/HealthChecker.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
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';
|
||||
|
||||
class FakeProvider implements InferenceProvider {
|
||||
readonly name = 'fake';
|
||||
results: HealthResult[] = [];
|
||||
private idx = 0;
|
||||
async healthCheck(): Promise<HealthResult> {
|
||||
const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true };
|
||||
this.idx += 1;
|
||||
return r;
|
||||
}
|
||||
async generate(_input: GenerateInput): Promise<AiResponse> {
|
||||
throw new Error('not used');
|
||||
}
|
||||
}
|
||||
|
||||
describe('HealthChecker — start/stop polling', () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
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 });
|
||||
hc.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBeGreaterThanOrEqual(3);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
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 });
|
||||
hc.start();
|
||||
hc.start();
|
||||
// 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect((provider as any).idx).toBe(2);
|
||||
hc.stop();
|
||||
});
|
||||
|
||||
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 });
|
||||
hc.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
const before = (provider as any).idx;
|
||||
hc.stop();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect((provider as any).idx).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthChecker — delta transitions + telemetry', () => {
|
||||
it('ok=true → ok=false 전이 시 onUpdate + ollama_unreachable emit', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }];
|
||||
const updates: HealthResult[] = [];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, {
|
||||
onUpdate: (s) => updates.push(s),
|
||||
onTelemetry: (e) => events.push(e)
|
||||
});
|
||||
await hc.runOnce();
|
||||
await hc.runOnce();
|
||||
expect(updates).toEqual([{ ok: false, reason: 'connection refused' }]);
|
||||
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'connection refused' }]);
|
||||
});
|
||||
|
||||
it('ok=false → ok=true 전이 시 onUpdate + ollama_recovered emit (downtimeMs 정확)', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: false, reason: 'refused' }, { ok: true }];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
let nowCounter = 0;
|
||||
const hc = new HealthChecker(provider, {
|
||||
onTelemetry: (e) => events.push(e),
|
||||
now: () => { nowCounter += 1; return nowCounter * 1000; }
|
||||
});
|
||||
await hc.runOnce();
|
||||
await hc.runOnce();
|
||||
const recovered = events.find((e) => e.kind === 'ollama_recovered');
|
||||
expect(recovered).toEqual({ kind: 'ollama_recovered', downtimeMs: 1000 });
|
||||
});
|
||||
|
||||
it('reason 변경만 (ok=false 유지) 시 onUpdate fire 하지만 telemetry emit 안 함', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [
|
||||
{ ok: false, reason: 'refused' },
|
||||
{ ok: false, reason: 'timeout' }
|
||||
];
|
||||
const updates: HealthResult[] = [];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, {
|
||||
onUpdate: (s) => updates.push(s),
|
||||
onTelemetry: (e) => events.push(e)
|
||||
});
|
||||
await hc.runOnce();
|
||||
await hc.runOnce();
|
||||
expect(updates).toHaveLength(2);
|
||||
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]);
|
||||
});
|
||||
|
||||
it('runOnce({manual:true}) 가 ollama_recheck_manual 1회 fire', async () => {
|
||||
const provider = new FakeProvider();
|
||||
provider.results = [{ ok: true }];
|
||||
const events: HealthTelemetryEvent[] = [];
|
||||
const hc = new HealthChecker(provider, { onTelemetry: (e) => events.push(e) });
|
||||
await hc.runOnce({ manual: true });
|
||||
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
|
||||
});
|
||||
});
|
||||
@@ -233,3 +233,81 @@ describe('ImportService', () => {
|
||||
expect(dbNote!.media[0]!.bytes).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ImportService — deletedAt preservation (v0.2.3 #4)', () => {
|
||||
it('id-collide skip: source deleted_at IS NOT NULL → dest deleted_at 갱신', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: 'identical' });
|
||||
const r = repo.importNote({
|
||||
id, rawText: 'identical',
|
||||
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: null, aiSummary: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
tags: [],
|
||||
deletedAt: '2026-05-01T12:00:00.000Z'
|
||||
});
|
||||
expect(r.status).toBe('skipped');
|
||||
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('id-collide skip: source deleted_at NULL + dest IS NOT NULL → dest 유지', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: 'identical' });
|
||||
repo.trash(id, '2026-05-01T00:00:00.000Z');
|
||||
repo.importNote({
|
||||
id, rawText: 'identical',
|
||||
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: null, aiSummary: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
tags: [],
|
||||
deletedAt: null
|
||||
});
|
||||
expect(repo.findById(id)!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('id-new insert: source deletedAt 보존', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const repo = new NoteRepository(db);
|
||||
const r = repo.importNote({
|
||||
id: 'fresh-id', rawText: 'fresh',
|
||||
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: null, aiSummary: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
tags: [],
|
||||
deletedAt: '2026-05-01T12:00:00.000Z'
|
||||
});
|
||||
expect(r.status).toBe('inserted');
|
||||
expect(repo.findById('fresh-id')!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('id-collide forked: deletedAt 도 fork 노트에 보존', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const repo = new NoteRepository(db);
|
||||
const { id } = repo.create({ rawText: 'original' });
|
||||
const r = repo.importNote({
|
||||
id, rawText: 'different',
|
||||
createdAt: '2026-04-01T00:00:00Z', updatedAt: '2026-04-01T00:00:00Z',
|
||||
aiTitle: null, aiSummary: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
tags: [],
|
||||
deletedAt: '2026-05-01T12:00:00.000Z'
|
||||
});
|
||||
expect(r.status).toBe('forked');
|
||||
expect(r.id).not.toBe(id);
|
||||
expect(repo.findById(r.id)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,25 @@ describe('LocalOllamaProvider', () => {
|
||||
expect(r.title).toBe('회의');
|
||||
});
|
||||
|
||||
it('generate passes vocab into prompt body', async () => {
|
||||
let capturedBody: string = '';
|
||||
mock.get('http://localhost:11434').intercept({
|
||||
path: '/api/generate', method: 'POST'
|
||||
}).reply((opts) => {
|
||||
capturedBody = opts.body as string;
|
||||
return { statusCode: 200, data: JSON.stringify({
|
||||
response: JSON.stringify({ title: '회의', summary: 'a\nb\nc', tags: ['design'] })
|
||||
}) };
|
||||
});
|
||||
await new LocalOllamaProvider().generate({
|
||||
text: 'x', todayKst: '2026-05-02', dueDateCandidates: [],
|
||||
vocab: ['design', 'meeting']
|
||||
});
|
||||
const parsed = JSON.parse(capturedBody) as { prompt: string };
|
||||
expect(parsed.prompt).toContain('design, meeting');
|
||||
expect(parsed.prompt).toContain('Prefer reusing');
|
||||
});
|
||||
|
||||
it('generate throws on non-JSON', async () => {
|
||||
mock.get('http://localhost:11434').intercept({ path: '/api/generate', method: 'POST' }).reply(200, {
|
||||
response: 'not json'
|
||||
|
||||
@@ -214,4 +214,585 @@ describe('NoteRepository', () => {
|
||||
expect(typeof n).toBe('number');
|
||||
expect(n).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('findRecallCandidate returns null for empty db', () => {
|
||||
expect(repo.findRecallCandidate()).toBeNull();
|
||||
});
|
||||
|
||||
it('findRecallCandidate excludes notes recalled within 7 days', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
// 5일 전 본 노트 → 제외
|
||||
const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
|
||||
repo.markRecallOpened(id, fiveDaysAgo);
|
||||
expect(repo.findRecallCandidate()).toBeNull();
|
||||
});
|
||||
|
||||
it('findRecallCandidate includes notes recalled 8+ days ago', () => {
|
||||
const id = repo.create({ rawText: 'x' }).id;
|
||||
repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
|
||||
repo.markRecallOpened(id, eightDaysAgo);
|
||||
expect(repo.findRecallCandidate()?.id).toBe(id);
|
||||
});
|
||||
|
||||
it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
|
||||
const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
|
||||
repo.dismissRecall(a, twentyFiveDaysAgo); // 25일 — 아직 dismiss 만료 안 됨
|
||||
repo.dismissRecall(b, thirtyFiveDaysAgo); // 35일 — dismiss 만료, 재추천 가능
|
||||
const candidate = repo.findRecallCandidate();
|
||||
expect(candidate?.id).toBe(b);
|
||||
});
|
||||
|
||||
it('findRecallCandidate excludes deleted/pending/imminent due', () => {
|
||||
const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
|
||||
// (a) deleted
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
|
||||
repo.trash(a, new Date().toISOString());
|
||||
// (b) pending (no AI)
|
||||
repo.create({ rawText: 'b' });
|
||||
// (c) due_date 어제
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
|
||||
expect(repo.findRecallCandidate()).toBeNull();
|
||||
// (d) due_date today 는 OK (>=today 통과)
|
||||
const d = repo.create({ rawText: 'd' }).id;
|
||||
repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
|
||||
expect(repo.findRecallCandidate()?.id).toBe(d);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trash', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('sets deleted_at and removes pending_jobs row atomically', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
it('updates updated_at to deletedAt timestamp', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.updatedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('is no-op when note does not exist', () => {
|
||||
expect(() => repo.trash('nonexistent', '2026-05-01T12:00:00.000Z')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.restore', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('clears deleted_at on a trashed note', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
repo.restore(id);
|
||||
const note = repo.findById(id)!;
|
||||
expect(note.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('updates updated_at', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.trash(id, '2026-05-01T12:00:00.000Z');
|
||||
const before = repo.findById(id)!.updatedAt;
|
||||
repo.restore(id);
|
||||
const after = repo.findById(id)!.updatedAt;
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('is no-op on already-active note', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
expect(() => repo.restore(id)).not.toThrow();
|
||||
expect(repo.findById(id)!.deletedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.permanentDelete', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('removes notes row + cascades note_tags / pending_jobs', () => {
|
||||
const { id } = repo.create({ rawText: 'x' });
|
||||
repo.updateAiResult(id, { title: 'T', summary: 'a\nb\nc', tags: ['tag-a'], provider: 'p', dueDate: null });
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 1 });
|
||||
repo.permanentDelete(id);
|
||||
expect(repo.findById(id)).toBeNull();
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM note_tags WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id=?').get(id)).toMatchObject({ c: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.emptyTrash', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('hard-deletes all trashed notes and returns their ids', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
repo.trash(c, '2026-05-01T01:00:00.000Z');
|
||||
const r = repo.emptyTrash();
|
||||
expect(r.noteIds.sort()).toEqual([a, c].sort());
|
||||
expect(repo.findById(a)).toBeNull();
|
||||
expect(repo.findById(b)).not.toBeNull();
|
||||
expect(repo.findById(c)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty array when trash is empty', () => {
|
||||
expect(repo.emptyTrash()).toEqual({ noteIds: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.listTrashed', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('returns trashed notes ordered by deleted_at DESC', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
repo.trash(c, '2026-05-01T02:00:00.000Z');
|
||||
repo.trash(b, '2026-05-01T01:00:00.000Z');
|
||||
const r = repo.listTrashed({ limit: 50 });
|
||||
expect(r.map((n) => n.id)).toEqual([c, b, a]);
|
||||
});
|
||||
|
||||
it('excludes active notes', () => {
|
||||
repo.create({ rawText: 'active' });
|
||||
const r = repo.listTrashed({ limit: 50 });
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
|
||||
it('respects limit', () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.trash(id, `2026-05-01T0${i}:00:00.000Z`);
|
||||
}
|
||||
const r = repo.listTrashed({ limit: 3 });
|
||||
expect(r).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.countTrashed', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('returns 0 when no trash', () => {
|
||||
repo.create({ rawText: 'active' });
|
||||
expect(repo.countTrashed()).toBe(0);
|
||||
});
|
||||
|
||||
it('counts only trashed notes', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.create({ rawText: 'b (active)' });
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
repo.trash(c, '2026-05-01T01:00:00.000Z');
|
||||
expect(repo.countTrashed()).toBe(2);
|
||||
});
|
||||
|
||||
it('returns count beyond listTrashed limit (no 200 cap drift)', () => {
|
||||
// listTrashed limit cap is 200; countTrashed must reflect actual count.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
repo.trash(id, `2026-05-01T${String(i).padStart(2, '0')}:00:00.000Z`);
|
||||
}
|
||||
expect(repo.countTrashed()).toBe(10);
|
||||
expect(repo.listTrashed({ limit: 5 })).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active queries exclude deleted notes', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('list() excludes trashed', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
const r = repo.list({ limit: 50 });
|
||||
expect(r.map((n) => n.id)).toEqual([b]);
|
||||
});
|
||||
|
||||
it('listAll() excludes trashed', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.create({ rawText: 'b' });
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
const r = repo.listAll();
|
||||
expect(r.map((n) => n.rawText)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('countToday() excludes trashed', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.create({ rawText: 'b' });
|
||||
repo.trash(a, new Date().toISOString());
|
||||
expect(repo.countToday(new Date())).toBe(1);
|
||||
});
|
||||
|
||||
it('findById() returns trashed notes (does NOT filter)', () => {
|
||||
const { id } = repo.create({ rawText: 'a' });
|
||||
repo.trash(id, '2026-05-01T00:00:00.000Z');
|
||||
const note = repo.findById(id);
|
||||
expect(note).not.toBeNull();
|
||||
expect(note!.deletedAt).toBe('2026-05-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('getPendingCount() excludes trashed pending notes (drift guard)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id; // ai_status=pending
|
||||
repo.create({ rawText: 'b' }); // ai_status=pending
|
||||
expect(repo.getPendingCount()).toBe(2);
|
||||
// trash() 가 pending_jobs row 는 정리하지만 notes.ai_status 는 'pending' 그대로.
|
||||
// getPendingCount 가 deleted_at IS NOT NULL 노트 포함하면 영구 over-count.
|
||||
repo.trash(a, '2026-05-01T00:00:00.000Z');
|
||||
expect(repo.getPendingCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.findExpiredCandidates', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
function makeDone(opts: {
|
||||
rawText: string;
|
||||
dueDate: string | null;
|
||||
edited?: boolean;
|
||||
deletedAt?: string | null;
|
||||
aiStatus?: 'pending' | 'done' | 'failed';
|
||||
}): string {
|
||||
const { id } = repo.create({ rawText: opts.rawText });
|
||||
db.prepare(
|
||||
`UPDATE notes
|
||||
SET due_date = ?,
|
||||
due_date_edited_by_user = ?,
|
||||
ai_status = ?,
|
||||
deleted_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
opts.dueDate,
|
||||
opts.edited ? 1 : 0,
|
||||
opts.aiStatus ?? 'done',
|
||||
opts.deletedAt ?? null,
|
||||
id
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
it('returns notes with due_date < today (KST), ORDER BY created_at DESC', () => {
|
||||
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T10:00:00Z', a);
|
||||
const b = makeDone({ rawText: 'b', dueDate: '2026-04-25' });
|
||||
db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`).run('2026-04-30T11:00:00Z', b);
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([b, a]);
|
||||
});
|
||||
|
||||
it('includes both AI-extracted and user-edited due_date (Q1=B 회귀 가드)', () => {
|
||||
const ai = makeDone({ rawText: 'a', dueDate: '2026-04-20', edited: false });
|
||||
const manual = makeDone({ rawText: 'b', dueDate: '2026-04-22', edited: true });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id).sort()).toEqual([ai, manual].sort());
|
||||
});
|
||||
|
||||
it('excludes trashed notes (deleted_at IS NOT NULL)', () => {
|
||||
const a = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-04-21', deletedAt: '2026-04-30T00:00:00Z' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([a]);
|
||||
});
|
||||
|
||||
it('excludes pending / failed notes (ai_status != done)', () => {
|
||||
const done = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-04-20', aiStatus: 'pending' });
|
||||
makeDone({ rawText: 'c', dueDate: '2026-04-20', aiStatus: 'failed' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([done]);
|
||||
});
|
||||
|
||||
it('excludes notes with NULL due_date (NULL < string 평가 가드)', () => {
|
||||
const dated = makeDone({ rawText: 'a', dueDate: '2026-04-20' });
|
||||
makeDone({ rawText: 'b', dueDate: null });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([dated]);
|
||||
});
|
||||
|
||||
it('excludes notes with due_date == today (boundary, not expired)', () => {
|
||||
const past = makeDone({ rawText: 'a', dueDate: '2026-04-30' });
|
||||
makeDone({ rawText: 'b', dueDate: '2026-05-01' });
|
||||
const r = repo.findExpiredCandidates(new Date('2026-05-01T12:00:00Z'));
|
||||
expect(r.map((n) => n.id)).toEqual([past]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository.trashBatch', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
it('atomically trashes all valid ids and returns trashedCount', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
const r = repo.trashBatch([a, b, c], '2026-05-01T12:00:00.000Z');
|
||||
expect(r.trashedCount).toBe(3);
|
||||
expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
expect(repo.findById(b)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
expect(repo.findById(c)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
expect(db.prepare('SELECT COUNT(*) AS c FROM pending_jobs WHERE note_id IN (?,?,?)').get(a, b, c))
|
||||
.toMatchObject({ c: 0 });
|
||||
});
|
||||
|
||||
it('returns trashedCount=0 for empty array (no-op)', () => {
|
||||
const r = repo.trashBatch([], '2026-05-01T12:00:00.000Z');
|
||||
expect(r.trashedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('skips ids that are already trashed (idempotent — count = 0 transitions)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.trash(a, '2026-04-30T00:00:00.000Z');
|
||||
const r = repo.trashBatch([a], '2026-05-01T12:00:00.000Z');
|
||||
expect(r.trashedCount).toBe(0);
|
||||
expect(repo.findById(a)!.deletedAt).toBe('2026-04-30T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('counts only the valid active ids (mix of valid + invalid + already-trashed)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
repo.trash(b, '2026-04-30T00:00:00.000Z');
|
||||
const r = repo.trashBatch([a, b, 'nonexistent-id'], '2026-05-01T12:00:00.000Z');
|
||||
expect(r.trashedCount).toBe(1);
|
||||
expect(repo.findById(a)!.deletedAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NoteRepository — failed retry helpers', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
function makeFailed(rawText: string, deletedAt: string | null = null): string {
|
||||
const { id } = repo.create({ rawText });
|
||||
db.prepare(
|
||||
`UPDATE notes SET ai_status='failed', ai_error='boom', deleted_at=? WHERE id=?`
|
||||
).run(deletedAt, id);
|
||||
db.prepare(`DELETE FROM pending_jobs WHERE note_id=?`).run(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
it('findFailedIds returns ai_status=failed AND deleted_at IS NULL only', () => {
|
||||
const a = makeFailed('a');
|
||||
makeFailed('b', '2026-04-30T00:00:00Z'); // trashed
|
||||
repo.create({ rawText: 'pending' }); // pending status
|
||||
expect(repo.findFailedIds().sort()).toEqual([a].sort());
|
||||
});
|
||||
|
||||
it('countFailed counts active failed notes only', () => {
|
||||
makeFailed('a');
|
||||
makeFailed('b');
|
||||
makeFailed('c', '2026-04-30T00:00:00Z');
|
||||
expect(repo.countFailed()).toBe(2);
|
||||
});
|
||||
|
||||
it('retryAllFailed atomic — ai_status reset + pending_jobs 재투입', () => {
|
||||
const a = makeFailed('a');
|
||||
const b = makeFailed('b');
|
||||
const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
|
||||
expect(r.ids.sort()).toEqual([a, b].sort());
|
||||
expect(repo.findById(a)!.aiStatus).toBe('pending');
|
||||
expect(repo.findById(b)!.aiStatus).toBe('pending');
|
||||
expect(repo.findById(a)!.aiError).toBeNull();
|
||||
const jobs = repo.getAllPendingJobs();
|
||||
expect(jobs.map((j) => j.noteId).sort()).toEqual([a, b].sort());
|
||||
for (const j of jobs) {
|
||||
expect(j.attempts).toBe(0);
|
||||
expect(j.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
}
|
||||
});
|
||||
|
||||
it('retryAllFailed empty — { ids: [] }', () => {
|
||||
expect(repo.retryAllFailed('2026-05-01T12:00:00.000Z')).toEqual({ ids: [] });
|
||||
});
|
||||
|
||||
it('retryAllFailed — pending_jobs 이미 존재 시 OR IGNORE (race 안전)', () => {
|
||||
// failed 노트인데 pending_jobs row 가 이미 존재하는 비정상 race 상태 시뮬레이션.
|
||||
// attempts=2, next_run_at=과거 — retryAllFailed 가 INSERT OR IGNORE 라 그대로 보존되어야.
|
||||
const id = makeFailed('a');
|
||||
db.prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 2, ?)`)
|
||||
.run(id, '2026-04-30T00:00:00.000Z');
|
||||
const r = repo.retryAllFailed('2026-05-01T12:00:00.000Z');
|
||||
expect(r.ids).toEqual([id]);
|
||||
const jobs = repo.getAllPendingJobs().filter((j) => j.noteId === id);
|
||||
expect(jobs).toHaveLength(1); // duplicate 안 됨
|
||||
// OR IGNORE 라 기존 row 보존 — attempts=2, nextRunAt 그대로
|
||||
expect(jobs[0]!.attempts).toBe(2);
|
||||
expect(jobs[0]!.nextRunAt).toBe('2026-04-30T00:00:00.000Z');
|
||||
});
|
||||
|
||||
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');
|
||||
// attempts 가 1 이 됨
|
||||
repo.setNextRunAt(id, '2026-05-01T12:00:00.000Z', 'unreachable');
|
||||
const job = repo.getAllPendingJobs().find((j) => j.noteId === id)!;
|
||||
expect(job.attempts).toBe(1); // 변화 없음
|
||||
expect(job.nextRunAt).toBe('2026-05-01T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('getTopUsedTags returns [] when no notes', () => {
|
||||
expect(repo.getTopUsedTags()).toEqual([]);
|
||||
});
|
||||
|
||||
it('getTopUsedTags orders by count desc, id asc tiebreaker', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting'], provider: 'p' });
|
||||
repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['design'], provider: 'p' });
|
||||
repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['design', 'meeting', 'qa'], provider: 'p' });
|
||||
// counts: design=3, meeting=2, qa=1
|
||||
expect(repo.getTopUsedTags()).toEqual(['design', 'meeting', 'qa']);
|
||||
});
|
||||
|
||||
it('getTopUsedTags filters non-kebab-case (한글/대문자/공백)', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
// user route 가 한글/공백 태그 들어올 수 있음 → vocab 에서 제외 검증
|
||||
repo.updateUserAiFields(a, { tags: ['design', '회의', 'Meeting', 'two words', 'api-timeout'] });
|
||||
expect(repo.getTopUsedTags()).toEqual(expect.arrayContaining(['design', 'api-timeout']));
|
||||
expect(repo.getTopUsedTags()).not.toContain('회의');
|
||||
expect(repo.getTopUsedTags()).not.toContain('Meeting');
|
||||
expect(repo.getTopUsedTags()).not.toContain('two words');
|
||||
});
|
||||
|
||||
it('getTopUsedTags counts AI + user sources together', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
// design: 1 AI (a) + 1 user (b) = 2 total; meeting: 1 AI (c) = 1 total
|
||||
// → design must rank first (proves source merging, not AI-only count)
|
||||
// Note: updateUserAiFields REPLACES tags (DELETE+reinsert), so each note
|
||||
// gets exactly the tags passed in the call.
|
||||
repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['design'], provider: 'p' });
|
||||
repo.updateUserAiFields(b, { tags: ['design'] });
|
||||
repo.updateAiResult(c, { title: 't', summary: 'x\ny\nz', tags: ['meeting'], provider: 'p' });
|
||||
const top = repo.getTopUsedTags();
|
||||
expect(top[0]).toBe('design'); // 2 (AI+user) > 1 (AI only)
|
||||
expect(top.indexOf('meeting')).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('getTopUsedTags excludes tags from deleted notes', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'x\ny\nz', tags: ['lonely'], provider: 'p' });
|
||||
repo.trash(a, new Date().toISOString());
|
||||
expect(repo.getTopUsedTags()).not.toContain('lonely');
|
||||
});
|
||||
|
||||
it('getTopUsedTags respects LIMIT parameter', () => {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = repo.create({ rawText: `n${i}` }).id;
|
||||
ids.push(id);
|
||||
repo.updateAiResult(id, {
|
||||
title: 't', summary: 'a\nb\nc',
|
||||
tags: [`tag-${i}`],
|
||||
provider: 'p'
|
||||
});
|
||||
}
|
||||
expect(repo.getTopUsedTags(3)).toHaveLength(3);
|
||||
expect(repo.getTopUsedTags(10)).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('getTopUsedTags result may be shorter than limit when top-N includes non-kebab tags', () => {
|
||||
// 비-kebab 1개 (한글) + kebab 2개 → top-3 으로 SQL 가져온 후 regex 필터로 한글 제외
|
||||
// 결과 length = 2 (limit=3 보다 작음)
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
const b = repo.create({ rawText: 'b' }).id;
|
||||
const c = repo.create({ rawText: 'c' }).id;
|
||||
repo.updateUserAiFields(a, { tags: ['회의'] }); // 한글 — SQL top 에 포함될 수 있지만 regex 통과 X
|
||||
repo.updateUserAiFields(b, { tags: ['design'] });
|
||||
repo.updateUserAiFields(c, { tags: ['meeting'] });
|
||||
const top = repo.getTopUsedTags(3);
|
||||
expect(top.length).toBeLessThan(3); // SQL 은 3개 가져왔지만 regex 가 1개 제거
|
||||
expect(top).not.toContain('회의');
|
||||
expect(top).toEqual(expect.arrayContaining(['design', 'meeting']));
|
||||
});
|
||||
|
||||
it('getTagIdByName returns id when present, null when absent', () => {
|
||||
const a = repo.create({ rawText: 'a' }).id;
|
||||
repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['hello'], provider: 'p' });
|
||||
const id = repo.getTagIdByName('hello');
|
||||
expect(typeof id).toBe('number');
|
||||
expect(id).toBeGreaterThan(0);
|
||||
// case-insensitive
|
||||
expect(repo.getTagIdByName('HELLO')).toBe(id);
|
||||
// absent
|
||||
expect(repo.getTagIdByName('nothere')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
216
tests/unit/TelemetryService.test.ts
Normal file
216
tests/unit/TelemetryService.test.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, readFileSync, existsSync, readdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { TelemetryService } from '@main/services/TelemetryService.js';
|
||||
|
||||
describe('TelemetryService.emit', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('appends a JSONL line to events-YYYY-MM-DD.jsonl (KST date)', async () => {
|
||||
// 2026-05-01 12:00 UTC → 2026-05-01 21:00 KST
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
|
||||
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
|
||||
const file = join(dir, 'events-2026-05-01.jsonl');
|
||||
expect(existsSync(file)).toBe(true);
|
||||
const content = readFileSync(file, 'utf8').trim();
|
||||
const parsed = JSON.parse(content);
|
||||
expect(parsed.kind).toBe('capture');
|
||||
expect(parsed.payload.noteId).toBe('n1');
|
||||
expect(typeof parsed.ts).toBe('string');
|
||||
});
|
||||
|
||||
it('uses KST date even when UTC date differs (around midnight)', async () => {
|
||||
// 2026-05-01 23:30 UTC → 2026-05-02 08:30 KST
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T23:30:00Z'));
|
||||
await svc.emit({ kind: 'capture', payload: { noteId: 'n2', rawTextLength: 1, hasMedia: false } });
|
||||
expect(existsSync(join(dir, 'events-2026-05-02.jsonl'))).toBe(true);
|
||||
});
|
||||
|
||||
it('appends multiple events to same-day file', async () => {
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
|
||||
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } });
|
||||
await svc.emit({ kind: 'ai_succeeded', payload: { noteId: 'n1', durationMs: 100, attempts: 0 } });
|
||||
const lines = readFileSync(join(dir, 'events-2026-05-01.jsonl'), 'utf8').trim().split('\n');
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(JSON.parse(lines[0]!).kind).toBe('capture');
|
||||
expect(JSON.parse(lines[1]!).kind).toBe('ai_succeeded');
|
||||
});
|
||||
|
||||
it('creates telemetry dir if absent', async () => {
|
||||
const fresh = join(dir, 'nested', 'telem');
|
||||
const svc = new TelemetryService(fresh, () => new Date('2026-05-01T12:00:00Z'));
|
||||
await svc.emit({ kind: 'capture', payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } });
|
||||
expect(existsSync(fresh)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects malformed event (privacy invariant) — does NOT write file', async () => {
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'));
|
||||
await expect(svc.emit({
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false, rawText: 'leak' } as never
|
||||
})).rejects.toThrow();
|
||||
// No file should have been created
|
||||
expect(readdirSync(dir).filter((f) => f.startsWith('events-'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('emit is silent (does not throw) when fs write fails — invariant: telemetry never breaks app', async () => {
|
||||
// Make the "dir" actually a file so mkdir({recursive:true}) reliably fails on every platform.
|
||||
// (Earlier draft used /proc/0/... which on Windows resolves to C:\proc\0\... and
|
||||
// mkdir({recursive:true}) silently *creates* it, leaking filesystem side-effects + the
|
||||
// silent code path was never exercised.)
|
||||
const blockingFile = join(dir, 'this-is-a-file-not-a-dir');
|
||||
writeFileSync(blockingFile, '');
|
||||
const svc = new TelemetryService(
|
||||
blockingFile,
|
||||
() => new Date('2026-05-01T12:00:00Z'),
|
||||
14,
|
||||
{ silent: true }
|
||||
);
|
||||
await expect(svc.emit({
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
|
||||
})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('emit DOES throw when fs write fails AND silent is not set (default)', async () => {
|
||||
// Companion case — confirms silent is opt-in. Without silent, fs failure surfaces.
|
||||
const blockingFile = join(dir, 'block-default');
|
||||
writeFileSync(blockingFile, '');
|
||||
const svc = new TelemetryService(blockingFile, () => new Date('2026-05-01T12:00:00Z'));
|
||||
await expect(svc.emit({
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false }
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TelemetryService.cleanupOldFiles', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('removes events-*.jsonl older than retentionDays', async () => {
|
||||
// 시드: 오래된 파일 + 최근 파일
|
||||
writeFileSync(join(dir, 'events-2026-04-01.jsonl'), '{}\n'); // 30일 전
|
||||
writeFileSync(join(dir, 'events-2026-04-25.jsonl'), '{}\n'); // 6일 전 (retain)
|
||||
writeFileSync(join(dir, 'events-2026-05-01.jsonl'), '{}\n'); // 오늘 (retain)
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const r = await svc.cleanupOldFiles();
|
||||
expect(r.removed).toEqual(['events-2026-04-01.jsonl']);
|
||||
expect(existsSync(join(dir, 'events-2026-04-25.jsonl'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'events-2026-05-01.jsonl'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty when no files match prefix', async () => {
|
||||
writeFileSync(join(dir, 'unrelated.txt'), 'x');
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const r = await svc.cleanupOldFiles();
|
||||
expect(r.removed).toEqual([]);
|
||||
expect(existsSync(join(dir, 'unrelated.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles missing dir gracefully (no throw)', async () => {
|
||||
const ghost = join(dir, 'ghost');
|
||||
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const r = await svc.cleanupOldFiles();
|
||||
expect(r.removed).toEqual([]);
|
||||
});
|
||||
|
||||
it('boundary: file exactly retentionDays old is retained', async () => {
|
||||
// 2026-04-17 = 14일 전 (boundary, retain)
|
||||
writeFileSync(join(dir, 'events-2026-04-17.jsonl'), '{}\n');
|
||||
// 2026-04-16 = 15일 전 (delete)
|
||||
writeFileSync(join(dir, 'events-2026-04-16.jsonl'), '{}\n');
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const r = await svc.cleanupOldFiles();
|
||||
expect(r.removed).toEqual(['events-2026-04-16.jsonl']);
|
||||
expect(existsSync(join(dir, 'events-2026-04-17.jsonl'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TelemetryService.readAllRecent', () => {
|
||||
let dir: string;
|
||||
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'inkling-telem-')); });
|
||||
afterEach(() => { rmSync(dir, { recursive: true, force: true }); });
|
||||
|
||||
it('reads events from all files within retentionDays', async () => {
|
||||
writeFileSync(join(dir, 'events-2026-04-25.jsonl'),
|
||||
JSON.stringify({ ts: '2026-04-25T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
|
||||
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
|
||||
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'b', rawTextLength: 2, hasMedia: false } }) + '\n' +
|
||||
JSON.stringify({ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_succeeded', payload: { noteId: 'b', durationMs: 100, attempts: 0 } }) + '\n');
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const events = await svc.readAllRecent();
|
||||
expect(events).toHaveLength(3);
|
||||
// discriminant narrowing — noteId 없는 kind(empty_trash/expired_banner_shown/expired_batch_trash) 가 섞이면 명시적으로 실패
|
||||
expect(events.map((e) =>
|
||||
(e.kind === 'empty_trash' || e.kind === 'expired_banner_shown' || e.kind === 'expired_batch_trash' || e.kind === 'ollama_unreachable' || e.kind === 'ollama_recovered' || e.kind === 'ollama_recheck_manual' || e.kind === 'ai_retry_manual' || e.kind === 'tag_vocab_hit' || e.kind === 'tag_vocab_miss')
|
||||
? null
|
||||
: e.payload.noteId
|
||||
)).toEqual(['a', 'b', 'b']);
|
||||
});
|
||||
|
||||
it('skips malformed lines (silent — invariant)', async () => {
|
||||
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
|
||||
'not-json\n' +
|
||||
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n' +
|
||||
'{}\n'); // valid JSON but invalid event
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const events = await svc.readAllRecent();
|
||||
expect(events).toHaveLength(1);
|
||||
const ev = events[0]!;
|
||||
expect(ev.kind).toBe('capture');
|
||||
if (ev.kind !== 'empty_trash' && ev.kind !== 'expired_banner_shown' && ev.kind !== 'expired_batch_trash' && ev.kind !== 'ollama_unreachable' && ev.kind !== 'ollama_recovered' && ev.kind !== 'ollama_recheck_manual' && ev.kind !== 'ai_retry_manual' && ev.kind !== 'tag_vocab_hit' && ev.kind !== 'tag_vocab_miss') expect(ev.payload.noteId).toBe('a');
|
||||
});
|
||||
|
||||
it('returns [] when dir missing', async () => {
|
||||
const ghost = join(dir, 'ghost');
|
||||
const svc = new TelemetryService(ghost, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const events = await svc.readAllRecent();
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] when dir empty', async () => {
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
expect(await svc.readAllRecent()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TelemetryService.exportTo', () => {
|
||||
let dir: string;
|
||||
let outDir: string;
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'inkling-telem-'));
|
||||
outDir = mkdtempSync(join(tmpdir(), 'inkling-export-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes events.jsonl (concat) + stats.md to folder', async () => {
|
||||
writeFileSync(join(dir, 'events-2026-05-01.jsonl'),
|
||||
JSON.stringify({ ts: '2026-05-01T00:00:00.000Z', kind: 'capture', payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }) + '\n');
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const r = await svc.exportTo(outDir);
|
||||
expect(r.eventCount).toBe(1);
|
||||
expect(existsSync(join(outDir, 'events.jsonl'))).toBe(true);
|
||||
expect(existsSync(join(outDir, 'stats.md'))).toBe(true);
|
||||
const events = readFileSync(join(outDir, 'events.jsonl'), 'utf8').trim().split('\n');
|
||||
expect(events).toHaveLength(1);
|
||||
const stats = readFileSync(join(outDir, 'stats.md'), 'utf8');
|
||||
expect(stats).toContain('총 이벤트: 1');
|
||||
});
|
||||
|
||||
it('handles empty input — writes 0-event stats', async () => {
|
||||
const svc = new TelemetryService(dir, () => new Date('2026-05-01T12:00:00Z'), 14);
|
||||
const r = await svc.exportTo(outDir);
|
||||
expect(r.eventCount).toBe(0);
|
||||
expect(readFileSync(join(outDir, 'events.jsonl'), 'utf8')).toBe('');
|
||||
expect(readFileSync(join(outDir, 'stats.md'), 'utf8')).toContain('총 이벤트: 0');
|
||||
});
|
||||
});
|
||||
37
tests/unit/kstDate.test.ts
Normal file
37
tests/unit/kstDate.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js';
|
||||
|
||||
describe('todayInKstString', () => {
|
||||
it('returns KST calendar date as YYYY-MM-DD', () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST
|
||||
expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01');
|
||||
});
|
||||
|
||||
it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => {
|
||||
expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02');
|
||||
});
|
||||
|
||||
it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => {
|
||||
expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextKstMidnightMs', () => {
|
||||
it('returns the next KST 00:00 epoch ms (UTC 12:00 → +12h to KST midnight)', () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → 다음 KST 자정 = 2026-05-02 00:00 KST
|
||||
// = 2026-05-01 15:00 UTC
|
||||
const now = Date.parse('2026-05-01T12:00:00Z');
|
||||
const next = nextKstMidnightMs(now);
|
||||
expect(new Date(next).toISOString()).toBe('2026-05-01T15:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns 24h-from-now-ish when called shortly after KST midnight', () => {
|
||||
// 2026-05-01 15:01 UTC = 2026-05-02 00:01 KST → 다음 KST 자정 = 2026-05-03 00:00 KST
|
||||
// = 2026-05-02 15:00 UTC (≈ 23h59m later)
|
||||
const now = Date.parse('2026-05-01T15:01:00Z');
|
||||
const next = nextKstMidnightMs(now);
|
||||
expect(new Date(next).toISOString()).toBe('2026-05-02T15:00:00.000Z');
|
||||
expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000);
|
||||
expect(next - now).toBeLessThan(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations, latestVersion } from '@main/db/migrations/index.js';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
|
||||
describe('migrations m002 due_date', () => {
|
||||
it('latestVersion returns 2', () => {
|
||||
expect(latestVersion()).toBe(2);
|
||||
});
|
||||
|
||||
it('runMigrations on fresh DB advances user_version to 2', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.pragma('user_version', { simple: true });
|
||||
expect(row).toBe(2);
|
||||
});
|
||||
|
||||
// v3 (m003 soft_delete) lands in v0.2.3 #4 — latest version + user_version
|
||||
// assertions migrate to migrations.test.ts. Here we keep only the m002-specific
|
||||
// assertion (due_date column existence) which is version-stable.
|
||||
it('due_date column exists with NULL default', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
|
||||
@@ -29,3 +29,47 @@ describe('migrations', () => {
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration v3 — soft delete columns', () => {
|
||||
it('adds deleted_at, last_recalled_at, recall_dismissed_at to notes', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const cols = db.prepare(`PRAGMA table_info(notes)`).all().map((r: any) => r.name);
|
||||
expect(cols).toEqual(
|
||||
expect.arrayContaining(['deleted_at', 'last_recalled_at', 'recall_dismissed_at'])
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('creates idx_notes_deleted_at index', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const indexes = db
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='notes'`)
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(indexes.map((i) => i.name)).toContain('idx_notes_deleted_at');
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('user_version reaches 3', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
const row = db.prepare('PRAGMA user_version').get() as { user_version: number };
|
||||
expect(row.user_version).toBe(3);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('all 3 new columns default to NULL', () => {
|
||||
const db = new Database(':memory:');
|
||||
runMigrations(db);
|
||||
db.prepare(
|
||||
`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
|
||||
VALUES ('n1', 't', 'pending', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')`
|
||||
).run();
|
||||
const row = db.prepare('SELECT deleted_at, last_recalled_at, recall_dismissed_at FROM notes WHERE id=?').get('n1') as any;
|
||||
expect(row.deleted_at).toBeNull();
|
||||
expect(row.last_recalled_at).toBeNull();
|
||||
expect(row.recall_dismissed_at).toBeNull();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
31
tests/unit/prompt.test.ts
Normal file
31
tests/unit/prompt.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildPrompt, PROMPT_VERSION } from '@main/ai/prompt.js';
|
||||
|
||||
describe('prompt', () => {
|
||||
it('PROMPT_VERSION is 4', () => {
|
||||
expect(PROMPT_VERSION).toBe(4);
|
||||
});
|
||||
|
||||
it('buildPrompt with empty vocab omits vocabulary line entirely', () => {
|
||||
const out = buildPrompt('hello', '2026-05-02', [], []);
|
||||
expect(out).not.toContain('vocabulary');
|
||||
expect(out).not.toContain('Prefer reusing');
|
||||
});
|
||||
|
||||
it('buildPrompt with vocab includes Prefer instruction + comma-separated list', () => {
|
||||
const out = buildPrompt('hello', '2026-05-02', [], ['design', 'meeting', 'qa']);
|
||||
expect(out).toContain('Existing vocabulary tags');
|
||||
expect(out).toContain('design, meeting, qa');
|
||||
expect(out).toContain('Prefer reusing');
|
||||
});
|
||||
|
||||
it('vocab block appears after header and before JSON rules', () => {
|
||||
const out = buildPrompt('hello', '2026-05-02', [], ['design']);
|
||||
const headerIdx = out.indexOf("Today's date");
|
||||
const vocabIdx = out.indexOf('Existing vocabulary');
|
||||
const jsonRulesIdx = out.indexOf('Return a JSON object');
|
||||
expect(headerIdx).toBeGreaterThan(-1);
|
||||
expect(vocabIdx).toBeGreaterThan(headerIdx);
|
||||
expect(jsonRulesIdx).toBeGreaterThan(vocabIdx);
|
||||
});
|
||||
});
|
||||
50
tests/unit/store.aiRetry.test.ts
Normal file
50
tests/unit/store.aiRetry.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listTrash: vi.fn(async () => []),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
listExpired: vi.fn(async () => []),
|
||||
trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })),
|
||||
ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })),
|
||||
onOllamaStatus: vi.fn(() => () => {}),
|
||||
retryAllFailed: vi.fn(async () => ({ count: 0 })),
|
||||
getFailedCount: vi.fn(async () => 0)
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
describe('useInbox — AI retry (v0.2.3 #2)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0, failedCount: 5,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
it('retryAllFailed action — failedCount=0 reset 후 IPC 호출', async () => {
|
||||
mockApi.retryAllFailed.mockResolvedValueOnce({ count: 5 });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().retryAllFailed();
|
||||
expect(mockApi.retryAllFailed).toHaveBeenCalledTimes(1);
|
||||
expect(useInbox.getState().failedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
101
tests/unit/store.expired.test.ts
Normal file
101
tests/unit/store.expired.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
listExpired: vi.fn(async () => [] as Note[]),
|
||||
trashExpiredBatch: vi.fn(async (_ids: string[]) => ({ trashedCount: 0, confirmed: false }))
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
const noteStub = (id: string): Note => ({
|
||||
id, rawText: 'x',
|
||||
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: '2026-04-20', dueDateEditedByUser: false,
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
describe('useInbox — expired state (v0.2.3 #5)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
it('loadExpired sets expiredCandidates from inboxApi', async () => {
|
||||
mockApi.listExpired.mockResolvedValueOnce([noteStub('n1')]);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().loadExpired();
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates).toHaveLength(1);
|
||||
expect(s.expiredCandidates[0]!.id).toBe('n1');
|
||||
});
|
||||
|
||||
it('trashExpiredBatch removes ids and increments trashCount when confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 2, confirmed: true });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
expiredCandidates: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
|
||||
notes: [noteStub('n1'), noteStub('n2'), noteStub('n3')],
|
||||
trashCount: 5
|
||||
});
|
||||
await useInbox.getState().trashExpiredBatch(['n1', 'n2']);
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates.map((n) => n.id)).toEqual(['n3']);
|
||||
expect(s.notes.map((n) => n.id)).toEqual(['n3']);
|
||||
expect(s.trashCount).toBe(7);
|
||||
});
|
||||
|
||||
it('trashExpiredBatch does NOT mutate state when not confirmed', async () => {
|
||||
mockApi.trashExpiredBatch.mockResolvedValueOnce({ trashedCount: 0, confirmed: false });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
expiredCandidates: [noteStub('n1'), noteStub('n2')],
|
||||
notes: [noteStub('n1'), noteStub('n2')],
|
||||
trashCount: 5
|
||||
});
|
||||
await useInbox.getState().trashExpiredBatch(['n1']);
|
||||
const s = useInbox.getState();
|
||||
expect(s.expiredCandidates).toHaveLength(2);
|
||||
expect(s.notes).toHaveLength(2);
|
||||
expect(s.trashCount).toBe(5);
|
||||
});
|
||||
|
||||
it('snoozeExpired sets expiredSnoozeUntilMs to next KST midnight', async () => {
|
||||
// 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST → next KST midnight = 2026-05-02 00:00 KST = 2026-05-01 15:00 UTC
|
||||
const fixedNow = Date.parse('2026-05-01T12:00:00Z');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().snoozeExpired();
|
||||
expect(useInbox.getState().expiredSnoozeUntilMs).toBe(Date.parse('2026-05-01T15:00:00Z'));
|
||||
});
|
||||
});
|
||||
55
tests/unit/store.ollama.test.ts
Normal file
55
tests/unit/store.ollama.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => []),
|
||||
listTrash: vi.fn(async () => []),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {}),
|
||||
listExpired: vi.fn(async () => []),
|
||||
trashExpiredBatch: vi.fn(async () => ({ trashedCount: 0, confirmed: false })),
|
||||
ollamaRecheck: vi.fn(async (): Promise<{ ok: boolean; reason?: string }> => ({ ok: true })),
|
||||
onOllamaStatus: vi.fn(() => () => {})
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
describe('useInbox — ollama (v0.2.3 #1)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: false, reason: 'refused' },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null },
|
||||
expiredCandidates: [], expiredSnoozeUntilMs: null
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
it('recheckOllama calls inboxApi.ollamaRecheck and updates ollamaStatus', async () => {
|
||||
mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: true });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().recheckOllama();
|
||||
expect(mockApi.ollamaRecheck).toHaveBeenCalledTimes(1);
|
||||
expect(useInbox.getState().ollamaStatus).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('recheckOllama propagates failure status', async () => {
|
||||
mockApi.ollamaRecheck.mockResolvedValueOnce({ ok: false, reason: 'timeout' });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().recheckOllama();
|
||||
expect(useInbox.getState().ollamaStatus).toEqual({ ok: false, reason: 'timeout' });
|
||||
});
|
||||
});
|
||||
77
tests/unit/store.recall.test.ts
Normal file
77
tests/unit/store.recall.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
listRecallCandidate: vi.fn(),
|
||||
markRecallOpened: vi.fn(),
|
||||
dismissRecall: vi.fn(),
|
||||
emitRecallShown: vi.fn(),
|
||||
emitRecallSnoozed: vi.fn(),
|
||||
listNotes: vi.fn(async () => []),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
listExpired: vi.fn(async () => []),
|
||||
getFailedCount: vi.fn(async () => 0)
|
||||
}
|
||||
}));
|
||||
|
||||
import { useInbox } from '../../src/renderer/inbox/store.js';
|
||||
import { inboxApi } from '../../src/renderer/inbox/api.js';
|
||||
|
||||
const inboxApiMock = inboxApi as unknown as {
|
||||
listRecallCandidate: ReturnType<typeof vi.fn>;
|
||||
markRecallOpened: ReturnType<typeof vi.fn>;
|
||||
dismissRecall: ReturnType<typeof vi.fn>;
|
||||
emitRecallShown: ReturnType<typeof vi.fn>;
|
||||
emitRecallSnoozed: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const note = (id: string): Note => ({
|
||||
id, rawText: 'x', aiTitle: 't', aiSummary: 'a\nb\nc',
|
||||
tags: [], media: [], aiStatus: 'done', aiProvider: null, aiGeneratedAt: null, aiError: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
|
||||
deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
|
||||
});
|
||||
|
||||
describe('store recall actions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useInbox.setState({
|
||||
recallCandidate: null,
|
||||
recallSnoozeUntilMs: null,
|
||||
} as Parameters<typeof useInbox.setState>[0]);
|
||||
});
|
||||
|
||||
it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => {
|
||||
useInbox.setState({ recallCandidate: note('n1') } as Parameters<typeof useInbox.setState>[0]);
|
||||
await useInbox.getState().snoozeRecall();
|
||||
const ms = useInbox.getState().recallSnoozeUntilMs;
|
||||
expect(ms).not.toBeNull();
|
||||
expect(ms!).toBeGreaterThan(Date.now());
|
||||
expect(inboxApiMock.emitRecallSnoozed).toHaveBeenCalledWith('n1');
|
||||
});
|
||||
|
||||
it('openRecall calls API + fetches next candidate', async () => {
|
||||
inboxApiMock.markRecallOpened.mockResolvedValueOnce({ note: note('n1') });
|
||||
inboxApiMock.listRecallCandidate.mockResolvedValueOnce(null);
|
||||
await useInbox.getState().openRecall('n1');
|
||||
expect(inboxApiMock.markRecallOpened).toHaveBeenCalledWith('n1');
|
||||
expect(inboxApiMock.listRecallCandidate).toHaveBeenCalled();
|
||||
expect(useInbox.getState().recallCandidate).toBeNull();
|
||||
});
|
||||
|
||||
it('dismissRecallNote calls API + fetches next candidate', async () => {
|
||||
inboxApiMock.dismissRecall.mockResolvedValueOnce({ note: note('n1') });
|
||||
inboxApiMock.listRecallCandidate.mockResolvedValueOnce(note('n2'));
|
||||
await useInbox.getState().dismissRecallNote('n1');
|
||||
expect(inboxApiMock.dismissRecall).toHaveBeenCalledWith('n1');
|
||||
expect(useInbox.getState().recallCandidate?.id).toBe('n2');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,9 @@ function sample(id: string, tags: string[]): Note {
|
||||
intentPromptedAt: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
deletedAt: null,
|
||||
lastRecalledAt: null,
|
||||
recallDismissedAt: null,
|
||||
createdAt: '2026-04-26T00:00:00Z',
|
||||
updatedAt: '2026-04-26T00:00:00Z',
|
||||
tags: tags.map((name) => ({ name, source: 'ai' as const })),
|
||||
|
||||
104
tests/unit/store.trash.test.ts
Normal file
104
tests/unit/store.trash.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
const mockApi = {
|
||||
listNotes: vi.fn(async () => [] as Note[]),
|
||||
listTrash: vi.fn(async () => [] as Note[]),
|
||||
getTrashCount: vi.fn(async () => 0),
|
||||
getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
|
||||
getPendingCount: vi.fn(async () => 0),
|
||||
getOllamaStatus: vi.fn(async () => ({ ok: true })),
|
||||
getTodayCount: vi.fn(async () => 0),
|
||||
restoreNote: vi.fn(async () => {}),
|
||||
permanentDeleteNote: vi.fn(async () => ({ confirmed: true })),
|
||||
emptyTrash: vi.fn(async () => ({ confirmed: true, count: 0 })),
|
||||
deleteNote: vi.fn(async () => {}),
|
||||
onNoteUpdated: vi.fn(() => () => {}),
|
||||
updateAiFields: vi.fn(async () => {}),
|
||||
setDueDate: vi.fn(async () => {}),
|
||||
setIntent: vi.fn(async () => {}),
|
||||
dismissIntent: vi.fn(async () => {})
|
||||
};
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: mockApi }));
|
||||
|
||||
const noteStub = (id: string, deletedAt: string | null = null): Note => ({
|
||||
id, rawText: 'x',
|
||||
aiTitle: null, aiSummary: null, aiStatus: 'done', aiError: null,
|
||||
aiProvider: null, aiGeneratedAt: null,
|
||||
titleEditedByUser: false, summaryEditedByUser: false,
|
||||
userIntent: null, intentPromptedAt: null,
|
||||
dueDate: null, dueDateEditedByUser: false,
|
||||
deletedAt, lastRecalledAt: null, recallDismissedAt: null,
|
||||
createdAt: '2026-05-01T00:00:00Z', updatedAt: '2026-05-01T00:00:00Z',
|
||||
tags: [], media: []
|
||||
});
|
||||
|
||||
describe('useInbox — trash state (v0.2.3 #4)', () => {
|
||||
beforeEach(async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.setState({
|
||||
notes: [], trashNotes: [], trashCount: 0, showTrash: false,
|
||||
loading: false, tagFilter: null, pendingCount: 0, todayCount: 0,
|
||||
ollamaStatus: { ok: true },
|
||||
continuity: { weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null }
|
||||
});
|
||||
Object.values(mockApi).forEach((fn) => 'mockClear' in fn && (fn as any).mockClear());
|
||||
});
|
||||
|
||||
it('toggleShowTrash flips state and triggers loadTrash on enter', async () => {
|
||||
mockApi.listTrash.mockResolvedValueOnce([noteStub('t1', '2026-05-01T00:00:00Z')]);
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
await useInbox.getState().toggleShowTrash();
|
||||
expect(useInbox.getState().showTrash).toBe(true);
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
expect(mockApi.listTrash).toHaveBeenCalled();
|
||||
await useInbox.getState().toggleShowTrash();
|
||||
expect(useInbox.getState().showTrash).toBe(false);
|
||||
});
|
||||
|
||||
it('upsertNote routes to trashNotes when deletedAt IS NOT NULL', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
expect(useInbox.getState().notes).toHaveLength(0);
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('upsertNote moves note from notes to trashNotes when trashed', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a'));
|
||||
expect(useInbox.getState().notes).toHaveLength(1);
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
expect(useInbox.getState().notes).toHaveLength(0);
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('restoreNote calls api + moves note from trashNotes to notes (낙관적 갱신)', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
await useInbox.getState().restoreNote('a');
|
||||
expect(mockApi.restoreNote).toHaveBeenCalledWith('a');
|
||||
// main 은 restore 시 pushNoteUpdated 안 보냄 — store 자가 갱신 검증
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(0);
|
||||
expect(useInbox.getState().notes).toHaveLength(1);
|
||||
expect(useInbox.getState().notes[0]!.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('upsertNote with showTrash=false preserves server trashCount (regression I1)', async () => {
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
// server 가 trashCount=5 알려줬는데 trashNotes 는 미로드 (showTrash=false 기본)
|
||||
useInbox.setState({ trashCount: 5, trashNotes: [] });
|
||||
useInbox.getState().upsertNote(noteStub('active-1'));
|
||||
expect(useInbox.getState().trashCount).toBe(5); // server 값 보존
|
||||
expect(useInbox.getState().notes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('emptyTrash with cancelled confirm leaves trashNotes intact', async () => {
|
||||
mockApi.emptyTrash.mockResolvedValueOnce({ confirmed: false, count: 0 });
|
||||
const { useInbox } = await import('../../src/renderer/inbox/store.js');
|
||||
useInbox.getState().upsertNote(noteStub('a', '2026-05-01T00:00:00Z'));
|
||||
await useInbox.getState().emptyTrash();
|
||||
expect(useInbox.getState().trashNotes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
335
tests/unit/telemetryEvents.test.ts
Normal file
335
tests/unit/telemetryEvents.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateEvent } from '@main/services/telemetryEvents.js';
|
||||
|
||||
describe('validateEvent — happy path', () => {
|
||||
it('accepts capture event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 12, hasMedia: false }
|
||||
});
|
||||
expect(e.kind).toBe('capture');
|
||||
});
|
||||
|
||||
it('accepts ai_succeeded event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_succeeded',
|
||||
payload: { noteId: 'n1', durationMs: 1234, attempts: 0 }
|
||||
});
|
||||
expect(e.kind).toBe('ai_succeeded');
|
||||
});
|
||||
|
||||
it('accepts ai_failed event with reason enum', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_failed',
|
||||
payload: { noteId: 'n1', reason: 'unreachable', attempts: 3 }
|
||||
});
|
||||
expect(e.kind).toBe('ai_failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — privacy invariant', () => {
|
||||
it('rejects payload with rawText leak', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects payload with title leak', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_succeeded',
|
||||
payload: { noteId: 'n1', durationMs: 1, attempts: 0, title: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects payload with summary leak', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_succeeded',
|
||||
payload: { noteId: 'n1', durationMs: 1, attempts: 0, summary: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects payload with userIntent leak', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, userIntent: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects payload with tag name leak', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'capture',
|
||||
payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false, tagNames: ['일정'] }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects unknown reason in ai_failed', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_failed',
|
||||
payload: { noteId: 'n1', reason: 'unicorn', attempts: 1 }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects unknown event kind', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'mystery',
|
||||
payload: {}
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — trash family (v0.2.3 #4)', () => {
|
||||
it('accepts trash event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'trash',
|
||||
payload: { noteId: 'n1' }
|
||||
});
|
||||
expect(e.kind).toBe('trash');
|
||||
});
|
||||
|
||||
it('accepts restore event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'restore',
|
||||
payload: { noteId: 'n1' }
|
||||
});
|
||||
expect(e.kind).toBe('restore');
|
||||
});
|
||||
|
||||
it('accepts permanent_delete event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'permanent_delete',
|
||||
payload: { noteId: 'n1' }
|
||||
});
|
||||
expect(e.kind).toBe('permanent_delete');
|
||||
});
|
||||
|
||||
it('accepts empty_trash event with count', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'empty_trash',
|
||||
payload: { count: 7 }
|
||||
});
|
||||
expect(e.kind).toBe('empty_trash');
|
||||
});
|
||||
|
||||
it('rejects trash payload with rawText leak', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'trash',
|
||||
payload: { noteId: 'n1', rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty_trash with negative count', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'empty_trash',
|
||||
payload: { count: -1 }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty_trash with non-integer count', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'empty_trash',
|
||||
payload: { count: 1.5 }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('expired_banner_shown / expired_batch_trash events', () => {
|
||||
it('parses valid expired_banner_shown', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'expired_banner_shown',
|
||||
payload: { candidateCount: 7 }
|
||||
});
|
||||
if (ev.kind !== 'expired_banner_shown') throw new Error('discriminant');
|
||||
expect(ev.payload.candidateCount).toBe(7);
|
||||
});
|
||||
|
||||
it('parses valid expired_batch_trash', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'expired_batch_trash',
|
||||
payload: { count: 3 }
|
||||
});
|
||||
if (ev.kind !== 'expired_batch_trash') throw new Error('discriminant');
|
||||
expect(ev.payload.count).toBe(3);
|
||||
});
|
||||
|
||||
it('rejects expired_banner_shown with extra payload field (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'expired_banner_shown',
|
||||
payload: { candidateCount: 7, rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects expired_batch_trash with negative count', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'expired_batch_trash',
|
||||
payload: { count: -1 }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects expired_batch_trash with extra payload field (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'expired_batch_trash',
|
||||
payload: { count: 3, rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ollama_unreachable / ollama_recovered / ollama_recheck_manual events', () => {
|
||||
it('parses valid ollama_unreachable', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ollama_unreachable',
|
||||
payload: { reason: 'connection refused' }
|
||||
});
|
||||
if (ev.kind !== 'ollama_unreachable') throw new Error('discriminant');
|
||||
expect(ev.payload.reason).toBe('connection refused');
|
||||
});
|
||||
|
||||
it('parses valid ollama_recovered', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ollama_recovered',
|
||||
payload: { downtimeMs: 60000 }
|
||||
});
|
||||
if (ev.kind !== 'ollama_recovered') throw new Error('discriminant');
|
||||
expect(ev.payload.downtimeMs).toBe(60000);
|
||||
});
|
||||
|
||||
it('parses valid ollama_recheck_manual (empty payload)', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ollama_recheck_manual',
|
||||
payload: {}
|
||||
});
|
||||
expect(ev.kind).toBe('ollama_recheck_manual');
|
||||
});
|
||||
|
||||
it('rejects ollama_unreachable with extra payload field (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ollama_unreachable',
|
||||
payload: { reason: 'refused', rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects ollama_recovered with negative downtimeMs', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ollama_recovered',
|
||||
payload: { downtimeMs: -1 }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects ollama_recheck_manual with non-empty payload (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ollama_recheck_manual',
|
||||
payload: { foo: 'bar' }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ai_retry_manual event', () => {
|
||||
it('parses valid ai_retry_manual', () => {
|
||||
const ev = validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: 5 }
|
||||
});
|
||||
if (ev.kind !== 'ai_retry_manual') throw new Error('discriminant');
|
||||
expect(ev.payload.failedCount).toBe(5);
|
||||
});
|
||||
|
||||
it('rejects ai_retry_manual with failedCount=0 (≥1 invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: 0 }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('rejects ai_retry_manual with extra payload field (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-01T00:00:00.000Z',
|
||||
kind: 'ai_retry_manual',
|
||||
payload: { failedCount: 5, rawText: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — tag vocab', () => {
|
||||
it('accepts tag_vocab_hit event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId: 42, vocabSize: 17 }
|
||||
});
|
||||
expect(e.kind).toBe('tag_vocab_hit');
|
||||
});
|
||||
|
||||
it('accepts tag_vocab_miss event without tagId', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'tag_vocab_miss',
|
||||
payload: { vocabSize: 17 }
|
||||
});
|
||||
expect(e.kind).toBe('tag_vocab_miss');
|
||||
});
|
||||
|
||||
it('rejects tag_vocab_hit with extra field (privacy invariant)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'tag_vocab_hit',
|
||||
payload: { tagId: 42, vocabSize: 17, tagName: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEvent — recall', () => {
|
||||
it('accepts recall_shown event', () => {
|
||||
const e = validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId: 'n1', ageDays: 14 }
|
||||
});
|
||||
expect(e.kind).toBe('recall_shown');
|
||||
});
|
||||
|
||||
it('rejects recall_shown with extra field (privacy)', () => {
|
||||
expect(() => validateEvent({
|
||||
ts: '2026-05-02T00:00:00.000Z',
|
||||
kind: 'recall_shown',
|
||||
payload: { noteId: 'n1', ageDays: 14, content: 'leak' }
|
||||
})).toThrow();
|
||||
});
|
||||
|
||||
it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => {
|
||||
for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) {
|
||||
const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } });
|
||||
expect(e.kind).toBe(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
213
tests/unit/telemetryStats.test.ts
Normal file
213
tests/unit/telemetryStats.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { aggregateStats } from '@main/services/telemetryStats.js';
|
||||
import type { TelemetryEvent } from '@main/services/telemetryEvents.js';
|
||||
|
||||
const e = (ts: string, kind: TelemetryEvent['kind'], payload: TelemetryEvent['payload']): TelemetryEvent =>
|
||||
({ ts, kind, payload } as TelemetryEvent);
|
||||
|
||||
describe('aggregateStats', () => {
|
||||
it('produces empty stats for empty input', () => {
|
||||
const r = aggregateStats([], new Date('2026-05-08T00:00:00Z'));
|
||||
expect(r.eventCount).toBe(0);
|
||||
expect(r.md).toContain('총 이벤트: 0');
|
||||
});
|
||||
|
||||
it('counts events per KST day per kind', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T12:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 5, hasMedia: false }),
|
||||
e('2026-05-01T12:01:00Z', 'capture', { noteId: 'n2', rawTextLength: 3, hasMedia: true }),
|
||||
e('2026-05-01T12:02:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1000, attempts: 0 }),
|
||||
e('2026-05-02T00:00:00Z', 'ai_failed', { noteId: 'n2', reason: 'unreachable', attempts: 3 })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
|
||||
expect(r.eventCount).toBe(4);
|
||||
expect(r.md).toContain('| 2026-05-01 | 2 | 1 | 0 |');
|
||||
expect(r.md).toContain('| 2026-05-02 | 0 | 0 | 1 |');
|
||||
});
|
||||
|
||||
it('computes AI 성공률', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T00:00:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1, attempts: 0 }),
|
||||
e('2026-05-01T00:00:01Z', 'ai_succeeded', { noteId: 'n2', durationMs: 1, attempts: 0 }),
|
||||
e('2026-05-01T00:00:02Z', 'ai_succeeded', { noteId: 'n3', durationMs: 1, attempts: 0 }),
|
||||
e('2026-05-01T00:00:03Z', 'ai_failed', { noteId: 'n4', reason: 'other', attempts: 1 })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('AI 성공률: 75.0%');
|
||||
expect(r.md).toContain('3/4');
|
||||
});
|
||||
|
||||
it('AI 성공률 N/A when no AI events', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('AI 성공률: N/A');
|
||||
});
|
||||
|
||||
it('computes 평균 ai_succeeded durationMs', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T00:00:00Z', 'ai_succeeded', { noteId: 'n1', durationMs: 1000, attempts: 0 }),
|
||||
e('2026-05-01T00:00:01Z', 'ai_succeeded', { noteId: 'n2', durationMs: 2000, attempts: 0 }),
|
||||
e('2026-05-01T00:00:02Z', 'ai_succeeded', { noteId: 'n3', durationMs: 3000, attempts: 0 })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('평균 ai_succeeded durationMs: 2000');
|
||||
});
|
||||
|
||||
it('buckets near-midnight UTC events on the correct KST day (regression: not naive UTC)', () => {
|
||||
// 2026-05-01T15:30:00Z → 2026-05-02 00:30 KST → KST day 2026-05-02
|
||||
// Naive UTC slice(0,10) would put this on 2026-05-01 — this test catches that regression.
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T15:30:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
|
||||
expect(r.md).toContain('| 2026-05-02 | 1 | 0 | 0 |');
|
||||
expect(r.md).not.toContain('| 2026-05-01 |');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateStats — trash family (v0.2.3 #4)', () => {
|
||||
it('counts trash/restore/permanent_delete/empty_trash per day', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'n1' }),
|
||||
e('2026-05-01T01:00:00Z', 'trash', { noteId: 'n2' }),
|
||||
e('2026-05-01T02:00:00Z', 'restore', { noteId: 'n1' }),
|
||||
e('2026-05-01T03:00:00Z', 'permanent_delete', { noteId: 'n3' }),
|
||||
e('2026-05-01T04:00:00Z', 'empty_trash', { count: 5 })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-08T00:00:00Z'));
|
||||
expect(r.eventCount).toBe(5);
|
||||
expect(r.md).toContain('| 2026-05-01 | 0 | 0 | 0 | 2 | 1 | 1 | 1 |');
|
||||
});
|
||||
|
||||
it('computes restore/trash ratio', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T00:00:00Z', 'trash', { noteId: 'a' }),
|
||||
e('2026-05-01T00:00:01Z', 'trash', { noteId: 'b' }),
|
||||
e('2026-05-01T00:00:02Z', 'trash', { noteId: 'c' }),
|
||||
e('2026-05-01T00:00:03Z', 'trash', { noteId: 'd' }),
|
||||
e('2026-05-01T00:00:04Z', 'restore', { noteId: 'a' })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('휴지통 회수율: 25.0% (1/4)');
|
||||
});
|
||||
|
||||
it('휴지통 회수율 N/A when no trash events', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-01T00:00:00Z', 'capture', { noteId: 'n1', rawTextLength: 1, hasMedia: false })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('휴지통 회수율: N/A');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateStats — expired_banner_shown / expired_batch_trash', () => {
|
||||
it('counts both kinds per day and computes 만료 trash ratio', () => {
|
||||
const events = [
|
||||
{ ts: '2026-05-01T00:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 5 } },
|
||||
{ ts: '2026-05-01T01:00:00.000Z', kind: 'expired_banner_shown' as const, payload: { candidateCount: 3 } },
|
||||
{ ts: '2026-05-01T02:00:00.000Z', kind: 'expired_batch_trash' as const, payload: { count: 4 } }
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('expired_banner_shown');
|
||||
expect(r.md).toContain('expired_batch_trash');
|
||||
// 4 / (5 + 3) = 50.0%
|
||||
expect(r.md).toMatch(/만료 trash ratio.*50\.0%/);
|
||||
});
|
||||
|
||||
it('shows N/A when 만료 배너 노출 0건', () => {
|
||||
const events = [
|
||||
{ ts: '2026-05-01T00:00:00.000Z', kind: 'capture' as const, payload: { noteId: 'a', rawTextLength: 1, hasMedia: false } }
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toMatch(/만료 trash ratio.*N\/A/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateStats — ollama_* events', () => {
|
||||
it('counts 3 kinds per day and computes downtime average', () => {
|
||||
const events = [
|
||||
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } },
|
||||
{ ts: '2026-05-01T01:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 60000 } },
|
||||
{ ts: '2026-05-01T02:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'timeout' } },
|
||||
{ ts: '2026-05-01T03:00:00.000Z', kind: 'ollama_recovered' as const, payload: { downtimeMs: 120000 } },
|
||||
{ ts: '2026-05-01T04:00:00.000Z', kind: 'ollama_recheck_manual' as const, payload: {} as Record<string, never> }
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('ollama_unreachable');
|
||||
expect(r.md).toContain('ollama_recovered');
|
||||
expect(r.md).toContain('ollama_recheck_manual');
|
||||
// (60000 + 120000) / 2 = 90000
|
||||
expect(r.md).toMatch(/평균 downtimeMs.*90000/);
|
||||
expect(r.md).toMatch(/수동 recheck.*1/);
|
||||
});
|
||||
|
||||
it('shows N/A for downtime when no recovered events', () => {
|
||||
const events = [
|
||||
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ollama_unreachable' as const, payload: { reason: 'refused' } }
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toMatch(/평균 downtimeMs.*N\/A/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateStats — ai_retry_manual', () => {
|
||||
it('counts events and sums failedCount', () => {
|
||||
const events = [
|
||||
{ ts: '2026-05-01T00:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 3 } },
|
||||
{ ts: '2026-05-01T01:00:00.000Z', kind: 'ai_retry_manual' as const, payload: { failedCount: 7 } }
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-02T00:00:00Z'));
|
||||
expect(r.md).toContain('ai_retry_manual');
|
||||
// 2회 / 누적 10건
|
||||
expect(r.md).toMatch(/AI 수동 재시도.*2회.*10건/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateStats — tag_vocab hit/miss', () => {
|
||||
it('aggregates tag_vocab hit/miss with success rate', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-02T00:00:00Z', 'tag_vocab_hit', { tagId: 1, vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:01Z', 'tag_vocab_hit', { tagId: 2, vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:02Z', 'tag_vocab_hit', { tagId: 3, vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:03Z', 'tag_vocab_hit', { tagId: 4, vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:04Z', 'tag_vocab_hit', { tagId: 5, vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:05Z', 'tag_vocab_miss', { vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:06Z', 'tag_vocab_miss', { vocabSize: 10 }),
|
||||
e('2026-05-02T00:00:07Z', 'tag_vocab_miss', { vocabSize: 10 })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
|
||||
expect(r.md).toContain('태그 vocab: hit/miss = 5/3');
|
||||
expect(r.md).toContain('적중률 62.5%');
|
||||
});
|
||||
|
||||
it('태그 vocab summary shows 데이터 없음 when no events', () => {
|
||||
const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
|
||||
expect(r.md).toContain('태그 vocab');
|
||||
expect(r.md).toContain('데이터 없음');
|
||||
});
|
||||
|
||||
it('aggregates recall events with open rate + average ageDays', () => {
|
||||
const events: TelemetryEvent[] = [
|
||||
e('2026-05-02T00:00:00Z', 'recall_shown', { noteId: 'n1', ageDays: 10 }),
|
||||
e('2026-05-02T00:00:01Z', 'recall_shown', { noteId: 'n2', ageDays: 20 }),
|
||||
e('2026-05-02T00:00:02Z', 'recall_shown', { noteId: 'n3', ageDays: 30 }),
|
||||
e('2026-05-02T00:00:03Z', 'recall_shown', { noteId: 'n4', ageDays: 40 }),
|
||||
e('2026-05-02T00:00:04Z', 'recall_opened', { noteId: 'n1' }),
|
||||
e('2026-05-02T00:00:05Z', 'recall_opened', { noteId: 'n2' }),
|
||||
e('2026-05-02T00:00:06Z', 'recall_dismissed', { noteId: 'n3' }),
|
||||
e('2026-05-02T00:00:07Z', 'recall_snoozed', { noteId: 'n4' })
|
||||
];
|
||||
const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
|
||||
expect(r.md).toContain('회상 추천: shown 4 / opened 2 / dismissed 1 / snoozed 1');
|
||||
expect(r.md).toContain('열림율 50.0%');
|
||||
expect(r.md).toContain('회상 평균 ageDays: 25'); // (10+20+30+40)/4
|
||||
});
|
||||
|
||||
it('회상 summary shows 데이터 없음 when no recall events', () => {
|
||||
const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
|
||||
expect(r.md).toContain('회상 추천');
|
||||
expect(r.md).toContain('데이터 없음');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user