feat(v029): ai_status 'disabled' enum + CaptureService ai_enabled 분기 (skip pending_jobs)

- 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.
This commit is contained in:
altair823
2026-05-09 15:43:01 +09:00
parent facbf54025
commit fd839f6afe
9 changed files with 238 additions and 17 deletions

View File

@@ -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;

View File

@@ -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;
`);
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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<boolean>;
}
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<void>;
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 };
}

View File

@@ -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<typeof SettingsSchema>;
@@ -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<boolean> {
const s = await this.load();
return s.ai_enabled ?? true;
}
async setAiEnabled(value: boolean): Promise<void> {
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<boolean> {
const s = await this.load();
return s.onboarding_completed ?? false;
}
async setOnboardingCompleted(value: boolean): Promise<void> {
const current = await this.load();
const next: Settings = { ...current, onboarding_completed: value };
await this.persist(next);
}
private async persist(next: Settings): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const tmpPath = this.filePath + '.tmp';
await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8');

View File

@@ -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';

View File

@@ -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;

View File

@@ -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();
});
});