1494 lines
64 KiB
TypeScript
1494 lines
64 KiB
TypeScript
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_date(NULL=単日)。追加のみ・冪等。
|
||
addColumnIfMissing(db, 'calendar_events', 'end_date', () => {
|
||
db.exec("ALTER TABLE calendar_events ADD COLUMN end_date TEXT");
|
||
});
|
||
// 終了時刻: end_time(NULL=終了時刻なし。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: normalizeCommentToIndexText(Task 2)、task_comment_index(Task 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_fts(FTS5 仮想テーブル)+同期トリガを作成する。
|
||
* FTS5 無効ビルドでは no-op(通常テーブルだけで動作させる)。
|
||
* migrateTaskCommentIndex(migrate 経路)と Repository.initSchema(bare 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 URL(src/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);
|
||
`);
|
||
}
|