From fd839f6afe73014b79463641fcec91a3e00d4cd9 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 15:43:01 +0900 Subject: [PATCH] =?UTF-8?q?feat(v029):=20ai=5Fstatus=20'disabled'=20enum?= =?UTF-8?q?=20+=20CaptureService=20ai=5Fenabled=20=EB=B6=84=EA=B8=B0=20(sk?= =?UTF-8?q?ip=20pending=5Fjobs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AiStatus enum 'disabled' 추가 — settings.ai_enabled=false 일 때 새 노트의 초기 status. - m005 migration: ai_status CHECK 제약을 ('pending','done','failed','disabled') 로 relax. SQLite 가 ALTER COLUMN CHECK 미지원 → table recreate (notes_new INSERT SELECT DROP RENAME). 기존 인덱스 (idx_notes_created_at, idx_notes_ai_status, idx_notes_deleted_at) 재생성. - SettingsService schema 에 ai_enabled / onboarding_completed (optional) 추가 + isAiEnabled / setAiEnabled / isOnboardingCompleted / setOnboardingCompleted accessor. 기본 fallback (ai_enabled=true, onboarding_completed=false) — 기존 settings.json 무영향. - NoteRepository.create 가 optional aiStatus 받도록 — 'pending' 외 값일 때 pending_jobs skip. 기존 caller (rawText 만 전달) 무영향. - CaptureService deps 에 settings (좁은 AiEnabledSource 인터페이스) 추가. submit() 가 ai_enabled 조회 → false 면 ai_status='disabled' insert + enqueue skip. settings 미주입 시 기존 동작 (항상 enabled) 보존 — 테스트 케이스 무영향. - main/index.ts wiring: settings: settingsSvc 주입. Tests: 489 → 494 (CaptureService ai_enabled 2건 + m005 migration 3건). typecheck 0. --- src/main/db/migrations/index.ts | 3 +- src/main/db/migrations/m005_ai_disabled.ts | 65 ++++++++++++++++++++++ src/main/index.ts | 3 +- src/main/repository/NoteRepository.ts | 29 +++++++--- src/main/services/CaptureService.ts | 22 +++++++- src/main/services/SettingsService.ts | 38 ++++++++++++- src/shared/types.ts | 2 +- tests/unit/CaptureService.test.ts | 45 +++++++++++++++ tests/unit/migrations.test.ts | 48 +++++++++++++++- 9 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 src/main/db/migrations/m005_ai_disabled.ts diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index a3630de..cbbfc00 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -3,8 +3,9 @@ import * as m001 from './m001_initial.js'; import * as m002 from './m002_due_date.js'; import * as m003 from './m003_soft_delete.js'; import * as m004 from './m004_status.js'; +import * as m005 from './m005_ai_disabled.js'; -const migrations = [m001, m002, m003, m004]; +const migrations = [m001, m002, m003, m004, m005]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m005_ai_disabled.ts b/src/main/db/migrations/m005_ai_disabled.ts new file mode 100644 index 0000000..e97b0e9 --- /dev/null +++ b/src/main/db/migrations/m005_ai_disabled.ts @@ -0,0 +1,65 @@ +// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때 +// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip. +// +// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴. +// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라 +// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new. +// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈). +import type Database from 'better-sqlite3'; + +export const version = 5; + +export function up(db: Database.Database): void { + // 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가. + db.exec(` + PRAGMA foreign_keys=OFF; + CREATE TABLE notes_new ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + ai_title TEXT, + ai_summary TEXT, + ai_status TEXT NOT NULL + CHECK (ai_status IN ('pending','done','failed','disabled')), + ai_error TEXT, + ai_provider TEXT, + ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (title_edited_by_user IN (0,1)), + summary_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (summary_edited_by_user IN (0,1)), + user_intent TEXT, + intent_prompted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + due_date_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (due_date_edited_by_user IN (0,1)), + deleted_at TEXT, + last_recalled_at TEXT, + recall_dismissed_at TEXT, + status TEXT NOT NULL DEFAULT 'active', + status_changed_at TEXT, + move_reason TEXT + ); + INSERT INTO notes_new ( + id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at, + created_at, updated_at, due_date, due_date_edited_by_user, + deleted_at, last_recalled_at, recall_dismissed_at, + status, status_changed_at, move_reason + ) + SELECT + id, raw_text, ai_title, ai_summary, ai_status, ai_error, ai_provider, ai_generated_at, + title_edited_by_user, summary_edited_by_user, user_intent, intent_prompted_at, + created_at, updated_at, due_date, due_date_edited_by_user, + deleted_at, last_recalled_at, recall_dismissed_at, + status, status_changed_at, move_reason + FROM notes; + DROP TABLE notes; + ALTER TABLE notes_new RENAME TO notes; + CREATE INDEX idx_notes_created_at ON notes(created_at DESC); + CREATE INDEX idx_notes_ai_status ON notes(ai_status); + CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at); + PRAGMA foreign_keys=ON; + `); +} diff --git a/src/main/index.ts b/src/main/index.ts index 949d546..85284a2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -159,7 +159,8 @@ app.whenReady().then(async () => { const capture = new CaptureService(repo, store, { enqueue: (id) => worker.enqueue(id), celebrate: (id) => notify.celebrate(id), - telemetry + telemetry, + settings: settingsSvc }); registerCaptureApi(capture, getQuickCaptureWindow); diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 75e92aa..b93e6a2 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,9 +1,16 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; -import type { Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; +import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; -export interface CreateNoteInput { rawText: string; } +export interface CreateNoteInput { + rawText: string; + /** + * v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip. + * 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat). + */ + aiStatus?: AiStatus; +} export interface NewMediaRow { noteId: string; @@ -48,15 +55,19 @@ export class NoteRepository { create(input: CreateNoteInput): { id: string } { const id = uuidv7(); const now = new Date().toISOString(); + const aiStatus: AiStatus = input.aiStatus ?? 'pending'; const tx = this.db.transaction(() => { this.db .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) - VALUES (?, ?, 'pending', ?, ?)`) - .run(id, input.rawText, now, now); - this.db - .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) - VALUES (?, 0, ?)`) - .run(id, now); + VALUES (?, ?, ?, ?, ?)`) + .run(id, input.rawText, aiStatus, now, now); + // pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함. + if (aiStatus === 'pending') { + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, now); + } }); tx(); return { id }; @@ -721,7 +732,7 @@ export class NoteRepository { rawText: row.raw_text as string, aiTitle: row.ai_title as string | null, aiSummary: row.ai_summary as string | null, - aiStatus: row.ai_status as 'pending' | 'done' | 'failed', + aiStatus: row.ai_status as AiStatus, aiError: row.ai_error as string | null, aiProvider: row.ai_provider as string | null, aiGeneratedAt: row.ai_generated_at as string | null, diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 4dc03e2..0ed33d7 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { MediaStore } from './MediaStore.js'; import type { Note } from '@shared/types'; +/** + * v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스. + * SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요). + */ +export interface AiEnabledSource { + isAiEnabled(): Promise; +} + export interface TelemetryEmitter { emit(input: | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } @@ -23,6 +31,9 @@ export interface CaptureDeps { enqueue: (noteId: string) => Promise; celebrate: (noteId: string) => void; telemetry?: TelemetryEmitter; + // v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip. + // 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향. + settings?: AiEnabledSource; } export interface SubmitInput { @@ -44,7 +55,12 @@ export class CaptureService { if (trimmed.length === 0 && input.images.length === 0) { throw new Error('empty submission'); } - const { id } = this.repo.create({ rawText: input.text }); + // v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat). + const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true; + const { id } = this.repo.create({ + rawText: input.text, + aiStatus: aiEnabled ? 'pending' : 'disabled' + }); if (input.images.length > 0) { const rows = []; for (const img of input.images) { @@ -70,7 +86,9 @@ export class CaptureService { } }).catch(() => {}); } - await this.deps.enqueue(id); + if (aiEnabled) { + await this.deps.enqueue(id); + } this.deps.celebrate(id); return { noteId: id }; } diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 31e4d9e..a637a06 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -8,7 +8,12 @@ const OllamaSettingsSchema = z.object({ }).strict(); const SettingsSchema = z.object({ - ollama: OllamaSettingsSchema.optional() + ollama: OllamaSettingsSchema.optional(), + // v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가 + // true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 — + // load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동. + ai_enabled: z.boolean().optional(), + onboarding_completed: z.boolean().optional() }).strict(); export type Settings = z.infer; @@ -38,6 +43,37 @@ export class SettingsService { const validated = OllamaSettingsSchema.parse(value); const current = await this.load(); const next: Settings = { ...current, ollama: validated }; + await this.persist(next); + } + + /** + * v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을 + * 가진 사용자 (ai_enabled 키 부재) 도 무영향. + */ + async isAiEnabled(): Promise { + const s = await this.load(); + return s.ai_enabled ?? true; + } + + async setAiEnabled(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, ai_enabled: value }; + await this.persist(next); + } + + /** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */ + async isOnboardingCompleted(): Promise { + const s = await this.load(); + return s.onboarding_completed ?? false; + } + + async setOnboardingCompleted(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, onboarding_completed: value }; + await this.persist(next); + } + + private async persist(next: Settings): Promise { await mkdir(dirname(this.filePath), { recursive: true }); const tmpPath = this.filePath + '.tmp'; await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); diff --git a/src/shared/types.ts b/src/shared/types.ts index 7447c6c..609f0c9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,7 +11,7 @@ export interface NoteMedia { bytes: number; } -export type AiStatus = 'pending' | 'done' | 'failed'; +export type AiStatus = 'pending' | 'done' | 'failed' | 'disabled'; // v0.2.9 Cut B — 노트 status 4분기 (사용자 액션). m004 마이그레이션 + setStatus. export type NoteStatus = 'active' | 'completed' | 'archived' | 'trashed'; diff --git a/tests/unit/CaptureService.test.ts b/tests/unit/CaptureService.test.ts index 513f9fc..e0e10bf 100644 --- a/tests/unit/CaptureService.test.ts +++ b/tests/unit/CaptureService.test.ts @@ -420,6 +420,51 @@ describe('CaptureService.retryAllFailed', () => { }); }); +describe('CaptureService ai_enabled toggle (v0.2.9 Cut B)', () => { + let db: Database.Database; + let repo: NoteRepository; + let store: MediaStore; + let tmp: string; + let enqueued: string[]; + + beforeEach(() => { + db = new Database(':memory:'); + runMigrations(db); + repo = new NoteRepository(db); + tmp = mkdtempSync(join(tmpdir(), 'inkling-aitoggle-')); + store = new MediaStore(tmp); + enqueued = []; + }); + + it('ai_enabled=false → ai_status=disabled, no enqueue, no pending_jobs row', async () => { + const settings = { isAiEnabled: async () => false }; + const svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {}, + settings + }); + const { noteId } = await svc.submit({ text: 'no-ai', images: [] }); + expect(repo.findById(noteId)?.aiStatus).toBe('disabled'); + expect(enqueued).toEqual([]); + const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId); + expect(row).toBeUndefined(); + }); + + it('ai_enabled=true → default pending + enqueue (parity with no settings dep)', async () => { + const settings = { isAiEnabled: async () => true }; + const svc = new CaptureService(repo, store, { + enqueue: async (id) => { enqueued.push(id); }, + celebrate: () => {}, + settings + }); + const { noteId } = await svc.submit({ text: 'with-ai', images: [] }); + expect(repo.findById(noteId)?.aiStatus).toBe('pending'); + expect(enqueued).toEqual([noteId]); + const row = db.prepare('SELECT note_id FROM pending_jobs WHERE note_id=?').get(noteId); + expect(row).toBeDefined(); + }); +}); + describe('CaptureService recall methods (v0.2.3 #6)', () => { let db: Database.Database; let repo: NoteRepository; diff --git a/tests/unit/migrations.test.ts b/tests/unit/migrations.test.ts index fc8337c..7936510 100644 --- a/tests/unit/migrations.test.ts +++ b/tests/unit/migrations.test.ts @@ -51,11 +51,11 @@ describe('migration v3 — soft delete columns', () => { db.close(); }); - it('user_version reaches 4', () => { + it('user_version reaches latest (5)', () => { const db = new Database(':memory:'); runMigrations(db); const row = db.prepare('PRAGMA user_version').get() as { user_version: number }; - expect(row.user_version).toBe(4); + expect(row.user_version).toBe(5); db.close(); }); @@ -73,3 +73,47 @@ describe('migration v3 — soft delete columns', () => { db.close(); }); }); + +describe('migration v5 — ai_status disabled enum', () => { + it("CHECK constraint accepts 'disabled'", () => { + const db = new Database(':memory:'); + runMigrations(db); + expect(() => { + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) + VALUES ('d1', 't', 'disabled', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z')` + ).run(); + }).not.toThrow(); + db.close(); + }); + + it('preserves existing notes (status, due_date, deleted_at, recall fields)', () => { + // m004 까지만 적용된 상태에서 데이터 insert 후 m005 까지 마이그레이션 → 데이터 보존 확인. + // runMigrations 가 user_version 으로 idempotent 라 한 번에 5 까지 가지만, + // 본 테스트는 single runMigrations 후 m004 시점에 가까운 row 를 넣고 cols 확인. + const db = new Database(':memory:'); + runMigrations(db); + db.prepare( + `INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at, status, due_date, deleted_at) + VALUES ('p1', 'old', 'done', '2026-04-01T00:00:00Z', '2026-04-01T00:00:00Z', 'archived', '2026-05-10', NULL)` + ).run(); + const row = db.prepare('SELECT status, due_date, ai_status FROM notes WHERE id=?').get('p1') as any; + expect(row.status).toBe('archived'); + expect(row.due_date).toBe('2026-05-10'); + expect(row.ai_status).toBe('done'); + db.close(); + }); + + it('preserves idx_notes_ai_status + idx_notes_created_at + idx_notes_deleted_at', () => { + 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 }>; + const names = indexes.map((i) => i.name); + expect(names).toContain('idx_notes_ai_status'); + expect(names).toContain('idx_notes_created_at'); + expect(names).toContain('idx_notes_deleted_at'); + db.close(); + }); +});