feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
+83
View File
@@ -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 });
});
});
+89
View File
@@ -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 callers 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');
});
});
+173
View File
@@ -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
});
});
+104
View File
@@ -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);
});
});
+59
View File
@@ -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);
});
});
+413
View File
@@ -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);
});
});
+204
View File
@@ -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();
});
});
+534
View File
@@ -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'))
);
`);
}
+267
View File
@@ -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);
});
});
+195
View File
@@ -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();
});
});
+182
View File
@@ -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
+3490
View File
File diff suppressed because it is too large Load Diff
+592
View File
@@ -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'))
);