779 lines
30 KiB
TypeScript
779 lines
30 KiB
TypeScript
import { readFileSync } from 'fs'
|
|
import { join } from 'path'
|
|
import type Database from 'better-sqlite3'
|
|
|
|
type Migration = {
|
|
id: string
|
|
up: (db: Database.Database) => void
|
|
}
|
|
|
|
const migrations: Migration[] = [
|
|
{
|
|
id: '001_init',
|
|
up: (db) => {
|
|
const schemaPath = join(process.cwd(), 'src', 'lib', 'schema.sql')
|
|
const schema = readFileSync(schemaPath, 'utf8')
|
|
const statements = schema.split(';').filter((stmt) => stmt.trim())
|
|
db.transaction(() => {
|
|
for (const statement of statements) {
|
|
db.exec(statement.trim())
|
|
}
|
|
})()
|
|
}
|
|
},
|
|
{
|
|
id: '002_quality_reviews',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS quality_reviews (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
task_id INTEGER NOT NULL,
|
|
reviewer TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
notes TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_quality_reviews_task_id ON quality_reviews(task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_quality_reviews_reviewer ON quality_reviews(reviewer);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '003_quality_review_status_backfill',
|
|
up: (db) => {
|
|
// Convert existing review tasks to quality_review to enforce the gate
|
|
db.exec(`
|
|
UPDATE tasks
|
|
SET status = 'quality_review'
|
|
WHERE status = 'review';
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '004_messages',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
conversation_id TEXT NOT NULL,
|
|
from_agent TEXT NOT NULL,
|
|
to_agent TEXT,
|
|
content TEXT NOT NULL,
|
|
message_type TEXT DEFAULT 'text',
|
|
metadata TEXT,
|
|
read_at INTEGER,
|
|
created_at INTEGER DEFAULT (unixepoch())
|
|
)
|
|
`)
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at)
|
|
`)
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_messages_agents ON messages(from_agent, to_agent)
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '005_users',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'operator',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
last_login_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
token TEXT NOT NULL UNIQUE,
|
|
user_id INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token);
|
|
CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '006_workflow_templates',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS workflow_templates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
model TEXT NOT NULL DEFAULT 'sonnet',
|
|
task_prompt TEXT NOT NULL,
|
|
timeout_seconds INTEGER NOT NULL DEFAULT 300,
|
|
agent_role TEXT,
|
|
tags TEXT,
|
|
created_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
last_used_at INTEGER,
|
|
use_count INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_name ON workflow_templates(name);
|
|
CREATE INDEX IF NOT EXISTS idx_workflow_templates_created_by ON workflow_templates(created_by);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '007_audit_log',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
action TEXT NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
actor_id INTEGER,
|
|
target_type TEXT,
|
|
target_id INTEGER,
|
|
detail TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log(created_at);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '008_webhooks',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS webhooks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
url TEXT NOT NULL,
|
|
secret TEXT,
|
|
events TEXT NOT NULL DEFAULT '["*"]',
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
last_fired_at INTEGER,
|
|
last_status INTEGER,
|
|
created_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
webhook_id INTEGER NOT NULL,
|
|
event_type TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
status_code INTEGER,
|
|
response_body TEXT,
|
|
error TEXT,
|
|
duration_ms INTEGER,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_id ON webhook_deliveries(webhook_id);
|
|
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_created_at ON webhook_deliveries(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_webhooks_enabled ON webhooks(enabled);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '009_pipelines',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS workflow_pipelines (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
steps TEXT NOT NULL DEFAULT '[]',
|
|
created_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
use_count INTEGER NOT NULL DEFAULT 0,
|
|
last_used_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
pipeline_id INTEGER NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
current_step INTEGER NOT NULL DEFAULT 0,
|
|
steps_snapshot TEXT NOT NULL DEFAULT '[]',
|
|
started_at INTEGER,
|
|
completed_at INTEGER,
|
|
triggered_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
FOREIGN KEY (pipeline_id) REFERENCES workflow_pipelines(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_pipeline_runs_pipeline_id ON pipeline_runs(pipeline_id);
|
|
CREATE INDEX IF NOT EXISTS idx_pipeline_runs_status ON pipeline_runs(status);
|
|
CREATE INDEX IF NOT EXISTS idx_workflow_pipelines_name ON workflow_pipelines(name);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '010_settings',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
description TEXT,
|
|
category TEXT NOT NULL DEFAULT 'general',
|
|
updated_by TEXT,
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_settings_category ON settings(category);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '011_alert_rules',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
enabled INTEGER NOT NULL DEFAULT 1,
|
|
entity_type TEXT NOT NULL,
|
|
condition_field TEXT NOT NULL,
|
|
condition_operator TEXT NOT NULL,
|
|
condition_value TEXT NOT NULL,
|
|
action_type TEXT NOT NULL DEFAULT 'notification',
|
|
action_config TEXT NOT NULL DEFAULT '{}',
|
|
cooldown_minutes INTEGER NOT NULL DEFAULT 60,
|
|
last_triggered_at INTEGER,
|
|
trigger_count INTEGER NOT NULL DEFAULT 0,
|
|
created_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_alert_rules_enabled ON alert_rules(enabled);
|
|
CREATE INDEX IF NOT EXISTS idx_alert_rules_entity_type ON alert_rules(entity_type);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '012_super_admin_tenants',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
display_name TEXT NOT NULL,
|
|
linux_user TEXT NOT NULL UNIQUE,
|
|
plan_tier TEXT NOT NULL DEFAULT 'standard',
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
openclaw_home TEXT NOT NULL,
|
|
workspace_root TEXT NOT NULL,
|
|
gateway_port INTEGER,
|
|
dashboard_port INTEGER,
|
|
config TEXT NOT NULL DEFAULT '{}',
|
|
created_by TEXT NOT NULL DEFAULT 'system',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS provision_jobs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tenant_id INTEGER NOT NULL,
|
|
job_type TEXT NOT NULL DEFAULT 'bootstrap',
|
|
status TEXT NOT NULL DEFAULT 'queued',
|
|
dry_run INTEGER NOT NULL DEFAULT 1,
|
|
requested_by TEXT NOT NULL DEFAULT 'system',
|
|
approved_by TEXT,
|
|
runner_host TEXT,
|
|
idempotency_key TEXT,
|
|
request_json TEXT NOT NULL DEFAULT '{}',
|
|
plan_json TEXT NOT NULL DEFAULT '[]',
|
|
result_json TEXT,
|
|
error_text TEXT,
|
|
started_at INTEGER,
|
|
completed_at INTEGER,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS provision_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
job_id INTEGER NOT NULL,
|
|
level TEXT NOT NULL DEFAULT 'info',
|
|
step_key TEXT,
|
|
message TEXT NOT NULL,
|
|
data TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
FOREIGN KEY (job_id) REFERENCES provision_jobs(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
|
|
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
|
|
CREATE INDEX IF NOT EXISTS idx_provision_jobs_tenant_id ON provision_jobs(tenant_id);
|
|
CREATE INDEX IF NOT EXISTS idx_provision_jobs_status ON provision_jobs(status);
|
|
CREATE INDEX IF NOT EXISTS idx_provision_jobs_created_at ON provision_jobs(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_provision_events_job_id ON provision_events(job_id);
|
|
CREATE INDEX IF NOT EXISTS idx_provision_events_created_at ON provision_events(created_at);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '013_tenant_owner_gateway',
|
|
up: (db) => {
|
|
// Check if tenants table exists (may not on fresh installs without super-admin)
|
|
const hasTenants = (db.prepare(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name='tenants'`
|
|
).get() as any)
|
|
if (!hasTenants) return
|
|
|
|
const columns = db.prepare(`PRAGMA table_info(tenants)`).all() as Array<{ name: string }>
|
|
const hasOwnerGateway = columns.some((c) => c.name === 'owner_gateway')
|
|
if (!hasOwnerGateway) {
|
|
db.exec(`ALTER TABLE tenants ADD COLUMN owner_gateway TEXT`)
|
|
}
|
|
|
|
const defaultGatewayName =
|
|
String(process.env.MC_DEFAULT_OWNER_GATEWAY || process.env.MC_DEFAULT_GATEWAY_NAME || 'primary').trim() ||
|
|
'primary'
|
|
|
|
// Check if gateways table exists (created lazily by gateways API, not in migrations)
|
|
const hasGateways = (db.prepare(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name='gateways'`
|
|
).get() as any)
|
|
|
|
if (hasGateways) {
|
|
db.prepare(`
|
|
UPDATE tenants
|
|
SET owner_gateway = COALESCE(
|
|
(SELECT name FROM gateways ORDER BY is_primary DESC, id ASC LIMIT 1),
|
|
?
|
|
)
|
|
WHERE owner_gateway IS NULL OR trim(owner_gateway) = ''
|
|
`).run(defaultGatewayName)
|
|
} else {
|
|
db.prepare(`
|
|
UPDATE tenants
|
|
SET owner_gateway = ?
|
|
WHERE owner_gateway IS NULL OR trim(owner_gateway) = ''
|
|
`).run(defaultGatewayName)
|
|
}
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_tenants_owner_gateway ON tenants(owner_gateway)`)
|
|
}
|
|
},
|
|
{
|
|
id: '014_auth_google_approvals',
|
|
up: (db) => {
|
|
const userCols = db.prepare(`PRAGMA table_info(users)`).all() as Array<{ name: string }>
|
|
const has = (name: string) => userCols.some((c) => c.name === name)
|
|
|
|
if (!has('provider')) db.exec(`ALTER TABLE users ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'`)
|
|
if (!has('provider_user_id')) db.exec(`ALTER TABLE users ADD COLUMN provider_user_id TEXT`)
|
|
if (!has('email')) db.exec(`ALTER TABLE users ADD COLUMN email TEXT`)
|
|
if (!has('avatar_url')) db.exec(`ALTER TABLE users ADD COLUMN avatar_url TEXT`)
|
|
if (!has('is_approved')) db.exec(`ALTER TABLE users ADD COLUMN is_approved INTEGER NOT NULL DEFAULT 1`)
|
|
if (!has('approved_by')) db.exec(`ALTER TABLE users ADD COLUMN approved_by TEXT`)
|
|
if (!has('approved_at')) db.exec(`ALTER TABLE users ADD COLUMN approved_at INTEGER`)
|
|
|
|
db.exec(`
|
|
UPDATE users
|
|
SET provider = COALESCE(NULLIF(provider, ''), 'local'),
|
|
is_approved = COALESCE(is_approved, 1)
|
|
`)
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS access_requests (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
provider TEXT NOT NULL DEFAULT 'google',
|
|
email TEXT NOT NULL,
|
|
provider_user_id TEXT,
|
|
display_name TEXT,
|
|
avatar_url TEXT,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
requested_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
last_attempt_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
reviewed_by TEXT,
|
|
reviewed_at INTEGER,
|
|
review_note TEXT,
|
|
approved_user_id INTEGER,
|
|
FOREIGN KEY (approved_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
)
|
|
`)
|
|
|
|
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_access_requests_email_provider ON access_requests(email, provider)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_access_requests_status ON access_requests(status)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_provider ON users(provider)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`)
|
|
}
|
|
},
|
|
{
|
|
id: '015_missing_indexes',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_read_at ON notifications(read_at);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_recipient_read ON notifications(recipient, read_at);
|
|
CREATE INDEX IF NOT EXISTS idx_activities_actor ON activities(actor);
|
|
CREATE INDEX IF NOT EXISTS idx_activities_entity ON activities(entity_type, entity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_read_at ON messages(read_at);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '016_direct_connections',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS direct_connections (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
|
tool_name TEXT NOT NULL,
|
|
tool_version TEXT,
|
|
connection_id TEXT NOT NULL UNIQUE,
|
|
status TEXT NOT NULL DEFAULT 'connected',
|
|
last_heartbeat INTEGER,
|
|
metadata TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_direct_connections_agent_id ON direct_connections(agent_id);
|
|
CREATE INDEX IF NOT EXISTS idx_direct_connections_connection_id ON direct_connections(connection_id);
|
|
CREATE INDEX IF NOT EXISTS idx_direct_connections_status ON direct_connections(status);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '017_github_sync',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS github_syncs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
repo TEXT NOT NULL,
|
|
last_synced_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
issue_count INTEGER NOT NULL DEFAULT 0,
|
|
sync_direction TEXT NOT NULL DEFAULT 'inbound',
|
|
status TEXT NOT NULL DEFAULT 'success',
|
|
error TEXT,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_github_syncs_repo ON github_syncs(repo);
|
|
CREATE INDEX IF NOT EXISTS idx_github_syncs_created_at ON github_syncs(created_at);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '018_token_usage',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS token_usage (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
model TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_token_usage_session_id ON token_usage(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_token_usage_created_at ON token_usage(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);
|
|
`)
|
|
}
|
|
},
|
|
{
|
|
id: '019_webhook_retry',
|
|
up: (db) => {
|
|
// Add retry columns to webhook_deliveries
|
|
const deliveryCols = db.prepare(`PRAGMA table_info(webhook_deliveries)`).all() as Array<{ name: string }>
|
|
const hasCol = (name: string) => deliveryCols.some((c) => c.name === name)
|
|
|
|
if (!hasCol('attempt')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN attempt INTEGER NOT NULL DEFAULT 0`)
|
|
if (!hasCol('next_retry_at')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN next_retry_at INTEGER`)
|
|
if (!hasCol('is_retry')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN is_retry INTEGER NOT NULL DEFAULT 0`)
|
|
if (!hasCol('parent_delivery_id')) db.exec(`ALTER TABLE webhook_deliveries ADD COLUMN parent_delivery_id INTEGER`)
|
|
|
|
// Add circuit breaker column to webhooks
|
|
const webhookCols = db.prepare(`PRAGMA table_info(webhooks)`).all() as Array<{ name: string }>
|
|
if (!webhookCols.some((c) => c.name === 'consecutive_failures')) {
|
|
db.exec(`ALTER TABLE webhooks ADD COLUMN consecutive_failures INTEGER NOT NULL DEFAULT 0`)
|
|
}
|
|
|
|
// Partial index for retry queue processing
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_retry ON webhook_deliveries(next_retry_at) WHERE next_retry_at IS NOT NULL`)
|
|
}
|
|
},
|
|
{
|
|
id: '020_claude_sessions',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS claude_sessions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
session_id TEXT NOT NULL UNIQUE,
|
|
project_slug TEXT NOT NULL,
|
|
project_path TEXT,
|
|
model TEXT,
|
|
git_branch TEXT,
|
|
user_messages INTEGER NOT NULL DEFAULT 0,
|
|
assistant_messages INTEGER NOT NULL DEFAULT 0,
|
|
tool_uses INTEGER NOT NULL DEFAULT 0,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
estimated_cost REAL NOT NULL DEFAULT 0,
|
|
first_message_at TEXT,
|
|
last_message_at TEXT,
|
|
last_user_prompt TEXT,
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
scanned_at INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
)
|
|
`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_active ON claude_sessions(is_active) WHERE is_active = 1`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_claude_sessions_project ON claude_sessions(project_slug)`)
|
|
}
|
|
},
|
|
{
|
|
id: '021_workspace_isolation_phase1',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS workspaces (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
slug TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
);
|
|
`)
|
|
|
|
db.prepare(`
|
|
INSERT OR IGNORE INTO workspaces (id, slug, name, created_at, updated_at)
|
|
VALUES (1, 'default', 'Default Workspace', unixepoch(), unixepoch())
|
|
`).run()
|
|
|
|
const addWorkspaceIdColumn = (table: string) => {
|
|
const tableExists = db
|
|
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
.get(table) as { ok?: number } | undefined
|
|
if (!tableExists?.ok) return
|
|
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
|
|
if (!cols.some((c) => c.name === 'workspace_id')) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
|
|
}
|
|
db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
|
|
}
|
|
|
|
const scopedTables = [
|
|
'users',
|
|
'user_sessions',
|
|
'tasks',
|
|
'agents',
|
|
'comments',
|
|
'activities',
|
|
'notifications',
|
|
'quality_reviews',
|
|
'standup_reports',
|
|
]
|
|
|
|
for (const table of scopedTables) {
|
|
addWorkspaceIdColumn(table)
|
|
}
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_workspaces_slug ON workspaces(slug)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_users_workspace_id ON users(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_user_sessions_workspace_id ON user_sessions(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_agents_workspace_id ON agents(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_workspace_id ON comments(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_activities_workspace_id ON activities(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_notifications_workspace_id ON notifications(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_quality_reviews_workspace_id ON quality_reviews(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_standup_reports_workspace_id ON standup_reports(workspace_id)`)
|
|
}
|
|
},
|
|
{
|
|
id: '022_workspace_isolation_phase2',
|
|
up: (db) => {
|
|
const addWorkspaceIdColumn = (table: string) => {
|
|
const tableExists = db
|
|
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
.get(table) as { ok?: number } | undefined
|
|
if (!tableExists?.ok) return
|
|
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
|
|
if (!cols.some((c) => c.name === 'workspace_id')) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
|
|
}
|
|
db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
|
|
}
|
|
|
|
const scopedTables = [
|
|
'messages',
|
|
'alert_rules',
|
|
'direct_connections',
|
|
'github_syncs',
|
|
'workflow_pipelines',
|
|
'pipeline_runs',
|
|
]
|
|
|
|
for (const table of scopedTables) {
|
|
addWorkspaceIdColumn(table)
|
|
}
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_messages_workspace_id ON messages(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_alert_rules_workspace_id ON alert_rules(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_direct_connections_workspace_id ON direct_connections(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_github_syncs_workspace_id ON github_syncs(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_workflow_pipelines_workspace_id ON workflow_pipelines(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_pipeline_runs_workspace_id ON pipeline_runs(workspace_id)`)
|
|
}
|
|
},
|
|
{
|
|
id: '023_workspace_isolation_phase3',
|
|
up: (db) => {
|
|
const addWorkspaceIdColumn = (table: string) => {
|
|
const tableExists = db
|
|
.prepare(`SELECT 1 as ok FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
.get(table) as { ok?: number } | undefined
|
|
if (!tableExists?.ok) return
|
|
|
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
|
|
if (!cols.some((c) => c.name === 'workspace_id')) {
|
|
db.exec(`ALTER TABLE ${table} ADD COLUMN workspace_id INTEGER NOT NULL DEFAULT 1`)
|
|
}
|
|
db.exec(`UPDATE ${table} SET workspace_id = COALESCE(workspace_id, 1)`)
|
|
}
|
|
|
|
const scopedTables = [
|
|
'workflow_templates',
|
|
'webhooks',
|
|
'webhook_deliveries',
|
|
'token_usage',
|
|
]
|
|
|
|
for (const table of scopedTables) {
|
|
addWorkspaceIdColumn(table)
|
|
}
|
|
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_workflow_templates_workspace_id ON workflow_templates(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_webhooks_workspace_id ON webhooks(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_workspace_id ON webhook_deliveries(workspace_id)`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_workspace_id ON token_usage(workspace_id)`)
|
|
}
|
|
},
|
|
{
|
|
id: '024_projects_support',
|
|
up: (db) => {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
workspace_id INTEGER NOT NULL DEFAULT 1,
|
|
name TEXT NOT NULL,
|
|
slug TEXT NOT NULL,
|
|
description TEXT,
|
|
ticket_prefix TEXT NOT NULL,
|
|
ticket_counter INTEGER NOT NULL DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
UNIQUE(workspace_id, slug),
|
|
UNIQUE(workspace_id, ticket_prefix)
|
|
)
|
|
`)
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_projects_workspace_status ON projects(workspace_id, status)`)
|
|
|
|
const taskCols = db.prepare(`PRAGMA table_info(tasks)`).all() as Array<{ name: string }>
|
|
if (!taskCols.some((c) => c.name === 'project_id')) {
|
|
db.exec(`ALTER TABLE tasks ADD COLUMN project_id INTEGER`)
|
|
}
|
|
if (!taskCols.some((c) => c.name === 'project_ticket_no')) {
|
|
db.exec(`ALTER TABLE tasks ADD COLUMN project_ticket_no INTEGER`)
|
|
}
|
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_project ON tasks(workspace_id, project_id)`)
|
|
|
|
const workspaceRows = db.prepare(`SELECT id FROM workspaces ORDER BY id ASC`).all() as Array<{ id: number }>
|
|
const ensureDefaultProject = db.prepare(`
|
|
INSERT OR IGNORE INTO projects (workspace_id, name, slug, description, ticket_prefix, ticket_counter, status, created_at, updated_at)
|
|
VALUES (?, 'General', 'general', 'Default project for uncategorized tasks', 'TASK', 0, 'active', unixepoch(), unixepoch())
|
|
`)
|
|
const getDefaultProject = db.prepare(`
|
|
SELECT id, ticket_counter FROM projects
|
|
WHERE workspace_id = ? AND slug = 'general'
|
|
LIMIT 1
|
|
`)
|
|
const setTaskProject = db.prepare(`
|
|
UPDATE tasks SET project_id = ?
|
|
WHERE workspace_id = ? AND (project_id IS NULL OR project_id = 0)
|
|
`)
|
|
const listProjectTasks = db.prepare(`
|
|
SELECT id FROM tasks
|
|
WHERE workspace_id = ? AND project_id = ?
|
|
ORDER BY created_at ASC, id ASC
|
|
`)
|
|
const setTaskNo = db.prepare(`UPDATE tasks SET project_ticket_no = ? WHERE id = ?`)
|
|
const setProjectCounter = db.prepare(`UPDATE projects SET ticket_counter = ?, updated_at = unixepoch() WHERE id = ?`)
|
|
|
|
for (const workspace of workspaceRows) {
|
|
ensureDefaultProject.run(workspace.id)
|
|
const defaultProject = getDefaultProject.get(workspace.id) as { id: number; ticket_counter: number } | undefined
|
|
if (!defaultProject) continue
|
|
|
|
setTaskProject.run(defaultProject.id, workspace.id)
|
|
|
|
const projectRows = db.prepare(`
|
|
SELECT id FROM projects
|
|
WHERE workspace_id = ?
|
|
ORDER BY id ASC
|
|
`).all(workspace.id) as Array<{ id: number }>
|
|
|
|
for (const project of projectRows) {
|
|
const tasks = listProjectTasks.all(workspace.id, project.id) as Array<{ id: number }>
|
|
let counter = 0
|
|
for (const task of tasks) {
|
|
counter += 1
|
|
setTaskNo.run(counter, task.id)
|
|
}
|
|
setProjectCounter.run(counter, project.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
|
|
export function runMigrations(db: Database.Database) {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
id TEXT PRIMARY KEY,
|
|
applied_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
)
|
|
`)
|
|
|
|
const applied = new Set(
|
|
db.prepare('SELECT id FROM schema_migrations').all().map((row: any) => row.id)
|
|
)
|
|
|
|
for (const migration of migrations) {
|
|
if (applied.has(migration.id)) continue
|
|
db.transaction(() => {
|
|
migration.up(db)
|
|
db.prepare('INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)').run(migration.id)
|
|
})()
|
|
}
|
|
}
|