-- ジョブテーブル
CREATE TABLE IF NOT EXISTS jobs (
  id               TEXT PRIMARY KEY,
  repo             TEXT NOT NULL,
  issue_number     INTEGER NOT NULL,
  pr_number        INTEGER,
  status           TEXT NOT NULL DEFAULT 'queued'
                   CHECK (status IN ('queued', 'dispatching', 'running', 'succeeded', 'failed', 'retry', 'cancelled', 'waiting_human', 'waiting_subtasks')),
  piece_name       TEXT NOT NULL DEFAULT 'general',
  required_profile TEXT NOT NULL DEFAULT 'auto',
  task_class       TEXT NOT NULL DEFAULT 'auto',
  current_movement TEXT,
  current_activity TEXT,
  instruction      TEXT NOT NULL DEFAULT '',
  branch_name      TEXT,
  worktree_path    TEXT,
  attempt          INTEGER NOT NULL DEFAULT 1,
  max_attempts     INTEGER NOT NULL DEFAULT 3,
  next_retry_at    TEXT,
  error_summary    TEXT,
  abort_reason     TEXT,  -- structured abort code (e.g. 'max_iterations_exceeded', 'agent_self_abort'); see piece-runner / agent-loop
  resume_movement  TEXT,
  wait_reason      TEXT,              -- 'ask' | 'browser_login' | 'mcp_auth_required' | NULL
  ask_count        INTEGER NOT NULL DEFAULT 0,
  worker_id        TEXT,
  last_backend_id  TEXT,  -- physical backend id (LiteLLM deployment) for proxy workers; NULL for direct
  parent_job_id    TEXT,
  subtask_depth    INTEGER NOT NULL DEFAULT 0,
  context_prompt_tokens   INTEGER,
  context_limit_tokens    INTEGER,
  context_updated_at      TEXT,
  task_kind        TEXT NOT NULL DEFAULT 'agent',
  payload          TEXT,
  space_id         TEXT,
  -- 実行ログ root（計画5）。NULL = 後方互換で workspace_path/logs に解決。
  runtime_dir      TEXT,
  created_at       TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at       TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status);
CREATE INDEX IF NOT EXISTS idx_jobs_repo_issue ON jobs (repo, issue_number);

-- Local task テーブル
CREATE TABLE IF NOT EXISTS local_tasks (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  title         TEXT NOT NULL,
  body          TEXT NOT NULL DEFAULT '',
  piece_name    TEXT NOT NULL DEFAULT 'general',
  profile       TEXT NOT NULL DEFAULT 'auto',
  output_format TEXT NOT NULL DEFAULT 'markdown',
  ask_policy    TEXT NOT NULL DEFAULT 'low',
  priority      TEXT NOT NULL DEFAULT 'medium',
  state         TEXT NOT NULL DEFAULT 'open'
                CHECK (state IN ('open', 'closed')),
  workspace_path TEXT,
  created_at    TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at    TEXT NOT NULL DEFAULT (datetime('now')),
  feedback_rating  TEXT,
  feedback_comment TEXT,
  feedback_tags    TEXT,
  feedback_at      TEXT,
  share_token      TEXT,
  shared_at        TEXT,
  -- Mission Brief: per-task pinned memo carrying { goal, done, open, clarifications }
  -- as a single JSON blob. Always rendered at the top of every movement's system
  -- prompt. Editable by both the LLM (mission_update tool) and the user (UI).
  mission_brief    TEXT,
  -- Per-task options (JSON blob). Controls runtime behaviour toggles such as
  -- { mcpDisabled: true, skillsDisabled: true }. Default '{}' = all enabled.
  options          TEXT DEFAULT '{}',
  -- Tool-request mechanism: per-task grant overlay (JSON string[] of tool
  -- names) that a user approved inline. Merged into every movement's effective
  -- allowed-tool set for this task. NULL/empty = no grants. Triple-path mirror.
  granted_tools    TEXT,
  -- Provenance of `title`: 'auto' = cheap fallback set at creation,
  -- 'agent' = derived from the Mission Brief goal during the run,
  -- 'user'  = manually edited (never overwritten by agent/regeneration).
  title_source     TEXT NOT NULL DEFAULT 'auto',
  -- スペース基盤: 所属スペース。NULL = 個人スペース既定として解決（計画2）
  space_id         TEXT,
  -- 実行先ワークスペースの種別。'persistent'(既定) / 'ephemeral'(使い捨て)（計画3）
  workspace_mode   TEXT NOT NULL DEFAULT 'persistent',
  -- 実行ログ root（計画5）。NULL = 後方互換で workspace_path/logs に解決。
  runtime_dir      TEXT
);

CREATE INDEX IF NOT EXISTS idx_local_tasks_updated_at ON local_tasks (updated_at DESC);

