maestro/src/db/migrate.ts
oss-sync ed3dc5529a
All checks were successful
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s
sync: update from private repo (9a86f49b)
2026-07-12 23:51:48 +00:00

1494 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type Database from 'better-sqlite3';
import { randomUUID } from 'node:crypto';
import { logger } from '../logger.js';
import { normalizeCommentToIndexText } from '../engine/task-index/normalize.js';
/**
* 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)");
}
// Audit 2026-06-08 fix: user_gitea_orgs was created ONLY in
// Repository.initSchema(), so a DB built via the migration path alone lacked
// it and the task-list display SELECT (correlated subquery on org_name in
// repository.ts) crashed with "no such table". Create it here too so the
// fresh path (schema.sql) and the migration path stay in sync.
db.exec(`
CREATE TABLE IF NOT EXISTS user_gitea_orgs (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
org_id TEXT NOT NULL,
org_name TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, org_id)
);
CREATE INDEX IF NOT EXISTS idx_user_gitea_orgs_org_id ON user_gitea_orgs(org_id);
`);
// Tool requests: agent-declared (RequestTool) or passively-captured (a tool
// call blocked because it was not available in the current movement). Surfaced
// in task detail + aggregation so workspace tool-policy omissions become visible/fixable.
// Keep in sync with schema.sql (fresh path) and Repository.initSchema.
db.exec(`
CREATE TABLE IF NOT EXISTS tool_requests (
id TEXT PRIMARY KEY,
task_id TEXT,
job_id TEXT,
space_id TEXT,
piece_name TEXT NOT NULL,
movement_name TEXT NOT NULL,
tool_name TEXT NOT NULL,
reason TEXT,
category TEXT NOT NULL DEFAULT 'requested',
status TEXT NOT NULL DEFAULT 'pending',
grant_scope TEXT,
decided_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_tool_requests_task ON tool_requests (task_id);
CREATE INDEX IF NOT EXISTS idx_tool_requests_piece ON tool_requests (piece_name);
CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status);
`);
// Package requests: agent-declared (RequestPackage) requests to install a
// Python wheel into the task's space overlay, pending a task-write approver.
// Keep in sync with schema.sql (fresh path) and Repository.initSchema.
db.exec(`
CREATE TABLE IF NOT EXISTS package_requests (
id TEXT PRIMARY KEY,
task_id TEXT,
job_id TEXT,
space_id TEXT,
piece_name TEXT,
movement_name TEXT,
spec TEXT NOT NULL,
normalized_name TEXT NOT NULL,
reason TEXT,
status TEXT NOT NULL DEFAULT 'pending',
decided_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_package_requests_task ON package_requests (task_id);
CREATE INDEX IF NOT EXISTS idx_package_requests_dedup ON package_requests (job_id, normalized_name, status);
`);
// A2A: oidc-provider 永続化
db.exec(`
CREATE TABLE IF NOT EXISTS oidc_models (
model TEXT NOT NULL,
id TEXT NOT NULL,
payload TEXT NOT NULL,
grant_id TEXT,
user_code TEXT,
uid TEXT,
expires_at INTEGER,
consumed_at INTEGER,
PRIMARY KEY (model, id)
);
CREATE INDEX IF NOT EXISTS idx_oidc_models_grant ON oidc_models (grant_id);
CREATE INDEX IF NOT EXISTS idx_oidc_models_uid ON oidc_models (uid);
CREATE INDEX IF NOT EXISTS idx_oidc_models_usercode ON oidc_models (user_code);
CREATE INDEX IF NOT EXISTS idx_oidc_models_expires ON oidc_models (expires_at);
`);
// A2A: 外部クライアント登録
db.exec(`
CREATE TABLE IF NOT EXISTS a2a_clients (
client_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
redirect_uris TEXT NOT NULL,
secret_hash TEXT, -- reserved for Plan 2 confidential clients; Plan 1 registers public clients only (always NULL). NOTE: oidc-provider compares client_secret in plaintext.
token_endpoint_auth_method TEXT NOT NULL DEFAULT 'client_secret_basic',
agent_card_url TEXT,
issuer TEXT,
key_fingerprint TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// A2A: ユーザー → 外部クライアントへのスコープ付き委任OAuth Grant と grant_id で 1:1
db.exec(`
CREATE TABLE IF NOT EXISTS a2a_delegations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
client_id TEXT NOT NULL,
grant_id TEXT,
granted_space_ids TEXT NOT NULL DEFAULT '[]',
granted_skills TEXT NOT NULL DEFAULT '[]',
audience TEXT,
expires_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_a2a_deleg_grant ON a2a_delegations (grant_id);
CREATE INDEX IF NOT EXISTS idx_a2a_deleg_user ON a2a_delegations (user_id);
`);
// A2A: A2A タスク追跡Plan 2B
db.exec(`
CREATE TABLE IF NOT EXISTS a2a_tasks (
id TEXT PRIMARY KEY,
context_id TEXT,
job_id TEXT,
local_task_id INTEGER,
payload TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_a2a_tasks_job ON a2a_tasks (job_id);
CREATE INDEX IF NOT EXISTS idx_a2a_tasks_context ON a2a_tasks (context_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");
});
// Attachment filenames (JSON array) saved to the task's input/ dir for a
// user comment, so the UI can show download links under the comment bubble.
addColumnIfMissing(db, 'local_task_comments', 'attachments', () => {
db.exec("ALTER TABLE local_task_comments ADD COLUMN attachments 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 '{}'");
});
// Title provenance: 'auto' (creation fallback) / 'agent' (derived from
// Mission Brief goal) / 'user' (manual edit, never auto-overwritten).
addColumnIfMissing(db, 'local_tasks', 'title_source', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN title_source TEXT NOT NULL DEFAULT 'auto'");
});
// LLM 選択 Phase 1: per-job worker/effort pin + per-task スティッキー選択。
// NULL = 従来の profile ルーティング(後方互換)。
// spec: docs/superpowers/specs/2026-07-09-llm-selection-effort-design.md
addColumnIfMissing(db, 'jobs', 'required_worker_id', () => {
db.exec('ALTER TABLE jobs ADD COLUMN required_worker_id TEXT');
});
addColumnIfMissing(db, 'jobs', 'reasoning_effort', () => {
db.exec('ALTER TABLE jobs ADD COLUMN reasoning_effort TEXT');
});
addColumnIfMissing(db, 'local_tasks', 'llm_worker_id', () => {
db.exec('ALTER TABLE local_tasks ADD COLUMN llm_worker_id TEXT');
});
addColumnIfMissing(db, 'local_tasks', 'llm_effort', () => {
db.exec('ALTER TABLE local_tasks ADD COLUMN llm_effort TEXT');
});
migrateMcpTables(db);
migrateSshTables(db);
migrateDashboardWidgets(db);
migrateGatewayVirtualKeys(db);
migratePushNotificationsTables(db);
migrateLlmUsageDaily(db);
migrateLlmUsageHourly(db);
migrateSpaces(db);
migrateSpaceUserPrefs(db);
migrateSpaceMembers(db);
migrateCalendarEvents(db);
migrateAgentReminders(db);
migrateSpaceInvites(db);
migrateAppShareLinks(db);
migratePrivatizeSpaceRows(db);
migrateRenamePersonalSpaceTitle(db);
migrateSpacesToolPolicy(db);
migrateSpacesA2aSkills(db);
migrateSpacesPythonPackages(db);
migrateWorkspaceFileProvenance(db);
migrateTaskCommentIndex(db);
migrateBackfillTaskCommentIndex(db);
migrateA2aTaskDelegationColumns(db);
migrateSpaceWebhooksTables(db);
migrateChatConnectorBindings(db);
}
/**
* workspace_file_provenance テーブルを作成(ファイル来歴台帳)。
* 永続ワークスペースの各ファイルがどのタスクに由来するかを追跡する。
* 冪等: CREATE TABLE IF NOT EXISTS。
* Mirrors schema.sql + Repository.initSchema (三重ミラー; memory: project_db_migration_dual_path).
*/
function migrateWorkspaceFileProvenance(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS workspace_file_provenance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_id TEXT,
workspace_path TEXT NOT NULL,
rel_path TEXT NOT NULL,
created_by_task_id INTEGER,
created_by_job_id TEXT,
created_by_piece TEXT,
created_by_movement TEXT,
source_kind TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_modified_by_task_id INTEGER,
last_modified_by_job_id TEXT,
last_modified_at TEXT,
checksum TEXT,
note TEXT,
UNIQUE(workspace_path, rel_path)
);
CREATE INDEX IF NOT EXISTS idx_wsfile_prov_ws ON workspace_file_provenance (workspace_path);
CREATE INDEX IF NOT EXISTS idx_wsfile_prov_created ON workspace_file_provenance (workspace_path, created_by_task_id);
`);
}
/**
* spaces.tool_policy カラムを追加nullable JSON
* スペース固有のツール制限ポリシーを保持する。
* 冪等: カラムが既に存在する場合は何もしない。
* Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
*/
function migrateSpacesToolPolicy(db: Database.Database): void {
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
if (!exists) return;
const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'tool_policy')) {
db.exec("ALTER TABLE spaces ADD COLUMN tool_policy TEXT");
}
}
/**
* spaces.a2a_skills カラムを追加nullable JSON
* A2A 公開する piece 名の JSON 配列。NULL/未設定 = 公開ゼロfail-closed
* 冪等: カラムが既に存在する場合は何もしない。
* Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
*/
function migrateSpacesA2aSkills(db: Database.Database): void {
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
if (!exists) return;
const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'a2a_skills')) {
db.exec("ALTER TABLE spaces ADD COLUMN a2a_skills TEXT");
}
}
/**
* spaces.python_packages カラムを追加nullable JSON
* admin が入れた Python パッケージの desired-state 配列を保持する。
* 冪等: カラムが既に存在する場合は何もしない。
* Mirrors schema.sql; both paths must stay in sync (memory: project_db_migration_dual_path).
*/
function migrateSpacesPythonPackages(db: Database.Database): void {
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
if (!exists) return;
const cols = db.prepare("PRAGMA table_info('spaces')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'python_packages')) {
db.exec("ALTER TABLE spaces ADD COLUMN python_packages TEXT");
}
}
/**
* 用語統一(スペース→ワークスペース)に伴い、自動生成された個人スペースのタイトル
* 「個人スペース」「{name} の個人スペース」を「個人ワークスペース」表記に置換する。
* `個人ワークスペース` は `個人スペース` を部分文字列に含まないため REPLACE は冪等。
* personal 種別のみ対象(案件スペースのユーザー命名タイトルは触らない)。
*/
function migrateRenamePersonalSpaceTitle(db: Database.Database): void {
const exists = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='spaces'").get();
if (!exists) return;
const info = db
.prepare(
`UPDATE spaces SET title = REPLACE(title, '個人スペース', '個人ワークスペース') ` +
`WHERE kind = 'personal' AND title LIKE '%個人スペース%'`,
)
.run();
if (info.changes > 0) logger.info(`[migrate] rename personal space title: updated=${info.changes}`);
}
/**
* 既存スペース行を private 化する一回限りの backfill冪等
*
* shared-space access モデルでは「スペースの行はメンバー(+根オーナー)にだけ見える」
* のが正。可視性は buildVisibilityWhere の membership OR ブランチが担保する。
* しかし space_id 付きの行が visibility='org'/'public' のまま残っていると、
* その org/public 経路から非メンバーに漏れてしまう。そこで space_id を持つ行を
* すべて private + visibility_scope_org_id=NULL に倒す。
*
* 冪等: 既に private の行は UPDATE 対象外WHERE visibility != 'private')なので
* 再実行は no-op。space_id IS NULL非スペース行は一切触らない。
*/
function migratePrivatizeSpaceRows(db: Database.Database): void {
const tableExists = (name: string): boolean =>
!!(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name));
let total = 0;
for (const table of ['local_tasks', 'jobs', 'scheduled_tasks']) {
if (!tableExists(table)) continue;
const cols = db.prepare(`PRAGMA table_info('${table}')`).all() as Array<{ name: string }>;
const names = new Set(cols.map(c => c.name));
// space_id / visibility 列を持つ DB だけ対象(古い経路ガード)。
if (!names.has('space_id') || !names.has('visibility')) continue;
const hasScope = names.has('visibility_scope_org_id');
const setScope = hasScope ? ', visibility_scope_org_id = NULL' : '';
const info = db
.prepare(
`UPDATE ${table} SET visibility = 'private'${setScope} ` +
`WHERE space_id IS NOT NULL AND visibility != 'private'`,
)
.run();
total += info.changes;
}
if (total > 0) {
logger.info(`[migrate] privatize space rows: updated=${total}`);
}
}
/**
* スペース基盤計画1。spaces テーブルと local_tasks の space_id /
* workspace_mode 列を追加する。追加のみ・冪等。schema.sql と
* Repository.initSchema にミラーがある(三重経路)。
* spec: docs/superpowers/specs/2026-06-17-space-foundation-design.md §5.2
*
* 注: CHECK(kind/visibility/status IN ...) は schema.sql 側のみ。ここで CHECK を
* 付けないのは意図的(既存 in-flight DB に enum 外の行があった場合に CREATE/ALTER が
* 失敗するのを避けるため。他の migrate* と同方針)。後から CHECK を足さないこと。
*/
function migrateSpaces(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS spaces (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL DEFAULT 'case',
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
owner_id TEXT,
visibility TEXT NOT NULL DEFAULT 'private',
visibility_scope_org_id TEXT,
status TEXT NOT NULL DEFAULT 'open',
brand_color TEXT,
workspace_dir TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_spaces_owner ON spaces(owner_id);
CREATE INDEX IF NOT EXISTS idx_spaces_visibility ON spaces(visibility, visibility_scope_org_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_personal_owner ON spaces(owner_id) WHERE kind = 'personal';
`);
addColumnIfMissing(db, 'local_tasks', 'space_id', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN space_id TEXT");
});
addColumnIfMissing(db, 'local_tasks', 'workspace_mode', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN workspace_mode TEXT NOT NULL DEFAULT 'persistent'");
});
addColumnIfMissing(db, 'jobs', 'space_id', () => {
db.exec("ALTER TABLE jobs ADD COLUMN space_id TEXT");
});
addColumnIfMissing(db, 'scheduled_tasks', 'space_id', () => {
db.exec("ALTER TABLE scheduled_tasks ADD COLUMN space_id TEXT");
});
// 計画5: 実行ログ root。NULL = 後方互換で workspace_path/logs に解決。
addColumnIfMissing(db, 'local_tasks', 'runtime_dir', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN runtime_dir TEXT");
});
addColumnIfMissing(db, 'jobs', 'runtime_dir', () => {
db.exec("ALTER TABLE jobs ADD COLUMN runtime_dir TEXT");
});
// Tool-request mechanism: per-task grant overlay (JSON string[] of approved
// tool names), merged into every movement's effective allowed-tool set.
addColumnIfMissing(db, 'local_tasks', 'granted_tools', () => {
db.exec("ALTER TABLE local_tasks ADD COLUMN granted_tools TEXT");
});
// workstream 2: ブラウザセッションプロファイルを per-space にする。
// space_id NULL = legacy/個人 or admin-global。owner_id は復号 DEK のため保持。
// 注: テーブルが存在する DB だけ対象古い経路ガード。index も同条件。
addColumnIfMissing(db, 'browser_session_profiles', 'space_id', () => {
db.exec("ALTER TABLE browser_session_profiles ADD COLUMN space_id TEXT");
});
const bspExists = !!db
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='browser_session_profiles'")
.get();
if (bspExists) {
db.exec("CREATE INDEX IF NOT EXISTS idx_bsp_space ON browser_session_profiles(space_id)");
}
backfillMcpServerSpaceIds(db);
backfillSshConnectionSpaceIds(db);
backfillBrowserSessionProfileSpaceIds(db);
}
/**
* スペース表示設定(ユーザー別)。お気に入り・非表示は権限に影響しない
* 一覧整理用の状態として保存する。schema.sql と Repository.initSchema にミラーがある。
*/
function migrateSpaceUserPrefs(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS space_user_prefs (
user_id TEXT NOT NULL,
space_id TEXT NOT NULL,
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0,1)),
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0,1)),
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, space_id),
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_user ON space_user_prefs(user_id);
CREATE INDEX IF NOT EXISTS idx_space_user_prefs_space ON space_user_prefs(space_id);
`);
}
/**
* スペース・メンバー(協働者)テーブルを追加する。追加のみ・冪等。
* owner は members 行を持たず owner_id で判定する追加協働者の表。
* schema.sql と Repository.initSchema にミラーがある(三重経路)。
* spec: docs/superpowers/specs/2026-06-19-shared-space-design.md §データモデル
*/
function migrateSpaceMembers(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS space_members (
space_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'editor' CHECK (role IN ('owner','editor','viewer')),
invited_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (space_id, user_id),
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_space_members_user ON space_members (user_id);
`);
}
/**
* スペース・カレンダー(予定)テーブルを追加する。追加のみ・冪等。
* schema.sql と Repository.initSchema にミラーがある(三重経路)。
* spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md §データモデル
*/
function migrateCalendarEvents(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
space_id TEXT NOT NULL,
owner_id TEXT,
date TEXT NOT NULL,
time TEXT,
title TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL DEFAULT 'user',
source_task_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_calendar_events_space_date ON calendar_events (space_id, date);
`);
// 複数日予定: end_dateNULL=単日)。追加のみ・冪等。
addColumnIfMissing(db, 'calendar_events', 'end_date', () => {
db.exec("ALTER TABLE calendar_events ADD COLUMN end_date TEXT");
});
// 終了時刻: end_timeNULL=終了時刻なし。time が NULL なら常に NULL。追加のみ・冪等。
addColumnIfMissing(db, 'calendar_events', 'end_time', () => {
db.exec("ALTER TABLE calendar_events ADD COLUMN end_time TEXT");
});
addColumnIfMissing(db, 'calendar_events', 'reminder_minutes', () => {
db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_minutes INTEGER");
});
addColumnIfMissing(db, 'calendar_events', 'reminder_delivered_at', () => {
db.exec("ALTER TABLE calendar_events ADD COLUMN reminder_delivered_at TEXT");
});
}
function migrateAgentReminders(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS agent_reminders (
id TEXT PRIMARY KEY, owner_id TEXT, space_id TEXT,
title TEXT, body TEXT NOT NULL, image_url TEXT, due_at TEXT NOT NULL,
delivered_at TEXT, cancelled_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_agent_reminders_due ON agent_reminders (due_at) WHERE delivered_at IS NULL AND cancelled_at IS NULL;
`);
}
/**
* スペース招待リンク(再利用トークン)テーブルを追加する。追加のみ・冪等。
* schema.sql / Repository.initSchema と三重ミラー。
* spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
*/
function migrateSpaceInvites(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS space_invites (
token TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('editor','viewer')),
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
revoked_at TEXT,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites (space_id);
`);
}
function migrateAppShareLinks(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS app_share_links (
token TEXT PRIMARY KEY,
space_id TEXT NOT NULL,
app_name TEXT NOT NULL,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
revoked_at TEXT,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_app_share_links_space_app
ON app_share_links(space_id, app_name);
`);
}
/**
* Per-space MCP isolation migration (Phase 2). Idempotent.
*
* Approved model = fully independent (space-only): a job sees ONLY its space's
* MCP servers. To preserve existing per-user MCP servers under that model, each
* user-owned server with no space (owner_id = <real user> AND space_id IS NULL)
* is migrated into that user's personal space (created if missing, mirroring
* Repository.ensurePersonalSpace). Global/admin-managed servers (owner_id IS
* NULL) are intentionally left with space_id NULL → invisible to spaces, only
* reachable by legacy (no-space) jobs via the NULL-space fallback.
*
* Runs after migrateMcpTables (mcp_servers.space_id added) and after the spaces
* table exists. Skips quietly when prerequisite tables are absent (fresh DB
* bootstrapped only partially).
*/
function backfillMcpServerSpaceIds(db: Database.Database): void {
const tableExists = (name: string): boolean =>
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
if (!tableExists('mcp_servers') || !tableExists('spaces') || !tableExists('users')) return;
const mcpCols = db.prepare("PRAGMA table_info('mcp_servers')").all() as Array<{ name: string }>;
if (!mcpCols.some((c) => c.name === 'space_id')) return;
// Candidates: user-owned servers with no space yet, whose owner is a real user.
const candidates = db
.prepare(
`SELECT m.id AS server_id, m.owner_id AS owner_id
FROM mcp_servers m
JOIN users u ON u.id = m.owner_id
WHERE m.owner_id IS NOT NULL AND m.space_id IS NULL`,
)
.all() as Array<{ server_id: string; owner_id: string }>;
if (candidates.length === 0) {
// Count what was intentionally left global for visibility.
const globalLeft = (
db.prepare('SELECT COUNT(*) AS n FROM mcp_servers WHERE owner_id IS NULL AND space_id IS NULL').get() as {
n: number;
}
).n;
logger.info(`[migrate] mcp space backfill: migrated=0 globalLeft=${globalLeft}`);
return;
}
const findPersonal = db.prepare(
`SELECT id FROM spaces WHERE owner_id = ? AND kind = 'personal' LIMIT 1`,
);
const insertPersonal = db.prepare(
`INSERT INTO spaces (id, kind, title, description, owner_id, visibility, status)
VALUES (?, 'personal', '個人ワークスペース', '', ?, 'private', 'open')`,
);
const setSpace = db.prepare('UPDATE mcp_servers SET space_id = ?, updated_at = datetime(\'now\') WHERE id = ?');
const personalCache = new Map<string, string>();
const apply = db.transaction(() => {
let migrated = 0;
let createdSpaces = 0;
for (const c of candidates) {
let spaceId = personalCache.get(c.owner_id);
if (!spaceId) {
const row = findPersonal.get(c.owner_id) as { id: string } | undefined;
if (row) {
spaceId = row.id;
} else {
spaceId = randomUUID();
insertPersonal.run(spaceId, c.owner_id);
createdSpaces += 1;
}
personalCache.set(c.owner_id, spaceId);
}
setSpace.run(spaceId, c.server_id);
migrated += 1;
}
return { migrated, createdSpaces };
});
const { migrated, createdSpaces } = apply();
const globalLeft = (
db.prepare('SELECT COUNT(*) AS n FROM mcp_servers WHERE owner_id IS NULL AND space_id IS NULL').get() as {
n: number;
}
).n;
logger.info(
`[migrate] mcp space backfill: migrated=${migrated} personalSpacesCreated=${createdSpaces} globalLeft=${globalLeft}`,
);
}
/**
* Per-space SSH isolation migration (Phase 3, spec §11). Idempotent.
*
* Approved model = fully independent (space-only): a job sees ONLY its space's
* SSH connections. To preserve existing per-user connections under that model,
* each user-owned connection with no space (owner_id = <real user> AND
* space_id IS NULL) is migrated into that user's personal space (created if
* missing, mirroring Repository.ensurePersonalSpace + backfillMcpServerSpaceIds).
* System/global connections (owner_id IS NULL) are intentionally left with
* space_id NULL → invisible to spaces, reachable only by legacy (no-space)
* jobs via the access-gate NULL-space fallback.
*
* owner_id is preserved on every row — it drives the per-user DEK used to
* decrypt the private key. No per-space DEK is introduced.
*
* Runs after ssh_connections.space_id is added (Phase 1) and after the spaces
* table exists. Skips quietly when prerequisite tables/columns are absent.
*/
function backfillSshConnectionSpaceIds(db: Database.Database): void {
const tableExists = (name: string): boolean =>
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
if (!tableExists('ssh_connections') || !tableExists('spaces') || !tableExists('users')) return;
const cols = db.prepare("PRAGMA table_info('ssh_connections')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'space_id')) return;
// Candidates: user-owned connections with no space yet, whose owner is a real
// user (the JOIN drops orphaned owner_ids).
const candidates = db
.prepare(
`SELECT s.id AS conn_id, s.owner_id AS owner_id
FROM ssh_connections s
JOIN users u ON u.id = s.owner_id
WHERE s.owner_id IS NOT NULL AND s.space_id IS NULL`,
)
.all() as Array<{ conn_id: string; owner_id: string }>;
if (candidates.length === 0) {
const systemLeft = (
db.prepare('SELECT COUNT(*) AS n FROM ssh_connections WHERE owner_id IS NULL AND space_id IS NULL').get() as {
n: number;
}
).n;
logger.info(`[migrate] ssh space backfill: migrated=0 systemLeft=${systemLeft}`);
return;
}
const findPersonal = db.prepare(
`SELECT id FROM spaces WHERE owner_id = ? AND kind = 'personal' LIMIT 1`,
);
const insertPersonal = db.prepare(
`INSERT INTO spaces (id, kind, title, description, owner_id, visibility, status)
VALUES (?, 'personal', '個人ワークスペース', '', ?, 'private', 'open')`,
);
const setSpace = db.prepare(
"UPDATE ssh_connections SET space_id = ?, updated_at = datetime('now') WHERE id = ?",
);
const personalCache = new Map<string, string>();
const apply = db.transaction(() => {
let migrated = 0;
let createdSpaces = 0;
for (const c of candidates) {
let spaceId = personalCache.get(c.owner_id);
if (!spaceId) {
const row = findPersonal.get(c.owner_id) as { id: string } | undefined;
if (row) {
spaceId = row.id;
} else {
spaceId = randomUUID();
insertPersonal.run(spaceId, c.owner_id);
createdSpaces += 1;
}
personalCache.set(c.owner_id, spaceId);
}
setSpace.run(spaceId, c.conn_id);
migrated += 1;
}
return { migrated, createdSpaces };
});
const { migrated, createdSpaces } = apply();
const systemLeft = (
db.prepare('SELECT COUNT(*) AS n FROM ssh_connections WHERE owner_id IS NULL AND space_id IS NULL').get() as {
n: number;
}
).n;
logger.info(
`[migrate] ssh space backfill: migrated=${migrated} personalSpacesCreated=${createdSpaces} systemLeft=${systemLeft}`,
);
}
/**
* Per-space browser-session-profile migration (workstream 2). Idempotent.
*
* Each user-owned profile with no space (owner_id = <real user> AND space_id IS
* NULL) is migrated into that user's personal space (created if missing,
* mirroring Repository.ensurePersonalSpace + the mcp/ssh backfills). Admin-global
* profiles (owner_id IS NULL) cannot exist today — owner_id is NOT NULL on the
* table — but the WHERE clause guards anyway and leaves any such row's space_id
* NULL.
*
* owner_id is preserved on every row — it drives the per-user DEK that decrypts
* encrypted_state_blob. No per-space DEK is introduced; a profile created by user
* A in a shared space remains decryptable only by A.
*
* Runs after browser_session_profiles.space_id is added and after the spaces
* table exists. Skips quietly when prerequisite tables/columns are absent.
* Safe to re-run: profiles already carrying a space_id are not candidates.
*/
function backfillBrowserSessionProfileSpaceIds(db: Database.Database): void {
const tableExists = (name: string): boolean =>
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
if (!tableExists('browser_session_profiles') || !tableExists('spaces') || !tableExists('users')) return;
const cols = db.prepare("PRAGMA table_info('browser_session_profiles')").all() as Array<{ name: string }>;
if (!cols.some((c) => c.name === 'space_id')) return;
// Candidates: user-owned profiles with no space yet, whose owner is a real
// user (the JOIN drops orphaned owner_ids).
const candidates = db
.prepare(
`SELECT p.id AS profile_id, p.owner_id AS owner_id
FROM browser_session_profiles p
JOIN users u ON u.id = p.owner_id
WHERE p.owner_id IS NOT NULL AND p.space_id IS NULL`,
)
.all() as Array<{ profile_id: number; owner_id: string }>;
if (candidates.length === 0) {
const globalLeft = (
db
.prepare('SELECT COUNT(*) AS n FROM browser_session_profiles WHERE owner_id IS NULL AND space_id IS NULL')
.get() as { n: number }
).n;
logger.info(`[migrate] browser-profile space backfill: migrated=0 globalLeft=${globalLeft}`);
return;
}
const findPersonal = db.prepare(
`SELECT id FROM spaces WHERE owner_id = ? AND kind = 'personal' LIMIT 1`,
);
const insertPersonal = db.prepare(
`INSERT INTO spaces (id, kind, title, description, owner_id, visibility, status)
VALUES (?, 'personal', '個人ワークスペース', '', ?, 'private', 'open')`,
);
const setSpace = db.prepare(
"UPDATE browser_session_profiles SET space_id = ?, updated_at = datetime('now') WHERE id = ?",
);
const personalCache = new Map<string, string>();
const apply = db.transaction(() => {
let migrated = 0;
let createdSpaces = 0;
for (const c of candidates) {
let spaceId = personalCache.get(c.owner_id);
if (!spaceId) {
const row = findPersonal.get(c.owner_id) as { id: string } | undefined;
if (row) {
spaceId = row.id;
} else {
spaceId = randomUUID();
insertPersonal.run(spaceId, c.owner_id);
createdSpaces += 1;
}
personalCache.set(c.owner_id, spaceId);
}
setSpace.run(spaceId, c.profile_id);
migrated += 1;
}
return { migrated, createdSpaces };
});
const { migrated, createdSpaces } = apply();
const globalLeft = (
db
.prepare('SELECT COUNT(*) AS n FROM browser_session_profiles WHERE owner_id IS NULL AND space_id IS NULL')
.get() as { n: number }
).n;
logger.info(
`[migrate] browser-profile space backfill: migrated=${migrated} personalSpacesCreated=${createdSpaces} globalLeft=${globalLeft}`,
);
}
/**
* Per-user daily LLM usage aggregation (gateway + direct). Idempotent.
* Mirrors schema.sql + Repository.initSchema (dual-path rule:
* project_db_migration_dual_path). Additive table, no mixed-version risk.
* Spec: docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md.
*/
function migrateLlmUsageDaily(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS llm_usage_daily (
day TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL,
route 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 (day, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_daily_user_day
ON llm_usage_daily (user_id, day);
`);
}
/**
* Usage dashboard v2: hour-grain ledger (supersedes llm_usage_daily as the
* write target). Idempotent; mirrors schema.sql + Repository.initSchema.
* Backfills the daily archive into the hourly table once (hour = day||'T00')
* via INSERT OR IGNORE so re-running the migration never double-counts.
* Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md.
*/
function migrateLlmUsageHourly(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS llm_usage_hourly (
hour TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL,
route 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 (hour, user_id, source, model, route)
);
CREATE INDEX IF NOT EXISTS idx_llm_usage_hourly_user_hour
ON llm_usage_hourly (user_id, hour);
INSERT OR IGNORE INTO llm_usage_hourly
(hour, user_id, source, model, route, tokens_in, tokens_out, requests, last_updated_at)
SELECT day || 'T00', user_id, source, model, route,
tokens_in, tokens_out, requests, last_updated_at
FROM llm_usage_daily;
`);
}
/** この better-sqlite3 ビルドで FTS5 が使えるか(実 CREATE で判定してキャッシュはしない)。 */
export function isFts5Available(db: Database.Database): boolean {
try {
db.exec('CREATE VIRTUAL TABLE IF NOT EXISTS __fts5_probe USING fts5(x, tokenize=\'trigram\')');
db.exec('DROP TABLE IF EXISTS __fts5_probe');
return true;
} catch {
return false;
}
}
/**
* task_comment_index他タスクの会話を検索するための索引テーブルを作成する。
* 通常テーブルは三重ミラー対象schema.sql + Repository.initSchema + ここ)。
* FTS5 仮想テーブル+同期トリガは ensureTaskCommentFts() に委譲する。
* Mirrors schema.sql + Repository.initSchema (三重ミラー; memory: project_db_migration_dual_path).
*/
function migrateTaskCommentIndex(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS task_comment_index (
comment_id INTEGER PRIMARY KEY,
task_id INTEGER NOT NULL,
author TEXT,
kind TEXT,
created_at TEXT,
text TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tci_task ON task_comment_index(task_id);
`);
ensureTaskCommentFts(db);
}
/**
* task_comment_index が作られる前から存在していたコメントを一括索引する(バックフィル)。
* 冪等: task_comment_index に既に存在する comment_id は LEFT JOIN で除外されるため、
* 複数回 runMigrations を呼んでも再挿入されない。
* Consumes: normalizeCommentToIndexTextTask 2、task_comment_indexTask 1
*/
function migrateBackfillTaskCommentIndex(db: Database.Database): void {
const hasComments = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_task_comments'").get();
const hasIndex = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_comment_index'").get();
if (!hasComments || !hasIndex) return;
const insert = db.prepare(
`INSERT OR IGNORE INTO task_comment_index (comment_id, task_id, author, kind, created_at, text)
VALUES (?, ?, ?, ?, ?, ?)`,
);
// 未索引分だけを拾うwatermark: task_comment_index に無い comment_id
const rows = db.prepare(
`SELECT c.id, c.task_id, c.author, c.kind, c.body, c.attachments, c.created_at
FROM local_task_comments c
LEFT JOIN task_comment_index i ON i.comment_id = c.id
WHERE i.comment_id IS NULL`,
).all() as Array<{ id: number; task_id: number; author: string; kind: string; body: string; attachments: string | null; created_at: string }>;
const tx = db.transaction((batch: typeof rows) => {
for (const r of batch) {
const text = normalizeCommentToIndexText(r.kind, r.body ?? '', r.attachments);
if (text === null) continue;
insert.run(r.id, r.task_id, r.author, r.kind, r.created_at, text);
}
});
const BATCH = 1000;
for (let i = 0; i < rows.length; i += BATCH) {
tx(rows.slice(i, i + BATCH));
}
if (rows.length > 0) logger.info(`[task-index] backfilled ${rows.length} comment(s)`);
}
/**
* task_comment_ftsFTS5 仮想テーブル)+同期トリガを作成する。
* FTS5 無効ビルドでは no-op通常テーブルだけで動作させる
* migrateTaskCommentIndexmigrate 経路)と Repository.initSchemabare construction 経路)
* の両方から呼ばれる唯一の DDL 定義箇所。呼び出し元は task_comment_index 本体を先に作成しておくこと。
*/
export function ensureTaskCommentFts(db: Database.Database): void {
if (!isFts5Available(db)) return; // FTS5 無し環境: 通常テーブルだけ作り FTS はスキップ
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS task_comment_fts USING fts5(
text, content='task_comment_index', content_rowid='comment_id', tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS tci_ai AFTER INSERT ON task_comment_index BEGIN
INSERT INTO task_comment_fts(rowid, text) VALUES (new.comment_id, new.text);
END;
CREATE TRIGGER IF NOT EXISTS tci_ad AFTER DELETE ON task_comment_index BEGIN
INSERT INTO task_comment_fts(task_comment_fts, rowid, text) VALUES('delete', old.comment_id, old.text);
END;
CREATE TRIGGER IF NOT EXISTS tci_au AFTER UPDATE ON task_comment_index BEGIN
INSERT INTO task_comment_fts(task_comment_fts, rowid, text) VALUES('delete', old.comment_id, old.text);
INSERT INTO task_comment_fts(rowid, text) VALUES (new.comment_id, new.text);
END;
`);
}
/**
* 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');
}
if (!mcpServerColNames.has('auth_header_name')) {
// Custom auth header name for api_key servers (e.g. 'xc-mcp-token').
// NULL = default Authorization: Bearer.
db.exec('ALTER TABLE mcp_servers ADD COLUMN auth_header_name TEXT');
}
if (!mcpServerColNames.has('space_id')) {
// Per-space isolation (spaces foundation). NULL = global/legacy
// (resolved globally; Phase 1 is a no-op, no consumer reads this yet).
db.exec('ALTER TABLE mcp_servers ADD COLUMN space_id TEXT');
}
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'))
);
-- Space-as-Principal P2: per-SPACE DEK (env-master domain, shared by SSH/MCP).
-- Mirrors ssh_user_deks but keyed by space_id so a space-owned credential
-- survives member turnover (the secret is wrapped under the space DEK, not a
-- departing user's DEK). Added unused in P2-1; wired in later slices.
CREATE TABLE IF NOT EXISTS space_ssh_deks (
space_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;
`);
// ALTERs for new ssh_connections columns via PRAGMA table_info pattern,
// matching the MCP migrations above.
const sshConnCols = db.prepare("PRAGMA table_info('ssh_connections')").all() as Array<{ name: string }>;
const sshConnColNames = new Set(sshConnCols.map(c => c.name));
if (!sshConnColNames.has('space_id')) {
// Per-space isolation (spaces foundation). NULL = global/legacy
// (resolved globally; Phase 1 is a no-op, no consumer reads this yet).
db.exec('ALTER TABLE ssh_connections ADD COLUMN space_id TEXT');
}
}
/**
* Drop the legacy Side Info Panel widget table.
*
* The configurable-widget dashboard (markdown + node-status widgets, plus the
* UpdateDashboardWidget agent tool) was removed in 2026-06 in favour of fixed
* worker / node (GPU) status panels. Older DBs still carry user_dashboard_widgets;
* drop it idempotently so the schema converges.
*/
function migrateDashboardWidgets(db: Database.Database): void {
db.exec(`DROP TABLE IF EXISTS user_dashboard_widgets;`);
}
/**
* 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'))
);
-- Local-auth credentials (email + password accounts). Idempotent add so
-- an existing no-auth / OAuth-only deployment gains local login on upgrade.
CREATE TABLE IF NOT EXISTS local_credentials (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
password_hash TEXT NOT NULL,
salt TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Local organizations + membership (provider-agnostic 'org' visibility for
-- local accounts). Idempotent add.
CREATE TABLE IF NOT EXISTS local_orgs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS local_org_members (
org_id TEXT NOT NULL REFERENCES local_orgs(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
added_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (org_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_local_org_members_user ON local_org_members(user_id);
`);
}
/**
* ワークスペース単位の Webhook 通知issue #797, PR1
* dual-path: schema.sql の CREATE TABLE と同一定義(三重ミラー; memory:
* project_db_migration_dual_path。冪等CREATE TABLE IF NOT EXISTS
* url_enc は AES-256-GCM で暗号化された Webhook URLsrc/mcp/crypto.ts
* 平文 URL は決して保存・ログ出力しない。
*/
function migrateSpaceWebhooksTables(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS space_webhooks (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'discord',
label TEXT NOT NULL DEFAULT '',
url_enc BLOB NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
events TEXT NOT NULL DEFAULT '["succeeded","failed","waiting_human"]',
include_details INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
disabled_reason TEXT,
last_success_at TEXT,
last_failure_at TEXT,
failure_count INTEGER NOT NULL DEFAULT 0,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id);
`);
}
/**
* a2a_tasks に委任列を追加Plan 2C-1 Task 2
* dual-path: schema.sql の CREATE TABLE にも同じ列を追加済み。
* Idempotent — PRAGMA table_info でカラム存在確認してから ALTER。
*/
function migrateA2aTaskDelegationColumns(db: Database.Database): void {
addColumnIfMissing(db, 'a2a_tasks', 'delegation_id', () => {
db.exec('ALTER TABLE a2a_tasks ADD COLUMN delegation_id TEXT');
});
addColumnIfMissing(db, 'a2a_tasks', 'grant_id', () => {
db.exec('ALTER TABLE a2a_tasks ADD COLUMN grant_id TEXT');
});
addColumnIfMissing(db, 'a2a_tasks', 'acting_user_id', () => {
db.exec('ALTER TABLE a2a_tasks ADD COLUMN acting_user_id TEXT');
});
}
/**
* chat_connector_bindings (issue #801, PR1 — Slack MVP).
* dual-path: schema.sql has the same CREATE TABLE for fresh DBs.
*/
function migrateChatConnectorBindings(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS chat_connector_bindings (
id TEXT PRIMARY KEY,
platform TEXT NOT NULL DEFAULT 'slack',
external_workspace_id TEXT NOT NULL,
external_channel_id TEXT NOT NULL,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
a2a_client_id TEXT NOT NULL,
a2a_delegation_id TEXT NOT NULL,
a2a_grant_id TEXT NOT NULL,
bot_credentials_enc BLOB NOT NULL,
key_version INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
created_by TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_bindings_channel
ON chat_connector_bindings (platform, external_workspace_id, external_channel_id);
CREATE INDEX IF NOT EXISTS idx_chat_bindings_space ON chat_connector_bindings (space_id);
CREATE INDEX IF NOT EXISTS idx_chat_bindings_delegation ON chat_connector_bindings (a2a_delegation_id);
CREATE TABLE IF NOT EXISTS chat_connector_processed_events (
event_id TEXT PRIMARY KEY,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_chat_processed_events_received_at
ON chat_connector_processed_events (received_at);
`);
}