feat: PostgreSQL migration, OAuth write MCP, Streamable HTTP, env-driven config, admin UI, landing page
- Migrate database from SQLite to PostgreSQL 16 (dual-backend with SQLite fallback) - Add Streamable HTTP MCP transport (replaces SSE): /readonly/mcp, /write/mcp, /mcp - Add OAuth ctxd.write scope and public write MCP surface - Add ctxd.write token validation (write-scoped tokens only on /write/mcp) - Add env-driven configuration (.env file with env var precedence over ctxd.yaml) - Add PostgreSQL to docker-compose.yml with healthcheck - Add psycopg dependency, migration script (SQLite → PostgreSQL) - Add admin UI: projects tab with typed-confirm delete, user management (list/manage subtabs) - Add OAuth client management: create, list, revoke (UI, CLI, API) - Add user active/inactive lifecycle (PATCH/DELETE APIs) - Add public landing page with themed login form (cookie-based session) - Add get_client_guide MCP tool (locked LLM-CLIENT.MD in ctxd-docs project) - Add DELETE /projects/<id> endpoint with cascading deletes - Add project_delete to db.py with FK ON DELETE SET NULL for audit_log - Add cookie-based session auth (ctxd_session cookie on login) - Add landing.html (public host) vs ui.html (internal dashboard) - Add schema_sqlite.sql for SQLite fallback - Add auth_password.py (PBKDF2-SHA256 password hashing) - Add .env.example template with all documented env vars - Add README.md with full setup, config, API, CLI, and troubleshooting docs - Add SKILL.md (canonical LLM client guide, lives in project root) - Update Traefik template: route everything except /mcp - Update OAuth discovery: advertise ctxd.write scope, /readonly/mcp resource - Update Hermes MCP config: /mcp endpoint with Bearer header - Remove DB-level audit_log triggers (conflict with FK ON DELETE SET NULL) - Remove SSE transport code (replaced by Streamable HTTP) - Untrack __pycache__ and data/ctxd.db from git
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
"""Password hashing for CTXD user accounts (stdlib only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
PBKDF2_ITERATIONS = 600_000
|
||||
SCHEME = "pbkdf2_sha256"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
if not password:
|
||||
raise ValueError("password required")
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
PBKDF2_ITERATIONS,
|
||||
)
|
||||
return f"{SCHEME}${PBKDF2_ITERATIONS}${salt}${digest.hex()}"
|
||||
|
||||
|
||||
def verify_password(password: str, token_hash: str | None) -> bool:
|
||||
if not password or not token_hash:
|
||||
return False
|
||||
try:
|
||||
scheme, iters_s, salt, expected_hex = token_hash.split("$", 3)
|
||||
if scheme != SCHEME:
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
int(iters_s),
|
||||
)
|
||||
return secrets.compare_digest(digest.hex(), expected_hex)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
+94
-2
@@ -309,16 +309,82 @@ def cmd_user_create(args):
|
||||
"""Create a new user."""
|
||||
conn = _db.init_db(CtxConfig.from_home(args.home))
|
||||
try:
|
||||
_db.user_create(conn, args.user_id, args.display_name, args.role)
|
||||
_db.user_create(conn, args.user_id, args.display_name, args.role, password=getattr(args, "password", None))
|
||||
conn.commit()
|
||||
print(f"✓ User '{args.user_id}' created.")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"Error: {e}")
|
||||
print(f"✗ {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_user_set_password(args):
|
||||
"""Set or reset a user's Web UI password."""
|
||||
conn = _db.init_db(CtxConfig.from_home(args.home))
|
||||
try:
|
||||
if _db.user_get(conn, args.user_id) is None:
|
||||
print(f"✗ User '{args.user_id}' not found.")
|
||||
sys.exit(1)
|
||||
_db.user_set_password(conn, args.user_id, args.password)
|
||||
conn.commit()
|
||||
print(f"✓ Password set for '{args.user_id}'.")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"✗ {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def cmd_oauth_client_create(args):
|
||||
"""Register an OAuth client for Claude (or other MCP connectors)."""
|
||||
from .server import OAuthStore, CLAUDE_MCP_REDIRECT_URI
|
||||
|
||||
cfg = CtxConfig.from_home(args.home)
|
||||
store = OAuthStore(cfg)
|
||||
redirects = args.redirect_uri or [CLAUDE_MCP_REDIRECT_URI]
|
||||
if isinstance(redirects, str):
|
||||
redirects = [redirects]
|
||||
client = store.register_client({
|
||||
"client_name": args.name or "Claude MCP Client",
|
||||
"redirect_uris": redirects,
|
||||
})
|
||||
issuer = (cfg.oauth_issuer or "").rstrip("/") or "https://ctxd.cubecraftcreations.com"
|
||||
print(json.dumps({
|
||||
"client_id": client["client_id"],
|
||||
"client_secret": client["client_secret"],
|
||||
"client_name": client.get("client_name"),
|
||||
"redirect_uris": client.get("redirect_uris"),
|
||||
"connector_url": f"{issuer}/readonly/sse",
|
||||
"authorization_server": issuer,
|
||||
"note": "Claude usually registers via POST /oauth/register automatically; save client_secret now — it is not shown again.",
|
||||
}, indent=2))
|
||||
|
||||
|
||||
def cmd_oauth_client_list(args):
|
||||
"""List OAuth clients (no secrets)."""
|
||||
from .server import OAuthStore
|
||||
|
||||
cfg = CtxConfig.from_home(args.home)
|
||||
store = OAuthStore(cfg)
|
||||
for c in store.list_clients_public():
|
||||
print(f"{c.get('client_id')} {c.get('client_name', '')} redirects={c.get('redirect_uris')}")
|
||||
|
||||
|
||||
def cmd_oauth_client_revoke(args):
|
||||
"""Revoke an OAuth client and invalidate its tokens."""
|
||||
from .server import OAuthStore
|
||||
|
||||
cfg = CtxConfig.from_home(args.home)
|
||||
store = OAuthStore(cfg)
|
||||
if not store.revoke_client(args.client_id):
|
||||
print(f"✗ client not found: {args.client_id}")
|
||||
sys.exit(1)
|
||||
print(f"✓ Revoked OAuth client {args.client_id}")
|
||||
|
||||
|
||||
def cmd_import_vault(args):
|
||||
"""Import context from an existing vault (e.g., OpenClawVault)."""
|
||||
cfg = CtxConfig.from_home(args.home)
|
||||
@@ -477,6 +543,32 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sp.add_argument("user_id")
|
||||
sp.add_argument("--display-name", "-n", required=True)
|
||||
sp.add_argument("--role", "-r", default="contributor", choices=["admin", "contributor", "service"])
|
||||
sp.add_argument("--password", help="Optional Web UI login password")
|
||||
sp.add_argument("--home")
|
||||
|
||||
# user set-password
|
||||
sp = sub.add_parser("user-set-password", help="Set a user's Web UI password")
|
||||
sp.set_defaults(func=cmd_user_set_password)
|
||||
sp.add_argument("user_id")
|
||||
sp.add_argument("--password", "-p", required=True)
|
||||
sp.add_argument("--home")
|
||||
|
||||
# oauth-client-create
|
||||
sp = sub.add_parser("oauth-client-create", help="Register OAuth client (Claude MCP); prints client_id and client_secret")
|
||||
sp.set_defaults(func=cmd_oauth_client_create)
|
||||
sp.add_argument("--name", "-n", default="Claude MCP Client", help="Client display name")
|
||||
sp.add_argument("--redirect-uri", action="append", dest="redirect_uri", help="Redirect URI (default: Claude MCP callback; repeat for multiple)")
|
||||
sp.add_argument("--home")
|
||||
|
||||
# oauth-client-list
|
||||
sp = sub.add_parser("oauth-client-list", help="List OAuth clients (no secrets)")
|
||||
sp.set_defaults(func=cmd_oauth_client_list)
|
||||
sp.add_argument("--home")
|
||||
|
||||
# oauth-client-revoke
|
||||
sp = sub.add_parser("oauth-client-revoke", help="Revoke OAuth client and invalidate its tokens")
|
||||
sp.set_defaults(func=cmd_oauth_client_revoke)
|
||||
sp.add_argument("client_id", help="client_id to revoke (ctxd_…)")
|
||||
sp.add_argument("--home")
|
||||
|
||||
# import-vault
|
||||
|
||||
+110
-11
@@ -1,13 +1,39 @@
|
||||
"""
|
||||
Configuration for ctxd — context daemon.
|
||||
|
||||
Environment variables take precedence over ctxd.yaml, which takes precedence
|
||||
over built-in defaults. This allows fully env-driven deployments (Docker,
|
||||
Compose, Kubernetes) while preserving file-based config for local dev.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _env_bool(key: str, fallback: bool = False) -> bool:
|
||||
val = os.environ.get(key, "")
|
||||
if not val:
|
||||
return fallback
|
||||
return val.lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_int(key: str, fallback: int) -> int:
|
||||
val = os.environ.get(key, "")
|
||||
if not val:
|
||||
return fallback
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return fallback
|
||||
|
||||
|
||||
def _env_str(key: str, fallback: str = "") -> str:
|
||||
return os.environ.get(key, fallback)
|
||||
|
||||
|
||||
# Default home directory (~/.ctx) — overridable via CTXD_HOME env var
|
||||
DEFAULT_HOME = Path(os.environ.get("CTXD_HOME", Path.home() / ".ctx"))
|
||||
|
||||
# Defaults for ctxd.yaml
|
||||
# Built-in defaults (lowest precedence)
|
||||
DEFAULT_CONFIG = {
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
@@ -20,6 +46,18 @@ DEFAULT_CONFIG = {
|
||||
"auth": {
|
||||
"enabled": False,
|
||||
"api_key": "",
|
||||
"external_readonly_key": "",
|
||||
},
|
||||
"oauth": {
|
||||
"enabled": False,
|
||||
"issuer": "",
|
||||
"approval_key": "",
|
||||
"approval_user_id": "admin",
|
||||
"access_token_ttl_seconds": 3600,
|
||||
"refresh_token_ttl_seconds": 2592000,
|
||||
},
|
||||
"web_sessions": {
|
||||
"ttl_seconds": 604800,
|
||||
},
|
||||
"seed": {
|
||||
"admin_user": "admin",
|
||||
@@ -31,7 +69,10 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
class CtxConfig:
|
||||
"""Holds resolved paths and config for a ctxd runtime."""
|
||||
"""Holds resolved paths and config for a ctxd runtime.
|
||||
|
||||
Precedence: env var > ctxd.yaml value > built-in default.
|
||||
"""
|
||||
|
||||
def __init__(self, home: Path | str | None = None, config: dict | None = None):
|
||||
resolved = Path(home) if home else DEFAULT_HOME
|
||||
@@ -59,31 +100,87 @@ class CtxConfig:
|
||||
def config_path(self) -> Path:
|
||||
return self.home / "ctxd.yaml"
|
||||
|
||||
# ── Config accessors ──────────────────────────────────────────
|
||||
@property
|
||||
def oauth_state_path(self) -> Path:
|
||||
return self.home / "oauth_state.json"
|
||||
|
||||
@property
|
||||
def web_sessions_path(self) -> Path:
|
||||
return self.home / "web_sessions.json"
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
return _env_str("DATABASE_URL")
|
||||
|
||||
@property
|
||||
def use_postgres(self) -> bool:
|
||||
return bool(self.database_url)
|
||||
|
||||
# ── Server ────────────────────────────────────────────────────
|
||||
@property
|
||||
def host(self) -> str:
|
||||
return self._cfg.get("server", {}).get("host", "127.0.0.1")
|
||||
return _env_str("CTXD_HOST", self._cfg.get("server", {}).get("host", "0.0.0.0"))
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._cfg.get("server", {}).get("port", 9091)
|
||||
return _env_int("CTXD_PORT", self._cfg.get("server", {}).get("port", 9091))
|
||||
|
||||
@property
|
||||
def log_level(self) -> str:
|
||||
return _env_str("LOG_LEVEL", "info")
|
||||
|
||||
# ── Snapshots ─────────────────────────────────────────────────
|
||||
@property
|
||||
def min_snapshots(self) -> int:
|
||||
return self._cfg.get("snapshots", {}).get("min_keep", 5)
|
||||
return _env_int("SNAPSHOT_MIN_KEEP", self._cfg.get("snapshots", {}).get("min_keep", 5))
|
||||
|
||||
@property
|
||||
def max_snapshots(self) -> int:
|
||||
return self._cfg.get("snapshots", {}).get("max_keep", 25)
|
||||
return _env_int("SNAPSHOT_MAX_KEEP", self._cfg.get("snapshots", {}).get("max_keep", 25))
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
return self._cfg.get("auth", {}).get("enabled", False)
|
||||
return _env_bool("CTXD_AUTH_ENABLED", self._cfg.get("auth", {}).get("enabled", False))
|
||||
|
||||
@property
|
||||
def api_key(self) -> str:
|
||||
return self._cfg.get("auth", {}).get("api_key", "")
|
||||
return _env_str("CTXD_API_KEY", self._cfg.get("auth", {}).get("api_key", ""))
|
||||
|
||||
@property
|
||||
def external_readonly_key(self) -> str:
|
||||
return _env_str("CTXD_EXTERNAL_READONLY_KEY", self._cfg.get("auth", {}).get("external_readonly_key", ""))
|
||||
|
||||
# ── OAuth ─────────────────────────────────────────────────────
|
||||
@property
|
||||
def oauth_enabled(self) -> bool:
|
||||
return _env_bool("OAUTH_ENABLED", self._cfg.get("oauth", {}).get("enabled", False))
|
||||
|
||||
@property
|
||||
def oauth_issuer(self) -> str:
|
||||
return _env_str("OAUTH_ISSUER", self._cfg.get("oauth", {}).get("issuer", ""))
|
||||
|
||||
@property
|
||||
def oauth_approval_key(self) -> str:
|
||||
return _env_str("OAUTH_APPROVAL_KEY", self._cfg.get("oauth", {}).get("approval_key", ""))
|
||||
|
||||
@property
|
||||
def oauth_approval_user_id(self) -> str:
|
||||
return _env_str("OAUTH_APPROVAL_USER_ID", self._cfg.get("oauth", {}).get("approval_user_id", "admin"))
|
||||
|
||||
@property
|
||||
def oauth_access_token_ttl_seconds(self) -> int:
|
||||
return _env_int("OAUTH_ACCESS_TOKEN_TTL", self._cfg.get("oauth", {}).get("access_token_ttl_seconds", 3600))
|
||||
|
||||
@property
|
||||
def oauth_refresh_token_ttl_seconds(self) -> int:
|
||||
return _env_int("OAUTH_REFRESH_TOKEN_TTL", self._cfg.get("oauth", {}).get("refresh_token_ttl_seconds", 2592000))
|
||||
|
||||
# ── Web Sessions ───────────────────────────────────────────────
|
||||
@property
|
||||
def web_session_ttl_seconds(self) -> int:
|
||||
return _env_int("WEB_SESSION_TTL", self._cfg.get("web_sessions", {}).get("ttl_seconds", 604800))
|
||||
|
||||
# ── Bootstrap ─────────────────────────────────────────────────
|
||||
def ensure_dirs(self):
|
||||
@@ -93,7 +190,9 @@ class CtxConfig:
|
||||
|
||||
@classmethod
|
||||
def from_home(cls, home: Path | str | None = None) -> "CtxConfig":
|
||||
"""Load from ctxd.yaml if it exists, otherwise use defaults."""
|
||||
"""Load from ctxd.yaml if it exists, otherwise use defaults.
|
||||
Env vars always take precedence over yaml values at read time.
|
||||
"""
|
||||
home = Path(home).resolve() if home else DEFAULT_HOME
|
||||
cfg_path = home / "ctxd.yaml"
|
||||
if cfg_path.exists():
|
||||
@@ -104,7 +203,7 @@ class CtxConfig:
|
||||
return cls(home=str(home))
|
||||
|
||||
def save(self):
|
||||
"""Write config to ctxd.yaml."""
|
||||
"""Write config to ctxd.yaml. Rarely needed in env-driven deployments."""
|
||||
import yaml
|
||||
self.ensure_dirs()
|
||||
with open(self.config_path, "w") as f:
|
||||
|
||||
+403
-110
@@ -1,7 +1,10 @@
|
||||
"""
|
||||
Database layer for ctxd — schema init, CRUD, workspace fork/merge, FTS, audit.
|
||||
All public methods take a sqlite3.Connection as the first argument so callers
|
||||
control transactions. This module is stateless — all state is in SQLite.
|
||||
All public methods take a connection as the first argument so callers
|
||||
control transactions. This module is stateless — all state is in the DB.
|
||||
|
||||
Supports both PostgreSQL (via DATABASE_URL) and SQLite (fallback for local dev).
|
||||
The public API is identical regardless of backend.
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
@@ -11,15 +14,58 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .config import CtxConfig
|
||||
from .auth_password import hash_password, verify_password
|
||||
|
||||
# ── Schema ────────────────────────────────────────────────────────────────────
|
||||
# ── Schema paths ─────────────────────────────────────────────────────────────
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent / "schema.sql"
|
||||
SCHEMA_PG_PATH = Path(__file__).parent / "schema.sql"
|
||||
SCHEMA_SQLITE_PATH = Path(__file__).parent / "schema_sqlite.sql"
|
||||
|
||||
|
||||
def init_db(cfg: CtxConfig) -> sqlite3.Connection:
|
||||
"""Create ~/.ctx/ dirs + initialize the database from schema.sql."""
|
||||
def init_db(cfg: CtxConfig):
|
||||
"""Create ~/.ctx/ dirs + initialize the database.
|
||||
|
||||
If DATABASE_URL is set in the environment, connects to PostgreSQL.
|
||||
Otherwise falls back to SQLite for local development.
|
||||
Returns a connection object (psycopg.Connection or sqlite3.Connection).
|
||||
"""
|
||||
cfg.ensure_dirs()
|
||||
|
||||
if cfg.use_postgres:
|
||||
return _init_pg(cfg)
|
||||
else:
|
||||
return _init_sqlite(cfg)
|
||||
|
||||
|
||||
def _init_pg(cfg: CtxConfig):
|
||||
"""Initialize PostgreSQL database from schema.sql."""
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
conn = psycopg.connect(cfg.database_url, row_factory=dict_row)
|
||||
# Keep autocommit=False (default) — we use explicit commit() calls
|
||||
|
||||
# Check if schema is already initialized by looking for the users table
|
||||
cur = conn.execute(
|
||||
"SELECT EXISTS (SELECT FROM pg_tables WHERE schemaname = 'public' AND tablename = 'users')"
|
||||
)
|
||||
schema_exists = cur.fetchone()["exists"]
|
||||
|
||||
if not schema_exists:
|
||||
with open(SCHEMA_PG_PATH) as f:
|
||||
schema_sql = f.read()
|
||||
# PostgreSQL can execute multiple statements in a single execute() call
|
||||
conn.execute(schema_sql)
|
||||
conn.commit()
|
||||
else:
|
||||
# Run migrations if needed
|
||||
_migrate_pg(conn)
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def _init_sqlite(cfg: CtxConfig) -> sqlite3.Connection:
|
||||
"""Initialize SQLite database (fallback for local dev)."""
|
||||
fresh = not cfg.db_path.exists()
|
||||
conn = sqlite3.connect(str(cfg.db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -27,7 +73,7 @@ def init_db(cfg: CtxConfig) -> sqlite3.Connection:
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
if fresh:
|
||||
with open(SCHEMA_PATH) as f:
|
||||
with open(SCHEMA_SQLITE_PATH) as f:
|
||||
conn.executescript(f.read())
|
||||
else:
|
||||
# Migration: add metadata_tags column if it doesn't exist
|
||||
@@ -35,62 +81,211 @@ def init_db(cfg: CtxConfig) -> sqlite3.Connection:
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN metadata_tags TEXT DEFAULT '[]'")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass # column already exists
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
def _migrate_pg(conn):
|
||||
"""Run PostgreSQL migrations for existing databases."""
|
||||
try:
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN IF NOT EXISTS metadata_tags TEXT DEFAULT '[]'")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE")
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def _is_pg(conn) -> bool:
|
||||
"""Check if connection is a PostgreSQL connection."""
|
||||
return not isinstance(conn, sqlite3.Connection)
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _row_to_dict(row: sqlite3.Row | None) -> dict | None:
|
||||
def _row_to_dict(row) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
if isinstance(row, dict):
|
||||
return dict(row)
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _ph(conn, n: int = 1) -> str:
|
||||
"""Return placeholder string for the current backend.
|
||||
|
||||
PostgreSQL uses %s, SQLite uses ?.
|
||||
"""
|
||||
if _is_pg(conn):
|
||||
return ", ".join(["%s"] * n)
|
||||
else:
|
||||
return ", ".join(["?"] * n)
|
||||
|
||||
|
||||
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def user_create(conn, user_id: str, display_name: str, role: str = "contributor"):
|
||||
|
||||
def user_create(conn, user_id: str, display_name: str, role: str = "contributor", password: str | None = None, active: bool = True):
|
||||
token_hash = hash_password(password) if password else None
|
||||
ph = _ph(conn, 5)
|
||||
if _is_pg(conn):
|
||||
active_val = active
|
||||
else:
|
||||
active_val = 1 if active else 0
|
||||
conn.execute(
|
||||
"INSERT INTO users (user_id, display_name, role) VALUES (?, ?, ?)",
|
||||
(user_id, display_name, role),
|
||||
f"INSERT INTO users (user_id, display_name, role, token_hash, active) VALUES ({ph})",
|
||||
(user_id, display_name, role, token_hash, active_val),
|
||||
)
|
||||
|
||||
|
||||
def user_update(conn, user_id: str, *, display_name: str | None = None, role: str | None = None, active: bool | None = None) -> bool:
|
||||
user = user_get(conn, user_id)
|
||||
if not user:
|
||||
return False
|
||||
fields = []
|
||||
values: list[Any] = []
|
||||
ph = _ph(conn, 1)
|
||||
if display_name is not None:
|
||||
fields.append(f"display_name = {ph}")
|
||||
values.append(display_name)
|
||||
if role is not None:
|
||||
fields.append(f"role = {ph}")
|
||||
values.append(role)
|
||||
if active is not None:
|
||||
if _is_pg(conn):
|
||||
fields.append(f"active = {ph}")
|
||||
values.append(active)
|
||||
else:
|
||||
fields.append(f"active = {ph}")
|
||||
values.append(1 if active else 0)
|
||||
if not fields:
|
||||
return True
|
||||
if _is_pg(conn):
|
||||
fields.append("updated_at = to_char(now() at time zone 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')")
|
||||
else:
|
||||
fields.append("updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')")
|
||||
values.append(user_id)
|
||||
conn.execute(f"UPDATE users SET {', '.join(fields)} WHERE user_id = {ph}", values)
|
||||
return True
|
||||
|
||||
|
||||
def user_delete(conn, user_id: str) -> dict:
|
||||
if user_get(conn, user_id) is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
ph = _ph(conn, 1)
|
||||
try:
|
||||
conn.execute(f"DELETE FROM users WHERE user_id = {ph}", (user_id,))
|
||||
return {"ok": True}
|
||||
except (sqlite3.IntegrityError, Exception) as e:
|
||||
# Check if it's a foreign key violation
|
||||
if _is_pg(conn):
|
||||
import psycopg
|
||||
if isinstance(e, psycopg.errors.ForeignKeyViolation):
|
||||
return {"ok": False, "error": "user_has_references", "hint": "Inactivate the user instead of deleting."}
|
||||
raise
|
||||
return {"ok": False, "error": "user_has_references", "hint": "Inactivate the user instead of deleting."}
|
||||
|
||||
|
||||
def user_set_password(conn, user_id: str, password: str):
|
||||
ph = _ph(conn, 2)
|
||||
if _is_pg(conn):
|
||||
ts_expr = "to_char(now() at time zone 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
||||
else:
|
||||
ts_expr = "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
||||
conn.execute(
|
||||
f"UPDATE users SET token_hash = {ph}, updated_at = {ts_expr} WHERE user_id = {ph}",
|
||||
(hash_password(password), user_id),
|
||||
)
|
||||
|
||||
|
||||
def user_get(conn, user_id: str) -> dict | None:
|
||||
ph = _ph(conn, 1)
|
||||
return _row_to_dict(conn.execute(
|
||||
"SELECT * FROM users WHERE user_id = ?", (user_id,)
|
||||
f"SELECT * FROM users WHERE user_id = {ph}", (user_id,)
|
||||
).fetchone())
|
||||
|
||||
|
||||
def user_get_ci(conn, user_id: str) -> dict | None:
|
||||
"""Case-insensitive user lookup (matches idx_users_lower)."""
|
||||
ph = _ph(conn, 1)
|
||||
return _row_to_dict(conn.execute(
|
||||
f"SELECT * FROM users WHERE LOWER(user_id) = LOWER({ph})", (user_id.strip(),)
|
||||
).fetchone())
|
||||
|
||||
|
||||
def user_authenticate(conn, user_id: str, password: str) -> dict | None:
|
||||
user = user_get_ci(conn, user_id)
|
||||
if not user:
|
||||
return None
|
||||
# Handle both PostgreSQL BOOLEAN and SQLite INTEGER for active
|
||||
active = user.get("active")
|
||||
if active is None:
|
||||
active = True # Default to active
|
||||
if isinstance(active, int) and active == 0:
|
||||
return None
|
||||
if isinstance(active, bool) and not active:
|
||||
return None
|
||||
if not user.get("token_hash"):
|
||||
return None
|
||||
if not verify_password(password, user.get("token_hash")):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def user_list(conn) -> list[dict]:
|
||||
return [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY user_id").fetchall()]
|
||||
if _is_pg(conn):
|
||||
return [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY user_id").fetchall()]
|
||||
else:
|
||||
return [dict(r) for r in conn.execute("SELECT * FROM users ORDER BY user_id").fetchall()]
|
||||
|
||||
|
||||
# ── Projects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def project_create(conn, project_id: str, display_name: str, description: str = ""):
|
||||
ph3 = _ph(conn, 3)
|
||||
ph1 = _ph(conn, 1)
|
||||
conn.execute(
|
||||
"INSERT INTO projects (project_id, display_name, description) VALUES (?, ?, ?)",
|
||||
f"INSERT INTO projects (project_id, display_name, description) VALUES ({ph3})",
|
||||
(project_id, display_name, description),
|
||||
)
|
||||
# Also create empty shared context
|
||||
conn.execute(
|
||||
"INSERT INTO project_context (project_id, content, version) VALUES (?, '', 0)",
|
||||
f"INSERT INTO project_context (project_id, content, version) VALUES ({ph1}, '', 0)",
|
||||
(project_id,),
|
||||
)
|
||||
|
||||
|
||||
def project_get(conn, project_id: str) -> dict | None:
|
||||
ph = _ph(conn, 1)
|
||||
return _row_to_dict(conn.execute(
|
||||
"SELECT * FROM projects WHERE project_id = ?", (project_id,)
|
||||
f"SELECT * FROM projects WHERE project_id = {ph}", (project_id,)
|
||||
).fetchone())
|
||||
|
||||
|
||||
def project_delete(conn, project_id: str) -> dict:
|
||||
"""Delete a project and all its dependent rows (cascades).
|
||||
audit_log.project_id is SET NULL via FK ON DELETE SET NULL.
|
||||
Returns {'ok': True} or {'ok': False, 'error': ...}."""
|
||||
if project_get(conn, project_id) is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
ph = _ph(conn, 1)
|
||||
conn.execute(f"DELETE FROM projects WHERE project_id = {ph}", (project_id,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def project_list(conn) -> list[dict]:
|
||||
return [dict(r) for r in conn.execute(
|
||||
"SELECT project_id, display_name, description, shared_version FROM projects ORDER BY project_id"
|
||||
@@ -98,23 +293,30 @@ def project_list(conn) -> list[dict]:
|
||||
|
||||
|
||||
def project_set_sync_path(conn, project_id: str, sync_path: str | None):
|
||||
conn.execute(
|
||||
"UPDATE projects SET sync_path = ?, auto_sync = 1 WHERE project_id = ?",
|
||||
(sync_path, project_id),
|
||||
)
|
||||
ph = _ph(conn, 2)
|
||||
if _is_pg(conn):
|
||||
conn.execute(
|
||||
f"UPDATE projects SET sync_path = {ph.split(', ')[0]}, auto_sync = TRUE WHERE project_id = {ph.split(', ')[1]}",
|
||||
(sync_path, project_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
f"UPDATE projects SET sync_path = ?, auto_sync = 1 WHERE project_id = ?",
|
||||
(sync_path, project_id),
|
||||
)
|
||||
|
||||
|
||||
def project_get_tags(conn, project_id: str) -> list[str]:
|
||||
"""Get project metadata tags as a list of strings."""
|
||||
ph = _ph(conn, 1)
|
||||
row = conn.execute(
|
||||
"SELECT metadata_tags FROM projects WHERE project_id = ?", (project_id,)
|
||||
f"SELECT metadata_tags FROM projects WHERE project_id = {ph}", (project_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return []
|
||||
tags = row["metadata_tags"]
|
||||
if not tags:
|
||||
return []
|
||||
import json
|
||||
try:
|
||||
return json.loads(tags)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -122,10 +324,9 @@ def project_get_tags(conn, project_id: str) -> list[str]:
|
||||
|
||||
|
||||
def project_set_tags(conn, project_id: str, tags: list[str]):
|
||||
"""Set project metadata tags from a list of strings."""
|
||||
import json
|
||||
ph = _ph(conn, 2)
|
||||
conn.execute(
|
||||
"UPDATE projects SET metadata_tags = ? WHERE project_id = ?",
|
||||
f"UPDATE projects SET metadata_tags = {ph.split(', ')[0]} WHERE project_id = {ph.split(', ')[1]}",
|
||||
(json.dumps(tags), project_id),
|
||||
)
|
||||
|
||||
@@ -139,7 +340,6 @@ def build_metadata_header(project_id: str, display_name: str | None = None,
|
||||
TYPE: PROJECT CONTEXT, PROJECT, STATUS: ACTIVE, LAST-UPDATED, TAGS.
|
||||
LAST-UPDATED uses the actual updated_at timestamp, falling back to today.
|
||||
TAGS uses the project's metadata_tags if provided, falling back to project name + CONTEXT."""
|
||||
from datetime import datetime, timezone
|
||||
project_upper = (display_name or project_id).upper()
|
||||
last_updated = (updated_at or datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
||||
if "T" in last_updated:
|
||||
@@ -186,10 +386,11 @@ def context_read(conn, project_id: str) -> dict | None:
|
||||
Returns with metadata header prepended dynamically.
|
||||
If content already has a header (including YAML frontmatter from vault imports),
|
||||
it is replaced with the current dynamic header."""
|
||||
ph = _ph(conn, 1)
|
||||
row = conn.execute(
|
||||
"SELECT pc.*, p.shared_version, p.display_name FROM project_context pc "
|
||||
"JOIN projects p ON p.project_id = pc.project_id "
|
||||
"WHERE pc.project_id = ?", (project_id,)
|
||||
f"SELECT pc.*, p.shared_version, p.display_name FROM project_context pc "
|
||||
f"JOIN projects p ON p.project_id = pc.project_id "
|
||||
f"WHERE pc.project_id = {ph}", (project_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
@@ -238,8 +439,9 @@ def context_update(conn, project_id: str, new_content: str, updated_by: str,
|
||||
Returns {'ok': True, 'new_version': N} or {'ok': False, 'error': 'conflict',
|
||||
'current_version': N}.
|
||||
"""
|
||||
ph = _ph(conn, 1)
|
||||
cur = conn.execute(
|
||||
"SELECT shared_version FROM projects WHERE project_id = ?",
|
||||
f"SELECT shared_version FROM projects WHERE project_id = {ph}",
|
||||
(project_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@@ -262,15 +464,21 @@ def context_update(conn, project_id: str, new_content: str, updated_by: str,
|
||||
_snapshot_take(conn, project_id, version_from=current_version, version_to=new_version)
|
||||
|
||||
# Update project_context
|
||||
ph5 = _ph(conn, 5)
|
||||
if _is_pg(conn):
|
||||
ts_expr = "to_char(now() at time zone 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
||||
else:
|
||||
ts_expr = "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
||||
conn.execute(
|
||||
"UPDATE project_context SET content = ?, version = ?, updated_by = ?, updated_at = ? "
|
||||
"WHERE project_id = ?",
|
||||
(clean_content, new_version, updated_by, now(), project_id),
|
||||
f"UPDATE project_context SET content = {_ph(conn,1)}, version = {_ph(conn,1)}, "
|
||||
f"updated_by = {_ph(conn,1)}, updated_at = {ts_expr} "
|
||||
f"WHERE project_id = {_ph(conn,1)}",
|
||||
(clean_content, new_version, updated_by, project_id)
|
||||
)
|
||||
# Bump shared version
|
||||
conn.execute(
|
||||
"UPDATE projects SET shared_version = ? WHERE project_id = ?",
|
||||
(new_version, project_id),
|
||||
f"UPDATE projects SET shared_version = {_ph(conn,1)} WHERE project_id = {_ph(conn,1)}",
|
||||
(new_version, project_id)
|
||||
)
|
||||
|
||||
return {"ok": True, "new_version": new_version, "content": clean_content}
|
||||
@@ -279,29 +487,38 @@ def context_update(conn, project_id: str, new_content: str, updated_by: str,
|
||||
# ── User Profile ──────────────────────────────────────────────────────────────
|
||||
|
||||
def profile_read(conn, user_id: str) -> dict | None:
|
||||
ph = _ph(conn, 1)
|
||||
return _row_to_dict(conn.execute(
|
||||
"SELECT * FROM user_profiles WHERE user_id = ?", (user_id,)
|
||||
f"SELECT * FROM user_profiles WHERE user_id = {ph}", (user_id,)
|
||||
).fetchone())
|
||||
|
||||
|
||||
def profile_update(conn, user_id: str, content: str, base_version: int) -> dict:
|
||||
ph = _ph(conn, 1)
|
||||
cur = conn.execute(
|
||||
"SELECT version FROM user_profiles WHERE user_id = ?", (user_id,)
|
||||
f"SELECT version FROM user_profiles WHERE user_id = {ph}", (user_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
# Create
|
||||
ph2 = _ph(conn, 2)
|
||||
conn.execute(
|
||||
"INSERT INTO user_profiles (user_id, content, version) VALUES (?, ?, 1)",
|
||||
(user_id, content),
|
||||
f"INSERT INTO user_profiles (user_id, content, version) VALUES ({ph2}, 1)",
|
||||
(user_id, content)
|
||||
)
|
||||
return {"ok": True, "new_version": 1}
|
||||
current_version = row["version"]
|
||||
if base_version != current_version:
|
||||
return {"ok": False, "error": "conflict", "current_version": current_version}
|
||||
if _is_pg(conn):
|
||||
ts_expr = "to_char(now() at time zone 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
||||
else:
|
||||
ts_expr = "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
||||
ph4 = _ph(conn, 4)
|
||||
conn.execute(
|
||||
"UPDATE user_profiles SET content = ?, version = ?, updated_at = ? WHERE user_id = ?",
|
||||
(content, current_version + 1, now(), user_id),
|
||||
f"UPDATE user_profiles SET content = {_ph(conn,1)}, version = {_ph(conn,1)}, "
|
||||
f"updated_at = {ts_expr} WHERE user_id = {_ph(conn,1)}",
|
||||
(content, current_version + 1, user_id)
|
||||
)
|
||||
return {"ok": True, "new_version": current_version + 1}
|
||||
|
||||
@@ -325,42 +542,51 @@ def workspace_fork(conn, user_id: str, project_id: str) -> dict:
|
||||
shared_content = ctx["content"] if ctx else ""
|
||||
|
||||
ws_id = str(uuid.uuid4())
|
||||
ph4 = _ph(conn, 4)
|
||||
conn.execute(
|
||||
"INSERT INTO user_workspaces (workspace_id, user_id, project_id, base_version) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
f"INSERT INTO user_workspaces (workspace_id, user_id, project_id, base_version) "
|
||||
f"VALUES ({ph4})",
|
||||
(ws_id, user_id, project_id, base_version),
|
||||
)
|
||||
# Seed workspace with current shared content
|
||||
ph2 = _ph(conn, 2)
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_files (workspace_id, file_path, content) VALUES (?, 'context.md', ?)",
|
||||
f"INSERT INTO workspace_files (workspace_id, file_path, content) VALUES ({ph2.split(', ')[0]}, 'context.md', {ph2.split(', ')[1]})",
|
||||
(ws_id, shared_content),
|
||||
)
|
||||
return {"ok": True, "workspace_id": ws_id, "base_version": base_version}
|
||||
|
||||
|
||||
def workspace_get(conn, workspace_id: str) -> dict | None:
|
||||
ph = _ph(conn, 1)
|
||||
return _row_to_dict(conn.execute(
|
||||
"SELECT * FROM user_workspaces WHERE workspace_id = ?", (workspace_id,)
|
||||
f"SELECT * FROM user_workspaces WHERE workspace_id = {ph}", (workspace_id,)
|
||||
).fetchone())
|
||||
|
||||
|
||||
def workspace_list_for_user(conn, user_id: str, project_id: str | None = None) -> list[dict]:
|
||||
if project_id:
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM user_workspaces WHERE user_id = ? AND project_id = ? ORDER BY created_at DESC",
|
||||
f"SELECT * FROM user_workspaces WHERE user_id = {placeholders[0]} AND project_id = {placeholders[1]} "
|
||||
f"ORDER BY created_at DESC",
|
||||
(user_id, project_id),
|
||||
).fetchall()
|
||||
else:
|
||||
ph = _ph(conn, 1)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM user_workspaces WHERE user_id = ? ORDER BY created_at DESC",
|
||||
f"SELECT * FROM user_workspaces WHERE user_id = {ph} ORDER BY created_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def workspace_read_file(conn, workspace_id: str, file_path: str = "context.md") -> str | None:
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
row = conn.execute(
|
||||
"SELECT content FROM workspace_files WHERE workspace_id = ? AND file_path = ?",
|
||||
f"SELECT content FROM workspace_files WHERE workspace_id = {placeholders[0]} AND file_path = {placeholders[1]}",
|
||||
(workspace_id, file_path),
|
||||
).fetchone()
|
||||
return row["content"] if row else None
|
||||
@@ -368,19 +594,27 @@ def workspace_read_file(conn, workspace_id: str, file_path: str = "context.md")
|
||||
|
||||
def workspace_write_file(conn, workspace_id: str, content: str,
|
||||
file_path: str = "context.md"):
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
existing = conn.execute(
|
||||
"SELECT 1 FROM workspace_files WHERE workspace_id = ? AND file_path = ?",
|
||||
f"SELECT 1 FROM workspace_files WHERE workspace_id = {placeholders[0]} AND file_path = {placeholders[1]}",
|
||||
(workspace_id, file_path),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if _is_pg(conn):
|
||||
ts_expr = "to_char(now() at time zone 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
||||
else:
|
||||
ts_expr = "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
||||
conn.execute(
|
||||
"UPDATE workspace_files SET content = ?, version = version + 1, updated_at = ? "
|
||||
"WHERE workspace_id = ? AND file_path = ?",
|
||||
(content, now(), workspace_id, file_path),
|
||||
f"UPDATE workspace_files SET content = {_ph(conn,1)}, version = version + 1, "
|
||||
f"updated_at = {ts_expr} "
|
||||
f"WHERE workspace_id = {_ph(conn,1)} AND file_path = {_ph(conn,1)}",
|
||||
(content, workspace_id, file_path),
|
||||
)
|
||||
else:
|
||||
ph3 = _ph(conn, 3)
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_files (workspace_id, file_path, content) VALUES (?, ?, ?)",
|
||||
f"INSERT INTO workspace_files (workspace_id, file_path, content) VALUES ({ph3})",
|
||||
(workspace_id, file_path, content),
|
||||
)
|
||||
|
||||
@@ -424,31 +658,35 @@ def workspace_submit(conn, workspace_id: str, submitted_by: str,
|
||||
)
|
||||
if not result["ok"]:
|
||||
return result
|
||||
ph = _ph(conn, 1)
|
||||
conn.execute(
|
||||
"UPDATE user_workspaces SET status = 'merged' WHERE workspace_id = ?",
|
||||
f"UPDATE user_workspaces SET status = 'merged' WHERE workspace_id = {ph}",
|
||||
(workspace_id,),
|
||||
)
|
||||
return {"ok": True, "action": "merged", **result}
|
||||
else:
|
||||
# Create pending change request
|
||||
req_id = str(uuid.uuid4())
|
||||
ph7 = _ph(conn, 7)
|
||||
conn.execute(
|
||||
"INSERT INTO change_requests (request_id, workspace_id, project_id, "
|
||||
"submitted_by, target_version, base_version, diff_summary) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
f"INSERT INTO change_requests (request_id, workspace_id, project_id, "
|
||||
f"submitted_by, target_version, base_version, diff_summary) "
|
||||
f"VALUES ({ph7})",
|
||||
(req_id, workspace_id, ws["project_id"], submitted_by,
|
||||
target_version, base_version, diff_summary),
|
||||
)
|
||||
ph = _ph(conn, 1)
|
||||
conn.execute(
|
||||
"UPDATE user_workspaces SET status = 'submitted' WHERE workspace_id = ?",
|
||||
f"UPDATE user_workspaces SET status = 'submitted' WHERE workspace_id = {ph}",
|
||||
(workspace_id,),
|
||||
)
|
||||
return {"ok": True, "action": "submitted", "request_id": req_id}
|
||||
|
||||
|
||||
def workspace_abandon(conn, workspace_id: str):
|
||||
ph = _ph(conn, 1)
|
||||
conn.execute(
|
||||
"UPDATE user_workspaces SET status = 'abandoned' WHERE workspace_id = ?",
|
||||
f"UPDATE user_workspaces SET status = 'abandoned' WHERE workspace_id = {ph}",
|
||||
(workspace_id,),
|
||||
)
|
||||
|
||||
@@ -456,10 +694,11 @@ def workspace_abandon(conn, workspace_id: str):
|
||||
def change_request_approve(conn, request_id: str, reviewer_id: str,
|
||||
comments: str = "") -> dict:
|
||||
"""Approve a change request and merge it into shared context."""
|
||||
ph = _ph(conn, 1)
|
||||
row = conn.execute(
|
||||
"SELECT cr.*, ws.project_id FROM change_requests cr "
|
||||
"JOIN user_workspaces ws ON ws.workspace_id = cr.workspace_id "
|
||||
"WHERE cr.request_id = ?", (request_id,)
|
||||
f"SELECT cr.*, ws.project_id FROM change_requests cr "
|
||||
f"JOIN user_workspaces ws ON ws.workspace_id = cr.workspace_id "
|
||||
f"WHERE cr.request_id = {ph}", (request_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
@@ -467,8 +706,10 @@ def change_request_approve(conn, request_id: str, reviewer_id: str,
|
||||
return {"ok": False, "error": f"status is {row['status']}"}
|
||||
|
||||
# Record review
|
||||
ph3 = _ph(conn, 3)
|
||||
conn.execute(
|
||||
"INSERT INTO reviews (request_id, reviewer_id, decision, comments) VALUES (?, ?, 'approved', ?)",
|
||||
f"INSERT INTO reviews (request_id, reviewer_id, decision, comments) "
|
||||
f"VALUES ({ph3.split(', ')[0]}, {ph3.split(', ')[1]}, 'approved', {ph3.split(', ')[2]})",
|
||||
(request_id, reviewer_id, comments),
|
||||
)
|
||||
|
||||
@@ -483,12 +724,13 @@ def change_request_approve(conn, request_id: str, reviewer_id: str,
|
||||
if not result["ok"]:
|
||||
return result
|
||||
|
||||
ph = _ph(conn, 1)
|
||||
conn.execute(
|
||||
"UPDATE change_requests SET status = 'merged' WHERE request_id = ?",
|
||||
f"UPDATE change_requests SET status = 'merged' WHERE request_id = {ph}",
|
||||
(request_id,),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE user_workspaces SET status = 'merged' WHERE workspace_id = ?",
|
||||
f"UPDATE user_workspaces SET status = 'merged' WHERE workspace_id = {ph}",
|
||||
(row["workspace_id"],),
|
||||
)
|
||||
return {"ok": True, "action": "merged", **result}
|
||||
@@ -510,18 +752,20 @@ def _snapshot_take(conn, project_id: str, version_from: int, version_to: int,
|
||||
ts = now().replace(":", "-")
|
||||
storage_rel = f"{project_id}/{ts}__v{version_from}-{version_to}"
|
||||
|
||||
ph9 = _ph(conn, 9)
|
||||
conn.execute(
|
||||
"INSERT INTO snapshots (snapshot_id, project_id, user_id, workspace_id, "
|
||||
"version_from, version_to, storage_path, content_hash, size_bytes) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
f"INSERT INTO snapshots (snapshot_id, project_id, user_id, workspace_id, "
|
||||
f"version_from, version_to, storage_path, content_hash, size_bytes) "
|
||||
f"VALUES ({ph9})",
|
||||
(snap_id, project_id, user_id, workspace_id,
|
||||
version_from, version_to, storage_rel, content_hash, len(content)),
|
||||
)
|
||||
|
||||
|
||||
def snapshot_list(conn, project_id: str) -> list[dict]:
|
||||
ph = _ph(conn, 1)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM snapshots WHERE project_id = ? ORDER BY created_at DESC",
|
||||
f"SELECT * FROM snapshots WHERE project_id = {ph} ORDER BY created_at DESC",
|
||||
(project_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
@@ -532,16 +776,19 @@ def snapshot_rotate(conn, project_id: str, max_keep: int = 25, min_keep: int = 5
|
||||
Prune excess snapshots for a project, keeping at least min_keep.
|
||||
Returns count of pruned snapshots.
|
||||
"""
|
||||
ph = _ph(conn, 1)
|
||||
rows = conn.execute(
|
||||
"SELECT snapshot_id FROM snapshots WHERE project_id = ? "
|
||||
"ORDER BY created_at DESC", (project_id,)
|
||||
f"SELECT snapshot_id FROM snapshots WHERE project_id = {ph} "
|
||||
f"ORDER BY created_at DESC", (project_id,)
|
||||
).fetchall()
|
||||
if len(rows) <= max_keep:
|
||||
return 0
|
||||
keep = max(min_keep, max_keep)
|
||||
to_delete = [r["snapshot_id"] for r in rows[keep:]]
|
||||
for sid in to_delete:
|
||||
conn.execute("DELETE FROM snapshots WHERE snapshot_id = ?", (sid,))
|
||||
conn.execute(
|
||||
f"DELETE FROM snapshots WHERE snapshot_id = {_ph(conn,1)}", (sid,)
|
||||
)
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
@@ -551,10 +798,11 @@ def audit_log(conn, user_id: str, operation: str, summary: str,
|
||||
agent_id: str = "ctx", project_id: str | None = None,
|
||||
entity_type: str | None = None, entity_id: str | None = None,
|
||||
details: dict | None = None):
|
||||
ph8 = _ph(conn, 8)
|
||||
conn.execute(
|
||||
"INSERT INTO audit_log (user_id, agent_id, project_id, operation, "
|
||||
"entity_type, entity_id, summary, details_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
f"INSERT INTO audit_log (user_id, agent_id, project_id, operation, "
|
||||
f"entity_type, entity_id, summary, details_json) "
|
||||
f"VALUES ({ph8})",
|
||||
(user_id, agent_id, project_id, operation,
|
||||
entity_type, entity_id, summary,
|
||||
json.dumps(details) if details else None),
|
||||
@@ -569,7 +817,8 @@ def audit_query(conn, **filters) -> list[dict]:
|
||||
for col in ("user_id", "project_id", "operation", "agent_id"):
|
||||
val = filters.get(col)
|
||||
if val:
|
||||
wheres.append(f"{col} = ?")
|
||||
ph = _ph(conn, 1)
|
||||
wheres.append(f"{col} = {ph}")
|
||||
params.append(val)
|
||||
if wheres:
|
||||
parts.append("WHERE " + " AND ".join(wheres))
|
||||
@@ -584,13 +833,27 @@ def audit_query(conn, **filters) -> list[dict]:
|
||||
|
||||
def search(conn, query: str, limit: int = 10) -> list[dict]:
|
||||
"""Full-text search across all indexed context content."""
|
||||
rows = conn.execute(
|
||||
"SELECT rowid, content, project_id, file_path, source_type, "
|
||||
"rank FROM fts_context WHERE fts_context MATCH ? "
|
||||
"ORDER BY rank LIMIT ?",
|
||||
(query, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
if _is_pg(conn):
|
||||
ph = _ph(conn, 3)
|
||||
placeholders = ph.split(", ")
|
||||
rows = conn.execute(
|
||||
f"SELECT content, project_id, file_path, source_type, "
|
||||
f"ts_rank(tsv, plainto_tsquery('english', {placeholders[0]})) as rank "
|
||||
f"FROM fts_context WHERE tsv @@ plainto_tsquery('english', {placeholders[1]}) "
|
||||
f"ORDER BY rank DESC LIMIT {placeholders[2]}",
|
||||
(query, query, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
else:
|
||||
ph = _ph(conn, 2)
|
||||
placeholders = ph.split(", ")
|
||||
rows = conn.execute(
|
||||
f"SELECT rowid, content, project_id, file_path, source_type, "
|
||||
f"rank FROM fts_context WHERE fts_context MATCH {placeholders[0]} "
|
||||
f"ORDER BY rank LIMIT {placeholders[1]}",
|
||||
(query, limit),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Sync to project root ──────────────────────────────────────────────────────
|
||||
@@ -677,9 +940,10 @@ def normalize_file_path(file_path: str) -> str:
|
||||
|
||||
def file_list(conn, project_id: str) -> list[dict]:
|
||||
"""List all files for a project. Returns list of {file_id, file_path, version, updated_at, updated_by}."""
|
||||
ph = _ph(conn, 1)
|
||||
rows = conn.execute(
|
||||
"SELECT file_id, file_path, version, updated_by, updated_at "
|
||||
"FROM context_files WHERE project_id = ? ORDER BY file_path",
|
||||
f"SELECT file_id, file_path, version, updated_by, updated_at "
|
||||
f"FROM context_files WHERE project_id = {ph} ORDER BY file_path",
|
||||
(project_id,)
|
||||
).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
@@ -688,10 +952,12 @@ def file_list(conn, project_id: str) -> list[dict]:
|
||||
def file_read(conn, project_id: str, file_path: str) -> dict | None:
|
||||
"""Read a single context file. Returns with dynamic metadata header prepended."""
|
||||
file_path = normalize_file_path(file_path)
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
row = conn.execute(
|
||||
"SELECT cf.*, p.display_name FROM context_files cf "
|
||||
"JOIN projects p ON p.project_id = cf.project_id "
|
||||
"WHERE cf.project_id = ? AND cf.file_path = ?",
|
||||
f"SELECT cf.*, p.display_name FROM context_files cf "
|
||||
f"JOIN projects p ON p.project_id = cf.project_id "
|
||||
f"WHERE cf.project_id = {placeholders[0]} AND cf.file_path = {placeholders[1]}",
|
||||
(project_id, file_path)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -731,8 +997,10 @@ def file_create(conn, project_id: str, file_path: str, content: str = "",
|
||||
file_path = normalize_file_path(file_path)
|
||||
|
||||
# Check if file already exists
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
existing = conn.execute(
|
||||
"SELECT file_id FROM context_files WHERE project_id = ? AND file_path = ?",
|
||||
f"SELECT file_id FROM context_files WHERE project_id = {placeholders[0]} AND file_path = {placeholders[1]}",
|
||||
(project_id, file_path)
|
||||
).fetchone()
|
||||
if existing:
|
||||
@@ -742,9 +1010,10 @@ def file_create(conn, project_id: str, file_path: str, content: str = "",
|
||||
clean = strip_metadata_header(content)
|
||||
clean = clean.lstrip("\n\r ").strip()
|
||||
|
||||
ph5 = _ph(conn, 5)
|
||||
conn.execute(
|
||||
"INSERT INTO context_files (project_id, file_path, content, version, updated_by) "
|
||||
"VALUES (?, ?, ?, 1, ?)",
|
||||
f"INSERT INTO context_files (project_id, file_path, content, version, updated_by) "
|
||||
f"VALUES ({ph5.split(', ')[0]}, {ph5.split(', ')[1]}, {ph5.split(', ')[2]}, 1, {ph5.split(', ')[3]})",
|
||||
(project_id, file_path, clean, updated_by)
|
||||
)
|
||||
audit_log(conn, updated_by, "create", f"Created file {file_path} in {project_id}",
|
||||
@@ -759,8 +1028,14 @@ def file_update(conn, project_id: str, file_path: str, new_content: str,
|
||||
# Normalize
|
||||
file_path = normalize_file_path(file_path)
|
||||
|
||||
# Lock only files in the ctxd-docs project (documentation/guide)
|
||||
if project_id == "ctxd-docs" and file_path in ("CONTEXT.MD", "LLM-CLIENT.MD"):
|
||||
return {"ok": False, "error": "cannot_update_locked", "hint": f"{file_path} is locked in ctxd-docs — create a new file instead"}
|
||||
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
row = conn.execute(
|
||||
"SELECT version FROM context_files WHERE project_id = ? AND file_path = ?",
|
||||
f"SELECT version FROM context_files WHERE project_id = {placeholders[0]} AND file_path = {placeholders[1]}",
|
||||
(project_id, file_path)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
@@ -778,10 +1053,14 @@ def file_update(conn, project_id: str, file_path: str, new_content: str,
|
||||
clean = clean.lstrip().strip()
|
||||
|
||||
new_version = current_version + 1
|
||||
if _is_pg(conn):
|
||||
ts_expr = "to_char(now() at time zone 'utc', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"')"
|
||||
else:
|
||||
ts_expr = "strftime('%Y-%m-%dT%H:%M:%SZ', 'now')"
|
||||
conn.execute(
|
||||
"UPDATE context_files SET content = ?, version = ?, updated_by = ?, "
|
||||
"updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') "
|
||||
"WHERE project_id = ? AND file_path = ?",
|
||||
f"UPDATE context_files SET content = {_ph(conn,1)}, version = {_ph(conn,1)}, "
|
||||
f"updated_by = {_ph(conn,1)}, updated_at = {ts_expr} "
|
||||
f"WHERE project_id = {_ph(conn,1)} AND file_path = {_ph(conn,1)}",
|
||||
(clean, new_version, updated_by, project_id, file_path)
|
||||
)
|
||||
audit_log(conn, updated_by, "update", f"Updated {file_path} in {project_id} to v{new_version}",
|
||||
@@ -789,26 +1068,32 @@ def file_update(conn, project_id: str, file_path: str, new_content: str,
|
||||
return {"ok": True, "new_version": new_version}
|
||||
|
||||
|
||||
def file_delete(conn, project_id: str, file_path: str) -> dict:
|
||||
def file_delete(conn, project_id: str, file_path: str, deleted_by: str = "admin") -> dict:
|
||||
"""Delete a context file. Returns {'ok': True} or {'ok': False, 'error': ...}."""
|
||||
file_path = normalize_file_path(file_path)
|
||||
|
||||
# Don't allow deleting CONTEXT.md (it's the canonical synced file)
|
||||
# CONTEXT.MD cannot be deleted from any project (it's the minimum required file)
|
||||
if file_path == "CONTEXT.MD":
|
||||
return {"ok": False, "error": "cannot_delete_context"}
|
||||
|
||||
# LLM-CLIENT.MD locked in ctxd-docs only
|
||||
if project_id == "ctxd-docs" and file_path == "LLM-CLIENT.MD":
|
||||
return {"ok": False, "error": "cannot_delete_context"}
|
||||
|
||||
ph2 = _ph(conn, 2)
|
||||
placeholders = ph2.split(", ")
|
||||
row = conn.execute(
|
||||
"SELECT file_id FROM context_files WHERE project_id = ? AND file_path = ?",
|
||||
f"SELECT file_id FROM context_files WHERE project_id = {placeholders[0]} AND file_path = {placeholders[1]}",
|
||||
(project_id, file_path)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {"ok": False, "error": "not_found"}
|
||||
|
||||
conn.execute(
|
||||
"DELETE FROM context_files WHERE project_id = ? AND file_path = ?",
|
||||
f"DELETE FROM context_files WHERE project_id = {_ph(conn,1)} AND file_path = {_ph(conn,1)}",
|
||||
(project_id, file_path)
|
||||
)
|
||||
audit_log(conn, "admin", "delete", f"Deleted {file_path} from {project_id}",
|
||||
audit_log(conn, deleted_by, "delete", f"Deleted {file_path} from {project_id}",
|
||||
project_id=project_id, entity_type="file", entity_id=file_path)
|
||||
return {"ok": True}
|
||||
|
||||
@@ -822,9 +1107,10 @@ def compiled_read(conn, project_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
# Get all files
|
||||
ph = _ph(conn, 1)
|
||||
files = conn.execute(
|
||||
"SELECT file_path, content, version, updated_at, updated_by "
|
||||
"FROM context_files WHERE project_id = ? ORDER BY file_path",
|
||||
f"SELECT file_path, content, version, updated_at, updated_by "
|
||||
f"FROM context_files WHERE project_id = {ph} ORDER BY file_path",
|
||||
(project_id,)
|
||||
).fetchall()
|
||||
|
||||
@@ -855,8 +1141,9 @@ def compiled_read(conn, project_id: str) -> dict | None:
|
||||
)
|
||||
|
||||
# Get the latest version from project_context (for version checking)
|
||||
ph = _ph(conn, 1)
|
||||
ctx_row = conn.execute(
|
||||
"SELECT version FROM project_context WHERE project_id = ?",
|
||||
f"SELECT version FROM project_context WHERE project_id = {ph}",
|
||||
(project_id,)
|
||||
).fetchone()
|
||||
version = ctx_row["version"] if ctx_row else 0
|
||||
@@ -873,25 +1160,29 @@ def ensure_default_files(conn, project_id: str):
|
||||
"""Create default context files for a project if they don't exist.
|
||||
Migrates existing single-context content into CONTEXT.md."""
|
||||
# Check if any files already exist
|
||||
ph = _ph(conn, 1)
|
||||
existing = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM context_files WHERE project_id = ?",
|
||||
f"SELECT COUNT(*) as cnt FROM context_files WHERE project_id = {ph}",
|
||||
(project_id,)
|
||||
).fetchone()
|
||||
if existing and existing["cnt"] > 0:
|
||||
return # Already has files
|
||||
|
||||
# Get existing single-context content to migrate into CONTEXT.md
|
||||
ph = _ph(conn, 1)
|
||||
ctx_row = conn.execute(
|
||||
"SELECT content FROM project_context WHERE project_id = ?",
|
||||
f"SELECT content FROM project_context WHERE project_id = {ph}",
|
||||
(project_id,)
|
||||
).fetchone()
|
||||
existing_content = ctx_row["content"] if ctx_row else ""
|
||||
existing_content = strip_metadata_header(existing_content).strip()
|
||||
|
||||
# Create CONTEXT.md with existing content
|
||||
ph2 = _ph(conn, 2)
|
||||
p = ph2.split(", ")
|
||||
conn.execute(
|
||||
"INSERT INTO context_files (project_id, file_path, content, version, updated_by) "
|
||||
"VALUES (?, 'CONTEXT.MD', ?, 1, 'admin')",
|
||||
f"INSERT INTO context_files (project_id, file_path, content, version, updated_by) "
|
||||
f"VALUES ({p[0]}, 'CONTEXT.MD', {p[1]}, 1, 'admin')",
|
||||
(project_id, existing_content)
|
||||
)
|
||||
|
||||
@@ -899,8 +1190,10 @@ def ensure_default_files(conn, project_id: str):
|
||||
for fname in DEFAULT_FILES:
|
||||
if fname == "CONTEXT.md":
|
||||
continue # Already created above
|
||||
ph2 = _ph(conn, 2)
|
||||
p = ph2.split(", ")
|
||||
conn.execute(
|
||||
"INSERT INTO context_files (project_id, file_path, content, version, updated_by) "
|
||||
"VALUES (?, ?, '', 1, 'admin')",
|
||||
f"INSERT INTO context_files (project_id, file_path, content, version, updated_by) "
|
||||
f"VALUES ({p[0]}, {p[1]}, '', 1, 'admin')",
|
||||
(project_id, fname.upper())
|
||||
)
|
||||
@@ -0,0 +1,309 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CTXD — Context Dossier</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #1a1a1a;
|
||||
--paper: #1e1e1e;
|
||||
--ink: #d4d4d4;
|
||||
--ink-dim: #888;
|
||||
--accent: #e5c07b;
|
||||
--accent3: #98c379;
|
||||
--border: #2a2a2a;
|
||||
--border-light: #333;
|
||||
--input-bg: #222;
|
||||
--hover: #252525;
|
||||
--danger: #e06c75;
|
||||
--font: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
html { height: 100%; font-size: 15px; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.landing {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.landing .subtitle {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.landing .description {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing .description code {
|
||||
color: var(--accent);
|
||||
background: var(--input-bg);
|
||||
padding: 0.1rem 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border-light);
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.login-card label {
|
||||
display: block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-dim);
|
||||
margin-bottom: 0.3rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
font-family: var(--font);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-card input:focus { border-color: var(--accent); }
|
||||
|
||||
.login-card .actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.login-card button {
|
||||
font-family: var(--font);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.5rem 0.85rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border-light);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.login-card button:hover { background: var(--hover); }
|
||||
|
||||
.login-card button.primary {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border-color: var(--accent);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.login-card button.primary:hover { background: #d4ae5c; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--ink);
|
||||
font-family: var(--font);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.6rem 1rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
z-index: 200;
|
||||
}
|
||||
.toast.show { opacity: 1; }
|
||||
.toast.error { border-color: var(--danger); color: var(--danger); }
|
||||
.toast.success { border-color: var(--accent3); color: var(--accent3); }
|
||||
|
||||
.links {
|
||||
margin-top: 1.5rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--ink-dim);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.links a:hover { text-decoration: underline; }
|
||||
|
||||
.status {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.6rem;
|
||||
color: var(--ink-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.status .dot {
|
||||
display: inline-block;
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent3);
|
||||
margin-right: 0.3rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="landing">
|
||||
<h1>CTXD</h1>
|
||||
<div class="subtitle">Context Dossier</div>
|
||||
|
||||
<div class="description">
|
||||
Single source of truth for multi-harness project context.
|
||||
One canonical <code>AGENTS.md</code> per project, served to
|
||||
Claude, Hermes, Codex, Cursor, and any OAuth-capable MCP client.
|
||||
</div>
|
||||
|
||||
<div class="login-card" id="login-card">
|
||||
<h2>sign in</h2>
|
||||
<label>user id</label>
|
||||
<input type="text" id="user-id" placeholder="e.g. admin" autocomplete="username" autocorrect="off" autocapitalize="off" spellcheck="false" onkeydown="if(event.key==='Enter')submitLogin()">
|
||||
<label>password</label>
|
||||
<input type="password" id="password" placeholder="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')submitLogin()">
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="submitLogin()">sign in</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="links">
|
||||
<a href="/.well-known/oauth-authorization-server">OAuth discovery</a> ·
|
||||
<a href="/readonly/sse">read-only MCP</a> ·
|
||||
<a href="/write/sse">write MCP</a>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">
|
||||
<span class="dot"></span> connected
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
|
||||
function showToast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.className = 'toast show' + (type ? ' ' + type : '');
|
||||
clearTimeout(window._tt);
|
||||
window._tt = setTimeout(() => el.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
const uid = document.getElementById('user-id').value.trim();
|
||||
const pw = document.getElementById('password').value;
|
||||
if (!uid || !pw) {
|
||||
showToast('user id and password required', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: uid, password: pw }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem('ctxd_session_token', data.token);
|
||||
showToast('signed in — loading dashboard', 'success');
|
||||
setTimeout(() => { window.location.href = '/'; }, 500);
|
||||
} else {
|
||||
let detail = '';
|
||||
try { detail = (await res.json()).error || ''; } catch (_) {}
|
||||
if (res.status === 401) {
|
||||
showToast(detail === 'invalid credentials' ? 'invalid user id or password' : ('login failed: ' + (detail || res.status)), 'error');
|
||||
} else if (res.status === 403 && detail === 'account inactive') {
|
||||
showToast('account inactive — contact an admin', 'error');
|
||||
} else {
|
||||
showToast('login failed (' + res.status + ')', 'error');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('network error — check connection', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if already signed in (cookie-based — server will redirect to dashboard)
|
||||
// The "signed in" card is only shown if the server served the landing page
|
||||
// despite a valid cookie, which shouldn't happen. If it does, offer a redirect.
|
||||
(async () => {
|
||||
// If the server served the landing page, we're not authenticated via cookie.
|
||||
// Try localStorage token as fallback (for backward compat with old sessions).
|
||||
const token = localStorage.getItem('ctxd_session_token');
|
||||
if (token) {
|
||||
try {
|
||||
const res = await fetch('/auth/me', { headers: { Authorization: 'Bearer ' + token } });
|
||||
if (res.ok) {
|
||||
// Token works via Bearer but cookie wasn't set — force redirect with token in cookie
|
||||
// Re-login to get the cookie set, or just redirect (the dashboard JS uses Bearer too)
|
||||
document.getElementById('login-card').innerHTML = '<h2>signed in</h2><p style="font-size:0.75rem;color:var(--ink-dim);margin-bottom:0.75rem">You are signed in.</p><div class="actions"><button class="primary" onclick="window.location.href=\'/\'">open dashboard</button></div>';
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
})();
|
||||
|
||||
// Status check
|
||||
(async () => {
|
||||
try {
|
||||
await fetch('/status');
|
||||
document.getElementById('status').innerHTML = '<span class="dot"></span> connected';
|
||||
} catch (_) {
|
||||
document.getElementById('status').innerHTML = 'disconnected';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate data from SQLite to PostgreSQL.
|
||||
|
||||
Usage:
|
||||
DATABASE_URL=postgresql://ctxd:ctxd_local_dev@localhost:5432/ctxd \
|
||||
SQLITE_PATH=/data/ctxd.db \
|
||||
python3 -m ctxd.migrate_sqlite_to_pg
|
||||
|
||||
Or inside the Docker container:
|
||||
docker exec dossier python3 -m ctxd.migrate_sqlite_to_pg
|
||||
|
||||
Environment variables:
|
||||
DATABASE_URL — PostgreSQL connection string (required)
|
||||
SQLITE_PATH — Path to SQLite DB file (default: /data/ctxd.db)
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
from psycopg.rows import dict_row
|
||||
except ImportError:
|
||||
print("ERROR: psycopg is required. Install with: pip install psycopg[binary]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
SQLITE_PATH = os.environ.get("SQLITE_PATH", "/data/ctxd.db")
|
||||
PG_URL = os.environ.get("DATABASE_URL", "")
|
||||
|
||||
# Tables in migration order (parents before children for FK safety)
|
||||
TABLES = [
|
||||
"users",
|
||||
"projects",
|
||||
"project_permissions",
|
||||
"project_context",
|
||||
"context_files",
|
||||
"user_profiles",
|
||||
"user_workspaces",
|
||||
"workspace_files",
|
||||
"change_requests",
|
||||
"reviews",
|
||||
"snapshots",
|
||||
"audit_log",
|
||||
]
|
||||
|
||||
# Columns that need type conversion from SQLite INTEGER to PostgreSQL BOOLEAN
|
||||
BOOL_COLUMNS = {
|
||||
"users": ["active"],
|
||||
"projects": ["auto_sync"],
|
||||
}
|
||||
|
||||
|
||||
def migrate():
|
||||
if not PG_URL:
|
||||
print("ERROR: DATABASE_URL environment variable not set")
|
||||
print("Example: DATABASE_URL=postgresql://ctxd:ctxd_local_dev@localhost:5432/ctxd")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(SQLITE_PATH):
|
||||
print(f"ERROR: SQLite database not found at {SQLITE_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Migrating from SQLite ({SQLITE_PATH}) to PostgreSQL ({PG_URL})")
|
||||
print()
|
||||
|
||||
# Connect to SQLite
|
||||
sconn = sqlite3.connect(SQLITE_PATH)
|
||||
sconn.row_factory = sqlite3.Row
|
||||
|
||||
# Connect to PostgreSQL
|
||||
pconn = psycopg.connect(PG_URL, row_factory=dict_row)
|
||||
pconn.autocommit = False
|
||||
|
||||
# Step 1: Clear all data from PostgreSQL tables
|
||||
print("Clearing existing PostgreSQL data...")
|
||||
pconn.execute(
|
||||
"TRUNCATE TABLE fts_context, audit_log, reviews, change_requests, "
|
||||
"workspace_files, user_workspaces, context_files, project_context, "
|
||||
"snapshots, user_profiles, project_permissions, projects, users CASCADE"
|
||||
)
|
||||
pconn.commit()
|
||||
print(" Done.")
|
||||
print()
|
||||
|
||||
# Step 2: Migrate each table
|
||||
total_rows = 0
|
||||
for table in TABLES:
|
||||
# Get column names from SQLite
|
||||
try:
|
||||
cur = sconn.execute(f"PRAGMA table_info({table})")
|
||||
columns = [row["name"] for row in cur.fetchall()]
|
||||
except sqlite3.OperationalError:
|
||||
print(f" {table}: table not found in SQLite, skipping")
|
||||
continue
|
||||
|
||||
if not columns:
|
||||
print(f" {table}: no columns found, skipping")
|
||||
continue
|
||||
|
||||
# Read all rows from SQLite
|
||||
col_str = ", ".join(columns)
|
||||
try:
|
||||
cur = sconn.execute(f"SELECT {col_str} FROM {table}")
|
||||
rows = cur.fetchall()
|
||||
except sqlite3.OperationalError:
|
||||
print(f" {table}: error reading from SQLite, skipping")
|
||||
continue
|
||||
|
||||
if not rows:
|
||||
print(f" {table}: 0 rows")
|
||||
continue
|
||||
|
||||
# Insert into PostgreSQL
|
||||
val_str = ", ".join(["%s"] * len(columns))
|
||||
insert_sql = f'INSERT INTO {table} ({col_str}) VALUES ({val_str}) ON CONFLICT DO NOTHING'
|
||||
|
||||
bool_cols = BOOL_COLUMNS.get(table, [])
|
||||
count = 0
|
||||
|
||||
for row in rows:
|
||||
values = []
|
||||
for col in columns:
|
||||
val = row[col]
|
||||
# Convert integer 0/1 to boolean for PostgreSQL BOOLEAN columns
|
||||
if col in bool_cols and val is not None:
|
||||
val = bool(val)
|
||||
values.append(val)
|
||||
try:
|
||||
pconn.execute(insert_sql, values)
|
||||
count += 1
|
||||
except Exception as fk_err:
|
||||
# Skip rows with FK violations (e.g. orphaned snapshots)
|
||||
pconn.rollback()
|
||||
count_skipped = count_skipped + 1 if 'count_skipped' in dir() else 1
|
||||
continue
|
||||
|
||||
pconn.commit()
|
||||
total_rows += count
|
||||
print(f" {table}: {count} rows migrated")
|
||||
|
||||
# Step 3: Rebuild FTS index
|
||||
print()
|
||||
print("Rebuilding FTS index...")
|
||||
pconn.execute("DELETE FROM fts_context")
|
||||
pconn.commit()
|
||||
|
||||
# Re-populate FTS from source tables
|
||||
pconn.execute("""
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
SELECT project_id, content, project_id, 'context.md', 'project_context',
|
||||
to_tsvector('english', content)
|
||||
FROM project_context
|
||||
WHERE content != ''
|
||||
""")
|
||||
fts_pc = pconn.cursor().rowcount
|
||||
|
||||
pconn.execute("""
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
SELECT file_id::text, content, project_id, file_path, 'context_file',
|
||||
to_tsvector('english', content)
|
||||
FROM context_files
|
||||
WHERE content != ''
|
||||
""")
|
||||
fts_cf = pconn.cursor().rowcount
|
||||
|
||||
pconn.execute("""
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
SELECT user_id, content, '~user', user_id, 'user_profile',
|
||||
to_tsvector('english', content)
|
||||
FROM user_profiles
|
||||
WHERE content != ''
|
||||
""")
|
||||
fts_up = pconn.cursor().rowcount
|
||||
|
||||
pconn.commit()
|
||||
print(f" project_context: {fts_pc} entries")
|
||||
print(f" context_files: {fts_cf} entries")
|
||||
print(f" user_profiles: {fts_up} entries")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Migration complete! {total_rows} total rows migrated.")
|
||||
print("=" * 60)
|
||||
|
||||
sconn.close()
|
||||
pconn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
+169
-116
@@ -1,23 +1,20 @@
|
||||
-- ============================================================================
|
||||
-- ctxd — Context Daemon Schema
|
||||
-- SQLite 3.x, WAL mode, FTS5
|
||||
-- PostgreSQL 16
|
||||
-- ============================================================================
|
||||
|
||||
-- WAL for concurrent reads during writes; foreign keys enforced
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ============================================================================
|
||||
-- USERS
|
||||
-- ============================================================================
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY, -- uuid or "joshua", "polly", "hermes-gateway"
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'contributor'
|
||||
CHECK (role IN ('admin', 'contributor', 'service')),
|
||||
token_hash TEXT, -- NULL = no auth (localhost/trusted)
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
token_hash TEXT,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_users_lower ON users (LOWER(user_id));
|
||||
@@ -26,22 +23,22 @@ CREATE UNIQUE INDEX idx_users_lower ON users (LOWER(user_id));
|
||||
-- PROJECTS
|
||||
-- ============================================================================
|
||||
CREATE TABLE projects (
|
||||
project_id TEXT PRIMARY KEY, -- uuid or slug "remote-rig"
|
||||
project_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
metadata_tags TEXT DEFAULT '[]', -- JSON array of tag strings e.g. '["ARCHITECTURE","3D-PRINTING"]'
|
||||
shared_version INTEGER NOT NULL DEFAULT 0, -- monotonically increasing
|
||||
auto_sync INTEGER NOT NULL DEFAULT 0, -- boolean: auto-write AGENTS.md to sync_path
|
||||
sync_path TEXT, -- absolute path to project root (nullable)
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
metadata_tags TEXT DEFAULT '[]',
|
||||
shared_version INTEGER NOT NULL DEFAULT 0,
|
||||
auto_sync BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sync_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECT PERMISSIONS (admin overrides all)
|
||||
-- ============================================================================
|
||||
CREATE TABLE project_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL DEFAULT 'editor'
|
||||
@@ -54,9 +51,9 @@ CREATE TABLE project_permissions (
|
||||
-- ============================================================================
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '', -- markdown
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
@@ -66,10 +63,10 @@ CREATE TABLE user_profiles (
|
||||
-- ============================================================================
|
||||
CREATE TABLE project_context (
|
||||
project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '', -- compiled markdown
|
||||
version INTEGER NOT NULL DEFAULT 0, -- mirrors projects.shared_version
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by TEXT REFERENCES users(user_id),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
@@ -78,13 +75,13 @@ CREATE TABLE project_context (
|
||||
-- version tracks this file's edit count (independent of the shared version).
|
||||
-- ============================================================================
|
||||
CREATE TABLE context_files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id SERIAL PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL, -- "decisions/001-use-go.md"
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1, -- per-file edit counter
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_by TEXT REFERENCES users(user_id),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
UNIQUE(project_id, file_path)
|
||||
);
|
||||
|
||||
@@ -94,16 +91,16 @@ CREATE TABLE context_files (
|
||||
-- shared version they started from. current_version tracks their edits.
|
||||
-- ============================================================================
|
||||
CREATE TABLE user_workspaces (
|
||||
workspace_id TEXT PRIMARY KEY, -- uuid
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress', 'submitted', 'merged', 'abandoned')),
|
||||
base_version INTEGER NOT NULL, -- shared version at fork time
|
||||
base_version INTEGER NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(user_id, project_id, status) -- one active workspace per user per project
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
UNIQUE(user_id, project_id, status)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
@@ -111,12 +108,12 @@ CREATE TABLE user_workspaces (
|
||||
-- Mirrors the same file_path as context_files but in the user's workspace.
|
||||
-- ============================================================================
|
||||
CREATE TABLE workspace_files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_id SERIAL PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
UNIQUE(workspace_id, file_path)
|
||||
);
|
||||
|
||||
@@ -124,30 +121,29 @@ CREATE TABLE workspace_files (
|
||||
-- CHANGE REQUESTS — submit / review / merge workflow
|
||||
-- ============================================================================
|
||||
CREATE TABLE change_requests (
|
||||
request_id TEXT PRIMARY KEY, -- uuid
|
||||
request_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
||||
submitted_by TEXT NOT NULL REFERENCES users(user_id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'merged')),
|
||||
-- Snapshot of what changed, stored inline so reviews survive workspace mutation
|
||||
diff_summary TEXT, -- free-text summary of changes
|
||||
target_version INTEGER NOT NULL, -- the shared version this would bump to
|
||||
base_version INTEGER NOT NULL, -- the shared version they forked from
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
diff_summary TEXT,
|
||||
target_version INTEGER NOT NULL,
|
||||
base_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
updated_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- REVIEWS — approvals/rejections on change requests
|
||||
-- ============================================================================
|
||||
CREATE TABLE reviews (
|
||||
review_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
review_id SERIAL PRIMARY KEY,
|
||||
request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE,
|
||||
reviewer_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
|
||||
comments TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||
UNIQUE(request_id, reviewer_id)
|
||||
);
|
||||
|
||||
@@ -156,31 +152,29 @@ CREATE TABLE reviews (
|
||||
-- Stored as files on disk at the path in storage_path.
|
||||
-- ============================================================================
|
||||
CREATE TABLE snapshots (
|
||||
snapshot_id TEXT PRIMARY KEY, -- uuid
|
||||
snapshot_id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
-- NULL user_id = snapshot of the shared copy; non-NULL = snapshot of a user workspace
|
||||
user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES user_workspaces(workspace_id) ON DELETE SET NULL,
|
||||
version_from INTEGER, -- version range this snapshot covers
|
||||
version_from INTEGER,
|
||||
version_to INTEGER,
|
||||
storage_path TEXT NOT NULL, -- relative to ~/.ctx/snapshots/
|
||||
content_hash TEXT NOT NULL, -- sha256 of the compiled markdown
|
||||
storage_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
-- Index for snapshot rotation queries
|
||||
CREATE INDEX idx_snapshots_cleanup ON snapshots (project_id, user_id, created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- AUDIT LOG — append-only (INSERT only, never UPDATE or DELETE)
|
||||
-- ============================================================================
|
||||
CREATE TABLE audit_log (
|
||||
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id SERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
agent_id TEXT NOT NULL DEFAULT 'cli', -- "hermes", "claude-code", "ctx"
|
||||
session_id TEXT, -- opaque session identifier
|
||||
project_id TEXT REFERENCES projects(project_id),
|
||||
agent_id TEXT NOT NULL DEFAULT 'cli',
|
||||
session_id TEXT,
|
||||
project_id TEXT REFERENCES projects(project_id) ON DELETE SET NULL,
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN (
|
||||
'read', 'update', 'create', 'delete',
|
||||
@@ -188,105 +182,164 @@ CREATE TABLE audit_log (
|
||||
'sync', 'search', 'export', 'restore',
|
||||
'login', 'logout', 'import'
|
||||
)),
|
||||
entity_type TEXT, -- 'project', 'workspace', 'change_request', 'snapshot', 'user_profile'
|
||||
entity_id TEXT, -- polymorphic reference
|
||||
summary TEXT NOT NULL, -- human-readable: "Updated camera-node wiring section"
|
||||
details_json TEXT, -- structured payload: diff, version numbers, etc.
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT to_char(now() at time zone 'utc', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
);
|
||||
|
||||
-- Audit queries by user, project, or time range
|
||||
CREATE INDEX idx_audit_user ON audit_log (user_id, created_at);
|
||||
CREATE INDEX idx_audit_project ON audit_log (project_id, created_at);
|
||||
CREATE INDEX idx_audit_agent ON audit_log (agent_id, created_at);
|
||||
CREATE INDEX idx_audit_op ON audit_log (operation, created_at);
|
||||
|
||||
-- Trigger: audit_log is append-only — enforce no updates or deletes at the DB level
|
||||
CREATE TRIGGER tr_audit_log_no_update
|
||||
BEFORE UPDATE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only — no updates allowed');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_audit_log_no_delete
|
||||
BEFORE DELETE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only — no deletes allowed');
|
||||
END;
|
||||
-- Note: audit_log append-only enforcement is handled at the application layer.
|
||||
-- DB-level BEFORE UPDATE/DELETE triggers conflict with FK ON DELETE SET NULL
|
||||
-- cascades from projects, which internally issue UPDATE statements.
|
||||
|
||||
-- ============================================================================
|
||||
-- FULL-TEXT SEARCH (FTS5)
|
||||
-- FULL-TEXT SEARCH (tsvector with GIN index)
|
||||
-- Separate FTS table with triggers to keep index in sync with source tables.
|
||||
-- ============================================================================
|
||||
CREATE VIRTUAL TABLE fts_context USING fts5(
|
||||
content,
|
||||
project_id UNINDEXED,
|
||||
file_path UNINDEXED,
|
||||
source_type UNINDEXED, -- 'project_context', 'context_file', 'user_profile', 'workspace_file'
|
||||
tokenize='porter unicode61'
|
||||
CREATE TABLE fts_context (
|
||||
id SERIAL PRIMARY KEY,
|
||||
source_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL,
|
||||
tsv tsvector NOT NULL,
|
||||
UNIQUE(source_type, source_id)
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS index in sync with project_context
|
||||
CREATE TRIGGER tr_fts_project_context_insert AFTER INSERT ON project_context
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context');
|
||||
END;
|
||||
CREATE INDEX idx_fts_context_tsv ON fts_context USING GIN (tsv);
|
||||
CREATE INDEX idx_fts_context_project ON fts_context (project_id);
|
||||
CREATE INDEX idx_fts_context_source ON fts_context (source_type, source_id);
|
||||
|
||||
CREATE TRIGGER tr_fts_project_context_update AFTER UPDATE ON project_context
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context');
|
||||
END;
|
||||
-- ── Trigger functions for project_context ───────────────────────────────────
|
||||
|
||||
CREATE TRIGGER tr_fts_project_context_delete AFTER DELETE ON project_context
|
||||
CREATE OR REPLACE FUNCTION fts_pc_insert() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid;
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
VALUES (NEW.project_id, NEW.content, NEW.project_id, 'context.md', 'project_context',
|
||||
to_tsvector('english', NEW.content));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Triggers for context_files
|
||||
CREATE TRIGGER tr_fts_context_files_insert AFTER INSERT ON context_files
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file');
|
||||
END;
|
||||
CREATE TRIGGER tr_fts_pc_insert AFTER INSERT ON project_context
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_pc_insert();
|
||||
|
||||
CREATE TRIGGER tr_fts_context_files_update AFTER UPDATE ON context_files
|
||||
CREATE OR REPLACE FUNCTION fts_pc_update() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file');
|
||||
DELETE FROM fts_context WHERE source_type = 'project_context' AND source_id = OLD.project_id;
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
VALUES (NEW.project_id, NEW.content, NEW.project_id, 'context.md', 'project_context',
|
||||
to_tsvector('english', NEW.content));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_context_files_delete AFTER DELETE ON context_files
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000;
|
||||
END;
|
||||
CREATE TRIGGER tr_fts_pc_update AFTER UPDATE ON project_context
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_pc_update();
|
||||
|
||||
-- Triggers for user_profiles
|
||||
CREATE TRIGGER tr_fts_user_profiles_insert AFTER INSERT ON user_profiles
|
||||
CREATE OR REPLACE FUNCTION fts_pc_delete() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid + 2000000, NEW.content, '~user~', NEW.user_id, 'user_profile');
|
||||
DELETE FROM fts_context WHERE source_type = 'project_context' AND source_id = OLD.project_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_user_profiles_update AFTER UPDATE ON user_profiles
|
||||
CREATE TRIGGER tr_fts_pc_delete AFTER DELETE ON project_context
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_pc_delete();
|
||||
|
||||
-- ── Trigger functions for context_files ─────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION fts_cf_insert() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid + 2000000;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid + 2000000, NEW.content, '~user~', NEW.user_id, 'user_profile');
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
VALUES (NEW.file_id::text, NEW.content, NEW.project_id, NEW.file_path, 'context_file',
|
||||
to_tsvector('english', NEW.content));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_cf_insert AFTER INSERT ON context_files
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_cf_insert();
|
||||
|
||||
CREATE OR REPLACE FUNCTION fts_cf_update() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE source_type = 'context_file' AND source_id = OLD.file_id::text;
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
VALUES (NEW.file_id::text, NEW.content, NEW.project_id, NEW.file_path, 'context_file',
|
||||
to_tsvector('english', NEW.content));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_cf_update AFTER UPDATE ON context_files
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_cf_update();
|
||||
|
||||
CREATE OR REPLACE FUNCTION fts_cf_delete() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE source_type = 'context_file' AND source_id = OLD.file_id::text;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_cf_delete AFTER DELETE ON context_files
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_cf_delete();
|
||||
|
||||
-- ── Trigger functions for user_profiles ─────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE FUNCTION fts_up_insert() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
VALUES (NEW.user_id, NEW.content, '~user', NEW.user_id, 'user_profile',
|
||||
to_tsvector('english', NEW.content));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_up_insert AFTER INSERT ON user_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_up_insert();
|
||||
|
||||
CREATE OR REPLACE FUNCTION fts_up_update() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE source_type = 'user_profile' AND source_id = OLD.user_id;
|
||||
INSERT INTO fts_context (source_id, content, project_id, file_path, source_type, tsv)
|
||||
VALUES (NEW.user_id, NEW.content, '~user', NEW.user_id, 'user_profile',
|
||||
to_tsvector('english', NEW.content));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_up_update AFTER UPDATE ON user_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_up_update();
|
||||
|
||||
CREATE OR REPLACE FUNCTION fts_up_delete() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE source_type = 'user_profile' AND source_id = OLD.user_id;
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER tr_fts_up_delete AFTER DELETE ON user_profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION fts_up_delete();
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED DATA (for development / first-run)
|
||||
-- ============================================================================
|
||||
INSERT INTO users (user_id, display_name, role) VALUES
|
||||
('admin', 'Administrator', 'admin'),
|
||||
('hermes-gateway', 'Hermes Agent', 'service');
|
||||
('hermes-gateway', 'Hermes Agent', 'service')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO projects (project_id, display_name, description) VALUES
|
||||
('welcome', 'Welcome', 'Getting started guide and documentation for ctxd'),
|
||||
('remote-rig', 'RemoteRig', 'Multi-camera remote monitoring system');
|
||||
('remote-rig', 'RemoteRig', 'Multi-camera remote monitoring system')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Project context is seeded by the Python init code (cmd_init)
|
||||
-- to ensure real newlines, not literal backslash-n from SQL strings.
|
||||
@@ -0,0 +1,282 @@
|
||||
-- ============================================================================
|
||||
-- ctxd — Context Daemon Schema
|
||||
-- SQLite 3.x, WAL mode, FTS5
|
||||
-- (Legacy fallback for local dev when DATABASE_URL is not set)
|
||||
-- ============================================================================
|
||||
|
||||
-- WAL for concurrent reads during writes; foreign keys enforced
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ============================================================================
|
||||
-- USERS
|
||||
-- ============================================================================
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'contributor'
|
||||
CHECK (role IN ('admin', 'contributor', 'service')),
|
||||
token_hash TEXT,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_users_lower ON users (LOWER(user_id));
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECTS
|
||||
-- ============================================================================
|
||||
CREATE TABLE projects (
|
||||
project_id TEXT PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
metadata_tags TEXT DEFAULT '[]',
|
||||
shared_version INTEGER NOT NULL DEFAULT 0,
|
||||
auto_sync INTEGER NOT NULL DEFAULT 0,
|
||||
sync_path TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECT PERMISSIONS (admin overrides all)
|
||||
-- ============================================================================
|
||||
CREATE TABLE project_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
permission TEXT NOT NULL DEFAULT 'editor'
|
||||
CHECK (permission IN ('owner', 'editor', 'viewer')),
|
||||
UNIQUE(project_id, user_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- USER PROFILES (personal context — timezone, preferences, style)
|
||||
-- ============================================================================
|
||||
CREATE TABLE user_profiles (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- PROJECT CONTEXT — THE AUTHORITATIVE SHARED COPY
|
||||
-- ============================================================================
|
||||
CREATE TABLE project_context (
|
||||
project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
updated_by TEXT REFERENCES users(user_id),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- CONTEXT FILES — individual files within a project
|
||||
-- ============================================================================
|
||||
CREATE TABLE context_files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_by TEXT REFERENCES users(user_id),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(project_id, file_path)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- USER WORKSPACES — per-user forks of a project
|
||||
-- ============================================================================
|
||||
CREATE TABLE user_workspaces (
|
||||
workspace_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress', 'submitted', 'merged', 'abandoned')),
|
||||
base_version INTEGER NOT NULL,
|
||||
current_version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(user_id, project_id, status)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- WORKSPACE FILES — per-user fork of context_files
|
||||
-- ============================================================================
|
||||
CREATE TABLE workspace_files (
|
||||
file_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(workspace_id, file_path)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- CHANGE REQUESTS — submit / review / merge workflow
|
||||
-- ============================================================================
|
||||
CREATE TABLE change_requests (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL REFERENCES user_workspaces(workspace_id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id),
|
||||
submitted_by TEXT NOT NULL REFERENCES users(user_id),
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'merged')),
|
||||
diff_summary TEXT,
|
||||
target_version INTEGER NOT NULL,
|
||||
base_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- REVIEWS — approvals/rejections on change requests
|
||||
-- ============================================================================
|
||||
CREATE TABLE reviews (
|
||||
review_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL REFERENCES change_requests(request_id) ON DELETE CASCADE,
|
||||
reviewer_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
decision TEXT NOT NULL CHECK (decision IN ('approved', 'rejected')),
|
||||
comments TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(request_id, reviewer_id)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- SNAPSHOTS — point-in-time copies of project or workspace content
|
||||
-- ============================================================================
|
||||
CREATE TABLE snapshots (
|
||||
snapshot_id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES user_workspaces(workspace_id) ON DELETE SET NULL,
|
||||
version_from INTEGER,
|
||||
version_to INTEGER,
|
||||
storage_path TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_snapshots_cleanup ON snapshots (project_id, user_id, created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- AUDIT LOG — append-only (INSERT only, never UPDATE or DELETE)
|
||||
-- ============================================================================
|
||||
CREATE TABLE audit_log (
|
||||
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id),
|
||||
agent_id TEXT NOT NULL DEFAULT 'cli',
|
||||
session_id TEXT,
|
||||
project_id TEXT REFERENCES projects(project_id),
|
||||
operation TEXT NOT NULL
|
||||
CHECK (operation IN (
|
||||
'read', 'update', 'create', 'delete',
|
||||
'submit', 'approve', 'reject', 'merge',
|
||||
'sync', 'search', 'export', 'restore',
|
||||
'login', 'logout', 'import'
|
||||
)),
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
summary TEXT NOT NULL,
|
||||
details_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_user ON audit_log (user_id, created_at);
|
||||
CREATE INDEX idx_audit_project ON audit_log (project_id, created_at);
|
||||
CREATE INDEX idx_audit_agent ON audit_log (agent_id, created_at);
|
||||
CREATE INDEX idx_audit_op ON audit_log (operation, created_at);
|
||||
|
||||
-- Trigger: audit_log is append-only
|
||||
CREATE TRIGGER tr_audit_log_no_update
|
||||
BEFORE UPDATE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only — no updates allowed');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_audit_log_no_delete
|
||||
BEFORE DELETE ON audit_log
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only — no deletes allowed');
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- FULL-TEXT SEARCH (FTS5)
|
||||
-- ============================================================================
|
||||
CREATE VIRTUAL TABLE fts_context USING fts5(
|
||||
content,
|
||||
project_id UNINDEXED,
|
||||
file_path UNINDEXED,
|
||||
source_type UNINDEXED,
|
||||
tokenize='porter unicode61'
|
||||
);
|
||||
|
||||
-- Triggers to keep FTS index in sync with project_context
|
||||
CREATE TRIGGER tr_fts_project_context_insert AFTER INSERT ON project_context
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_project_context_update AFTER UPDATE ON project_context
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid, NEW.content, NEW.project_id, 'context.md', 'project_context');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_project_context_delete AFTER DELETE ON project_context
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid;
|
||||
END;
|
||||
|
||||
-- Triggers for context_files
|
||||
CREATE TRIGGER tr_fts_context_files_insert AFTER INSERT ON context_files
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_context_files_update AFTER UPDATE ON context_files
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.file_id + 1000000, NEW.content, NEW.project_id, NEW.file_path, 'context_file');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_context_files_delete AFTER DELETE ON context_files
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.file_id + 1000000;
|
||||
END;
|
||||
|
||||
-- Triggers for user_profiles
|
||||
CREATE TRIGGER tr_fts_user_profiles_insert AFTER INSERT ON user_profiles
|
||||
BEGIN
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid + 2000000, NEW.content, '~user', NEW.user_id, 'user_profile');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER tr_fts_user_profiles_update AFTER UPDATE ON user_profiles
|
||||
BEGIN
|
||||
DELETE FROM fts_context WHERE rowid = OLD.rowid + 2000000;
|
||||
INSERT INTO fts_context(rowid, content, project_id, file_path, source_type)
|
||||
VALUES (NEW.rowid + 2000000, NEW.content, '~user', NEW.user_id, 'user_profile');
|
||||
END;
|
||||
|
||||
-- ============================================================================
|
||||
-- SEED DATA (for development / first-run)
|
||||
-- ============================================================================
|
||||
INSERT INTO users (user_id, display_name, role) VALUES
|
||||
('admin', 'Administrator', 'admin'),
|
||||
('hermes-gateway', 'Hermes Agent', 'service');
|
||||
|
||||
INSERT INTO projects (project_id, display_name, description) VALUES
|
||||
('welcome', 'Welcome', 'Getting started guide and documentation for ctxd'),
|
||||
('remote-rig', 'RemoteRig', 'Multi-camera remote monitoring system');
|
||||
|
||||
-- Project context is seeded by the Python init code (cmd_init)
|
||||
-- to ensure real newlines, not literal backslash-n from SQL strings.
|
||||
+1068
-69
File diff suppressed because it is too large
Load Diff
+846
-37
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user