-- ─── スペース（個人 / 案件）──────────────────────────────────
-- AI Suite 化の土台。個人スペースと案件スペースを同一エンティティで表す。
-- spec: docs/superpowers/specs/2026-06-17-space-foundation-design.md §5.2
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
CREATE TABLE IF NOT EXISTS spaces (
  id                      TEXT PRIMARY KEY,            -- UUID
  kind                    TEXT NOT NULL DEFAULT 'case' CHECK (kind IN ('personal','case')),
  title                   TEXT NOT NULL,
  description             TEXT NOT NULL DEFAULT '',
  owner_id                TEXT,
  visibility              TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private','org','public')),
  visibility_scope_org_id TEXT,
  status                  TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','archived')),
  brand_color             TEXT,                        -- nullable hex; 解決順は DESIGN.md §2
  workspace_dir           TEXT,                        -- {worktree_dir}/space/{id}
  tool_policy             TEXT,                        -- nullable JSON; スペース固有ツール制限ポリシー
  a2a_skills              TEXT,                        -- A2A 公開する piece 名の JSON 配列。NULL/未設定 = 公開ゼロ（fail-closed）
  python_packages         TEXT,                        -- nullable JSON; admin が入れた Python パッケージの desired-state 配列（engine/python-packages.ts）
  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';

-- ─── スペース・メンバー（協働者）─────────────────────────────────
-- owner_id 以外の追加協働者。owner は members 行を持たず owner_id で判定する。
-- 可視性は buildVisibilityWhere(spaceColumn) の OR ブランチで membership を通す。
-- spec: docs/superpowers/specs/2026-06-19-shared-space-design.md §データモデル
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
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,                                       -- 招待者 user_id
  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);

-- ─── スペース・カレンダー（予定）──────────────────────────────────
-- スペース別の受動的な「予定」エントリ。cron の scheduled_tasks（実行する）とは別概念。
-- 可視性はスペースのメンバーシップに従い、イベント単独の可視性は持たない。
-- spec: docs/superpowers/specs/2026-06-19-space-calendar-design.md §データモデル
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
CREATE TABLE IF NOT EXISTS calendar_events (
  id             INTEGER PRIMARY KEY AUTOINCREMENT,
  space_id       TEXT NOT NULL,                 -- どのスペースの予定か
  owner_id       TEXT,                          -- 作成者（no-auth 時は 'local'）
  date           TEXT NOT NULL,                 -- 開始日 YYYY-MM-DD（ローカル日付）
  end_date       TEXT,                          -- 終了日 YYYY-MM-DD。NULL = date と同じ（単日）
  time           TEXT,                          -- 開始 HH:MM。NULL = 終日
  end_time       TEXT,                          -- 終了 HH:MM。NULL = 終了時刻なし（time が NULL なら常に NULL）
  title          TEXT NOT NULL,
  description    TEXT,
  created_by     TEXT NOT NULL DEFAULT 'user',  -- 'user' / 'agent'
  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);

