feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync, readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
|
||||
describe('browser session persistence migrations', () => {
|
||||
let tempDir = '';
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('creates the new tables and columns via Repository constructor', () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsm-'));
|
||||
const dbPath = join(tempDir, 'orchestrator.db');
|
||||
// Repository constructor runs schema.sql + idempotent migrations.
|
||||
new Repository(dbPath);
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
try {
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
const names = new Set(tables.map(t => t.name));
|
||||
expect(names.has('user_deks')).toBe(true);
|
||||
expect(names.has('browser_session_profiles')).toBe(true);
|
||||
expect(names.has('browser_session_audit')).toBe(true);
|
||||
|
||||
const localCols = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
|
||||
expect(localCols.some(c => c.name === 'browser_session_profile_id')).toBe(true);
|
||||
|
||||
const scheduledCols = db.prepare("PRAGMA table_info('scheduled_tasks')").all() as Array<{ name: string }>;
|
||||
expect(scheduledCols.some(c => c.name === 'browser_session_profile_id')).toBe(true);
|
||||
|
||||
const jobCols = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
expect(jobCols.some(c => c.name === 'browser_session_profile_id')).toBe(true);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('schema.sql alone (without migrations) creates the new tables', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
const schema = readFileSync(new URL('./schema.sql', import.meta.url), 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
|
||||
const names = new Set(tables.map(t => t.name));
|
||||
expect(names.has('user_deks')).toBe(true);
|
||||
expect(names.has('browser_session_profiles')).toBe(true);
|
||||
expect(names.has('browser_session_audit')).toBe(true);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects invalid status / action / result values', () => {
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'crg-mig-'));
|
||||
const dbPath = join(tmp, 'test.db');
|
||||
new Repository(dbPath);
|
||||
const db = new Database(dbPath);
|
||||
|
||||
expect(() => db.prepare(
|
||||
`INSERT INTO browser_session_profiles (owner_id, label, start_url, status)
|
||||
VALUES ('u1','x','https://x.com','garbage')`
|
||||
).run()).toThrow();
|
||||
|
||||
expect(() => db.prepare(
|
||||
`INSERT INTO browser_session_audit (action, result) VALUES ('garbage','success')`
|
||||
).run()).toThrow();
|
||||
|
||||
expect(() => db.prepare(
|
||||
`INSERT INTO browser_session_audit (action, result) VALUES ('create','garbage')`
|
||||
).run()).toThrow();
|
||||
|
||||
db.close();
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
import { BrowserSessionRepo } from './browser-session-repo.js';
|
||||
|
||||
describe('BrowserSessionRepo', () => {
|
||||
let tempDir = '';
|
||||
let repository: Repository;
|
||||
let db: Database.Database;
|
||||
let repo: BrowserSessionRepo;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsr-'));
|
||||
const dbPath = join(tempDir, 'orchestrator.db');
|
||||
repository = new Repository(dbPath);
|
||||
db = repository.getDb();
|
||||
// Seed a user
|
||||
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
|
||||
.run('u1', 'u1@test');
|
||||
repo = new BrowserSessionRepo(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repository.close();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('upserts and reads a user DEK', () => {
|
||||
repo.setUserDek('u1', Buffer.from('a'.repeat(48)));
|
||||
const out = repo.getUserDek('u1');
|
||||
expect(out?.toString()).toBe('a'.repeat(48));
|
||||
});
|
||||
|
||||
it('creates a profile then loads it by id with owner check', () => {
|
||||
const id = repo.createProfile({
|
||||
ownerId: 'u1', label: 'GitHub', startUrl: 'https://github.com',
|
||||
matchPatterns: ['https://github.com/**'], storageOrigins: ['https://github.com'],
|
||||
loggedInSelector: 'header [aria-label*="View profile"]',
|
||||
loginUrlPatterns: ['https://github.com/login**'],
|
||||
});
|
||||
const profile = repo.getProfileById(id, 'u1');
|
||||
expect(profile?.label).toBe('GitHub');
|
||||
expect(profile?.matchPatterns).toEqual(['https://github.com/**']);
|
||||
expect(repo.getProfileById(id, 'someone-else')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists only the caller’s profiles', () => {
|
||||
repo.createProfile({ ownerId: 'u1', label: 'A', startUrl: 'https://a.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
|
||||
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
|
||||
VALUES ('u2','u2@test','active','active',datetime('now'),datetime('now'))`).run();
|
||||
repo.createProfile({ ownerId: 'u2', label: 'B', startUrl: 'https://b.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
|
||||
expect(repo.listProfilesByOwner('u1').length).toBe(1);
|
||||
expect(repo.listProfilesByOwner('u1')[0]!.label).toBe('A');
|
||||
});
|
||||
|
||||
it('saves an encrypted blob and bumps state_version', () => {
|
||||
const id = repo.createProfile({ ownerId: 'u1', label: 'X', startUrl: 'https://x.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
|
||||
repo.saveProfileBlob(id, Buffer.from('encrypted'), '1.40.0');
|
||||
const p = repo.getProfileById(id, 'u1');
|
||||
expect(p?.encryptedStateBlob?.toString()).toBe('encrypted');
|
||||
expect(p?.stateVersion).toBe(1);
|
||||
expect(p?.status).toBe('active');
|
||||
repo.saveProfileBlob(id, Buffer.from('encrypted2'), '1.40.0');
|
||||
expect(repo.getProfileById(id, 'u1')?.stateVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('marks profile expired with reason', () => {
|
||||
const id = repo.createProfile({ ownerId: 'u1', label: 'X', startUrl: 'https://x.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
|
||||
repo.markProfileStatus(id, 'expired', 'redirected to /login');
|
||||
const p = repo.getProfileById(id, 'u1');
|
||||
expect(p?.status).toBe('expired');
|
||||
expect(p?.lastError).toBe('redirected to /login');
|
||||
});
|
||||
|
||||
it('writes audit entries', () => {
|
||||
const id = repo.createProfile({ ownerId: 'u1', label: 'X', startUrl: 'https://x.com', matchPatterns: [], storageOrigins: [], loginUrlPatterns: [] });
|
||||
repo.audit({ actorUserId: 'u1', ownerId: 'u1', profileId: id, action: 'create', result: 'success' });
|
||||
const rows = db.prepare('SELECT * FROM browser_session_audit').all() as Array<{ action: string; result: string }>;
|
||||
expect(rows.length).toBe(1);
|
||||
expect(rows[0]!.action).toBe('create');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export interface BrowserSessionProfile {
|
||||
id: number;
|
||||
ownerId: string;
|
||||
label: string;
|
||||
startUrl: string;
|
||||
matchPatterns: string[];
|
||||
storageOrigins: string[];
|
||||
loggedInSelector: string | null;
|
||||
loginUrlPatterns: string[];
|
||||
encryptedStateBlob: Buffer | null;
|
||||
stateVersion: number;
|
||||
playwrightVersion: string | null;
|
||||
status: 'pending' | 'active' | 'expired' | 'revoked' | 'error';
|
||||
lastSavedAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
lastValidatedAt: string | null;
|
||||
lastError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateProfileInput {
|
||||
ownerId: string;
|
||||
label: string;
|
||||
startUrl: string;
|
||||
matchPatterns: string[];
|
||||
storageOrigins: string[];
|
||||
loggedInSelector?: string | null;
|
||||
loginUrlPatterns: string[];
|
||||
}
|
||||
|
||||
export interface AuditInput {
|
||||
actorUserId?: string | null;
|
||||
ownerId?: string | null;
|
||||
profileId?: number | null;
|
||||
action:
|
||||
| 'create' | 'save' | 'decrypt' | 'use' | 'delete'
|
||||
| 'expire' | 'revoke' | 'test' | 'login_start' | 'login_cancel';
|
||||
taskId?: number | null;
|
||||
jobId?: string | null;
|
||||
result: 'success' | 'error';
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
function rowToProfile(row: Record<string, unknown>): BrowserSessionProfile {
|
||||
return {
|
||||
id: row['id'] as number,
|
||||
ownerId: row['owner_id'] as string,
|
||||
label: row['label'] as string,
|
||||
startUrl: row['start_url'] as string,
|
||||
matchPatterns: JSON.parse((row['match_patterns'] as string) || '[]') as string[],
|
||||
storageOrigins: JSON.parse((row['storage_origins'] as string) || '[]') as string[],
|
||||
loggedInSelector: (row['logged_in_selector'] as string | null) ?? null,
|
||||
loginUrlPatterns: JSON.parse((row['login_url_patterns'] as string) || '[]') as string[],
|
||||
encryptedStateBlob: (row['encrypted_state_blob'] as Buffer | null) ?? null,
|
||||
stateVersion: (row['state_version'] as number) ?? 0,
|
||||
playwrightVersion: (row['playwright_version'] as string | null) ?? null,
|
||||
status: row['status'] as BrowserSessionProfile['status'],
|
||||
lastSavedAt: (row['last_saved_at'] as string | null) ?? null,
|
||||
lastUsedAt: (row['last_used_at'] as string | null) ?? null,
|
||||
lastValidatedAt: (row['last_validated_at'] as string | null) ?? null,
|
||||
lastError: (row['last_error'] as string | null) ?? null,
|
||||
createdAt: row['created_at'] as string,
|
||||
updatedAt: row['updated_at'] as string,
|
||||
};
|
||||
}
|
||||
|
||||
export class BrowserSessionRepo {
|
||||
constructor(private readonly db: Database.Database) {}
|
||||
|
||||
// ── DEK management ────────────────────────────────────────────────
|
||||
setUserDek(userId: string, encryptedDek: Buffer): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO user_deks (user_id, encrypted_dek)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET encrypted_dek = excluded.encrypted_dek
|
||||
`).run(userId, encryptedDek);
|
||||
}
|
||||
|
||||
getUserDek(userId: string): Buffer | null {
|
||||
const row = this.db.prepare('SELECT encrypted_dek FROM user_deks WHERE user_id = ?').get(userId) as { encrypted_dek: Buffer } | undefined;
|
||||
return row?.encrypted_dek ?? null;
|
||||
}
|
||||
|
||||
// ── Profiles ──────────────────────────────────────────────────────
|
||||
createProfile(input: CreateProfileInput): number {
|
||||
const result = this.db.prepare(`
|
||||
INSERT INTO browser_session_profiles
|
||||
(owner_id, label, start_url, match_patterns, storage_origins,
|
||||
logged_in_selector, login_url_patterns, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||
`).run(
|
||||
input.ownerId,
|
||||
input.label,
|
||||
input.startUrl,
|
||||
JSON.stringify(input.matchPatterns),
|
||||
JSON.stringify(input.storageOrigins),
|
||||
input.loggedInSelector ?? null,
|
||||
JSON.stringify(input.loginUrlPatterns),
|
||||
);
|
||||
return Number(result.lastInsertRowid);
|
||||
}
|
||||
|
||||
getProfileById(id: number, ownerId: string): BrowserSessionProfile | null {
|
||||
const row = this.db.prepare('SELECT * FROM browser_session_profiles WHERE id = ? AND owner_id = ?').get(id, ownerId) as Record<string, unknown> | undefined;
|
||||
return row ? rowToProfile(row) : null;
|
||||
}
|
||||
|
||||
/** Admin / worker path that does NOT enforce ownership. Caller must check elsewhere. */
|
||||
getProfileByIdUnsafe(id: number): BrowserSessionProfile | null {
|
||||
const row = this.db.prepare('SELECT * FROM browser_session_profiles WHERE id = ?').get(id) as Record<string, unknown> | undefined;
|
||||
return row ? rowToProfile(row) : null;
|
||||
}
|
||||
|
||||
listProfilesByOwner(ownerId: string): BrowserSessionProfile[] {
|
||||
const rows = this.db.prepare('SELECT * FROM browser_session_profiles WHERE owner_id = ? ORDER BY label ASC').all(ownerId) as Array<Record<string, unknown>>;
|
||||
return rows.map(rowToProfile);
|
||||
}
|
||||
|
||||
saveProfileBlob(id: number, encrypted: Buffer, playwrightVersion: string): void {
|
||||
this.db.prepare(`
|
||||
UPDATE browser_session_profiles
|
||||
SET encrypted_state_blob = ?,
|
||||
state_version = state_version + 1,
|
||||
playwright_version = ?,
|
||||
status = 'active',
|
||||
last_saved_at = datetime('now'),
|
||||
last_validated_at = datetime('now'),
|
||||
last_error = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(encrypted, playwrightVersion, id);
|
||||
}
|
||||
|
||||
markProfileStatus(id: number, status: BrowserSessionProfile['status'], reason: string | null = null): void {
|
||||
this.db.prepare(`
|
||||
UPDATE browser_session_profiles
|
||||
SET status = ?,
|
||||
last_error = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(status, reason, id);
|
||||
}
|
||||
|
||||
touchUsed(id: number): void {
|
||||
this.db.prepare(`UPDATE browser_session_profiles SET last_used_at = datetime('now') WHERE id = ?`).run(id);
|
||||
}
|
||||
|
||||
deleteProfile(id: number, ownerId: string): boolean {
|
||||
const result = this.db.prepare('DELETE FROM browser_session_profiles WHERE id = ? AND owner_id = ?').run(id, ownerId);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ── Audit ─────────────────────────────────────────────────────────
|
||||
audit(input: AuditInput): void {
|
||||
this.db.prepare(`
|
||||
INSERT INTO browser_session_audit
|
||||
(actor_user_id, profile_id, owner_id, action, task_id, job_id, result, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.actorUserId ?? null,
|
||||
input.profileId ?? null,
|
||||
input.ownerId ?? null,
|
||||
input.action,
|
||||
input.taskId ?? null,
|
||||
input.jobId ?? null,
|
||||
input.result,
|
||||
input.reason ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
|
||||
describe('Repository dashboard widgets', () => {
|
||||
let tmpDir: string;
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'dashboard-repo-test-'));
|
||||
repo = new Repository(join(tmpDir, 'test.db'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates and lists widgets scoped to user_id', async () => {
|
||||
const a = await repo.createDashboardWidget({ userId: 'u1', slug: 'memo', title: 'Memo', content: 'hello' });
|
||||
await repo.createDashboardWidget({ userId: 'u2', slug: 'memo', title: 'Memo2', content: 'other-user' });
|
||||
const widgets = await repo.listDashboardWidgets('u1');
|
||||
expect(widgets).toHaveLength(1);
|
||||
expect(widgets[0]!.id).toBe(a.id);
|
||||
expect(widgets[0]!.slug).toBe('memo');
|
||||
expect(widgets[0]!.title).toBe('Memo');
|
||||
expect(widgets[0]!.markdownContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('rejects duplicate slug for same user', async () => {
|
||||
await repo.createDashboardWidget({ userId: 'u1', slug: 'memo', title: 'a', content: '' });
|
||||
await expect(
|
||||
repo.createDashboardWidget({ userId: 'u1', slug: 'memo', title: 'b', content: '' })
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('updates content and bumps updated_at', async () => {
|
||||
const w = await repo.createDashboardWidget({ userId: 'u1', slug: 's', title: 't', content: 'old' });
|
||||
const originalUpdatedAt = w.updatedAt;
|
||||
await new Promise(r => setTimeout(r, 1100)); // datetime('now') has 1s precision
|
||||
const updated = await repo.updateDashboardWidget(w.id, 'u1', { content: 'new' });
|
||||
expect(updated.markdownContent).toBe('new');
|
||||
expect(updated.updatedAt).not.toBe(originalUpdatedAt);
|
||||
});
|
||||
|
||||
it('updateDashboardWidget rejects updates from other users', async () => {
|
||||
const w = await repo.createDashboardWidget({ userId: 'u1', slug: 's', title: 't', content: 'orig' });
|
||||
await expect(
|
||||
repo.updateDashboardWidget(w.id, 'u2', { content: 'hack' })
|
||||
).rejects.toThrow(/not found/i);
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list[0]!.markdownContent).toBe('orig');
|
||||
});
|
||||
|
||||
it('upserts by (user_id, slug): returns existing if slug already exists', async () => {
|
||||
const created = await repo.upsertDashboardWidgetBySlug({ userId: 'u1', slug: 'news', title: 'News', content: 'a' });
|
||||
const second = await repo.upsertDashboardWidgetBySlug({ userId: 'u1', slug: 'news', content: 'b' });
|
||||
expect(second.id).toBe(created.id);
|
||||
expect(second.markdownContent).toBe('b');
|
||||
});
|
||||
|
||||
it('deletes widget by id', async () => {
|
||||
const w = await repo.createDashboardWidget({ userId: 'u1', slug: 's', title: 't', content: '' });
|
||||
await repo.deleteDashboardWidget(w.id, 'u1');
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reorders widgets by id list', async () => {
|
||||
const a = await repo.createDashboardWidget({ userId: 'u1', slug: 'a', title: 'A', content: '' });
|
||||
const b = await repo.createDashboardWidget({ userId: 'u1', slug: 'b', title: 'B', content: '' });
|
||||
const c = await repo.createDashboardWidget({ userId: 'u1', slug: 'c', title: 'C', content: '' });
|
||||
await repo.reorderDashboardWidgets('u1', [c.id, a.id, b.id]);
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list.map(w => w.slug)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('reorder rejects ids from other users (silent skip + leave order intact)', async () => {
|
||||
const u1a = await repo.createDashboardWidget({ userId: 'u1', slug: 'a', title: 'A', content: '' });
|
||||
const u2x = await repo.createDashboardWidget({ userId: 'u2', slug: 'x', title: 'X', content: '' });
|
||||
await repo.reorderDashboardWidgets('u1', [u2x.id, u1a.id]);
|
||||
const list = await repo.listDashboardWidgets('u1');
|
||||
expect(list.map(w => w.id)).toEqual([u1a.id]); // u2x should not appear
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* AAO Gateway Phase 2b migration test.
|
||||
*
|
||||
* Coverage:
|
||||
* - Fresh DB: new columns exist, gateway_key_usage table exists
|
||||
* - Idempotency: runMigrations called twice produces the same shape
|
||||
* - Pre-Phase-2b shape: drops the new columns, runs migrate, columns are
|
||||
* re-added as nullable INTEGER
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
function getColumnNames(db: Database.Database, table: string): string[] {
|
||||
return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(c => c.name);
|
||||
}
|
||||
|
||||
function getColumnInfo(db: Database.Database, table: string, col: string): { notnull: number; dflt_value: unknown; type: string } | undefined {
|
||||
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string; notnull: number; dflt_value: unknown; type: string }>;
|
||||
return rows.find(r => r.name === col);
|
||||
}
|
||||
|
||||
describe('Phase 2b migration: gateway_virtual_keys columns + gateway_key_usage table', () => {
|
||||
let dir: string;
|
||||
let dbPath: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'gw-2b-mig-'));
|
||||
dbPath = join(dir, 'db.sqlite');
|
||||
db = new Database(dbPath);
|
||||
db.pragma('foreign_keys = ON');
|
||||
// Seed an existing Phase 2a-shaped gateway_virtual_keys (no Phase 2b
|
||||
// columns) to simulate a deployed pre-2b DB.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gateway_virtual_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
key_prefix TEXT NOT NULL,
|
||||
team TEXT NOT NULL,
|
||||
allowed_models TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT,
|
||||
revoked_at TEXT,
|
||||
revoked_by TEXT,
|
||||
last_used_at TEXT
|
||||
);
|
||||
`);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('adds tokens_budget + rate_limit_rpm columns to a Phase 2a-shaped DB', () => {
|
||||
expect(getColumnNames(db, 'gateway_virtual_keys')).not.toContain('tokens_budget');
|
||||
expect(getColumnNames(db, 'gateway_virtual_keys')).not.toContain('rate_limit_rpm');
|
||||
|
||||
runMigrations(db);
|
||||
|
||||
const cols = getColumnNames(db, 'gateway_virtual_keys');
|
||||
expect(cols).toContain('tokens_budget');
|
||||
expect(cols).toContain('rate_limit_rpm');
|
||||
|
||||
// Nullable (no NOT NULL constraint) so existing rows survive.
|
||||
const tb = getColumnInfo(db, 'gateway_virtual_keys', 'tokens_budget');
|
||||
expect(tb?.notnull).toBe(0);
|
||||
expect(tb?.type.toUpperCase()).toBe('INTEGER');
|
||||
const rl = getColumnInfo(db, 'gateway_virtual_keys', 'rate_limit_rpm');
|
||||
expect(rl?.notnull).toBe(0);
|
||||
expect(rl?.type.toUpperCase()).toBe('INTEGER');
|
||||
});
|
||||
|
||||
it('creates gateway_key_usage table with composite PK', () => {
|
||||
runMigrations(db);
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='gateway_key_usage'")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(tables).toHaveLength(1);
|
||||
|
||||
const cols = getColumnNames(db, 'gateway_key_usage');
|
||||
expect(cols).toEqual(
|
||||
expect.arrayContaining(['key_id', 'period_start', 'tokens_in', 'tokens_out', 'requests', 'last_updated_at']),
|
||||
);
|
||||
|
||||
// PRIMARY KEY columns are flagged in pk > 0 in PRAGMA table_info.
|
||||
const pkInfo = db.prepare(`PRAGMA table_info(gateway_key_usage)`).all() as Array<{ name: string; pk: number }>;
|
||||
const pkCols = pkInfo.filter(c => c.pk > 0).map(c => c.name).sort();
|
||||
expect(pkCols).toEqual(['key_id', 'period_start']);
|
||||
});
|
||||
|
||||
it('is idempotent (runMigrations twice has no effect)', () => {
|
||||
runMigrations(db);
|
||||
const colsAfter1 = getColumnNames(db, 'gateway_virtual_keys');
|
||||
runMigrations(db); // should not throw or duplicate columns
|
||||
const colsAfter2 = getColumnNames(db, 'gateway_virtual_keys');
|
||||
expect(colsAfter2).toEqual(colsAfter1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
describe('migrate: notes tables', () => {
|
||||
let tmpRoot: string;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpRoot = mkdtempSync(join(tmpdir(), 'migrate-notes-test-'));
|
||||
db = new Database(join(tmpRoot, 'test.db'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates note_index, note_subscriptions, pending_reindex, note_index_fts', () => {
|
||||
runMigrations(db);
|
||||
const tables = db
|
||||
// FTS5 virtual tables appear as type='table' in sqlite_master, not 'virtual'.
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
|
||||
.all()
|
||||
.map((r: any) => r.name);
|
||||
expect(tables).toContain('note_index');
|
||||
expect(tables).toContain('note_subscriptions');
|
||||
expect(tables).toContain('pending_reindex');
|
||||
expect(tables).toContain('note_index_fts');
|
||||
});
|
||||
|
||||
it('note_index_fts is kept in sync via triggers on note_index insert', () => {
|
||||
runMigrations(db);
|
||||
db.prepare(`INSERT INTO users (id, email) VALUES ('u1','[email protected]')`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO note_index (owner_id, folder, file_name, title, visibility, tags_json, content_size, content_hash, updated_at, body)
|
||||
VALUES ('u1','cve','foo.md','CVE foo','public','["cve"]',100,'h',1,'this is the body')
|
||||
`).run();
|
||||
const row: any = db.prepare(`SELECT title, tags, body FROM note_index_fts WHERE owner_id='u1'`).get();
|
||||
expect(row.title).toBe('CVE foo');
|
||||
expect(row.tags).toBe('["cve"]');
|
||||
expect(row.body).toBe('this is the body');
|
||||
});
|
||||
|
||||
it('CASCADE deletes note_index rows when user is deleted', () => {
|
||||
runMigrations(db);
|
||||
db.prepare(`INSERT INTO users (id, email) VALUES ('u1','[email protected]')`).run();
|
||||
db.prepare(`
|
||||
INSERT INTO note_index (owner_id, folder, file_name, visibility, updated_at, content_size, content_hash, body)
|
||||
VALUES ('u1','f','x.md','private',1,0,'h','')
|
||||
`).run();
|
||||
db.prepare(`DELETE FROM users WHERE id='u1'`).run();
|
||||
const count: any = db.prepare(`SELECT COUNT(*) c FROM note_index WHERE owner_id='u1'`).get();
|
||||
expect(count.c).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Repository } from './repository.js';
|
||||
|
||||
describe('jobs.task_kind / payload columns', () => {
|
||||
let dir: string;
|
||||
let r: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'reflect-cols-'));
|
||||
r = new Repository(join(dir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
r.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
it('exist after Repository init', () => {
|
||||
const cols = r.getDb()
|
||||
.prepare("PRAGMA table_info(jobs)")
|
||||
.all() as Array<{ name: string; dflt_value: string | null }>;
|
||||
const taskKind = cols.find(c => c.name === 'task_kind');
|
||||
const payload = cols.find(c => c.name === 'payload');
|
||||
expect(taskKind).toBeTruthy();
|
||||
expect(taskKind?.dflt_value).toBe("'agent'");
|
||||
expect(payload).toBeTruthy();
|
||||
});
|
||||
|
||||
it('createJob accepts taskKind/payload and reads back via getJob', async () => {
|
||||
const job = await r.createJob({
|
||||
repo: 'local/reflection-x',
|
||||
issueNumber: 0,
|
||||
instruction: '',
|
||||
pieceName: 'reflection',
|
||||
role: 'reflection',
|
||||
taskKind: 'reflection',
|
||||
payload: JSON.stringify({ originalJobId: 'j1', userId: 'u1' }),
|
||||
} as any);
|
||||
expect(job.taskKind).toBe('reflection');
|
||||
expect(job.payload).toBeTruthy();
|
||||
expect(JSON.parse(job.payload!).originalJobId).toBe('j1');
|
||||
});
|
||||
|
||||
it('createJob defaults taskKind to "agent" and payload to null when omitted', async () => {
|
||||
const job = await r.createJob({
|
||||
repo: 'local/task-1',
|
||||
issueNumber: 1,
|
||||
instruction: 'hello',
|
||||
pieceName: 'chat',
|
||||
} as any);
|
||||
expect(job.taskKind).toBe('agent');
|
||||
expect(job.payload).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reflection_piece_edits table', () => {
|
||||
let dir: string;
|
||||
let r: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'rpe-cols-'));
|
||||
r = new Repository(join(dir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
r.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('table exists with expected columns after Repository init', () => {
|
||||
const cols = r.getDb()
|
||||
.prepare("PRAGMA table_info(reflection_piece_edits)")
|
||||
.all() as Array<{ name: string; notnull: number; pk: number }>;
|
||||
|
||||
const names = cols.map(c => c.name);
|
||||
expect(names).toContain('user_id');
|
||||
expect(names).toContain('piece_name');
|
||||
expect(names).toContain('snapshot_id');
|
||||
expect(names).toContain('created_at');
|
||||
|
||||
// Primary key: (user_id, piece_name, created_at)
|
||||
const pkCols = cols.filter(c => c.pk > 0).map(c => c.name).sort();
|
||||
expect(pkCols).toEqual(['created_at', 'piece_name', 'user_id']);
|
||||
});
|
||||
|
||||
it('index idx_rpe_user_piece_time exists', () => {
|
||||
const indexes = r.getDb()
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reflection_piece_edits'")
|
||||
.all() as Array<{ name: string }>;
|
||||
const indexNames = indexes.map(i => i.name);
|
||||
expect(indexNames).toContain('idx_rpe_user_piece_time');
|
||||
});
|
||||
|
||||
it('recordPieceEdit inserts a row and countRecentPieceEdits counts it', () => {
|
||||
r.recordPieceEdit('u1', 'chat', 'snap-1');
|
||||
const count = r.countRecentPieceEdits('u1', 'chat', 60_000);
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('countRecentPieceEdits is scoped to user_id and piece_name', () => {
|
||||
r.recordPieceEdit('u1', 'chat', 'snap-1');
|
||||
r.recordPieceEdit('u2', 'chat', 'snap-2'); // different user
|
||||
r.recordPieceEdit('u1', 'research', 'snap-3'); // different piece
|
||||
|
||||
expect(r.countRecentPieceEdits('u1', 'chat', 60_000)).toBe(1);
|
||||
expect(r.countRecentPieceEdits('u2', 'chat', 60_000)).toBe(1);
|
||||
expect(r.countRecentPieceEdits('u1', 'research', 60_000)).toBe(1);
|
||||
});
|
||||
|
||||
it('countRecentPieceEdits excludes rows older than sinceMs', () => {
|
||||
// Insert a row with a created_at 2 hours in the past by manipulating DB directly.
|
||||
const twoHoursAgo = Date.now() - 2 * 3600 * 1000;
|
||||
r.getDb()
|
||||
.prepare(
|
||||
`INSERT INTO reflection_piece_edits (user_id, piece_name, snapshot_id, created_at)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run('u1', 'old-piece', 'snap-old', twoHoursAgo);
|
||||
|
||||
// 1-hour window should not see it.
|
||||
expect(r.countRecentPieceEdits('u1', 'old-piece', 3600 * 1000)).toBe(0);
|
||||
// 3-hour window should see it.
|
||||
expect(r.countRecentPieceEdits('u1', 'old-piece', 3 * 3600 * 1000)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reflection_metrics table', () => {
|
||||
let dir: string;
|
||||
let r: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'rm-cols-'));
|
||||
r = new Repository(join(dir, 'db.sqlite'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
r.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('table exists with expected columns after Repository init', () => {
|
||||
const cols = r.getDb()
|
||||
.prepare('PRAGMA table_info(reflection_metrics)')
|
||||
.all() as Array<{ name: string }>;
|
||||
const names = cols.map(c => c.name);
|
||||
expect(names).toContain('id');
|
||||
expect(names).toContain('reflection_job_id');
|
||||
expect(names).toContain('original_job_id');
|
||||
expect(names).toContain('user_id');
|
||||
expect(names).toContain('piece_name');
|
||||
expect(names).toContain('outcome');
|
||||
expect(names).toContain('memory_changes');
|
||||
expect(names).toContain('piece_edited');
|
||||
expect(names).toContain('tokens_in');
|
||||
expect(names).toContain('tokens_out');
|
||||
expect(names).toContain('duration_ms');
|
||||
expect(names).toContain('created_at');
|
||||
});
|
||||
|
||||
it('index idx_rm_user_time exists', () => {
|
||||
const indexes = r.getDb()
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reflection_metrics'")
|
||||
.all() as Array<{ name: string }>;
|
||||
expect(indexes.map(i => i.name)).toContain('idx_rm_user_time');
|
||||
});
|
||||
|
||||
it('recordReflectionMetric inserts a row', () => {
|
||||
r.recordReflectionMetric({
|
||||
reflection_job_id: 'rj-1',
|
||||
original_job_id: 'oj-1',
|
||||
user_id: 'u1',
|
||||
piece_name: 'chat',
|
||||
outcome: 'applied',
|
||||
memory_changes: 2,
|
||||
piece_edited: 0,
|
||||
tokens_in: 500,
|
||||
tokens_out: 80,
|
||||
duration_ms: 1234,
|
||||
});
|
||||
|
||||
const rows = r.getDb()
|
||||
.prepare('SELECT * FROM reflection_metrics WHERE reflection_job_id = ?')
|
||||
.all('rj-1') as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.outcome).toBe('applied');
|
||||
expect(rows[0]!.memory_changes).toBe(2);
|
||||
expect(rows[0]!.tokens_in).toBe(500);
|
||||
expect(rows[0]!.tokens_out).toBe(80);
|
||||
expect(rows[0]!.duration_ms).toBe(1234);
|
||||
expect(rows[0]!.piece_edited).toBe(0);
|
||||
});
|
||||
|
||||
it('aggregateReflectionMetrics sums correctly across outcomes', () => {
|
||||
const now = Date.now();
|
||||
r.recordReflectionMetric({
|
||||
reflection_job_id: 'rj-a',
|
||||
original_job_id: 'oj-a',
|
||||
user_id: 'u1',
|
||||
piece_name: 'chat',
|
||||
outcome: 'applied',
|
||||
memory_changes: 2,
|
||||
piece_edited: 1,
|
||||
tokens_in: 100,
|
||||
tokens_out: 20,
|
||||
duration_ms: 500,
|
||||
});
|
||||
r.recordReflectionMetric({
|
||||
reflection_job_id: 'rj-b',
|
||||
original_job_id: 'oj-b',
|
||||
user_id: 'u1',
|
||||
piece_name: 'chat',
|
||||
outcome: 'abstained',
|
||||
memory_changes: 0,
|
||||
piece_edited: 0,
|
||||
tokens_in: 50,
|
||||
tokens_out: 10,
|
||||
duration_ms: 300,
|
||||
});
|
||||
// Different user — should not be counted
|
||||
r.recordReflectionMetric({
|
||||
reflection_job_id: 'rj-c',
|
||||
original_job_id: 'oj-c',
|
||||
user_id: 'u2',
|
||||
piece_name: 'chat',
|
||||
outcome: 'applied',
|
||||
memory_changes: 1,
|
||||
piece_edited: 0,
|
||||
tokens_in: 999,
|
||||
tokens_out: 999,
|
||||
duration_ms: 999,
|
||||
});
|
||||
|
||||
const agg = r.aggregateReflectionMetrics('u1', now - 60_000);
|
||||
expect(agg.applied).toBe(1);
|
||||
expect(agg.abstained).toBe(1);
|
||||
expect(agg.failed).toBe(0);
|
||||
expect(agg.totalRuns).toBe(2);
|
||||
expect(agg.tokensIn).toBe(150);
|
||||
expect(agg.tokensOut).toBe(30);
|
||||
expect(agg.pieceEdits).toBe(1);
|
||||
});
|
||||
|
||||
it('aggregateReflectionMetrics excludes rows before sinceMs', () => {
|
||||
// Insert an old row directly
|
||||
const twoHoursAgo = Date.now() - 2 * 3600 * 1000;
|
||||
r.getDb()
|
||||
.prepare(
|
||||
`INSERT INTO reflection_metrics
|
||||
(reflection_job_id, original_job_id, user_id, piece_name, outcome,
|
||||
memory_changes, piece_edited, tokens_in, tokens_out, duration_ms, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run('rj-old', 'oj-old', 'u1', 'chat', 'applied', 1, 0, 100, 20, 500, twoHoursAgo);
|
||||
|
||||
// 1-hour window should exclude it
|
||||
const agg = r.aggregateReflectionMetrics('u1', Date.now() - 3600 * 1000);
|
||||
expect(agg.totalRuns).toBe(0);
|
||||
expect(agg.applied).toBe(0);
|
||||
});
|
||||
|
||||
it('recordReflectionRun with pieceEdit inserts both rows atomically', () => {
|
||||
r.recordReflectionRun(
|
||||
{
|
||||
reflection_job_id: 'rj-tx',
|
||||
original_job_id: 'oj-tx',
|
||||
user_id: 'u1',
|
||||
piece_name: 'chat',
|
||||
outcome: 'applied',
|
||||
memory_changes: 1,
|
||||
piece_edited: 1,
|
||||
tokens_in: 200,
|
||||
tokens_out: 40,
|
||||
duration_ms: 800,
|
||||
},
|
||||
{ pieceName: 'chat', snapshotId: 'snap-tx' },
|
||||
);
|
||||
|
||||
// Both tables should have a row
|
||||
const metricRows = r.getDb()
|
||||
.prepare('SELECT * FROM reflection_metrics WHERE reflection_job_id = ?')
|
||||
.all('rj-tx');
|
||||
expect(metricRows).toHaveLength(1);
|
||||
|
||||
const editCount = r.countRecentPieceEdits('u1', 'chat', 60_000);
|
||||
expect(editCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runReflectionJob integration', () => {
|
||||
let dir: string;
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'rr-int-'));
|
||||
repo = new Repository(join(dir, 'db.sqlite'));
|
||||
// Seed a minimal built-in piece YAML
|
||||
const piecesDir = join(dir, 'pieces');
|
||||
mkdirSync(piecesDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(piecesDir, 'chat.yaml'),
|
||||
'name: chat\ndescription: basic chat\nmovements:\n - name: respond\n rules: []\n',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
repo.close?.();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('records a metric row with real outcome and writes a snapshot directory', async () => {
|
||||
// Seed a minimal original job + local task so loadReflectionInputs can find them
|
||||
const originalJob = await repo.createJob({
|
||||
repo: 'local/task-1',
|
||||
issueNumber: 1,
|
||||
instruction: 'do the thing',
|
||||
pieceName: 'chat',
|
||||
taskKind: 'agent',
|
||||
} as any);
|
||||
|
||||
// Create a reflection job
|
||||
const reflJob = await repo.createJob({
|
||||
repo: 'local/task-1',
|
||||
issueNumber: 0,
|
||||
instruction: '',
|
||||
pieceName: 'reflection',
|
||||
role: 'reflection',
|
||||
taskKind: 'reflection',
|
||||
payload: JSON.stringify({
|
||||
originalJobId: originalJob.id,
|
||||
userId: 'u-int-test',
|
||||
pieceName: 'chat',
|
||||
outcome: 'succeeded',
|
||||
}),
|
||||
} as any);
|
||||
|
||||
// Mock fetch so callReflectionLlm returns a valid abstain result
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
tool_calls: [{
|
||||
function: {
|
||||
arguments: JSON.stringify({
|
||||
memory_changes: [],
|
||||
piece_changes: { should_edit: false },
|
||||
reasoning: 'nothing to learn',
|
||||
abstain_reason: 'task completed successfully without issues',
|
||||
}),
|
||||
},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
usage: { prompt_tokens: 123, completion_tokens: 45 },
|
||||
}),
|
||||
} as any);
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
const { runReflectionJob } = await import('../engine/reflection/reflection-runner.js');
|
||||
|
||||
const outcome = await runReflectionJob(
|
||||
{
|
||||
repo,
|
||||
config: {
|
||||
userFolderRoot: dir,
|
||||
reflection: {
|
||||
enabled: true,
|
||||
workerRequired: false,
|
||||
maxMemoryChangesPerJob: 3,
|
||||
maxEntryBodyBytes: 8192,
|
||||
pieceEditCooldownHours: 24,
|
||||
snapshotRetentionDays: 90,
|
||||
activityLogMaxBytes: 4096,
|
||||
abstainRateFloor: 0.2,
|
||||
perUserDailyBudgetTokens: 100_000,
|
||||
snapshotMaxBytesPerUser: 10 * 1024 * 1024,
|
||||
snapshotMaxBytesPerEntry: 512 * 1024,
|
||||
storeLlmRaw: false,
|
||||
},
|
||||
} as any,
|
||||
llmEndpoint: 'http://localhost:11434',
|
||||
llmModel: 'test-model',
|
||||
},
|
||||
reflJob,
|
||||
);
|
||||
|
||||
// Outcome should be 'abstained' (no changes, abstain_reason present)
|
||||
expect(outcome).toBe('abstained');
|
||||
|
||||
// Metric row must exist
|
||||
const rows = repo.getDb()
|
||||
.prepare('SELECT * FROM reflection_metrics WHERE reflection_job_id = ?')
|
||||
.all(reflJob.id) as Array<Record<string, unknown>>;
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.outcome).toBe('abstained');
|
||||
expect(rows[0]!.memory_changes).toBe(0);
|
||||
expect(rows[0]!.piece_edited).toBe(0);
|
||||
expect(rows[0]!.tokens_in).toBe(123);
|
||||
expect(rows[0]!.tokens_out).toBe(45);
|
||||
|
||||
// Snapshot directory must exist under data/users/{userId}/.reflection-history/
|
||||
const historyDir = join(dir, 'u-int-test', '.reflection-history');
|
||||
expect(existsSync(historyDir)).toBe(true);
|
||||
// index.jsonl should have one entry
|
||||
const indexPath = join(historyDir, 'index.jsonl');
|
||||
expect(existsSync(indexPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
/**
|
||||
* Seed the minimal pre-existing schema that runMigrations expects to find.
|
||||
* Shared between top-level describes so all tests exercise identical fixtures
|
||||
* (matches the shape produced by schema.sql for the columns/constraints that
|
||||
* migrate.ts actually reads).
|
||||
*/
|
||||
function seedMinimalSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo TEXT NOT NULL DEFAULT '',
|
||||
issue_number INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS local_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
workspace_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(provider, provider_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expired TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
describe('runMigrations', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
seedMinimalSchema(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('adds owner_id column to jobs', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
expect(columns.some(c => c.name === 'owner_id')).toBe(true);
|
||||
});
|
||||
|
||||
it('adds owner_id column to local_tasks', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
|
||||
expect(columns.some(c => c.name === 'owner_id')).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent — running twice does not error', () => {
|
||||
runMigrations(db);
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
});
|
||||
|
||||
it('adds continued_from_job_id column to jobs (idempotent)', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
expect(columns.some(c => c.name === 'continued_from_job_id')).toBe(true);
|
||||
|
||||
// 2nd run is no-op (idempotent)
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
const columnsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
expect(columnsAfter.filter(c => c.name === 'continued_from_job_id').length).toBe(1);
|
||||
});
|
||||
|
||||
it('adds last_backend_id column to jobs (idempotent)', () => {
|
||||
runMigrations(db);
|
||||
const columns = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
expect(columns.some(c => c.name === 'last_backend_id')).toBe(true);
|
||||
|
||||
// 2nd run is no-op
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
const columnsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
expect(columnsAfter.filter(c => c.name === 'last_backend_id').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP table migrations', () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
seedMinimalSchema(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('creates mcp_servers/user_mcp_tokens/mcp_server_tools/mcp_oauth_pending idempotently and preserves data', () => {
|
||||
runMigrations(db);
|
||||
|
||||
// Insert a sentinel row into mcp_servers after the first migration. If the
|
||||
// second runMigrations re-creates (or DROPs) the table, this row will
|
||||
// disappear — which is what makes the idempotency claim meaningful beyond
|
||||
// mere `CREATE TABLE IF NOT EXISTS` behaviour.
|
||||
db.prepare(
|
||||
`INSERT INTO mcp_servers
|
||||
(id, name, url, oauth_client_id, oauth_client_secret_enc)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run('test-srv', 'Test', 'https://example.com', 'cid', Buffer.from([0x00]));
|
||||
|
||||
// Second run must not throw AND must not wipe data.
|
||||
expect(() => runMigrations(db)).not.toThrow();
|
||||
|
||||
const row = db.prepare(
|
||||
"SELECT id, name FROM mcp_servers WHERE id = 'test-srv'"
|
||||
).get() as { id: string; name: string } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row?.name).toBe('Test');
|
||||
|
||||
const tables = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
).all() as Array<{ name: string }>;
|
||||
const names = tables.map((t) => t.name);
|
||||
|
||||
expect(names).toContain('mcp_servers');
|
||||
expect(names).toContain('user_mcp_tokens');
|
||||
expect(names).toContain('mcp_server_tools');
|
||||
expect(names).toContain('mcp_oauth_pending');
|
||||
});
|
||||
|
||||
it('creates idx_mcp_oauth_pending_created index', () => {
|
||||
runMigrations(db);
|
||||
const indexes = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_mcp_oauth_pending_created'"
|
||||
).all() as Array<{ name: string }>;
|
||||
expect(indexes.length).toBe(1);
|
||||
});
|
||||
|
||||
it('uses BLOB type for encrypted columns', () => {
|
||||
runMigrations(db);
|
||||
|
||||
const findColType = (table: string, column: string): string | undefined => {
|
||||
const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
}>;
|
||||
return cols.find((c) => c.name === column)?.type;
|
||||
};
|
||||
|
||||
expect(findColType('mcp_servers', 'oauth_client_secret_enc')).toBe('BLOB');
|
||||
expect(findColType('mcp_servers', 'static_token_enc')).toBe('BLOB');
|
||||
expect(findColType('user_mcp_tokens', 'access_token_enc')).toBe('BLOB');
|
||||
expect(findColType('user_mcp_tokens', 'refresh_token_enc')).toBe('BLOB');
|
||||
});
|
||||
|
||||
it('adds auth_kind, static_token_enc, and owner_id columns to mcp_servers', () => {
|
||||
runMigrations(db);
|
||||
const cols = db.prepare("PRAGMA table_info('mcp_servers')").all() as Array<{ name: string }>;
|
||||
const names = cols.map(c => c.name);
|
||||
expect(names).toContain('auth_kind');
|
||||
expect(names).toContain('static_token_enc');
|
||||
expect(names).toContain('owner_id');
|
||||
});
|
||||
|
||||
it('creates idx_mcp_servers_owner index', () => {
|
||||
runMigrations(db);
|
||||
const indexes = db.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_mcp_servers_owner'"
|
||||
).all() as Array<{ name: string }>;
|
||||
expect(indexes.length).toBe(1);
|
||||
});
|
||||
|
||||
it('auth_kind defaults to oauth for existing rows', () => {
|
||||
runMigrations(db);
|
||||
// Insert a row without specifying auth_kind to test the DEFAULT
|
||||
db.prepare(
|
||||
`INSERT INTO mcp_servers (id, name, url, oauth_client_id, oauth_client_secret_enc)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run('test-default', 'Test', 'https://example.com', 'cid', Buffer.from([0x01]));
|
||||
const row = db.prepare("SELECT auth_kind, owner_id FROM mcp_servers WHERE id = 'test-default'").get() as { auth_kind: string; owner_id: string | null };
|
||||
expect(row.auth_kind).toBe('oauth');
|
||||
expect(row.owner_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,534 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
/**
|
||||
* Run database migrations. Safe to call on a fresh DB or on an existing production DB
|
||||
* (idempotent throughout). On fresh DBs, prerequisite tables are bootstrapped by the
|
||||
* individual migrate* functions as needed.
|
||||
*/
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
// Helper: check if a table exists in this DB.
|
||||
const tableExists = (name: string): boolean =>
|
||||
!!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name));
|
||||
|
||||
// Add owner_id to jobs (if not exists)
|
||||
// Guard: table may not exist when runMigrations is called on a fresh DB
|
||||
// (schema.sql hasn't been applied yet); the ALTER is a no-op in that case.
|
||||
const jobsCols = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
if (tableExists('jobs') && !jobsCols.some(c => c.name === 'owner_id')) {
|
||||
db.exec("ALTER TABLE jobs ADD COLUMN owner_id TEXT REFERENCES users(id)");
|
||||
}
|
||||
|
||||
// Add owner_id to local_tasks (if not exists)
|
||||
const tasksCols = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
|
||||
if (tableExists('local_tasks') && !tasksCols.some(c => c.name === 'owner_id')) {
|
||||
db.exec("ALTER TABLE local_tasks ADD COLUMN owner_id TEXT REFERENCES users(id)");
|
||||
}
|
||||
|
||||
// Add context tracking columns to jobs (if not exists)
|
||||
// re-fetch after the owner_id ALTER above to reflect the updated schema
|
||||
const jobsColsAfter = db.prepare("PRAGMA table_info('jobs')").all() as Array<{ name: string }>;
|
||||
const existingCols = new Set(jobsColsAfter.map(c => c.name));
|
||||
if (tableExists('jobs') && !existingCols.has('context_prompt_tokens')) {
|
||||
db.exec("ALTER TABLE jobs ADD COLUMN context_prompt_tokens INTEGER");
|
||||
}
|
||||
if (tableExists('jobs') && !existingCols.has('context_limit_tokens')) {
|
||||
db.exec("ALTER TABLE jobs ADD COLUMN context_limit_tokens INTEGER");
|
||||
}
|
||||
if (tableExists('jobs') && !existingCols.has('context_updated_at')) {
|
||||
db.exec("ALTER TABLE jobs ADD COLUMN context_updated_at TEXT");
|
||||
}
|
||||
|
||||
// Phase: piece handoff (Continue-with-another-piece feature).
|
||||
// continued_from_job_id links a continuation job to its predecessor on the
|
||||
// same local_task. NULL for normal jobs. SQLite's REFERENCES clause in
|
||||
// ALTER TABLE is informational only — integrity is enforced at the API
|
||||
// layer (POST /api/local/tasks/:id/continue).
|
||||
if (tableExists('jobs') && !existingCols.has('continued_from_job_id')) {
|
||||
db.exec("ALTER TABLE jobs ADD COLUMN continued_from_job_id TEXT REFERENCES jobs(id)");
|
||||
}
|
||||
|
||||
// Phase A: multi-team GPU pool + node status
|
||||
// last_backend_id records the physical backend (LiteLLM deployment id)
|
||||
// that handled this job's LLM calls. NULL for direct workers; set to
|
||||
// the value of the proxy's x-litellm-model-id header on the FIRST LLM
|
||||
// call of the job and never overwritten (sticky-backend policy per
|
||||
// design Open Question #3).
|
||||
if (tableExists('jobs') && !existingCols.has('last_backend_id')) {
|
||||
db.exec("ALTER TABLE jobs ADD COLUMN last_backend_id TEXT");
|
||||
}
|
||||
|
||||
// Mission Brief: per-task pinned memo (JSON blob).
|
||||
const tasksColsAfter = db.prepare("PRAGMA table_info('local_tasks')").all() as Array<{ name: string }>;
|
||||
if (tableExists('local_tasks') && !tasksColsAfter.some(c => c.name === 'mission_brief')) {
|
||||
db.exec("ALTER TABLE local_tasks ADD COLUMN mission_brief TEXT");
|
||||
}
|
||||
|
||||
// Add injected_at to local_task_comments (interjection feature: tracks
|
||||
// when a user comment was injected into the running agent's conversation).
|
||||
addColumnIfMissing(db, 'local_task_comments', 'injected_at', () => {
|
||||
db.exec("ALTER TABLE local_task_comments ADD COLUMN injected_at TEXT");
|
||||
});
|
||||
|
||||
// Per-task options (JSON blob): controls runtime toggles like mcpDisabled / skillsDisabled.
|
||||
addColumnIfMissing(db, 'local_tasks', 'options', () => {
|
||||
db.exec("ALTER TABLE local_tasks ADD COLUMN options TEXT DEFAULT '{}'");
|
||||
});
|
||||
|
||||
migrateMcpTables(db);
|
||||
migrateSshTables(db);
|
||||
migrateNotesTables(db);
|
||||
migrateDashboardWidgets(db);
|
||||
migrateGatewayVirtualKeys(db);
|
||||
migratePushNotificationsTables(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent column addition helper. Checks PRAGMA table_info and runs the
|
||||
* callback only when the column is missing.
|
||||
*/
|
||||
function addColumnIfMissing(
|
||||
db: Database.Database,
|
||||
table: string,
|
||||
column: string,
|
||||
apply: () => void,
|
||||
): void {
|
||||
const tableExists = !!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table));
|
||||
if (!tableExists) return;
|
||||
const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === column)) {
|
||||
apply();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure MCP (Model Context Protocol) tables exist.
|
||||
* Idempotent (uses CREATE TABLE IF NOT EXISTS). FK clauses are omitted here
|
||||
* vs. schema.sql to keep this migration safe for in-flight DBs where FK
|
||||
* enforcement may already be ON before the referenced tables have been
|
||||
* migrated to their final shape.
|
||||
*/
|
||||
function migrateMcpTables(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
oauth_client_id TEXT NOT NULL,
|
||||
oauth_client_secret_enc BLOB NOT NULL,
|
||||
oauth_scopes TEXT,
|
||||
issuer TEXT,
|
||||
authorization_endpoint TEXT,
|
||||
token_endpoint TEXT,
|
||||
discovery_fingerprint TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_mcp_tokens (
|
||||
user_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
access_token_enc BLOB NOT NULL,
|
||||
refresh_token_enc BLOB,
|
||||
expires_at TEXT,
|
||||
scope TEXT,
|
||||
scope_type TEXT NOT NULL DEFAULT 'user' CHECK(scope_type IN ('user', 'org')),
|
||||
scope_id TEXT,
|
||||
connected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, server_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_tools (
|
||||
server_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT,
|
||||
refreshed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (server_id, tool_name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS mcp_oauth_pending (
|
||||
state TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
code_verifier TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_pending_created ON mcp_oauth_pending(created_at);
|
||||
`);
|
||||
|
||||
// Phase 8: API key auth + user-owned servers
|
||||
// ALTER TABLE additions are idempotent via PRAGMA table_info check.
|
||||
const mcpServerCols = db.prepare("PRAGMA table_info('mcp_servers')").all() as Array<{ name: string }>;
|
||||
const mcpServerColNames = new Set(mcpServerCols.map(c => c.name));
|
||||
|
||||
if (!mcpServerColNames.has('auth_kind')) {
|
||||
db.exec("ALTER TABLE mcp_servers ADD COLUMN auth_kind TEXT NOT NULL DEFAULT 'oauth'");
|
||||
}
|
||||
if (!mcpServerColNames.has('static_token_enc')) {
|
||||
// Nullable BLOB: present for api_key servers, NULL for oauth servers.
|
||||
db.exec('ALTER TABLE mcp_servers ADD COLUMN static_token_enc BLOB');
|
||||
}
|
||||
if (!mcpServerColNames.has('owner_id')) {
|
||||
// NULL = global/admin-managed; NOT NULL = user-owned.
|
||||
db.exec('ALTER TABLE mcp_servers ADD COLUMN owner_id TEXT REFERENCES users(id) ON DELETE CASCADE');
|
||||
}
|
||||
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_mcp_servers_owner ON mcp_servers(owner_id);');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SSH tables exist.
|
||||
* Idempotent (uses CREATE TABLE IF NOT EXISTS).
|
||||
* Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1).
|
||||
*
|
||||
* FK clauses are omitted vs schema.sql to keep the migration safe when applied
|
||||
* to in-flight DBs where FK enforcement may already be ON.
|
||||
*/
|
||||
function migrateSshTables(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS system_deks (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
encrypted_dek BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_user_deks (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_dek BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT,
|
||||
label TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
private_key_enc BLOB NOT NULL,
|
||||
passphrase_enc BLOB,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
key_fingerprint TEXT,
|
||||
|
||||
host_key_type TEXT,
|
||||
host_key_b64 TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_recorded_at TEXT,
|
||||
host_key_verified_at TEXT,
|
||||
host_key_pending INTEGER NOT NULL DEFAULT 0,
|
||||
host_key_pending_b64 TEXT,
|
||||
host_key_pending_fingerprint TEXT,
|
||||
host_key_pending_token TEXT,
|
||||
host_key_pending_source TEXT,
|
||||
|
||||
command_deny_patterns TEXT,
|
||||
command_allow_patterns TEXT,
|
||||
remote_path_prefix TEXT NOT NULL CHECK (LENGTH(remote_path_prefix) > 0),
|
||||
allow_remote_unrestricted INTEGER NOT NULL DEFAULT 0,
|
||||
allow_private_addresses INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
disabled_by_admin INTEGER NOT NULL DEFAULT 0,
|
||||
disabled_by_admin_reason TEXT,
|
||||
disabled_by_admin_at TEXT,
|
||||
disabled_by_admin_user_id TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_connections_owner ON ssh_connections(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_connections_enabled ON ssh_connections(enabled, disabled_by_admin);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_connection_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','org')),
|
||||
subject_id TEXT NOT NULL,
|
||||
piece_name TEXT,
|
||||
applies_to_all_pieces INTEGER NOT NULL DEFAULT 0,
|
||||
granted_by_user_id TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (LENGTH(reason) >= 8),
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(applies_to_all_pieces = 1 AND piece_name IS NULL) OR
|
||||
(applies_to_all_pieces = 0 AND piece_name IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_grants_connection ON ssh_connection_grants(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_grants_subject ON ssh_connection_grants(subject_type, subject_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
connection_id TEXT,
|
||||
owner_id TEXT,
|
||||
acting_user_id TEXT,
|
||||
job_id TEXT,
|
||||
piece_name TEXT,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('pending','success','failed','denied','aborted')),
|
||||
reason TEXT,
|
||||
detail TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_action ON ssh_audit_log(action, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_connection ON ssh_audit_log(connection_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_owner ON ssh_audit_log(owner_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_outcome ON ssh_audit_log(outcome, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_pending ON ssh_audit_log(outcome) WHERE outcome = 'pending';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_abuse_counters (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('conn','userhost','globalhost')),
|
||||
enforce_lock INTEGER NOT NULL DEFAULT 1,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
failure_window_start TEXT,
|
||||
lock_until TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_abuse_kind ON ssh_abuse_counters(scope_kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_abuse_locked ON ssh_abuse_counters(lock_until) WHERE lock_until IS NOT NULL;
|
||||
`);
|
||||
|
||||
// Future ALTERs: add new columns to ssh_connections via PRAGMA table_info pattern,
|
||||
// matching the MCP migrations above.
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Shared Knowledge Notes tables exist.
|
||||
* Idempotent (uses CREATE TABLE IF NOT EXISTS / CREATE VIRTUAL TABLE IF NOT EXISTS).
|
||||
* Plan: docs/superpowers/plans/2026-05-15-shared-knowledge-notes.md (Task 1).
|
||||
*
|
||||
* FK clauses match schema.sql. Note that this function also creates the users
|
||||
* table as a prerequisite so it is safe to call on a fresh DB (e.g. in tests)
|
||||
* before schema.sql has been applied.
|
||||
*/
|
||||
function migrateNotesTables(db: Database.Database): void {
|
||||
// Ensure the users prerequisite table exists (no-op on production DBs where
|
||||
// schema.sql was already applied).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Enable FK enforcement so CASCADE DELETE works (also set by Repository in production;
|
||||
// this covers test DBs that don't go through the Repository constructor).
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS note_index (
|
||||
owner_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
title TEXT,
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('private','org','public')),
|
||||
visibility_scope_org_id TEXT,
|
||||
mode_hint TEXT CHECK (mode_hint IS NULL OR mode_hint IN ('search','inject')),
|
||||
tags_json TEXT,
|
||||
content_size INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, folder, file_name),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_index_visibility ON note_index(visibility, visibility_scope_org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_index_owner_folder ON note_index(owner_id, folder);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_subscriptions (
|
||||
consumer_user_id TEXT NOT NULL,
|
||||
publisher_user_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('search','inject')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (consumer_user_id, publisher_user_id, folder),
|
||||
FOREIGN KEY (consumer_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (publisher_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_subscriptions_consumer_mode ON note_subscriptions(consumer_user_id, mode, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_reindex (
|
||||
owner_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, folder, file_name)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS note_index_fts USING fts5(
|
||||
owner_id UNINDEXED,
|
||||
folder UNINDEXED,
|
||||
file_name UNINDEXED,
|
||||
title,
|
||||
tags,
|
||||
body
|
||||
);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS note_index_ai AFTER INSERT ON note_index BEGIN
|
||||
INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body)
|
||||
VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS note_index_ad AFTER DELETE ON note_index BEGIN
|
||||
DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS note_index_au AFTER UPDATE ON note_index BEGIN
|
||||
DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name;
|
||||
INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body)
|
||||
VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure user_dashboard_widgets table exists for the Side Info Panel feature.
|
||||
* Idempotent (CREATE TABLE IF NOT EXISTS).
|
||||
*/
|
||||
function migrateDashboardWidgets(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'markdown',
|
||||
markdown_content TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, slug)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user
|
||||
ON user_dashboard_widgets (user_id, sort_order);
|
||||
`);
|
||||
// Phase B (2026-05): existing deployments created the table without the
|
||||
// kind column. ALTER is idempotent because we check column existence first.
|
||||
const columns = db.prepare(`PRAGMA table_info(user_dashboard_widgets)`).all() as Array<{ name: string }>;
|
||||
if (!columns.some(c => c.name === 'kind')) {
|
||||
db.exec(`ALTER TABLE user_dashboard_widgets ADD COLUMN kind TEXT NOT NULL DEFAULT 'markdown'`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure gateway_virtual_keys table exists for AAO Gateway Phase 2a.
|
||||
* Idempotent (CREATE TABLE IF NOT EXISTS + partial / non-unique CREATE
|
||||
* INDEX). Mirrors the shape in src/db/schema.sql; both paths must stay in
|
||||
* sync (see memory: project_db_migration_dual_path).
|
||||
*
|
||||
* Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2a).
|
||||
*/
|
||||
function migrateGatewayVirtualKeys(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gateway_virtual_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
key_prefix TEXT NOT NULL,
|
||||
team TEXT NOT NULL,
|
||||
allowed_models TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')),
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT,
|
||||
revoked_at TEXT,
|
||||
revoked_by TEXT,
|
||||
last_used_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active
|
||||
ON gateway_virtual_keys (key_hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_keys_team
|
||||
ON gateway_virtual_keys (team);
|
||||
`);
|
||||
|
||||
// Phase 2b: per-key budget + rate limit columns. Idempotent via
|
||||
// PRAGMA table_info — repeated calls are safe and no-op once present.
|
||||
const cols = db.prepare("PRAGMA table_info('gateway_virtual_keys')").all() as Array<{ name: string }>;
|
||||
const colNames = new Set(cols.map(c => c.name));
|
||||
if (!colNames.has('tokens_budget')) {
|
||||
db.exec('ALTER TABLE gateway_virtual_keys ADD COLUMN tokens_budget INTEGER');
|
||||
}
|
||||
if (!colNames.has('rate_limit_rpm')) {
|
||||
db.exec('ALTER TABLE gateway_virtual_keys ADD COLUMN rate_limit_rpm INTEGER');
|
||||
}
|
||||
|
||||
// Phase 2b: monthly usage tracker. CREATE TABLE IF NOT EXISTS is
|
||||
// idempotent. ON DELETE CASCADE ensures a hard-delete of a virtual key
|
||||
// wipes its usage rows too — but we still write the FK clause here
|
||||
// (matching schema.sql) because the gateway boot enables foreign_keys
|
||||
// pragma. Composite PK doubles as the lookup index for the hot-path
|
||||
// budget check (`getGatewayKeyUsage`).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS gateway_key_usage (
|
||||
key_id TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE,
|
||||
period_start TEXT NOT NULL,
|
||||
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||
requests INTEGER NOT NULL DEFAULT 0,
|
||||
last_updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (key_id, period_start)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
|
||||
ON gateway_key_usage (key_id);
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser Notifications V2 (Web Push) tables. Idempotent.
|
||||
* Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
|
||||
* Mirrors schema.sql; both paths must stay in sync (memory:
|
||||
* project_db_migration_dual_path).
|
||||
*/
|
||||
function migratePushNotificationsTables(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
vapid_key_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id
|
||||
ON push_subscriptions(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_prefs (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
event_running INTEGER NOT NULL DEFAULT 1,
|
||||
event_succeeded INTEGER NOT NULL DEFAULT 1,
|
||||
event_failed INTEGER NOT NULL DEFAULT 1,
|
||||
event_waiting_human INTEGER NOT NULL DEFAULT 1,
|
||||
include_details INTEGER NOT NULL DEFAULT 0,
|
||||
v1_migrated INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
describe('Repository user CRUD', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-auth-'));
|
||||
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
||||
runMigrations(repo.getDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
// ── createUser ────────────────────────────────────────────────
|
||||
|
||||
it('createUser creates a user with correct fields', () => {
|
||||
const user = repo.createUser({
|
||||
email: '[email protected]',
|
||||
name: 'Alice',
|
||||
role: 'user',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
expect(user.id).toBeTruthy();
|
||||
expect(user.email).toBe('[email protected]');
|
||||
expect(user.name).toBe('Alice');
|
||||
expect(user.role).toBe('user');
|
||||
expect(user.status).toBe('pending');
|
||||
expect(user.avatarUrl).toBeNull();
|
||||
expect(user.createdAt).toBeTruthy();
|
||||
expect(user.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('createUser stores avatarUrl when provided', () => {
|
||||
const user = repo.createUser({
|
||||
email: '[email protected]',
|
||||
name: 'Bob',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
avatarUrl: 'https://example.com/avatar.png',
|
||||
});
|
||||
|
||||
expect(user.avatarUrl).toBe('https://example.com/avatar.png');
|
||||
expect(user.role).toBe('admin');
|
||||
expect(user.status).toBe('active');
|
||||
});
|
||||
|
||||
// ── getUserByEmail ────────────────────────────────────────────
|
||||
|
||||
it('getUserByEmail returns null for non-existent email', () => {
|
||||
const user = repo.getUserByEmail('[email protected]');
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('getUserByEmail returns the user for known email', () => {
|
||||
repo.createUser({ email: '[email protected]', name: 'Carol', role: 'user', status: 'active' });
|
||||
const user = repo.getUserByEmail('[email protected]');
|
||||
expect(user).not.toBeNull();
|
||||
expect(user?.email).toBe('[email protected]');
|
||||
expect(user?.name).toBe('Carol');
|
||||
});
|
||||
|
||||
// ── getUserById ───────────────────────────────────────────────
|
||||
|
||||
it('getUserById returns the user for known id', () => {
|
||||
const created = repo.createUser({ email: '[email protected]', name: 'Dave', role: 'user', status: 'active' });
|
||||
const found = repo.getUserById(created.id);
|
||||
expect(found).not.toBeNull();
|
||||
expect(found?.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('getUserById returns null for unknown id', () => {
|
||||
const found = repo.getUserById('00000000-0000-0000-0000-000000000000');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
// ── findOrCreateUserByOAuth ───────────────────────────────────
|
||||
|
||||
it('findOrCreateUserByOAuth creates new user on first login with status=pending', () => {
|
||||
const user = repo.findOrCreateUserByOAuth({
|
||||
provider: 'discord',
|
||||
providerId: 'discord-uid-1',
|
||||
email: '[email protected]',
|
||||
name: 'Eve',
|
||||
});
|
||||
|
||||
expect(user.email).toBe('[email protected]');
|
||||
expect(user.name).toBe('Eve');
|
||||
expect(user.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('findOrCreateUserByOAuth returns same user on subsequent login (same provider_id)', () => {
|
||||
const params = {
|
||||
provider: 'discord',
|
||||
providerId: 'discord-uid-2',
|
||||
email: '[email protected]',
|
||||
name: 'Frank',
|
||||
};
|
||||
|
||||
const first = repo.findOrCreateUserByOAuth(params);
|
||||
const second = repo.findOrCreateUserByOAuth(params);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.email).toBe('[email protected]');
|
||||
});
|
||||
|
||||
it('findOrCreateUserByOAuth links second provider to same user by matching email', () => {
|
||||
// First login via discord
|
||||
const discordUser = repo.findOrCreateUserByOAuth({
|
||||
provider: 'discord',
|
||||
providerId: 'discord-uid-3',
|
||||
email: '[email protected]',
|
||||
name: 'Grace',
|
||||
});
|
||||
|
||||
// Second login via github with same email
|
||||
const githubUser = repo.findOrCreateUserByOAuth({
|
||||
provider: 'github',
|
||||
providerId: 'github-uid-3',
|
||||
email: '[email protected]',
|
||||
name: 'Grace GitHub',
|
||||
});
|
||||
|
||||
// Should return same user
|
||||
expect(githubUser.id).toBe(discordUser.id);
|
||||
|
||||
// Both providers should now be linked to this user
|
||||
const userAgain = repo.findOrCreateUserByOAuth({
|
||||
provider: 'discord',
|
||||
providerId: 'discord-uid-3',
|
||||
email: '[email protected]',
|
||||
name: 'Grace',
|
||||
});
|
||||
expect(userAgain.id).toBe(discordUser.id);
|
||||
});
|
||||
|
||||
// ── listUsers ─────────────────────────────────────────────────
|
||||
|
||||
it('listUsers returns all users', () => {
|
||||
repo.createUser({ email: '[email protected]', name: 'U1', role: 'user', status: 'active' });
|
||||
repo.createUser({ email: '[email protected]', name: 'U2', role: 'user', status: 'pending' });
|
||||
repo.createUser({ email: '[email protected]', name: 'U3', role: 'admin', status: 'active' });
|
||||
|
||||
const users = repo.listUsers();
|
||||
expect(users.length).toBe(3);
|
||||
const emails = users.map(u => u.email);
|
||||
expect(emails).toContain('[email protected]');
|
||||
expect(emails).toContain('[email protected]');
|
||||
expect(emails).toContain('[email protected]');
|
||||
});
|
||||
|
||||
// ── updateUser ────────────────────────────────────────────────
|
||||
|
||||
it('updateUser changes status and role', () => {
|
||||
const user = repo.createUser({ email: '[email protected]', name: 'Henry', role: 'user', status: 'pending' });
|
||||
|
||||
repo.updateUser(user.id, { status: 'active', role: 'admin' });
|
||||
|
||||
const updated = repo.getUserById(user.id);
|
||||
expect(updated?.status).toBe('active');
|
||||
expect(updated?.role).toBe('admin');
|
||||
});
|
||||
|
||||
// ── deleteUser ────────────────────────────────────────────────
|
||||
|
||||
it('deleteUser removes user from DB', () => {
|
||||
const user = repo.createUser({ email: '[email protected]', name: 'Iris', role: 'user', status: 'active' });
|
||||
repo.deleteUser(user.id);
|
||||
const found = repo.getUserById(user.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteUser cascades to oauth_accounts', () => {
|
||||
const user = repo.findOrCreateUserByOAuth({
|
||||
provider: 'discord',
|
||||
providerId: 'discord-uid-cascade',
|
||||
email: '[email protected]',
|
||||
name: 'Jack',
|
||||
});
|
||||
|
||||
repo.deleteUser(user.id);
|
||||
|
||||
// OAuth account should be gone too (CASCADE)
|
||||
// Verify by trying to re-create the same OAuth -> should create fresh user
|
||||
const newUser = repo.findOrCreateUserByOAuth({
|
||||
provider: 'discord',
|
||||
providerId: 'discord-uid-cascade',
|
||||
email: '[email protected]',
|
||||
name: 'Jack',
|
||||
});
|
||||
expect(newUser.id).not.toBe(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ── owner_id filtering ────────────────────────────────────────────
|
||||
|
||||
describe('Repository owner_id filtering', () => {
|
||||
let tempDir = '';
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'maestro-auth-'));
|
||||
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
||||
runMigrations(repo.getDb());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = '';
|
||||
}
|
||||
});
|
||||
|
||||
it('createLocalTask accepts ownerId parameter', async () => {
|
||||
const user = repo.createUser({ email: '[email protected]', name: 'Owner', role: 'user', status: 'active' });
|
||||
|
||||
const task = await repo.createLocalTask({
|
||||
title: 'Test Task',
|
||||
body: 'body',
|
||||
ownerId: user.id,
|
||||
});
|
||||
|
||||
expect(task.id).toBeTruthy();
|
||||
|
||||
// Verify owner_id was stored in DB
|
||||
const db = repo.getDb();
|
||||
const row = db.prepare('SELECT owner_id FROM local_tasks WHERE id = ?').get(task.id) as { owner_id: string | null } | undefined;
|
||||
expect(row?.owner_id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('listLocalTasks filters by ownerId', async () => {
|
||||
const user1 = repo.createUser({ email: '[email protected]', name: 'Owner1', role: 'user', status: 'active' });
|
||||
const user2 = repo.createUser({ email: '[email protected]', name: 'Owner2', role: 'user', status: 'active' });
|
||||
|
||||
await repo.createLocalTask({ title: 'Task A', body: '', ownerId: user1.id });
|
||||
await repo.createLocalTask({ title: 'Task B', body: '', ownerId: user1.id });
|
||||
await repo.createLocalTask({ title: 'Task C', body: '', ownerId: user2.id });
|
||||
await repo.createLocalTask({ title: 'Task D', body: '' }); // no owner
|
||||
|
||||
const user1Tasks = await repo.listLocalTasks({ ownerId: user1.id });
|
||||
expect(user1Tasks.length).toBe(2);
|
||||
const titles1 = user1Tasks.map(t => t.title);
|
||||
expect(titles1).toContain('Task A');
|
||||
expect(titles1).toContain('Task B');
|
||||
|
||||
const user2Tasks = await repo.listLocalTasks({ ownerId: user2.id });
|
||||
expect(user2Tasks.length).toBe(1);
|
||||
expect(user2Tasks[0].title).toBe('Task C');
|
||||
|
||||
// Without filter returns all tasks
|
||||
const allTasks = await repo.listLocalTasks();
|
||||
expect(allTasks.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Repository tests for AAO Gateway Phase 2a virtual keys.
|
||||
*
|
||||
* Coverage targets:
|
||||
* - create / findByHash / findById / list
|
||||
* - allowedModels JSON round-trip (incl. null vs [] vs ['a','b'])
|
||||
* - source defaulting + persistence
|
||||
* - revoke idempotency + activeOnly filter
|
||||
* - UNIQUE(key_hash) constraint
|
||||
* - touch updates last_used_at
|
||||
* - delete returns boolean and removes the row
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { Repository } from './repository.js';
|
||||
|
||||
describe('Repository gateway_virtual_keys (Phase 2a)', () => {
|
||||
let tmpDir: string;
|
||||
let repo: Repository;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'gw-keys-repo-test-'));
|
||||
repo = new Repository(join(tmpDir, 'test.db'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
repo.close();
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a key with admin defaults and is retrievable by hash', () => {
|
||||
const created = repo.createGatewayVirtualKey({
|
||||
keyHash: 'hash-1',
|
||||
keyPrefix: 'sk-aao-AAAAAA',
|
||||
team: 'alpha',
|
||||
});
|
||||
expect(created.id).toBeTruthy();
|
||||
expect(created.source).toBe('admin');
|
||||
expect(created.allowedModels).toBeNull();
|
||||
expect(created.revokedAt).toBeNull();
|
||||
|
||||
const found = repo.findGatewayVirtualKeyByHash('hash-1');
|
||||
expect(found?.id).toBe(created.id);
|
||||
expect(found?.team).toBe('alpha');
|
||||
});
|
||||
|
||||
it('persists allowed_models JSON and distinguishes null vs empty array', () => {
|
||||
const withList = repo.createGatewayVirtualKey({
|
||||
keyHash: 'hash-list',
|
||||
keyPrefix: 'sk-aao-LIST',
|
||||
team: 't1',
|
||||
allowedModels: ['qwen3:8b', 'qwen3:14b'],
|
||||
});
|
||||
expect(withList.allowedModels).toEqual(['qwen3:8b', 'qwen3:14b']);
|
||||
const empty = repo.createGatewayVirtualKey({
|
||||
keyHash: 'hash-empty',
|
||||
keyPrefix: 'sk-aao-EMPTY',
|
||||
team: 't1',
|
||||
allowedModels: [],
|
||||
});
|
||||
expect(empty.allowedModels).toEqual([]);
|
||||
const explicitNull = repo.createGatewayVirtualKey({
|
||||
keyHash: 'hash-null',
|
||||
keyPrefix: 'sk-aao-NULL0',
|
||||
team: 't1',
|
||||
allowedModels: null,
|
||||
});
|
||||
expect(explicitNull.allowedModels).toBeNull();
|
||||
});
|
||||
|
||||
it('records the source value verbatim (config-import vs admin)', () => {
|
||||
const cfg = repo.createGatewayVirtualKey({
|
||||
keyHash: 'h-cfg',
|
||||
keyPrefix: 'sk-aao-CFGCFG',
|
||||
team: 'imported',
|
||||
source: 'config-import',
|
||||
createdBy: 'config',
|
||||
});
|
||||
expect(cfg.source).toBe('config-import');
|
||||
expect(cfg.createdBy).toBe('config');
|
||||
});
|
||||
|
||||
it('rejects duplicate key_hash via UNIQUE constraint', () => {
|
||||
repo.createGatewayVirtualKey({
|
||||
keyHash: 'dup-hash',
|
||||
keyPrefix: 'sk-aao-DUPDUP',
|
||||
team: 'alpha',
|
||||
});
|
||||
expect(() =>
|
||||
repo.createGatewayVirtualKey({
|
||||
keyHash: 'dup-hash',
|
||||
keyPrefix: 'sk-aao-OTHER',
|
||||
team: 'beta',
|
||||
}),
|
||||
).toThrow(/UNIQUE/);
|
||||
});
|
||||
|
||||
it('hides revoked keys from findGatewayVirtualKeyByHash', () => {
|
||||
const k = repo.createGatewayVirtualKey({
|
||||
keyHash: 'will-revoke',
|
||||
keyPrefix: 'sk-aao-REVOKE',
|
||||
team: 'alpha',
|
||||
});
|
||||
expect(repo.findGatewayVirtualKeyByHash('will-revoke')).not.toBeNull();
|
||||
const ok = repo.revokeGatewayVirtualKey(k.id, 'admin-user');
|
||||
expect(ok).toBe(true);
|
||||
expect(repo.findGatewayVirtualKeyByHash('will-revoke')).toBeNull();
|
||||
// Second revoke is a no-op.
|
||||
expect(repo.revokeGatewayVirtualKey(k.id, 'admin-user')).toBe(false);
|
||||
// But findById still exposes it for auditing.
|
||||
const audited = repo.findGatewayVirtualKeyById(k.id);
|
||||
expect(audited?.revokedAt).toBeTruthy();
|
||||
expect(audited?.revokedBy).toBe('admin-user');
|
||||
});
|
||||
|
||||
it('lists with team + activeOnly filters', () => {
|
||||
const a = repo.createGatewayVirtualKey({
|
||||
keyHash: 'h-a',
|
||||
keyPrefix: 'sk-aao-AAAAA1',
|
||||
team: 'alpha',
|
||||
});
|
||||
repo.createGatewayVirtualKey({
|
||||
keyHash: 'h-b',
|
||||
keyPrefix: 'sk-aao-BBBBBB',
|
||||
team: 'beta',
|
||||
});
|
||||
const revoked = repo.createGatewayVirtualKey({
|
||||
keyHash: 'h-a-old',
|
||||
keyPrefix: 'sk-aao-OLD000',
|
||||
team: 'alpha',
|
||||
});
|
||||
repo.revokeGatewayVirtualKey(revoked.id, 'admin');
|
||||
|
||||
const allAlpha = repo.listGatewayVirtualKeys({ team: 'alpha' });
|
||||
expect(allAlpha).toHaveLength(2);
|
||||
const activeAlpha = repo.listGatewayVirtualKeys({ team: 'alpha', activeOnly: true });
|
||||
expect(activeAlpha).toHaveLength(1);
|
||||
expect(activeAlpha[0]!.id).toBe(a.id);
|
||||
});
|
||||
|
||||
it('touches last_used_at without changing other columns', () => {
|
||||
const k = repo.createGatewayVirtualKey({
|
||||
keyHash: 'h-touch',
|
||||
keyPrefix: 'sk-aao-TOUCH0',
|
||||
team: 'alpha',
|
||||
});
|
||||
expect(k.lastUsedAt).toBeNull();
|
||||
repo.touchGatewayVirtualKeyLastUsed(k.id, '2026-05-19T12:00:00.000Z');
|
||||
const after = repo.findGatewayVirtualKeyById(k.id);
|
||||
expect(after?.lastUsedAt).toBe('2026-05-19T12:00:00.000Z');
|
||||
expect(after?.team).toBe('alpha');
|
||||
expect(after?.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('delete returns true on hit and false on miss', () => {
|
||||
const k = repo.createGatewayVirtualKey({
|
||||
keyHash: 'h-delete',
|
||||
keyPrefix: 'sk-aao-DELETE',
|
||||
team: 'alpha',
|
||||
});
|
||||
expect(repo.deleteGatewayVirtualKey(k.id)).toBe(true);
|
||||
expect(repo.findGatewayVirtualKeyById(k.id)).toBeNull();
|
||||
expect(repo.deleteGatewayVirtualKey(k.id)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to delete a config-import key (defense-in-depth)', () => {
|
||||
// The admin REST API also rejects this, but the Repository must
|
||||
// refuse too: a future internal caller could otherwise hard-delete
|
||||
// a config-import row that would just be replayed on next gateway
|
||||
// boot when importConfigKeysToDb re-imports it with a different id.
|
||||
const cfg = repo.createGatewayVirtualKey({
|
||||
keyHash: 'cfg-protect',
|
||||
keyPrefix: 'sk-aao-CFGGGG',
|
||||
team: 'imported',
|
||||
source: 'config-import',
|
||||
createdBy: 'config',
|
||||
});
|
||||
expect(() => repo.deleteGatewayVirtualKey(cfg.id)).toThrow(/config-import/i);
|
||||
// Row must still be present.
|
||||
expect(repo.findGatewayVirtualKeyById(cfg.id)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still deletes admin-issued keys after the source check is in place', () => {
|
||||
const admin = repo.createGatewayVirtualKey({
|
||||
keyHash: 'admin-key',
|
||||
keyPrefix: 'sk-aao-ADMIN0',
|
||||
team: 'team1',
|
||||
source: 'admin',
|
||||
});
|
||||
expect(repo.deleteGatewayVirtualKey(admin.id)).toBe(true);
|
||||
expect(repo.findGatewayVirtualKeyById(admin.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* AAO Gateway Phase 2b — gateway_key_usage repository tests.
|
||||
*
|
||||
* Coverage:
|
||||
* - getGatewayKeyUsage returns null on miss, row on hit
|
||||
* - incrementGatewayKeyUsage UPSERTs first call, accumulates on second
|
||||
* - incrementGatewayKeyUsage clamps negative deltas to zero
|
||||
* - listGatewayKeyUsagesByKey orders by period_start DESC
|
||||
* - Cascade delete removes usage rows when key is deleted
|
||||
* - tokens_budget / rate_limit_rpm fields persist & round-trip
|
||||
* - updateGatewayVirtualKey patches fields independently and supports null reset
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import { Repository } from './repository.js';
|
||||
import { hashKey } from '../gateway/key-format.js';
|
||||
|
||||
function makeRepo(): Repository {
|
||||
// ':memory:' triggers Repository.initSchema (creates all tables fresh).
|
||||
return new Repository(':memory:');
|
||||
}
|
||||
|
||||
function seedKey(repo: Repository, raw: string, team = 'alpha'): string {
|
||||
return repo.createGatewayVirtualKey({
|
||||
keyHash: hashKey(raw),
|
||||
keyPrefix: raw.slice(0, 14),
|
||||
team,
|
||||
}).id;
|
||||
}
|
||||
|
||||
describe('gateway_key_usage repository', () => {
|
||||
let repo: Repository;
|
||||
beforeEach(() => {
|
||||
repo = makeRepo();
|
||||
});
|
||||
|
||||
it('getGatewayKeyUsage returns null when no row exists', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-1');
|
||||
expect(repo.getGatewayKeyUsage(id, '2026-05')).toBeNull();
|
||||
});
|
||||
|
||||
it('incrementGatewayKeyUsage creates row on first call, accumulates on second', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-2');
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 100, tokensOut: 50, requests: 1 });
|
||||
let usage = repo.getGatewayKeyUsage(id, '2026-05');
|
||||
expect(usage).not.toBeNull();
|
||||
expect(usage!.tokensIn).toBe(100);
|
||||
expect(usage!.tokensOut).toBe(50);
|
||||
expect(usage!.requests).toBe(1);
|
||||
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 200, tokensOut: 80, requests: 1 });
|
||||
usage = repo.getGatewayKeyUsage(id, '2026-05');
|
||||
expect(usage!.tokensIn).toBe(300);
|
||||
expect(usage!.tokensOut).toBe(130);
|
||||
expect(usage!.requests).toBe(2);
|
||||
});
|
||||
|
||||
it('different periods get separate rows', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-3');
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', tokensIn: 10, requests: 1 });
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 20, requests: 1 });
|
||||
expect(repo.getGatewayKeyUsage(id, '2026-04')!.tokensIn).toBe(10);
|
||||
expect(repo.getGatewayKeyUsage(id, '2026-05')!.tokensIn).toBe(20);
|
||||
});
|
||||
|
||||
it('clamps negative deltas to zero', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-4');
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 100 });
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: -500, tokensOut: -10, requests: -1 });
|
||||
const usage = repo.getGatewayKeyUsage(id, '2026-05')!;
|
||||
expect(usage.tokensIn).toBe(100); // unchanged
|
||||
expect(usage.tokensOut).toBe(0);
|
||||
expect(usage.requests).toBe(0);
|
||||
});
|
||||
|
||||
it('listGatewayKeyUsagesByKey returns rows newest period first', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-5');
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-03', requests: 1 });
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', requests: 1 });
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-04', requests: 1 });
|
||||
const list = repo.listGatewayKeyUsagesByKey(id);
|
||||
expect(list.map(u => u.periodStart)).toEqual(['2026-05', '2026-04', '2026-03']);
|
||||
});
|
||||
|
||||
it('listGatewayKeyUsagesByKey honors limit', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-6');
|
||||
for (const m of ['2026-01', '2026-02', '2026-03', '2026-04']) {
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: m, requests: 1 });
|
||||
}
|
||||
const list = repo.listGatewayKeyUsagesByKey(id, { limit: 2 });
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]!.periodStart).toBe('2026-04');
|
||||
});
|
||||
|
||||
it('cascade-deletes usage rows when key is hard-deleted', () => {
|
||||
const id = seedKey(repo, 'sk-aao-test-7');
|
||||
repo.incrementGatewayKeyUsage({ keyId: id, period: '2026-05', tokensIn: 1 });
|
||||
expect(repo.getGatewayKeyUsage(id, '2026-05')).not.toBeNull();
|
||||
|
||||
// The Repository's defense-in-depth guard refuses to delete
|
||||
// config-import rows. Seeded above as 'admin' so this is legal.
|
||||
repo.deleteGatewayVirtualKey(id);
|
||||
expect(repo.getGatewayKeyUsage(id, '2026-05')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway_virtual_keys budget/rate fields', () => {
|
||||
let repo: Repository;
|
||||
beforeEach(() => {
|
||||
repo = makeRepo();
|
||||
});
|
||||
|
||||
it('round-trips tokensBudget and rateLimitRpm on create', () => {
|
||||
const created = repo.createGatewayVirtualKey({
|
||||
keyHash: hashKey('sk-aao-create-budget'),
|
||||
keyPrefix: 'sk-aao-create',
|
||||
team: 'alpha',
|
||||
tokensBudget: 1_000_000,
|
||||
rateLimitRpm: 60,
|
||||
});
|
||||
expect(created.tokensBudget).toBe(1_000_000);
|
||||
expect(created.rateLimitRpm).toBe(60);
|
||||
|
||||
const refreshed = repo.findGatewayVirtualKeyById(created.id)!;
|
||||
expect(refreshed.tokensBudget).toBe(1_000_000);
|
||||
expect(refreshed.rateLimitRpm).toBe(60);
|
||||
});
|
||||
|
||||
it('defaults to null when budget/rate omitted', () => {
|
||||
const created = repo.createGatewayVirtualKey({
|
||||
keyHash: hashKey('sk-aao-no-limits'),
|
||||
keyPrefix: 'sk-aao-no-lim',
|
||||
team: 'beta',
|
||||
});
|
||||
expect(created.tokensBudget).toBeNull();
|
||||
expect(created.rateLimitRpm).toBeNull();
|
||||
});
|
||||
|
||||
it('coerces zero or negative limits to null (defensive)', () => {
|
||||
const created = repo.createGatewayVirtualKey({
|
||||
keyHash: hashKey('sk-aao-bad-limits'),
|
||||
keyPrefix: 'sk-aao-bad',
|
||||
team: 'gamma',
|
||||
tokensBudget: 0,
|
||||
rateLimitRpm: -5,
|
||||
});
|
||||
expect(created.tokensBudget).toBeNull();
|
||||
expect(created.rateLimitRpm).toBeNull();
|
||||
});
|
||||
|
||||
it('updateGatewayVirtualKey patches only specified fields', () => {
|
||||
const created = repo.createGatewayVirtualKey({
|
||||
keyHash: hashKey('sk-aao-patch'),
|
||||
keyPrefix: 'sk-aao-patch',
|
||||
team: 'alpha',
|
||||
tokensBudget: 1000,
|
||||
rateLimitRpm: 60,
|
||||
allowedModels: ['qwen3:8b'],
|
||||
});
|
||||
// Patch only tokensBudget — other fields untouched
|
||||
const after1 = repo.updateGatewayVirtualKey(created.id, { tokensBudget: 5000 });
|
||||
expect(after1.tokensBudget).toBe(5000);
|
||||
expect(after1.rateLimitRpm).toBe(60);
|
||||
expect(after1.allowedModels).toEqual(['qwen3:8b']);
|
||||
|
||||
// Patch allowedModels alone
|
||||
const after2 = repo.updateGatewayVirtualKey(created.id, { allowedModels: ['qwen3:14b'] });
|
||||
expect(after2.allowedModels).toEqual(['qwen3:14b']);
|
||||
expect(after2.tokensBudget).toBe(5000);
|
||||
|
||||
// Reset rate limit to null (unlimited)
|
||||
const after3 = repo.updateGatewayVirtualKey(created.id, { rateLimitRpm: null });
|
||||
expect(after3.rateLimitRpm).toBeNull();
|
||||
|
||||
// Reset allowedModels to null (no allowlist)
|
||||
const after4 = repo.updateGatewayVirtualKey(created.id, { allowedModels: null });
|
||||
expect(after4.allowedModels).toBeNull();
|
||||
});
|
||||
|
||||
it('updateGatewayVirtualKey throws for unknown id', () => {
|
||||
expect(() => repo.updateGatewayVirtualKey('does-not-exist', { tokensBudget: 1 })).toThrow();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,592 @@
|
||||
-- ジョブテーブル
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
repo TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
pr_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued', 'dispatching', 'running', 'succeeded', 'failed', 'retry', 'cancelled', 'waiting_human', 'waiting_subtasks')),
|
||||
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||
required_profile TEXT NOT NULL DEFAULT 'auto',
|
||||
task_class TEXT NOT NULL DEFAULT 'auto',
|
||||
current_movement TEXT,
|
||||
current_activity TEXT,
|
||||
instruction TEXT NOT NULL DEFAULT '',
|
||||
branch_name TEXT,
|
||||
worktree_path TEXT,
|
||||
attempt INTEGER NOT NULL DEFAULT 1,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
next_retry_at TEXT,
|
||||
error_summary TEXT,
|
||||
abort_reason TEXT, -- structured abort code (e.g. 'max_iterations_exceeded', 'agent_self_abort'); see piece-runner / agent-loop
|
||||
resume_movement TEXT,
|
||||
wait_reason TEXT, -- 'ask' | 'browser_login' | 'mcp_auth_required' | NULL
|
||||
ask_count INTEGER NOT NULL DEFAULT 0,
|
||||
worker_id TEXT,
|
||||
last_backend_id TEXT, -- physical backend id (LiteLLM deployment) for proxy workers; NULL for direct
|
||||
parent_job_id TEXT,
|
||||
subtask_depth INTEGER NOT NULL DEFAULT 0,
|
||||
context_prompt_tokens INTEGER,
|
||||
context_limit_tokens INTEGER,
|
||||
context_updated_at TEXT,
|
||||
task_kind TEXT NOT NULL DEFAULT 'agent',
|
||||
payload TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_repo_issue ON jobs (repo, issue_number);
|
||||
|
||||
-- Local task テーブル
|
||||
CREATE TABLE IF NOT EXISTS local_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
piece_name TEXT NOT NULL DEFAULT 'general',
|
||||
profile TEXT NOT NULL DEFAULT 'auto',
|
||||
output_format TEXT NOT NULL DEFAULT 'markdown',
|
||||
ask_policy TEXT NOT NULL DEFAULT 'low',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
state TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK (state IN ('open', 'closed')),
|
||||
workspace_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
feedback_rating TEXT,
|
||||
feedback_comment TEXT,
|
||||
feedback_tags TEXT,
|
||||
feedback_at TEXT,
|
||||
share_token TEXT,
|
||||
shared_at TEXT,
|
||||
-- Mission Brief: per-task pinned memo carrying { goal, done, open, clarifications }
|
||||
-- as a single JSON blob. Always rendered at the top of every movement's system
|
||||
-- prompt. Editable by both the LLM (mission_update tool) and the user (UI).
|
||||
mission_brief TEXT,
|
||||
-- Per-task options (JSON blob). Controls runtime behaviour toggles such as
|
||||
-- { mcpDisabled: true, skillsDisabled: true }. Default '{}' = all enabled.
|
||||
options TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);
|
||||
|
||||
-- Local task コメント / イベント
|
||||
CREATE TABLE IF NOT EXISTS local_task_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
author TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'comment',
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
injected_at TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES local_tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_local_task_comments_task_id ON local_task_comments (task_id, created_at ASC);
|
||||
|
||||
-- Issue ロックテーブル (同一 Issue の並列実行を防ぐ)
|
||||
CREATE TABLE IF NOT EXISTS issue_locks (
|
||||
repo TEXT NOT NULL,
|
||||
issue_number INTEGER NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
locked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (repo, issue_number)
|
||||
);
|
||||
|
||||
-- 監査ログテーブル
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT,
|
||||
detail TEXT, -- JSON
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_job_id ON audit_log (job_id);
|
||||
|
||||
-- Worker ノード状態
|
||||
CREATE TABLE IF NOT EXISTS worker_nodes (
|
||||
worker_id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
healthy INTEGER NOT NULL DEFAULT 0,
|
||||
profile_tags TEXT NOT NULL DEFAULT ',auto,',
|
||||
task_class_tags TEXT NOT NULL DEFAULT ',auto,',
|
||||
max_concurrency INTEGER NOT NULL DEFAULT 1,
|
||||
available_models TEXT,
|
||||
inflight_jobs INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- スケジュール実行定義
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
body TEXT NOT NULL,
|
||||
piece_name TEXT NOT NULL DEFAULT 'auto',
|
||||
profile TEXT NOT NULL DEFAULT 'auto',
|
||||
output_format TEXT NOT NULL DEFAULT 'markdown',
|
||||
cron_expression TEXT NOT NULL,
|
||||
next_run_at TEXT NOT NULL,
|
||||
last_run_at TEXT,
|
||||
last_job_id TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
task_kind TEXT NOT NULL DEFAULT 'agent' CHECK (task_kind IN ('agent','script')),
|
||||
script_name TEXT,
|
||||
script_params TEXT, -- JSON-encoded object when task_kind = 'script'
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Auth: users
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
name TEXT,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Auth: OAuth account linkage
|
||||
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(provider, provider_id)
|
||||
);
|
||||
|
||||
-- Auth: sessions (express-session)
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
sess TEXT NOT NULL,
|
||||
expired TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired);
|
||||
|
||||
-- ── Browser session persistence (2026-05) ─────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS user_deks (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
encrypted_dek BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS browser_session_profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
start_url TEXT NOT NULL,
|
||||
match_patterns TEXT NOT NULL DEFAULT '[]',
|
||||
storage_origins TEXT NOT NULL DEFAULT '[]',
|
||||
logged_in_selector TEXT,
|
||||
login_url_patterns TEXT NOT NULL DEFAULT '[]',
|
||||
encrypted_state_blob BLOB,
|
||||
state_version INTEGER NOT NULL DEFAULT 0,
|
||||
playwright_version TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','expired','revoked','error')),
|
||||
last_saved_at TEXT,
|
||||
last_used_at TEXT,
|
||||
last_validated_at TEXT,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bsp_owner ON browser_session_profiles(owner_id);
|
||||
|
||||
-- audit log: intentionally no FK — must survive deletion of referenced rows
|
||||
CREATE TABLE IF NOT EXISTS browser_session_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
actor_user_id TEXT,
|
||||
profile_id INTEGER,
|
||||
owner_id TEXT,
|
||||
action TEXT NOT NULL CHECK (action IN ('create','save','decrypt','use','delete','expire','revoke','test','login_start','login_cancel')),
|
||||
task_id INTEGER,
|
||||
job_id TEXT,
|
||||
result TEXT NOT NULL CHECK (result IN ('success','error')),
|
||||
reason TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_bsa_profile ON browser_session_audit(profile_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_bsa_actor ON browser_session_audit(actor_user_id);
|
||||
|
||||
-- ─── MCP (Model Context Protocol) ──────────────────────────────
|
||||
-- Note: migrate.ts intentionally omits the FK clauses below for
|
||||
-- in-flight DBs; see migrateMcpTables in src/db/migrate.ts.
|
||||
|
||||
-- Admin-registered MCP servers
|
||||
CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
oauth_client_id TEXT NOT NULL,
|
||||
oauth_client_secret_enc BLOB NOT NULL,
|
||||
oauth_scopes TEXT,
|
||||
issuer TEXT,
|
||||
authorization_endpoint TEXT,
|
||||
token_endpoint TEXT,
|
||||
discovery_fingerprint TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_by TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Per-user OAuth tokens
|
||||
CREATE TABLE IF NOT EXISTS user_mcp_tokens (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
|
||||
access_token_enc BLOB NOT NULL,
|
||||
refresh_token_enc BLOB,
|
||||
expires_at TEXT,
|
||||
scope TEXT,
|
||||
scope_type TEXT NOT NULL DEFAULT 'user' CHECK(scope_type IN ('user', 'org')),
|
||||
scope_id TEXT,
|
||||
connected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, server_id)
|
||||
);
|
||||
|
||||
-- list_tools cache
|
||||
CREATE TABLE IF NOT EXISTS mcp_server_tools (
|
||||
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
|
||||
tool_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
input_schema TEXT,
|
||||
refreshed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (server_id, tool_name)
|
||||
);
|
||||
|
||||
-- Pending OAuth state/verifier (single-use, TTL-cleaned)
|
||||
CREATE TABLE IF NOT EXISTS mcp_oauth_pending (
|
||||
state TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
|
||||
code_verifier TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_pending_created ON mcp_oauth_pending(created_at);
|
||||
|
||||
-- ─── SSH (2026-05) ──────────────────────────────────────────
|
||||
-- Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md
|
||||
-- Feature gated by config.ssh.enabled; tables are inert when disabled.
|
||||
|
||||
-- System DEK for envelope-encrypting global connection keys.
|
||||
-- Single-row table (CHECK id=1). Bootstrapped at boot when ssh.enabled.
|
||||
-- Wrapped under MCP_ENCRYPTION_KEY env var (the SSH "master key").
|
||||
CREATE TABLE IF NOT EXISTS system_deks (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
encrypted_dek BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Per-user DEK for user-owned SSH connection keys.
|
||||
-- Distinct from `user_deks` (used by browser-sessions under master.key file).
|
||||
-- ssh_user_deks is wrapped under MCP_ENCRYPTION_KEY env var.
|
||||
-- Created lazily on first SSH connection creation per user.
|
||||
CREATE TABLE IF NOT EXISTS ssh_user_deks (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
encrypted_dek BLOB NOT NULL,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- SSH connection records. Private key is envelope-encrypted (per-user DEK for
|
||||
-- user-owned, system DEK for globals). Host key is TOFU with explicit UI verify.
|
||||
-- Lock/abuse state lives in ssh_abuse_counters (single source of truth).
|
||||
CREATE TABLE IF NOT EXISTS ssh_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT,
|
||||
label TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
private_key_enc BLOB NOT NULL,
|
||||
passphrase_enc BLOB,
|
||||
key_version INTEGER NOT NULL DEFAULT 1,
|
||||
key_fingerprint TEXT,
|
||||
|
||||
host_key_type TEXT,
|
||||
host_key_b64 TEXT,
|
||||
host_key_fingerprint TEXT,
|
||||
host_key_recorded_at TEXT,
|
||||
host_key_verified_at TEXT,
|
||||
host_key_pending INTEGER NOT NULL DEFAULT 0,
|
||||
host_key_pending_b64 TEXT,
|
||||
host_key_pending_fingerprint TEXT,
|
||||
host_key_pending_token TEXT,
|
||||
host_key_pending_source TEXT,
|
||||
|
||||
command_deny_patterns TEXT,
|
||||
command_allow_patterns TEXT,
|
||||
remote_path_prefix TEXT NOT NULL CHECK (LENGTH(remote_path_prefix) > 0),
|
||||
allow_remote_unrestricted INTEGER NOT NULL DEFAULT 0,
|
||||
allow_private_addresses INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
disabled_by_admin INTEGER NOT NULL DEFAULT 0,
|
||||
disabled_by_admin_reason TEXT,
|
||||
disabled_by_admin_at TEXT,
|
||||
disabled_by_admin_user_id TEXT,
|
||||
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_connections_owner ON ssh_connections(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_connections_enabled ON ssh_connections(enabled, disabled_by_admin);
|
||||
|
||||
-- Per-(user|org, piece) grants. Required for non-owner non-admin access to a
|
||||
-- connection (typical case: granting access to a global connection).
|
||||
-- applies_to_all_pieces=1 makes piece_name NULL and requires admin + reason.
|
||||
CREATE TABLE IF NOT EXISTS ssh_connection_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL REFERENCES ssh_connections(id) ON DELETE CASCADE,
|
||||
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','org')),
|
||||
subject_id TEXT NOT NULL,
|
||||
piece_name TEXT,
|
||||
applies_to_all_pieces INTEGER NOT NULL DEFAULT 0,
|
||||
granted_by_user_id TEXT NOT NULL,
|
||||
reason TEXT NOT NULL CHECK (LENGTH(reason) >= 8),
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
CHECK (
|
||||
(applies_to_all_pieces = 1 AND piece_name IS NULL) OR
|
||||
(applies_to_all_pieces = 0 AND piece_name IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_grants_connection ON ssh_connection_grants(connection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_grants_subject ON ssh_connection_grants(subject_type, subject_id);
|
||||
|
||||
-- Dedicated audit log with pending→completed lifecycle.
|
||||
-- beginAudit inserts outcome='pending' and commits before any remote call.
|
||||
-- completeAudit updates outcome + completed_at + detail.
|
||||
-- Startup recovery sweeps remaining 'pending' rows to 'aborted'.
|
||||
CREATE TABLE IF NOT EXISTS ssh_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
connection_id TEXT,
|
||||
owner_id TEXT,
|
||||
acting_user_id TEXT,
|
||||
job_id TEXT,
|
||||
piece_name TEXT,
|
||||
outcome TEXT NOT NULL CHECK (outcome IN ('pending','success','failed','denied','aborted')),
|
||||
reason TEXT,
|
||||
detail TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
FOREIGN KEY (connection_id) REFERENCES ssh_connections(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_action ON ssh_audit_log(action, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_connection ON ssh_audit_log(connection_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_owner ON ssh_audit_log(owner_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_outcome ON ssh_audit_log(outcome, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_audit_pending ON ssh_audit_log(outcome) WHERE outcome = 'pending';
|
||||
|
||||
-- Abuse counters: single source of truth for failure_count + lock_until.
|
||||
-- Three scope kinds: 'conn' (per connection), 'userhost' (per user+host+username),
|
||||
-- 'globalhost' (per host+username; enforce_lock=0 for user-owned connections, 1 for globals).
|
||||
CREATE TABLE IF NOT EXISTS ssh_abuse_counters (
|
||||
scope_key TEXT PRIMARY KEY,
|
||||
scope_kind TEXT NOT NULL CHECK (scope_kind IN ('conn','userhost','globalhost')),
|
||||
enforce_lock INTEGER NOT NULL DEFAULT 1,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
failure_window_start TEXT,
|
||||
lock_until TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_abuse_kind ON ssh_abuse_counters(scope_kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_ssh_abuse_locked ON ssh_abuse_counters(lock_until) WHERE lock_until IS NOT NULL;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Shared Knowledge Notes
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_index (
|
||||
owner_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
title TEXT,
|
||||
visibility TEXT NOT NULL CHECK (visibility IN ('private','org','public')),
|
||||
visibility_scope_org_id TEXT,
|
||||
mode_hint TEXT CHECK (mode_hint IS NULL OR mode_hint IN ('search','inject')),
|
||||
tags_json TEXT,
|
||||
content_size INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, folder, file_name),
|
||||
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_index_visibility ON note_index(visibility, visibility_scope_org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_index_owner_folder ON note_index(owner_id, folder);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_subscriptions (
|
||||
consumer_user_id TEXT NOT NULL,
|
||||
publisher_user_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('search','inject')),
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (consumer_user_id, publisher_user_id, folder),
|
||||
FOREIGN KEY (consumer_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (publisher_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_subscriptions_consumer_mode ON note_subscriptions(consumer_user_id, mode, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_reindex (
|
||||
owner_id TEXT NOT NULL,
|
||||
folder TEXT NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (owner_id, folder, file_name)
|
||||
);
|
||||
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS note_index_fts USING fts5(
|
||||
owner_id UNINDEXED,
|
||||
folder UNINDEXED,
|
||||
file_name UNINDEXED,
|
||||
title,
|
||||
tags,
|
||||
body
|
||||
);
|
||||
|
||||
-- Sync triggers
|
||||
CREATE TRIGGER IF NOT EXISTS note_index_ai AFTER INSERT ON note_index BEGIN
|
||||
INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body)
|
||||
VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS note_index_ad AFTER DELETE ON note_index BEGIN
|
||||
DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name;
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS note_index_au AFTER UPDATE ON note_index BEGIN
|
||||
DELETE FROM note_index_fts WHERE owner_id = old.owner_id AND folder = old.folder AND file_name = old.file_name;
|
||||
INSERT INTO note_index_fts(owner_id, folder, file_name, title, tags, body)
|
||||
VALUES (new.owner_id, new.folder, new.file_name, new.title, new.tags_json, new.body);
|
||||
END;
|
||||
|
||||
-- Per-user dashboard widgets (Side Info Panel feature, 2026-05).
|
||||
-- Markdown content authored by the user or by an agent via UpdateDashboardWidget.
|
||||
-- `slug` is user-scoped and used as an upsert key by the agent tool.
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
-- Widget kind. 'markdown' (default) stores user/agent-authored Markdown in
|
||||
-- markdown_content; 'node-status' is a built-in view that ignores
|
||||
-- markdown_content and renders BackendStatusRegistry data live. See
|
||||
-- docs/superpowers/specs/2026-05-18-multi-team-gpu-pool-and-node-status-design.md (Phase B).
|
||||
kind TEXT NOT NULL DEFAULT 'markdown',
|
||||
markdown_content TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(user_id, slug)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_user
|
||||
ON user_dashboard_widgets (user_id, sort_order);
|
||||
|
||||
-- ── AAO Gateway: virtual keys (Phase 2a) ────────────────────────────────
|
||||
-- Per-team bearer keys for gateway-mode auth. Only the SHA-256 hash is
|
||||
-- stored (raw key is returned to the admin exactly once at issuance).
|
||||
-- `source` distinguishes admin-API-issued keys from keys auto-imported
|
||||
-- from config.yaml at gateway boot; the latter are protected from hard
|
||||
-- delete via the admin API.
|
||||
-- Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2a).
|
||||
CREATE TABLE IF NOT EXISTS gateway_virtual_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
key_prefix TEXT NOT NULL,
|
||||
team TEXT NOT NULL,
|
||||
allowed_models TEXT, -- JSON array, NULL = no allowlist
|
||||
source TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')),
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT,
|
||||
revoked_at TEXT,
|
||||
revoked_by TEXT,
|
||||
last_used_at TEXT,
|
||||
-- Phase 2b additions: per-key monthly token budget + per-minute rate
|
||||
-- limit. NULL = unlimited (default). Enforced by the gateway middleware
|
||||
-- chain; the DB only persists the limit values.
|
||||
tokens_budget INTEGER,
|
||||
rate_limit_rpm INTEGER
|
||||
);
|
||||
|
||||
-- Partial index so hot-path auth lookup only walks active keys.
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active
|
||||
ON gateway_virtual_keys (key_hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_keys_team
|
||||
ON gateway_virtual_keys (team);
|
||||
|
||||
-- ── AAO Gateway: per-key usage tracking (Phase 2b) ──────────────────────
|
||||
-- Monthly token + request counters. period_start is the UTC 'YYYY-MM'
|
||||
-- bucket. PRIMARY KEY (key_id, period_start) lets the budget enforcement
|
||||
-- read happen as a single point lookup.
|
||||
-- Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2b).
|
||||
CREATE TABLE IF NOT EXISTS gateway_key_usage (
|
||||
key_id TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE,
|
||||
period_start TEXT NOT NULL, -- 'YYYY-MM' (UTC)
|
||||
tokens_in INTEGER NOT NULL DEFAULT 0,
|
||||
tokens_out INTEGER NOT NULL DEFAULT 0,
|
||||
requests INTEGER NOT NULL DEFAULT 0,
|
||||
last_updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (key_id, period_start)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
|
||||
ON gateway_key_usage (key_id);
|
||||
|
||||
-- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ───
|
||||
-- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md
|
||||
-- endpoint is globally UNIQUE so logging into a different user in the same
|
||||
-- browser reassigns the subscription via ON CONFLICT(endpoint) DO UPDATE.
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
user_agent TEXT,
|
||||
vapid_key_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_success_at TEXT,
|
||||
last_failure_at TEXT,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id
|
||||
ON push_subscriptions(user_id);
|
||||
|
||||
-- Per-user notification preferences. v1_migrated tracks the one-time import
|
||||
-- from V1 localStorage. include_details gates whether task title/piece name
|
||||
-- are placed in the push payload (privacy-default off).
|
||||
CREATE TABLE IF NOT EXISTS user_notification_prefs (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
event_running INTEGER NOT NULL DEFAULT 1,
|
||||
event_succeeded INTEGER NOT NULL DEFAULT 1,
|
||||
event_failed INTEGER NOT NULL DEFAULT 1,
|
||||
event_waiting_human INTEGER NOT NULL DEFAULT 1,
|
||||
include_details INTEGER NOT NULL DEFAULT 0,
|
||||
v1_migrated INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
Reference in New Issue
Block a user