diff --git a/docs/superpowers/plans/2026-05-01-v023-telemetry.md b/docs/superpowers/plans/2026-05-01-v023-telemetry.md new file mode 100644 index 0000000..4fb0991 --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-v023-telemetry.md @@ -0,0 +1,1627 @@ +# #7 Telemetry skeleton 구현 plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** v0.2.3 의 첫 항목 — 사용자 동작/결과의 append-only 로컬 로그 (`/telemetry/events-YYYY-MM-DD.jsonl`) 와 trayexport (`events.jsonl` + `stats.md`) 인프라를 박는다. 다른 v0.2.3 항목 (#4~#6) 이 emit hook 만 추가하면 되도록 skeleton + 3 기본 이벤트 (`capture` / `ai_succeeded` / `ai_failed`) 까지 wiring. + +**Architecture:** zod discriminatedUnion + `.strict()` payload schema 가 privacy invariant (raw_text / title / summary / intent / tag name 미포함) 를 강제. JSONL append-only, KST 일자 rotation, 14일 후 rolling 삭제. Stats 는 별 파일 (`telemetryStats.ts`) 의 순수 함수. Tray 메뉴 → folder dialog → 2 파일 (`events.jsonl` concat + `stats.md`) 출력 (zip 미사용 — 신규 dep 0 정책). + +**Tech Stack:** TypeScript / electron-vite / better-sqlite3 (간접) / zod 4.3.6 / vitest 4 / Node `node:fs/promises` `node:path`. 신규 dep 없음. + +**선행 spec:** `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` §1·§3 #7·§4.1·§6.2 + +--- + +## File Structure + +| 경로 | 책임 | +|------|------| +| `src/main/services/telemetryEvents.ts` (**new**) | zod 이벤트 schema (`capture`/`ai_succeeded`/`ai_failed` discriminated union, `.strict()` payload). `TelemetryEvent` / `TelemetryKind` type export. `validateEvent()` parser. | +| `src/main/services/telemetryStats.ts` (**new**) | 순수 함수 `aggregateStats(events: TelemetryEvent[]): { md: string; eventCount: number }`. | +| `src/main/services/TelemetryService.ts` (**new**) | `emit` (append JSONL) / `cleanupOldFiles` / `readAllRecent` / `exportTo(folder)`. profileDir 의 `telemetry/` 디렉터리 자동 생성. KST 일자 rotation. | +| `src/main/index.ts` (**modify**) | TelemetryService 인스턴스 생성 + 시작 시 cleanupOldFiles + capture/ai 이벤트 hook + tray 콜백 추가. | +| `src/main/services/CaptureService.ts` (**modify**) | `submit()` 성공 시 `capture` emit. dep 으로 telemetry 주입. | +| `src/main/ai/AiWorker.ts` (**modify**) | success 경로 `ai_succeeded` emit (`durationMs`/`attempts`), 마지막 실패 경로 `ai_failed` emit (reason 분류). dep 으로 telemetry 주입 (옵션). | +| `src/main/tray.ts` (**modify**) | 7번째 콜백 `runExportTelemetry` 추가, 메뉴 항목 "사용 로그 내보내기..." 추가. | +| `tests/unit/telemetryEvents.test.ts` (**new**) | 3 이벤트 valid path + privacy invariant (rawText / title / summary / userIntent / tag name 포함 시 zod 거부). | +| `tests/unit/telemetryStats.test.ts` (**new**) | aggregateStats: 일자별 카운트 표 + AI 성공률 + 평균 durationMs. | +| `tests/unit/TelemetryService.test.ts` (**new**) | emit (KST 일자 파일명), cleanupOldFiles (14일 경계), readAllRecent (concat), exportTo (folder 출력 2 파일). | +| `tests/unit/CaptureService.test.ts` (**modify**) | 기존 케이스 손대지 않고 telemetry hook fire 검증 추가. | +| `tests/unit/AiWorker.test.ts` (**modify**) | success/failure 시 telemetry emit 검증 추가. | + +--- + +## Task 1: 이벤트 schema + privacy invariant + +**Files:** +- Create: `src/main/services/telemetryEvents.ts` +- Test: `tests/unit/telemetryEvents.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +```typescript +// tests/unit/telemetryEvents.test.ts +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(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: FAIL — 모듈 미존재 + +- [ ] **Step 3: 구현** + +```typescript +// src/main/services/telemetryEvents.ts +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(); + +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() +]); + +export type TelemetryEvent = z.infer; +export type TelemetryKind = TelemetryEvent['kind']; + +export function validateEvent(raw: unknown): TelemetryEvent { + return TelemetryEventSchema.parse(raw); +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `npm test -- tests/unit/telemetryEvents.test.ts` +Expected: PASS — 9 케이스 모두 그린 + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/telemetryEvents.ts tests/unit/telemetryEvents.test.ts +git commit -m "feat(telemetry): event schema + privacy invariant (#7 v0.2.3)" +``` + +--- + +## Task 2: TelemetryService.emit + KST 일자 rotation + +**Files:** +- Create: `src/main/services/TelemetryService.ts` +- Test: `tests/unit/TelemetryService.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +```typescript +// tests/unit/TelemetryService.test.ts +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 () => { + // Pass a non-writable path; emit should swallow the error. + const svc = new TelemetryService( + '/proc/0/no-such-thing-readonly', // platform-agnostic invalid path + () => new Date('2026-05-01T12:00:00Z'), + 14, + { silent: true } // explicit opt-in for silent mode + ); + // Should resolve, not throw + await expect(svc.emit({ + kind: 'capture', + payload: { noteId: 'n1', rawTextLength: 1, hasMedia: false } + })).resolves.toBeUndefined(); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: FAIL — `TelemetryService` 모듈 미존재 + +- [ ] **Step 3: 구현** + +```typescript +// src/main/services/TelemetryService.ts +import { mkdir, appendFile, readFile, readdir, unlink } from 'node:fs/promises'; +import { join } from 'node:path'; +import { validateEvent, type TelemetryEvent, type TelemetryKind } from './telemetryEvents.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 } }; + +export class TelemetryService { + constructor( + private dir: string, + private now: () => Date = () => new Date(), + private retentionDays: number = 14, + private opts: TelemetryServiceOptions = {} + ) {} + + async emit(input: EmitInput): Promise { + const ts = this.now().toISOString(); + const event = validateEvent({ ts, kind: input.kind, payload: input.payload }); + const filePath = join(this.dir, `events-${todayKstIso(this.now())}.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; + } + } +} +``` + +Note: zod parse 자체는 silent 미적용 — 입력이 잘못된 건 코드 결함이라 나타나야 함. fs 실패만 silent 모드 대상. + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: PASS — 6 케이스 그린. **단**, "rejects malformed event" 케이스에서 zod throw 가 silent 와 충돌하면 안됨 — 위 구현은 zod throw 는 그대로 던지고 fs 만 silent 처리. + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts +git commit -m "feat(telemetry): TelemetryService.emit with KST rotation (#7 v0.2.3)" +``` + +--- + +## Task 3: cleanupOldFiles (14일 retention) + +**Files:** +- Modify: `src/main/services/TelemetryService.ts` +- Modify: `tests/unit/TelemetryService.test.ts` + +- [ ] **Step 1: 실패 테스트 추가** + +```typescript +// tests/unit/TelemetryService.test.ts — 기존 import 아래에 추가 +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); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL 확인** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: FAIL — `cleanupOldFiles` 미정의 + +- [ ] **Step 3: 구현** + +`src/main/services/TelemetryService.ts` 의 `TelemetryService` 클래스에 메서드 추가: + +```typescript +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 }; +} +``` + +상단 import 에 `readdir`, `unlink` 가 이미 있음 (Task 2 에서 import 했으니 확인). + +- [ ] **Step 4: 테스트 실행 — PASS 확인** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: PASS — 4 신규 케이스 + 기존 6 그린 + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts +git commit -m "feat(telemetry): cleanupOldFiles with 14-day KST retention (#7 v0.2.3)" +``` + +--- + +## Task 4: readAllRecent — 14일 범위 events concat + +**Files:** +- Modify: `src/main/services/TelemetryService.ts` +- Modify: `tests/unit/TelemetryService.test.ts` + +- [ ] **Step 1: 실패 테스트 추가** + +```typescript +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); + expect(events.map((e) => 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); + expect(events[0]!.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([]); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: FAIL — `readAllRecent` 미정의 + +- [ ] **Step 3: 구현** + +`TelemetryService` 에 추가: + +```typescript +async readAllRecent(): Promise { + 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)); + const fileNames = entries + .filter((n) => /^events-\d{4}-\d{2}-\d{2}\.jsonl$/.test(n)) + .filter((n) => n.slice(7, 17) >= 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; +} +``` + +import 에 `readFile` 있는지 확인 — Task 2 의 `node:fs/promises` import 에 추가: + +```typescript +import { mkdir, appendFile, readFile, readdir, unlink } from 'node:fs/promises'; +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: PASS + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts +git commit -m "feat(telemetry): readAllRecent with malformed-line tolerance (#7 v0.2.3)" +``` + +--- + +## Task 5: telemetryStats — aggregateStats 순수 함수 + +**Files:** +- Create: `src/main/services/telemetryStats.ts` +- Test: `tests/unit/telemetryStats.test.ts` + +- [ ] **Step 1: 실패 테스트 작성** + +```typescript +// tests/unit/telemetryStats.test.ts +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'); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/telemetryStats.test.ts` +Expected: FAIL — 모듈 미존재 + +- [ ] **Step 3: 구현** + +```typescript +// src/main/services/telemetryStats.ts +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; +} + +export interface StatsResult { + md: string; + eventCount: number; +} + +export function aggregateStats(events: TelemetryEvent[], generatedAt: Date): StatsResult { + const eventCount = events.length; + const byDay = new Map(); + let aiSucceeded = 0; + let aiFailed = 0; + let durationSum = 0; + let durationN = 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 }; + 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; + } + } + 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 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 |'); + lines.push('|------|---------|--------------|-----------|'); + for (const row of days) { + lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} |`); + } + lines.push(''); + lines.push('## 핵심 ratio'); + lines.push(''); + lines.push(`- AI 성공률: ${successRate}`); + lines.push(`- 평균 ai_succeeded durationMs: ${avgDuration}`); + lines.push(''); + return { md: lines.join('\n'), eventCount }; +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm test -- tests/unit/telemetryStats.test.ts` +Expected: PASS + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/telemetryStats.ts tests/unit/telemetryStats.test.ts +git commit -m "feat(telemetry): telemetryStats.aggregateStats (#7 v0.2.3)" +``` + +--- + +## Task 6: TelemetryService.exportTo(folder) + +**Files:** +- Modify: `src/main/services/TelemetryService.ts` +- Modify: `tests/unit/TelemetryService.test.ts` + +- [ ] **Step 1: 실패 테스트 추가** + +```typescript +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'); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: FAIL — `exportTo` 미정의 + +- [ ] **Step 3: 구현** + +`TelemetryService.ts` 상단 import 에 `writeFile` 추가: + +```typescript +import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; +``` + +`telemetryStats` import 도 추가: + +```typescript +import { aggregateStats } from './telemetryStats.js'; +``` + +`TelemetryService` 에 메서드 추가: + +```typescript +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 }; +} +``` + +테스트의 빈 케이스에 맞게 events 0건일 때 trailing newline 없도록 — 위 코드 `+ (events.length > 0 ? '\n' : '')` 가 처리. + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm test -- tests/unit/TelemetryService.test.ts` +Expected: PASS + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/TelemetryService.ts tests/unit/TelemetryService.test.ts +git commit -m "feat(telemetry): exportTo writes events.jsonl + stats.md (#7 v0.2.3)" +``` + +--- + +## Task 7: CaptureService 의 `capture` emit hook + +**Files:** +- Modify: `src/main/services/CaptureService.ts` +- Modify: `tests/unit/CaptureService.test.ts` + +- [ ] **Step 1: 실패 테스트 추가** + +`tests/unit/CaptureService.test.ts` 의 기존 case 들을 손대지 않고 신규 describe 블록 추가: + +```typescript +// tests/unit/CaptureService.test.ts — 파일 끝에 추가 +describe('CaptureService telemetry emit', () => { + it('emits capture event with noteId/rawTextLength/hasMedia', async () => { + // 기존 fixture 유틸을 재사용 (있으면). 없으면 inline 으로 minimal stubs: + const repo = { + create: ({ rawText }: { rawText: string }) => ({ id: 'n-test' }), + insertMedia: () => {} + } as unknown as import('@main/repository/NoteRepository.js').NoteRepository; + const store = { + saveImage: async () => ({ relPath: 'x', mime: 'image/png', bytes: 1 }), + deleteNoteDirectory: async () => {} + } as unknown as import('@main/services/MediaStore.js').MediaStore; + const events: Array<{ kind: string; payload: any }> = []; + const telemetry = { + emit: async (ev: { kind: string; payload: any }) => { events.push(ev); } + }; + const { CaptureService } = await import('@main/services/CaptureService.js'); + const svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry + }); + await svc.submit({ text: 'hi there', images: [] }); + expect(events).toHaveLength(1); + expect(events[0]!.kind).toBe('capture'); + expect(events[0]!.payload).toMatchObject({ + noteId: 'n-test', + rawTextLength: 'hi there'.length, + hasMedia: false + }); + }); + + it('emits hasMedia=true when images present', async () => { + const repo = { + create: () => ({ id: 'n-img' }), + insertMedia: () => {} + } as unknown as import('@main/repository/NoteRepository.js').NoteRepository; + const store = { + saveImage: async () => ({ relPath: 'x', mime: 'image/png', bytes: 1 }), + deleteNoteDirectory: async () => {} + } as unknown as import('@main/services/MediaStore.js').MediaStore; + const events: Array<{ kind: string; payload: any }> = []; + const { CaptureService } = await import('@main/services/CaptureService.js'); + const svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {}, + telemetry: { emit: async (ev) => { events.push(ev); } } + }); + await svc.submit({ text: 'with image', images: [new ArrayBuffer(8)] }); + expect(events[0]!.payload.hasMedia).toBe(true); + }); + + it('does NOT emit when telemetry dep absent (backward compat)', async () => { + const repo = { + create: () => ({ id: 'n-back' }), + insertMedia: () => {} + } as unknown as import('@main/repository/NoteRepository.js').NoteRepository; + const store = { saveImage: async () => ({ relPath: 'x', mime: 'image/png', bytes: 1 }), deleteNoteDirectory: async () => {} } as unknown as import('@main/services/MediaStore.js').MediaStore; + const { CaptureService } = await import('@main/services/CaptureService.js'); + const svc = new CaptureService(repo, store, { + enqueue: async () => {}, + celebrate: () => {} + }); + await expect(svc.submit({ text: 'no telem', images: [] })).resolves.toMatchObject({ noteId: 'n-back' }); + }); +}); +``` + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/CaptureService.test.ts` +Expected: FAIL — `telemetry` dep 미인식 (TypeScript 또는 runtime) + +- [ ] **Step 3: 구현** + +`src/main/services/CaptureService.ts` 변경: + +```typescript +import type { NoteRepository } from '../repository/NoteRepository.js'; +import type { MediaStore } from './MediaStore.js'; + +export interface TelemetryEmitter { + emit(input: + | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } + ): Promise; +} + +export interface CaptureDeps { + enqueue: (noteId: string) => Promise; + celebrate: (noteId: string) => void; + telemetry?: TelemetryEmitter; +} + +export interface SubmitInput { + text: string; + images: ArrayBuffer[]; +} + +export class CaptureService { + constructor( + private repo: NoteRepository, + private store: MediaStore, + private deps: CaptureDeps + ) {} + + async submit(input: SubmitInput): Promise<{ noteId: string }> { + const trimmed = input.text.trim(); + if (trimmed.length === 0 && input.images.length === 0) { + throw new Error('empty submission'); + } + const { id } = this.repo.create({ rawText: input.text }); + if (input.images.length > 0) { + const rows = []; + for (const img of input.images) { + const buf = Buffer.from(img); + const saved = await this.store.saveImage(id, buf, 'image/png'); + rows.push({ + noteId: id, + kind: 'image' as const, + relPath: saved.relPath, + mime: saved.mime, + bytes: saved.bytes + }); + } + 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 + } + }); + } + await this.deps.enqueue(id); + this.deps.celebrate(id); + return { noteId: id }; + } + + async deleteNote(noteId: string): Promise { + this.repo.delete(noteId); + await this.store.deleteNoteDirectory(noteId); + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm test -- tests/unit/CaptureService.test.ts` +Expected: PASS — 신규 3 케이스 + 기존 케이스 모두 그린 + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts +git commit -m "feat(telemetry): CaptureService emits capture event (#7 v0.2.3)" +``` + +--- + +## Task 8: AiWorker 의 `ai_succeeded` / `ai_failed` emit hook + reason 분류 + +**Files:** +- Modify: `src/main/ai/AiWorker.ts` +- Modify: `tests/unit/AiWorker.test.ts` + +- [ ] **Step 1: 실패 테스트 추가** + +`tests/unit/AiWorker.test.ts` 끝에 추가 (기존 fixture 유틸 사용 패턴 따라): + +```typescript +describe('AiWorker telemetry emit', () => { + it('emits ai_succeeded with durationMs/attempts on success', async () => { + // 기존 fixture: in-memory db + repo + working provider + const { db, repo } = makeRepo(); // 기존 helper + repo.create({ rawText: '수요일 회의 메모' }); + const ids = repo.findRecent(10).map((n) => n.id); + const noteId = ids[0]!; + const provider = { + name: 'fake/v1', + generate: async () => ({ title: '회의 메모', summary: '한 줄\n\n', tags: [], dueDate: null }) + }; + const events: Array<{ kind: string; payload: any }> = []; + const telemetry = { emit: async (ev: any) => { events.push(ev); } }; + const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, { telemetry }); + await worker.enqueue(noteId); + await worker.drain(); + expect(events.find((e) => e.kind === 'ai_succeeded')).toBeDefined(); + const ev = events.find((e) => e.kind === 'ai_succeeded')!; + expect(ev.payload.noteId).toBe(noteId); + expect(ev.payload.attempts).toBe(0); + expect(ev.payload.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('emits ai_failed with reason=unreachable on network error', async () => { + const { db, repo } = makeRepo(); + repo.create({ rawText: '메모' }); + const noteId = repo.findRecent(10)[0]!.id; + const provider = { + name: 'fake/v1', + generate: async () => { throw new Error('fetch failed: ECONNREFUSED 11434'); } + }; + const events: Array<{ kind: string; payload: any }> = []; + const telemetry = { emit: async (ev: any) => { events.push(ev); } }; + const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, { + telemetry, + backoffsMs: [0, 0, 0] // 빠른 실패 + }); + await worker.enqueue(noteId); + await worker.drain(); + const failed = events.find((e) => e.kind === 'ai_failed'); + expect(failed).toBeDefined(); + expect(failed!.payload.reason).toBe('unreachable'); + }); + + it('emits ai_failed with reason=schema on zod failure', async () => { + const { db, repo } = makeRepo(); + repo.create({ rawText: '메모' }); + const noteId = repo.findRecent(10)[0]!.id; + const provider = { + name: 'fake/v1', + generate: async () => { throw new (await import('zod')).ZodError([]); } + }; + const events: Array<{ kind: string; payload: any }> = []; + const telemetry = { emit: async (ev: any) => { events.push(ev); } }; + const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, { + telemetry, + backoffsMs: [0, 0, 0] + }); + await worker.enqueue(noteId); + await worker.drain(); + const failed = events.find((e) => e.kind === 'ai_failed'); + expect(failed!.payload.reason).toBe('schema'); + }); + + it('emits ai_failed with reason=other on unrecognized error', async () => { + const { db, repo } = makeRepo(); + repo.create({ rawText: '메모' }); + const noteId = repo.findRecent(10)[0]!.id; + const provider = { + name: 'fake/v1', + generate: async () => { throw new Error('mystery'); } + }; + const events: Array<{ kind: string; payload: any }> = []; + const telemetry = { emit: async (ev: any) => { events.push(ev); } }; + const worker = new (await import('@main/ai/AiWorker.js')).AiWorker(repo, provider as any, { + telemetry, + backoffsMs: [0, 0, 0] + }); + await worker.enqueue(noteId); + await worker.drain(); + const failed = events.find((e) => e.kind === 'ai_failed'); + expect(failed!.payload.reason).toBe('other'); + }); +}); +``` + +`makeRepo()` helper 가 기존 `tests/unit/AiWorker.test.ts` 에 있는지 확인. 없으면 기존 setup 패턴 그대로 쓰는 inline 으로 대체. (실제 테스트 작성 시 첫 단계에서 파일 head 확인 후 헬퍼 재사용 결정.) + +- [ ] **Step 2: 테스트 실행 — FAIL** + +Run: `npm test -- tests/unit/AiWorker.test.ts` +Expected: FAIL — `telemetry` opt 미인식 + +- [ ] **Step 3: 구현** + +`src/main/ai/AiWorker.ts` 변경: + +```typescript +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; + +function todayKstAsDate(now: Date): Date { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); +} + +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 } } + ): Promise; +} + +export interface AiWorkerOptions { + backoffsMs?: number[]; + onUpdate?: (note: Note) => void; + logger?: { + info: (msg: string, meta?: Record) => void; + warn: (msg: string, meta?: Record) => void; + error: (msg: string, meta?: Record) => void; + }; + now?: () => Date; + telemetry?: AiTelemetryEmitter; +} + +interface Job { noteId: string; attempts: number; } + +export class AiWorker { + private queue: Job[] = []; + private running = false; + private drainResolvers: Array<() => void> = []; + private backoffsMs: number[]; + private onUpdate?: (note: Note) => void; + private logger: NonNullable; + private now: () => Date; + private telemetry?: AiTelemetryEmitter; + + constructor( + private repo: NoteRepository, + private provider: InferenceProvider, + opts: AiWorkerOptions = {} + ) { + this.backoffsMs = opts.backoffsMs ?? [0, 30_000, 120_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 { + this.queue.push({ noteId, attempts: 0 }); + this.kick(); + } + + async loadFromDb(): Promise { + for (const j of this.repo.getAllPendingJobs()) { + this.queue.push({ noteId: j.noteId, attempts: j.attempts }); + } + this.kick(); + } + + async drain(): Promise { + if (!this.running && this.queue.length === 0) return; + await new Promise((resolve) => { + this.drainResolvers.push(resolve); + this.kick(); + }); + } + + private kick(): void { + if (this.running) return; + if (this.queue.length === 0) { this.resolveDrainers(); return; } + this.running = true; + void this.loop(); + } + + private async loop(): Promise { + try { + while (this.queue.length > 0) { + const job = this.queue.shift()!; + await this.processJob(job); + } + } finally { + this.running = false; + this.resolveDrainers(); + } + } + + private resolveDrainers(): void { + const r = this.drainResolvers.splice(0); + for (const fn of r) fn(); + } + + private async processJob(job: Job): Promise { + const max = this.backoffsMs.length; + for (let attempt = job.attempts; attempt < max; attempt++) { + const startMs = Date.now(); + try { + const note = this.repo.findById(job.noteId); + if (!note || note.aiStatus !== 'pending') return; + const nowDate = this.now(); + const todayDate = todayKstAsDate(nowDate); + const todayIso = todayKstAsIso(nowDate); + const candidates = parseAllCandidates(note.rawText, todayDate); + const res = await this.provider.generate({ + text: note.rawText, + todayKst: todayIso, + dueDateCandidates: candidates + }); + this.repo.updateAiResult(job.noteId, { + title: res.title, + summary: res.summary, + tags: res.tags, + provider: this.provider.name, + dueDate: res.dueDate ?? null + }); + 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: Date.now() - startMs, + attempts: attempt + } + }).catch(() => {}); + } + this.emit(job.noteId); + return; + } catch (err) { + const isLast = attempt === max - 1; + const msg = (err as Error).message; + this.logger.warn('ai.retry', { noteId: job.noteId, attempt, err: msg }); + 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: classifyReason(err), + attempts: attempt + 1 + } + }).catch(() => {}); + } + this.emit(job.noteId); + return; + } + await this.sleep(this.backoffsMs[attempt + 1] ?? 0); + } + } + } + + private emit(noteId: string): void { + if (!this.onUpdate) return; + const note = this.repo.findById(noteId); + if (note) this.onUpdate(note); + } + + private sleep(ms: number): Promise { + if (ms <= 0) return Promise.resolve(); + return new Promise((r) => setTimeout(r, ms)); + } +} +``` + +- [ ] **Step 4: 테스트 실행 — PASS** + +Run: `npm test -- tests/unit/AiWorker.test.ts` +Expected: PASS — 신규 4 케이스 + 기존 케이스 모두 그린 + +- [ ] **Step 5: 커밋** + +```bash +git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts +git commit -m "feat(telemetry): AiWorker emits ai_succeeded/ai_failed with reason (#7 v0.2.3)" +``` + +--- + +## Task 9: Tray menu "사용 로그 내보내기..." + 콜백 + +**Files:** +- Modify: `src/main/tray.ts` + +- [ ] **Step 1: 변경** + +기존 `createTray` 시그니처에 7번째 콜백 추가, 메뉴 항목 1줄 추가: + +```typescript +// src/main/tray.ts +import electron from 'electron'; +import type { Tray as TrayType, MenuItemConstructorOptions } from 'electron'; +const { app, Tray, Menu, nativeImage } = electron; + +let tray: TrayType | null = null; +let _showInbox: () => void = () => {}; +let _showCapture: () => void = () => {}; +let _runBackup: () => void = () => {}; +let _runExport: () => void = () => {}; +let _runImport: () => void = () => {}; +let _runSync: () => void = () => {}; +let _runExportTelemetry: () => void = () => {}; +let _todayCount = 0; + +function buildMenu() { + const items: MenuItemConstructorOptions[] = []; + if (_todayCount > 0) { + items.push({ label: `오늘 ${_todayCount}번 잡아둠`, enabled: false }); + items.push({ type: 'separator' }); + } + items.push({ label: '보관한 메모 보기', click: _showInbox }); + items.push({ label: '한 줄 적기', click: _showCapture }); + items.push({ type: 'separator' }); + items.push({ label: '지금 백업', click: _runBackup }); + items.push({ label: '내보내기...', click: _runExport }); + items.push({ label: '백업에서 복원...', click: _runImport }); + items.push({ label: '지금 동기화', click: _runSync }); + items.push({ label: '사용 로그 내보내기...', click: _runExportTelemetry }); + if (app.isPackaged) { + const { openAtLogin } = app.getLoginItemSettings(); + items.push({ + label: '윈도우 시작 시 자동 실행', + type: 'checkbox', + checked: openAtLogin, + click: (item) => { + app.setLoginItemSettings({ + openAtLogin: item.checked, + args: ['--hidden'] + }); + } + }); + items.push({ type: 'separator' }); + } else { + items.push({ type: 'separator' }); + } + items.push({ label: '종료', click: () => { app.isQuitting = true; app.quit(); } }); + return Menu.buildFromTemplate(items); +} + +export function createTray( + showInbox: () => void, + showCapture: () => void, + runBackup: () => void, + runExport: () => void, + runImport: () => void, + runSync: () => void, + runExportTelemetry: () => void +): TrayType { + _showInbox = showInbox; + _showCapture = showCapture; + _runBackup = runBackup; + _runExport = runExport; + _runImport = runImport; + _runSync = runSync; + _runExportTelemetry = runExportTelemetry; + const icon = nativeImage.createEmpty(); + tray = new Tray(icon); + tray.setToolTip(`Inkling — 오늘 ${_todayCount}`); + tray.setContextMenu(buildMenu()); + tray.on('click', showInbox); + return tray; +} + +export function refreshTray(todayCount: number): void { + _todayCount = todayCount; + if (tray === null) return; + tray.setToolTip(`Inkling — 오늘 ${todayCount}`); + tray.setContextMenu(buildMenu()); +} +``` + +- [ ] **Step 2: typecheck** + +Run: `npm run typecheck` +Expected: FAIL — `index.ts` 의 `createTray` 호출이 6 args 로 끝나서 `runExportTelemetry` 누락 + +이 실패는 Task 10 에서 해결. 일단 typecheck 깨진 상태로 진행. + +- [ ] **Step 3: 커밋** + +```bash +git add src/main/tray.ts +git commit -m "feat(telemetry): tray menu '사용 로그 내보내기...' (#7 v0.2.3)" +``` + +`pre-commit` hook 이 typecheck 강제하면 잠시 통과 안 될 수 있음 — Task 10 직전까지는 단일 커밋으로 묶어 한 번에 머지하는 옵션도 있다. 본 plan 은 분리 commit 권장. + +--- + +## Task 10: index.ts 에 TelemetryService wire-up + tray 콜백 + +**Files:** +- Modify: `src/main/index.ts` + +- [ ] **Step 1: 변경** + +`src/main/index.ts` 의 main entry 에: + +(1) Import 추가: + +```typescript +import { TelemetryService } from './services/TelemetryService.js'; +``` + +(2) `paths` 결정 후 TelemetryService 생성 + cleanupOldFiles 1회: + +```typescript +// 기존 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 })); +``` + +(3) AiWorker 생성 시 `telemetry` 주입: + +```typescript +const worker = new AiWorker(repo, provider, { + onUpdate: (note) => { + pushNoteUpdated(getInboxWindow, note); + refreshTray(repo.countToday()); + }, + logger, + telemetry // 추가 +}); +``` + +(4) CaptureService 생성 시 `telemetry` 주입: + +```typescript +const capture = new CaptureService(repo, store, { + enqueue: (id) => worker.enqueue(id), + celebrate: (id) => notify.celebrate(id), + telemetry // 추가 +}); +``` + +(5) `createTray(...)` 호출에 7번째 콜백 추가: + +```typescript +createTray( + () => createInboxWindow(), + () => showQuickCapture(), + /* runBackup */ async () => { /* 기존 콜백 그대로 */ }, + /* runExport */ async () => { /* 기존 콜백 그대로 */ }, + /* runImport */ async () => { /* 기존 콜백 그대로 */ }, + /* runSync */ async () => { /* 기존 콜백 그대로 */ }, + /* 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(); + } + } +); +``` + +콜백 5종 (Backup/Export/Import/Sync) 의 본문은 기존 코드 (`src/main/index.ts:149-287`) 와 동일 — 한 줄도 변경 없음. 위 예시는 7번째만 신규. + +- [ ] **Step 2: typecheck — 0 errors** + +Run: `npm run typecheck` +Expected: PASS — 0 errors + +- [ ] **Step 3: 단위 테스트 — all pass** + +Run: `npm test` +Expected: PASS — 205 (기존) + 신규 테스트 (~25개) 모두 그린. 정확한 수는 Task 1~8 의 신규 케이스 합계 + 1. + +- [ ] **Step 4: e2e smoke** + +Run: `npm run test:e2e` +Expected: PASS — 1/1. e2e 가 quickcapture/inbox 흐름만 보므로 telemetry 의 silent emit 는 깨지 않아야 함. + +- [ ] **Step 5: 수동 sanity check (개발 환경)** + +```bash +npm run dev +``` + +- 노트 1건 캡처 +- `/Inkling/profiles/default/telemetry/events-YYYY-MM-DD.jsonl` 존재 + `capture` 라인 1줄 + `ai_succeeded` 또는 `ai_failed` 1줄 (ollama 상태 따라) 확인 +- 트레이 → "사용 로그 내보내기..." → 빈 폴더 → 알림 + `events.jsonl` (몇 줄) + `stats.md` 확인 +- `events.jsonl` 의 어떤 라인에도 `rawText`, `title`, `summary`, `userIntent`, `tagNames` substring 없음 확인 (`grep -E 'rawText|"title"|"summary"|userIntent|tagNames' events.jsonl` → 0 hits) + +- [ ] **Step 6: 커밋** + +```bash +git add src/main/index.ts +git commit -m "feat(telemetry): wire TelemetryService + tray export (#7 v0.2.3)" +``` + +--- + +## Task 11: 종료 게이트 + roadmap 항목 #7 closure + +**Files:** +- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (작은 closure 표시) + +- [ ] **Step 1: 게이트 종합 실행** + +```bash +npm run typecheck && npm test && npm run test:e2e +``` + +Expected: 모두 PASS. + +- [ ] **Step 2: 신규 테스트 카운트 확인** + +`npm test` 출력의 총 테스트 수가 v0.2.2 기준선 205 보다 ≥ 25 증가 했는지 확인 (Task 별 케이스: T1=9, T2=6, T3=4, T4=4, T5=5, T6=2, T7=3, T8=4 = 합 37 — 단, T8 helper 재사용 등으로 일부 변동 가능). + +신규 카운트가 예상보다 너무 적으면 (예: 30 미만) 어떤 describe 가 skip 되었는지 점검. + +- [ ] **Step 3: roadmap 의 항목 #7 closure 마커 추가** + +`docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` 의 §3 #7 Telemetry 헤더 옆에 ✓ 추가 (다른 항목 도착 전까지는 #7 만 closed): + +```markdown +### #7 Telemetry skeleton (1번) ✓ 완료 +``` + +- [ ] **Step 4: closure 커밋** + +```bash +git add docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md +git commit -m "docs(spec): mark #7 telemetry as completed (v0.2.3 1/7)" +``` + +--- + +## Self-Review (작성 후 즉시) + +**Spec coverage** (roadmap §3 #7 In 항목 vs plan task 매핑): + +| spec In 항목 | plan task | 상태 | +|-------------|-----------|------| +| TelemetryService.emit + JSONL append | T2 | ✓ | +| 일자별 KST rotation | T2 | ✓ | +| 14일 후 rolling 삭제 | T3 | ✓ | +| write 실패 시 silent log only | T2 (silent opt + 별 케이스) | ✓ | +| zod schema + privacy invariant | T1 | ✓ | +| `capture` 기본 hook | T7 | ✓ | +| `ai_succeeded` 기본 hook | T8 | ✓ | +| `ai_failed` 기본 hook + reason 분류 | T8 | ✓ | +| 트레이 메뉴 "사용 로그 내보내기..." | T9 + T10 | ✓ | +| folder dialog → events.jsonl + stats.md | T10 | ✓ | +| stats.md 집계 (항목별 ratio) | T5 | ✓ | +| IPC `tray:exportTelemetry` | T10 (트레이 콜백이 직접 호출 → 별 IPC 핸들러 불필요) | ✓ — IPC 채널 없이 main process 안에서 처리. Roadmap §3 의 "IPC: tray:exportTelemetry" 표현은 인접 명명일 뿐, 실제 트레이는 main 내부 콜백이라 IPC 채널 없이도 동작. spec 의 IPC 라인은 인접 이름 fictitious — closure 시 spec 수정 필요할 수 있음. T11 에서 closure 마커 추가하며 검토. | + +**Placeholder scan**: "TODO" / "TBD" / "implement later" — 0 hit. 단, T8 의 `makeRepo()` helper 가 기존 `tests/unit/AiWorker.test.ts` 에 존재하는지 미확인. 실제 작업 시 테스트 파일 head 1회 읽고 헬퍼 패턴 일치시킬 것. + +**Type consistency**: `TelemetryEvent` / `EmitInput` / `TelemetryEmitter` (CaptureService 의) / `AiTelemetryEmitter` (AiWorker 의) — 두 emitter interface 가 살짝 다름 (서비스마다 자기 kind 만). 이는 의도적 (consumer 별 narrow type). `TelemetryService.emit` 의 `EmitInput` 이 둘의 union 이라 호환. 충돌 없음. + +**Spec 'IPC tray:exportTelemetry' note**: 위에서 언급한 대로, 트레이는 main 내부 콜백이므로 별 IPC 채널 불필요. spec §3 #7 의 "IPC: tray:exportTelemetry" 한 줄은 closure 시 "트레이 콜백 (main 내부)" 으로 정정 권장. 일단 plan 은 IPC 없이 진행.