-- ─── スペース招待リンク（再利用トークン）─────────────────────────────
-- 組織非依存の招待経路。canManageSpace が発行し、リンクを知る相手が参加する。
-- スペースごとに有効リンクは最大1本（再生成で旧 invite を削除して回す）。
-- role は editor/viewer のみ（owner 付与不可＝権限昇格防止）。
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
-- spec: docs/superpowers/specs/2026-06-19-space-invite-links-design.md
CREATE TABLE IF NOT EXISTS space_invites (
  token       TEXT PRIMARY KEY,             -- randomUUID。推測不能
  space_id    TEXT NOT NULL,
  role        TEXT NOT NULL DEFAULT 'viewer' CHECK (role IN ('editor','viewer')),
  created_by  TEXT,                          -- 発行者 user_id
  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
  expires_at  TEXT,                          -- nullable ISO。NULL = 無期限
  revoked_at  TEXT,                          -- nullable。無効化時にセット
  FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_space_invites_space ON space_invites (space_id);

-- ─── 公開アプリ共有リンク（read-only） ─────────────────────────────
-- スペース内の 1 ワークスペースアプリ（apps/{app_name}/）を、ログイン不要の
-- 公開 URL で read-only 共有するためのトークン。タスク共有（local_tasks.share_token）
-- とは完全別系統。失効は revoked_at に記録（DELETE で履歴を消さない）。
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
-- spec: docs/superpowers/specs/2026-06-23-public-app-share-link-design.md
CREATE TABLE IF NOT EXISTS app_share_links (
  token       TEXT PRIMARY KEY,             -- randomUUID。推測不能
  space_id    TEXT NOT NULL,
  app_name    TEXT NOT NULL,                -- apps/{app_name}/
  created_by  TEXT,                          -- 発行者 user_id（nullable）
  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
  revoked_at  TEXT,                          -- NULL=有効、datetime=失効済み
  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);

-- ─── ツール要求（足りないツールの記録）─────────────────────────────
-- エージェントが RequestTool で能動申告した、または現在の movement で許可されて
-- いないツールを呼んで弾かれた（受動）記録。タスク詳細＋集計でワークスペースの
-- ツール設定の漏れを可視化し、後段（承認フロー）で付与・恒久修正につなぐ。
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
-- spec: docs/superpowers/specs/2026-06-23-tool-request-mechanism-design.md
CREATE TABLE IF NOT EXISTS tool_requests (
  id            TEXT PRIMARY KEY,             -- randomUUID
  task_id       TEXT,                         -- local_tasks 擬似 id（nullable）
  job_id        TEXT,                         -- jobs.id（nullable）
  space_id      TEXT,                         -- スペーススコープ集計用（nullable）
  piece_name    TEXT NOT NULL,
  movement_name TEXT NOT NULL,
  tool_name     TEXT NOT NULL,
  reason        TEXT,                         -- 能動申告の理由（受動は自動文言）
  category      TEXT NOT NULL DEFAULT 'requested', -- requested | blocked | unknown
  status        TEXT NOT NULL DEFAULT 'pending',   -- pending | approved | denied | auto_denied
  grant_scope   TEXT,                         -- 承認時 task | piece
  decided_by    TEXT,                         -- 承認/拒否した user_id
  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);
-- de-dup lookup (job-scoped pending row) — avoids a full scan per record call
CREATE INDEX IF NOT EXISTS idx_tool_requests_dedup ON tool_requests (job_id, movement_name, tool_name, status);

-- エージェント発 Python パッケージ要求（RequestPackage）。実行中に「このライブラリを
-- 使いたい」と申告 → task write 権限者が承認 → そのスペースの overlay に install →
-- ジョブ再開で import 可能に。承認時のインストールは PR2 の installSpacePackages
-- （bwrap 必須 fail-closed / wheels のみ / 固定 index / shadowing 拒否）を再利用する。
-- 三重経路: schema.sql(fresh) / migrate.ts(upgrade) / repository.initSchema(整合)
CREATE TABLE IF NOT EXISTS package_requests (
  id              TEXT PRIMARY KEY,             -- randomUUID
  task_id         TEXT,                         -- local_tasks 擬似 id（nullable）
  job_id          TEXT,                         -- jobs.id（nullable、承認後 resume 対象）
  space_id        TEXT,                         -- install 先スペース（承認に必須）
  piece_name      TEXT,                         -- 要求元 piece（診断用・nullable）
  movement_name   TEXT,                         -- 要求元 movement（UI 表示用・nullable）
  spec            TEXT NOT NULL,                -- 正規化済み pip spec（例: requests==2.32.3）
  normalized_name TEXT NOT NULL,                -- PEP 503 正規化名（例: requests）
  reason          TEXT,                         -- なぜ必要か（エージェント申告）
  status          TEXT NOT NULL DEFAULT 'pending',  -- pending | approved | denied | auto_denied
  decided_by      TEXT,                         -- 承認/拒否した user_id
  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);
-- de-dup lookup (job-scoped pending row) — avoids a full scan per record call
CREATE INDEX IF NOT EXISTS idx_package_requests_dedup ON package_requests (job_id, normalized_name, status);

-- ワークスペースファイルの来歴（プロヴェナンス）台帳。永続ワークスペースが
-- 複数タスク間で共有されるため、各ファイルを「どのタスクが作った / アップロード
-- したか」「最後に変更したのは誰か」を追跡する。ファイル本文にはタグを埋めない。
-- 三重ミラー: schema.sql + migrate.ts + Repository.initSchema。
CREATE TABLE IF NOT EXISTS workspace_file_provenance (
  id                        INTEGER PRIMARY KEY AUTOINCREMENT,
  space_id                  TEXT,                          -- スペース ID（個人WS/legacy は NULL）
  workspace_path            TEXT NOT NULL,                 -- 共有ファイルツリー root（スコープの要）
  rel_path                  TEXT NOT NULL,                 -- workspace 相対パス（例 output/foo.md）
  created_by_task_id        INTEGER,                       -- 作成タスク（backfill 行は NULL）
  created_by_job_id         TEXT,
  created_by_piece          TEXT,
  created_by_movement       TEXT,
  source_kind               TEXT NOT NULL,                 -- user_input | agent_output | agent_edit | bash_generated | subtask_output | imported_existing | unknown
  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);

-- Local task コメント / イベント
CREATE TABLE IF NOT EXISTS local_task_comments (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  task_id    INTEGER NOT NULL,
  author     TEXT NOT NULL,
  kind       TEXT NOT NULL DEFAULT 'comment',
  body       TEXT NOT NULL,
  attachments TEXT,
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  injected_at TEXT,
  FOREIGN KEY (task_id) REFERENCES local_tasks(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_local_task_comments_task_id ON local_task_comments (task_id, created_at ASC);

-- タスク横断検索用の会話コメント索引（通常テーブルのみ; FTS5 仮想テーブル＋同期トリガは
-- src/db/migrate.ts の migrateTaskCommentIndex（isFts5Available ガード）が単経路で作成する）
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);

-- Issue ロックテーブル (同一 Issue の並列実行を防ぐ)
CREATE TABLE IF NOT EXISTS issue_locks (
  repo         TEXT NOT NULL,
  issue_number INTEGER NOT NULL,
  job_id       TEXT NOT NULL,
  locked_at    TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (repo, issue_number)
);

-- 監査ログテーブル
CREATE TABLE IF NOT EXISTS audit_log (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  job_id     TEXT,
  action     TEXT NOT NULL,
  actor      TEXT,
  detail     TEXT,  -- JSON
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_audit_log_job_id ON audit_log (job_id);

-- A2A: oidc-provider の永続化（Session/AccessToken/AuthorizationCode/Grant/Interaction 等を 1 表で保持）
CREATE TABLE IF NOT EXISTS oidc_models (
  model       TEXT NOT NULL,            -- 'AccessToken' | 'AuthorizationCode' | 'Grant' | 'Session' | ...
  id          TEXT NOT NULL,
  payload     TEXT NOT NULL,            -- JSON
  grant_id    TEXT,                     -- revokeByGrantId 用
  user_code   TEXT,                     -- device flow（未使用だが index 用）
  uid         TEXT,                     -- Session の findByUid 用
  expires_at  INTEGER,                  -- epoch 秒。NULL = 無期限
  consumed_at INTEGER,                  -- epoch 秒。consume() で設定
  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: 管理者登録した外部 A2A クライアント（OAuth client）
CREATE TABLE IF NOT EXISTS a2a_clients (
  client_id                 TEXT PRIMARY KEY,
  name                      TEXT NOT NULL,
  redirect_uris             TEXT NOT NULL,           -- JSON 文字列配列
  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,                    -- 相手の Agent Card URL（identity 固定用、Plan 2 で検証）
  issuer                    TEXT,                    -- 相手 issuer（Plan 2 で検証）
  key_fingerprint           TEXT,                    -- 相手鍵 fingerprint（Plan 2 で検証）
  status                    TEXT NOT NULL DEFAULT 'active', -- active | disabled
  created_by                TEXT,                    -- 登録した admin の user_id
  created_at                TEXT NOT NULL DEFAULT (datetime('now'))
);

-- A2A: ユーザー → 外部クライアントへのスコープ付き委任（OAuth Grant と grant_id で 1:1）
CREATE TABLE IF NOT EXISTS a2a_delegations (
  id                TEXT PRIMARY KEY,        -- randomUUID
  user_id           TEXT NOT NULL,           -- acting user（委任元）
  client_id         TEXT NOT NULL,           -- a2a_clients.client_id
  grant_id          TEXT,                    -- oidc Grant.save() の戻り値。トークン → 委任の引き当てキー
  granted_space_ids TEXT NOT NULL DEFAULT '[]', -- JSON string[]
  granted_skills    TEXT NOT NULL DEFAULT '[]', -- JSON string[]（piece 名）
  audience          TEXT,
  expires_at        TEXT,                    -- ISO8601。NULL = grant に従う
  revoked_at        TEXT,                    -- 失効時刻。非 NULL = 失効
  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）
CREATE TABLE IF NOT EXISTS a2a_tasks (
  id              TEXT PRIMARY KEY,   -- A2A task id
  context_id      TEXT,               -- A2A contextId
  job_id          TEXT,               -- MAESTRO jobs.id (link)
  local_task_id   INTEGER,            -- local_tasks.id (link)
  payload         TEXT NOT NULL,      -- A2A Task object JSON
  updated_at      TEXT NOT NULL DEFAULT (datetime('now')),
  delegation_id   TEXT,               -- a2a_delegations.id (委任 ID)
  grant_id        TEXT,               -- OAuth grant id (委任紐付け)
  acting_user_id  TEXT                -- 委任された実行ユーザー
);
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);

-- Worker ノード状態
CREATE TABLE IF NOT EXISTS worker_nodes (
  worker_id        TEXT PRIMARY KEY,
  endpoint         TEXT NOT NULL,
  enabled          INTEGER NOT NULL DEFAULT 1,
  healthy          INTEGER NOT NULL DEFAULT 0,
  profile_tags     TEXT NOT NULL DEFAULT ',auto,',
  task_class_tags  TEXT NOT NULL DEFAULT ',auto,',
  max_concurrency  INTEGER NOT NULL DEFAULT 1,
  available_models TEXT,
  inflight_jobs    INTEGER NOT NULL DEFAULT 0,
  last_error       TEXT,
  last_seen_at     TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at       TEXT NOT NULL DEFAULT (datetime('now'))
);

-- スケジュール実行定義
CREATE TABLE IF NOT EXISTS scheduled_tasks (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  title           TEXT,
  body            TEXT NOT NULL,
  piece_name      TEXT NOT NULL DEFAULT 'auto',
  profile         TEXT NOT NULL DEFAULT 'auto',
  output_format   TEXT NOT NULL DEFAULT 'markdown',
  cron_expression TEXT NOT NULL,
  next_run_at     TEXT NOT NULL,
  last_run_at     TEXT,
  last_job_id     TEXT,
  is_active       INTEGER NOT NULL DEFAULT 1,
  task_kind       TEXT NOT NULL DEFAULT 'agent' CHECK (task_kind IN ('agent','script')),
  script_name     TEXT,
  script_params   TEXT, -- JSON-encoded object when task_kind = 'script'
  space_id        TEXT,
  created_at      TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at      TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Auth: users
CREATE TABLE IF NOT EXISTS users (
  id         TEXT PRIMARY KEY,
  email      TEXT UNIQUE NOT NULL,
  name       TEXT,
  avatar_url TEXT,
  role       TEXT NOT NULL DEFAULT 'user',
  status     TEXT NOT NULL DEFAULT 'pending',
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Auth: OAuth account linkage
CREATE TABLE IF NOT EXISTS oauth_accounts (
  id          TEXT PRIMARY KEY,
  user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider    TEXT NOT NULL,
  provider_id TEXT NOT NULL,
  created_at  TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(provider, provider_id)
);

-- Auth: local-account credentials (email + password). One row per user that
-- has a local password; OAuth-only users have none. scrypt hash + per-user
-- salt. Cascades with the user so deletion leaves no orphaned credential.
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'))
);

-- Auth: local organizations. A provider-agnostic 'org' visibility scope for
-- local accounts (Gitea orgs come from the Gitea API instead). org ids are
-- prefixed 'lorg:' so they never collide with Gitea numeric org ids; a member's
-- orgIds are injected into the session so buildVisibilityWhere works unchanged.
-- See docs/superpowers/plans/2026-06-09-local-orgs.md.
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);

-- Auth: sessions (express-session)
CREATE TABLE IF NOT EXISTS sessions (
  sid     TEXT PRIMARY KEY,
  sess    TEXT NOT NULL,
  expired TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired);

-- ── Browser session persistence (2026-05) ─────────────────────────────
CREATE TABLE IF NOT EXISTS user_deks (
  user_id        TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  encrypted_dek  BLOB NOT NULL,
  created_at     TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Space-as-Principal P2: per-SPACE browser-session DEK (file-master domain).
-- Mirrors user_deks but keyed by space_id, so a space-owned session profile's
-- state survives member turnover (encrypted under the space DEK, not the
-- creating member's DEK). Wrapped under the file master key like user_deks.
CREATE TABLE IF NOT EXISTS space_browser_deks (
  space_id       TEXT PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,
  encrypted_dek  BLOB NOT NULL,
  created_at     TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS browser_session_profiles (
  id                   INTEGER PRIMARY KEY AUTOINCREMENT,
  owner_id             TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  -- space_id: NULL = legacy / admin-global; otherwise the profile is grouped
  -- under that space (visibility = space membership). owner_id is preserved
  -- regardless — the DEK that decrypts encrypted_state_blob is owner-bound, so
  -- a profile created by user A in a shared space is only decryptable by A.
  space_id             TEXT,
  label                TEXT NOT NULL,
  start_url            TEXT NOT NULL,
  match_patterns       TEXT NOT NULL DEFAULT '[]',
  storage_origins      TEXT NOT NULL DEFAULT '[]',
  logged_in_selector   TEXT,
  login_url_patterns   TEXT NOT NULL DEFAULT '[]',
  encrypted_state_blob BLOB,
  state_version        INTEGER NOT NULL DEFAULT 0,
  playwright_version   TEXT,
  status               TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','expired','revoked','error')),
  last_saved_at        TEXT,
  last_used_at         TEXT,
  last_validated_at    TEXT,
  last_error           TEXT,
  created_at           TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at           TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_bsp_owner ON browser_session_profiles(owner_id);
CREATE INDEX IF NOT EXISTS idx_bsp_space ON browser_session_profiles(space_id);

-- audit log: intentionally no FK — must survive deletion of referenced rows
CREATE TABLE IF NOT EXISTS browser_session_audit (
  id              INTEGER PRIMARY KEY AUTOINCREMENT,
  ts              TEXT NOT NULL DEFAULT (datetime('now')),
  actor_user_id   TEXT,
  profile_id      INTEGER,
  owner_id        TEXT,
  action          TEXT NOT NULL CHECK (action IN ('create','save','decrypt','use','delete','expire','revoke','test','login_start','login_cancel')),
  task_id         INTEGER,
  job_id          TEXT,
  result          TEXT NOT NULL CHECK (result IN ('success','error')),
  reason          TEXT
);
CREATE INDEX IF NOT EXISTS idx_bsa_profile ON browser_session_audit(profile_id);
CREATE INDEX IF NOT EXISTS idx_bsa_actor ON browser_session_audit(actor_user_id);

-- ─── MCP (Model Context Protocol) ──────────────────────────────
-- Note: migrate.ts intentionally omits the FK clauses below for
-- in-flight DBs; see migrateMcpTables in src/db/migrate.ts.

-- Admin-registered MCP servers
CREATE TABLE IF NOT EXISTS mcp_servers (
  id                      TEXT PRIMARY KEY,
  name                    TEXT NOT NULL,
  url                     TEXT NOT NULL,
  oauth_client_id         TEXT NOT NULL,
  oauth_client_secret_enc BLOB NOT NULL,
  oauth_scopes            TEXT,
  issuer                  TEXT,
  authorization_endpoint  TEXT,
  token_endpoint          TEXT,
  discovery_fingerprint   TEXT,
  enabled                 INTEGER NOT NULL DEFAULT 1,
  created_by              TEXT,
  -- Phase 8: API-key auth + user-owned servers. Mirrors migrate.ts; both
  -- paths must stay in sync (project_db_migration_dual_path).
  auth_kind               TEXT NOT NULL DEFAULT 'oauth',
  -- Nullable BLOB: present for api_key servers, NULL for oauth servers.
  static_token_enc        BLOB,
  -- Custom auth header name for api_key servers (NULL = Authorization: Bearer).
  auth_header_name        TEXT,
  -- NULL = global/admin-managed; NOT NULL = user-owned.
  owner_id                TEXT REFERENCES users(id) ON DELETE CASCADE,
  -- Per-space isolation (spaces foundation). NULL = global/legacy (resolved
  -- globally; Phase 1 is a no-op, no consumer reads this yet).
  space_id                TEXT,
  created_at              TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at              TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Per-user OAuth tokens
CREATE TABLE IF NOT EXISTS user_mcp_tokens (
  user_id            TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  server_id          TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
  access_token_enc   BLOB NOT NULL,
  refresh_token_enc  BLOB,
  expires_at         TEXT,
  scope              TEXT,
  scope_type         TEXT NOT NULL DEFAULT 'user' CHECK(scope_type IN ('user', 'org')),
  scope_id           TEXT,
  connected_at       TEXT NOT NULL DEFAULT (datetime('now')),
  updated_at         TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (user_id, server_id)
);

-- list_tools cache
CREATE TABLE IF NOT EXISTS mcp_server_tools (
  server_id    TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
  tool_name    TEXT NOT NULL,
  description  TEXT,
  input_schema TEXT,
  refreshed_at TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (server_id, tool_name)
);

-- Pending OAuth state/verifier (single-use, TTL-cleaned)
CREATE TABLE IF NOT EXISTS mcp_oauth_pending (
  state         TEXT PRIMARY KEY,
  user_id       TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  server_id     TEXT NOT NULL REFERENCES mcp_servers(id) ON DELETE CASCADE,
  code_verifier TEXT NOT NULL,
  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_mcp_oauth_pending_created ON mcp_oauth_pending(created_at);

-- ─── SSH (2026-05) ──────────────────────────────────────────
-- Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md
-- Feature gated by config.ssh.enabled; tables are inert when disabled.

-- System DEK for envelope-encrypting global connection keys.
-- Single-row table (CHECK id=1). Bootstrapped at boot when ssh.enabled.
-- Wrapped under MCP_ENCRYPTION_KEY env var (the SSH "master key").
CREATE TABLE IF NOT EXISTS system_deks (
  id            INTEGER PRIMARY KEY CHECK (id = 1),
  encrypted_dek BLOB NOT NULL,
  key_version   INTEGER NOT NULL DEFAULT 1,
  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Per-user DEK for user-owned SSH connection keys.
-- Distinct from `user_deks` (used by browser-sessions under master.key file).
-- ssh_user_deks is wrapped under MCP_ENCRYPTION_KEY env var.
-- Created lazily on first SSH connection creation per user.
CREATE TABLE IF NOT EXISTS ssh_user_deks (
  user_id       TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  encrypted_dek BLOB NOT NULL,
  key_version   INTEGER NOT NULL DEFAULT 1,
  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Space-as-Principal P2: per-SPACE DEK (env-master domain, shared by SSH/MCP).
-- Keyed by space_id so a space-owned credential survives member turnover — the
-- secret is wrapped under the space DEK, not a departing member's DEK. Added
-- unused in P2-1; SSH/MCP wired to it in later slices.
CREATE TABLE IF NOT EXISTS space_ssh_deks (
  space_id      TEXT PRIMARY KEY REFERENCES spaces(id) ON DELETE CASCADE,
  encrypted_dek BLOB NOT NULL,
  key_version   INTEGER NOT NULL DEFAULT 1,
  created_at    TEXT NOT NULL DEFAULT (datetime('now'))
);

-- SSH connection records. Private key is envelope-encrypted (per-user DEK for
-- user-owned, system DEK for globals). Host key is TOFU with explicit UI verify.
-- Lock/abuse state lives in ssh_abuse_counters (single source of truth).
CREATE TABLE IF NOT EXISTS ssh_connections (
  id                            TEXT PRIMARY KEY,
  owner_id                      TEXT,
  label                         TEXT NOT NULL,
  host                          TEXT NOT NULL,
  port                          INTEGER NOT NULL DEFAULT 22,
  username                      TEXT NOT NULL,

  private_key_enc               BLOB NOT NULL,
  passphrase_enc                BLOB,
  key_version                   INTEGER NOT NULL DEFAULT 1,
  key_fingerprint               TEXT,

  host_key_type                 TEXT,
  host_key_b64                  TEXT,
  host_key_fingerprint          TEXT,
  host_key_recorded_at          TEXT,
  host_key_verified_at          TEXT,
  host_key_pending              INTEGER NOT NULL DEFAULT 0,
  host_key_pending_b64          TEXT,
  host_key_pending_fingerprint  TEXT,
  host_key_pending_token        TEXT,
  host_key_pending_source       TEXT,

  command_deny_patterns         TEXT,
  command_allow_patterns        TEXT,
  remote_path_prefix            TEXT NOT NULL CHECK (LENGTH(remote_path_prefix) > 0),
  allow_remote_unrestricted     INTEGER NOT NULL DEFAULT 0,
  allow_private_addresses       INTEGER NOT NULL DEFAULT 0,

  enabled                       INTEGER NOT NULL DEFAULT 1,
  disabled_by_admin             INTEGER NOT NULL DEFAULT 0,
  disabled_by_admin_reason      TEXT,
  disabled_by_admin_at          TEXT,
  disabled_by_admin_user_id     TEXT,

  -- Per-space isolation (spaces foundation). NULL = global/legacy (resolved
  -- globally; Phase 1 is a no-op, no consumer reads this yet).
  space_id                      TEXT,

  created_at                    TEXT NOT NULL,
  updated_at                    TEXT NOT NULL,
  FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_ssh_connections_owner    ON ssh_connections(owner_id);
CREATE INDEX IF NOT EXISTS idx_ssh_connections_enabled  ON ssh_connections(enabled, disabled_by_admin);

-- Per-(user|org, piece) grants. Required for non-owner non-admin access to a
-- connection (typical case: granting access to a global connection).
-- applies_to_all_pieces=1 makes piece_name NULL and requires admin + reason.
CREATE TABLE IF NOT EXISTS ssh_connection_grants (
  id                       TEXT PRIMARY KEY,
  connection_id            TEXT NOT NULL REFERENCES ssh_connections(id) ON DELETE CASCADE,
  subject_type             TEXT NOT NULL CHECK (subject_type IN ('user','org')),
  subject_id               TEXT NOT NULL,
  piece_name               TEXT,
  applies_to_all_pieces    INTEGER NOT NULL DEFAULT 0,
  granted_by_user_id       TEXT NOT NULL,
  reason                   TEXT NOT NULL CHECK (LENGTH(reason) >= 8),
  expires_at               TEXT,
  created_at               TEXT NOT NULL,
  CHECK (
    (applies_to_all_pieces = 1 AND piece_name IS NULL) OR
    (applies_to_all_pieces = 0 AND piece_name IS NOT NULL)
  )
);

CREATE INDEX IF NOT EXISTS idx_ssh_grants_connection ON ssh_connection_grants(connection_id);
CREATE INDEX IF NOT EXISTS idx_ssh_grants_subject    ON ssh_connection_grants(subject_type, subject_id);

-- Dedicated audit log with pending→completed lifecycle.
-- beginAudit inserts outcome='pending' and commits before any remote call.
-- completeAudit updates outcome + completed_at + detail.
-- Startup recovery sweeps remaining 'pending' rows to 'aborted'.
CREATE TABLE IF NOT EXISTS ssh_audit_log (
  id                INTEGER PRIMARY KEY AUTOINCREMENT,
  action            TEXT NOT NULL,
  entity_type       TEXT,
  entity_id         TEXT,
  connection_id     TEXT,
  owner_id          TEXT,
  acting_user_id    TEXT,
  job_id            TEXT,
  piece_name        TEXT,
  outcome           TEXT NOT NULL CHECK (outcome IN ('pending','success','failed','denied','aborted')),
  reason            TEXT,
  detail            TEXT,
  started_at        TEXT NOT NULL,
  completed_at      TEXT,
  FOREIGN KEY (connection_id) REFERENCES ssh_connections(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_ssh_audit_action     ON ssh_audit_log(action, started_at);
CREATE INDEX IF NOT EXISTS idx_ssh_audit_connection ON ssh_audit_log(connection_id, started_at);
CREATE INDEX IF NOT EXISTS idx_ssh_audit_owner      ON ssh_audit_log(owner_id, started_at);
CREATE INDEX IF NOT EXISTS idx_ssh_audit_outcome    ON ssh_audit_log(outcome, started_at);
CREATE INDEX IF NOT EXISTS idx_ssh_audit_pending    ON ssh_audit_log(outcome) WHERE outcome = 'pending';

-- Abuse counters: single source of truth for failure_count + lock_until.
-- Three scope kinds: 'conn' (per connection), 'userhost' (per user+host+username),
-- 'globalhost' (per host+username; enforce_lock=0 for user-owned connections, 1 for globals).
CREATE TABLE IF NOT EXISTS ssh_abuse_counters (
  scope_key            TEXT PRIMARY KEY,
  scope_kind           TEXT NOT NULL CHECK (scope_kind IN ('conn','userhost','globalhost')),
  enforce_lock         INTEGER NOT NULL DEFAULT 1,
  failure_count        INTEGER NOT NULL DEFAULT 0,
  failure_window_start TEXT,
  lock_until           TEXT,
  updated_at           TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_ssh_abuse_kind   ON ssh_abuse_counters(scope_kind);
CREATE INDEX IF NOT EXISTS idx_ssh_abuse_locked ON ssh_abuse_counters(lock_until) WHERE lock_until IS NOT NULL;

-- (Removed 2026-06) The Side Info Panel dashboard widget table
-- (user_dashboard_widgets) was dropped when the configurable-widget feature
-- was retired in favour of fixed worker / node (GPU) status panels. The
-- migration drops it if an older DB still carries it (see migrate.ts).

-- ── AAO Gateway: virtual keys (Phase 2a) ────────────────────────────────
-- Per-team bearer keys for gateway-mode auth. Only the SHA-256 hash is
-- stored (raw key is returned to the admin exactly once at issuance).
-- `source` distinguishes admin-API-issued keys from keys auto-imported
-- from config.yaml at gateway boot; the latter are protected from hard
-- delete via the admin API.
-- Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2a).
CREATE TABLE IF NOT EXISTS gateway_virtual_keys (
  id             TEXT PRIMARY KEY,
  key_hash       TEXT NOT NULL UNIQUE,
  key_prefix     TEXT NOT NULL,
  team           TEXT NOT NULL,
  allowed_models TEXT,                          -- JSON array, NULL = no allowlist
  source         TEXT NOT NULL DEFAULT 'admin' CHECK (source IN ('admin','config-import')),
  created_at     TEXT NOT NULL,
  created_by     TEXT,
  revoked_at     TEXT,
  revoked_by     TEXT,
  last_used_at   TEXT,
  -- Phase 2b additions: per-key monthly token budget + per-minute rate
  -- limit. NULL = unlimited (default). Enforced by the gateway middleware
  -- chain; the DB only persists the limit values.
  tokens_budget  INTEGER,
  rate_limit_rpm INTEGER
);

-- Partial index so hot-path auth lookup only walks active keys.
CREATE INDEX IF NOT EXISTS idx_gateway_keys_hash_active
  ON gateway_virtual_keys (key_hash)
  WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_gateway_keys_team
  ON gateway_virtual_keys (team);

-- ── AAO Gateway: per-key usage tracking (Phase 2b) ──────────────────────
-- Monthly token + request counters. period_start is the UTC 'YYYY-MM'
-- bucket. PRIMARY KEY (key_id, period_start) lets the budget enforcement
-- read happen as a single point lookup.
-- Plan: docs/superpowers/specs/2026-05-18-aao-gateway-mode-design.md (Phase 2b).
CREATE TABLE IF NOT EXISTS gateway_key_usage (
  key_id          TEXT NOT NULL REFERENCES gateway_virtual_keys(id) ON DELETE CASCADE,
  period_start    TEXT NOT NULL,                  -- 'YYYY-MM' (UTC)
  tokens_in       INTEGER NOT NULL DEFAULT 0,
  tokens_out      INTEGER NOT NULL DEFAULT 0,
  requests        INTEGER NOT NULL DEFAULT 0,
  last_updated_at TEXT NOT NULL,
  PRIMARY KEY (key_id, period_start)
);

CREATE INDEX IF NOT EXISTS idx_gateway_usage_key
  ON gateway_key_usage (key_id);

-- ── LLM usage: per-user daily aggregation (gateway + direct) ────────────
-- Daily UPSERT buckets recorded at the OpenAICompatClient completion
-- boundary, covering BOTH gateway-routed and direct LLM calls. This is a
-- separate lens from gateway_key_usage (which is per-virtual-key / billing).
-- grain = (day, user_id, source, model, route); user_id is NOT NULL with a
-- 'system' / 'local' sentinel so ON CONFLICT keys never hit the SQLite
-- NULL != NULL trap. Spec:
-- docs/superpowers/specs/2026-06-11-llm-usage-aggregation-design.md
CREATE TABLE IF NOT EXISTS llm_usage_daily (
  day             TEXT    NOT NULL,                -- 'YYYY-MM-DD' (UTC)
  user_id         TEXT    NOT NULL,                -- owner id / 'local' / 'system'
  source          TEXT    NOT NULL,                -- 'gateway' | 'direct'
  model           TEXT    NOT NULL,                -- real model name (chunk.model), routing key fallback
  route           TEXT    NOT NULL,                -- backend server name (gateway backendId / direct host)
  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 so the dashboard can re-bucket UTC
-- usage into the viewer's local calendar day/week/month, and group by any of
-- source/model/route/user/org. This supersedes llm_usage_daily as the write
-- target; the daily table is kept as a frozen pre-migration archive and is
-- backfilled into here once (hour = day || 'T00'). user_id keeps the same
-- 'local'/'system' sentinel convention so ON CONFLICT never hits NULL!=NULL.
-- Spec: docs/superpowers/specs/2026-06-11-usage-dashboard-v2-design.md
CREATE TABLE IF NOT EXISTS llm_usage_hourly (
  hour            TEXT    NOT NULL,                -- 'YYYY-MM-DDTHH' (UTC)
  user_id         TEXT    NOT NULL,                -- owner id / 'local' / 'system'
  source          TEXT    NOT NULL,                -- 'gateway' | 'direct'
  model           TEXT    NOT NULL,                -- real model name (chunk.model)
  route           TEXT    NOT NULL,                -- backend server (gateway backendId / direct host)
  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);

-- ── Browser Notifications V2: Web Push subscriptions + per-user prefs ───
-- Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md
-- endpoint is globally UNIQUE so logging into a different user in the same
-- browser reassigns the subscription via ON CONFLICT(endpoint) DO UPDATE.
CREATE TABLE IF NOT EXISTS push_subscriptions (
  id              TEXT PRIMARY KEY,
  user_id         TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  endpoint        TEXT NOT NULL UNIQUE,
  p256dh          TEXT NOT NULL,
  auth            TEXT NOT NULL,
  user_agent      TEXT,
  vapid_key_id    TEXT NOT NULL,
  created_at      TEXT NOT NULL DEFAULT (datetime('now')),
  last_success_at TEXT,
  last_failure_at TEXT,
  failure_count   INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id
  ON push_subscriptions(user_id);

-- Per-user notification preferences. v1_migrated tracks the one-time import
-- from V1 localStorage. include_details gates whether task title/piece name
-- are placed in the push payload (privacy-default off).
CREATE TABLE IF NOT EXISTS user_notification_prefs (
  user_id              TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
  enabled              INTEGER NOT NULL DEFAULT 1,
  event_running        INTEGER NOT NULL DEFAULT 1,
  event_succeeded      INTEGER NOT NULL DEFAULT 1,
  event_failed         INTEGER NOT NULL DEFAULT 1,
  event_waiting_human  INTEGER NOT NULL DEFAULT 1,
  include_details      INTEGER NOT NULL DEFAULT 0,
  v1_migrated          INTEGER NOT NULL DEFAULT 0,
  updated_at           TEXT NOT NULL DEFAULT (datetime('now'))
);

-- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created
-- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts
-- (upgrade path) so the task-list display SELECT never hits "no such table".
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);
