schema.py 17.3 KB
"""SQLite schema for graphlemon's own structured storage.

U-001 scope: the 15 platform entities from docs/sdd/01-M-模型设计.md §1.0-1.14
(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16, added here in U-034,
2026-08-02) are PH5-only -- 17 tables total as of PH5.

Interpretive conventions used below, where 01-M leaves the physical type implicit:
- Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type.
- A `*_at` column that records "when this row was created" (or, for skill_iteration,
  when the row's one-shot event happened) is NOT NULL DEFAULT CURRENT_TIMESTAMP.
  A `*_at` column that records a later lifecycle event which may not have happened
  yet (started_at, completed_at, purged_at, ...) is nullable with no default -- the
  application sets it only when that event actually occurs.
- JSON-typed columns (graph_manifest, nodes, edges, payload, ...) are stored as TEXT;
  (de)serialization is an application-layer concern, not enforced by this schema.
- task.assigned_org_node is documented as "not null, fk" but there is no standalone
  org-node lookup table to reference (planner/coder/validator only exist as entries
  inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum
  instead, matching work_graph_run.org_node / skill.owner_org_node / agent_profile.org_node.
- bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) --
  05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph
  Engine scheduler/executor core logic changes, this schema's own changes), routed by
  `c_org_loop_route` to `p_deploy_supervisor_blue_green` instead of fast_lane/full_replan.
- bug_record and skill have no created_at in 01-M (skill is Git-versioned, so git log
  covers "when"; bug_record has no such backing and this looks like a possible spec
  gap, but adding an undocumented column is out of scope for U-001 -- flagged, not fixed).
- deployment.previous_commit_hash/previous_instance_dir are "not null" per 01-M 1.15,
  but the very first deployment ever run has no prior `finalized` deployment to point
  at -- that bootstrap case is c_deploy_supervisor_stage's problem (U-037), not this
  schema's: it must supply the currently-running commit/checkout dir as the "previous"
  values on that first call, not leave the schema-level NOT NULL as aspirational.
"""

import sqlite3

TABLE_NAMES = (
    "project",
    "org_graph_definition",
    "skill",
    "task",
    "work_graph_run",
    "work_node_execution",
    "artifact",
    "bug_record",
    "skill_iteration",
    "agent_profile",
    "budget_guardrail_config",
    "org_concurrency_config",
    "execution_event",
    "execution_log_file",
    "log_retention_config",
    "deployment",
    "deploy_health_signal",
)

SCHEMA_SQL = """
-- 1.0 project
CREATE TABLE project (
    project_id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    target_folder_path TEXT NOT NULL UNIQUE,
    has_git_repo INTEGER NOT NULL CHECK (has_git_repo IN (0, 1)),
    graphlemon_subdir_initialized INTEGER NOT NULL DEFAULT 0
        CHECK (graphlemon_subdir_initialized IN (0, 1)),
    status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 1.1 org_graph_definition
CREATE TABLE org_graph_definition (
    graph_id TEXT PRIMARY KEY,
    version TEXT NOT NULL,
    nodes TEXT NOT NULL,
    edges TEXT NOT NULL,
    file_path TEXT NOT NULL,
    status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'deprecated'))
);

-- 1.7 skill (created before task/work_node_execution: work_node_execution.skill_ref
-- references it)
CREATE TABLE skill (
    skill_id TEXT PRIMARY KEY,
    owner_org_node TEXT NOT NULL CHECK (owner_org_node IN ('planner', 'coder', 'validator')),
    file_path TEXT NOT NULL,
    current_git_commit_sha TEXT NOT NULL,
    source TEXT NOT NULL CHECK (source IN ('migrated_from_hermes', 'new', 'auto_iterated')),
    migrated_from_profile TEXT CHECK (migrated_from_profile IN ('code', 'planning', 'qa')),
    status TEXT NOT NULL CHECK (status IN ('active', 'archived'))
);

-- 1.2 task
CREATE TABLE task (
    task_id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL REFERENCES project(project_id),
    goal_text TEXT NOT NULL,
    task_type TEXT NOT NULL CHECK (task_type IN ('goal', 'bug_fix')),
    origin_ref TEXT,
    created_via TEXT NOT NULL DEFAULT 'api' CHECK (created_via IN ('api', 'human_console')),
    status TEXT NOT NULL CHECK (status IN (
        'queued', 'planning_brd_prd_sdd', 'planning_failed',
        'coding', 'coding_failed', 'validating',
        'routed_fast_lane', 'routed_full_replan',
        'blocked_pending_human', 'done'
    )),
    route TEXT CHECK (route IN ('fast_lane', 'full_replan')),
    loop_round INTEGER NOT NULL DEFAULT 0,
    blocked_reason TEXT,
    assigned_org_node TEXT NOT NULL CHECK (assigned_org_node IN ('planner', 'coder', 'validator')),
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_task_status ON task(status);
CREATE INDEX idx_task_assigned_org_node ON task(assigned_org_node);
CREATE INDEX idx_task_project_id ON task(project_id);

-- 1.3 work_graph_run
CREATE TABLE work_graph_run (
    run_id TEXT PRIMARY KEY,
    task_id TEXT NOT NULL REFERENCES task(task_id),
    org_node TEXT NOT NULL CHECK (org_node IN ('planner', 'coder', 'validator')),
    graph_manifest TEXT NOT NULL,
    orchestration_script_ref TEXT,
    status TEXT NOT NULL CHECK (status IN (
        'planning', 'running', 'merging', 'verifying',
        'reflecting', 'blocked', 'done', 'failed'
    )),
    started_at TEXT,
    completed_at TEXT,
    result_artifact_refs TEXT
);

-- only one active (non-terminal) run per (task_id, org_node) at a time
CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)
    WHERE status IN ('planning', 'running', 'merging', 'verifying');

-- 1.4 work_node_execution
CREATE TABLE work_node_execution (
    node_exec_id TEXT PRIMARY KEY,
    run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
    node_id TEXT NOT NULL,
    subagent_type TEXT NOT NULL,
    skill_ref TEXT NOT NULL REFERENCES skill(skill_id),
    input_schema_ref TEXT NOT NULL,
    output_schema_ref TEXT NOT NULL,
    input_payload TEXT NOT NULL,
    output_payload TEXT,
    model_tier TEXT NOT NULL DEFAULT 'standard' CHECK (model_tier IN ('cheap', 'standard', 'premium')),
    status TEXT NOT NULL CHECK (status IN (
        'pending', 'running', 'reflecting', 'retrying',
        'waiting_for_quota', 'blocked', 'done', 'failed'
    )),
    retry_count INTEGER NOT NULL DEFAULT 0,
    reflection_notes TEXT,
    git_worktree_ref TEXT,
    quota_wait_started_at TEXT,
    quota_limit_type TEXT CHECK (quota_limit_type IN ('session', 'weekly', 'opus'))
);

-- 1.5 artifact
CREATE TABLE artifact (
    artifact_id TEXT PRIMARY KEY,
    artifact_type TEXT NOT NULL CHECK (artifact_type IN (
        'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',
        'test_dataset', 'test_script',
        'validator_benchmark', 'validator_test_dataset', 'validator_test_script'
    )),
    task_id TEXT NOT NULL REFERENCES task(task_id),
    produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
    file_path TEXT NOT NULL,
    git_commit_sha TEXT NOT NULL,
    depends_on_artifact_ids TEXT,
    validation_status TEXT NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending', 'passed', 'failed')),
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX ux_artifact_task_type_commit ON artifact(task_id, artifact_type, git_commit_sha);

-- 1.6 bug_record (skill_iteration_suggestion_id forward-references skill_iteration,
-- created further below -- SQLite does not validate FK target existence at CREATE
-- TABLE time, only at DML time, so table creation order here is fine)
CREATE TABLE bug_record (
    bug_id TEXT PRIMARY KEY,
    task_id TEXT REFERENCES task(task_id),
    source TEXT NOT NULL CHECK (source IN ('validator', 'human')),
    reported_by TEXT,
    description TEXT NOT NULL,
    repro_steps TEXT,
    root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect', 'core_engine')),
    root_cause_detail TEXT,
    related_artifact_ids TEXT,
    suggested_route TEXT CHECK (suggested_route IN ('fast_lane', 'full_replan')),
    skill_iteration_suggestion_id TEXT REFERENCES skill_iteration(iteration_id),
    status TEXT NOT NULL CHECK (status IN ('open', 'triaging', 'routed', 'resolved'))
);

-- 1.8 skill_iteration
CREATE TABLE skill_iteration (
    iteration_id TEXT PRIMARY KEY,
    skill_id TEXT NOT NULL REFERENCES skill(skill_id),
    triggered_by_bug_id TEXT NOT NULL REFERENCES bug_record(bug_id),
    before_git_commit_sha TEXT NOT NULL,
    after_git_commit_sha TEXT NOT NULL,
    change_summary TEXT NOT NULL,
    applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    rollback_of_iteration_id TEXT REFERENCES skill_iteration(iteration_id)
);

-- 1.9 agent_profile
CREATE TABLE agent_profile (
    org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator')),
    soul_file_path TEXT NOT NULL,
    agents_file_path TEXT,
    migrated_from_profile TEXT NOT NULL CHECK (migrated_from_profile IN ('planning', 'code', 'qa')),
    current_git_commit_sha TEXT NOT NULL
);

-- 1.10 budget_guardrail_config
CREATE TABLE budget_guardrail_config (
    config_id TEXT PRIMARY KEY DEFAULT 'default',
    scope TEXT NOT NULL CHECK (scope IN ('global', 'per_task', 'per_day')),
    limit_type TEXT NOT NULL CHECK (limit_type IN ('token_count', 'call_count')),
    limit_value INTEGER NOT NULL,
    current_usage INTEGER NOT NULL DEFAULT 0,
    on_exceed_action TEXT NOT NULL DEFAULT 'pause_and_notify',
    updated_by TEXT NOT NULL,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 1.11 org_concurrency_config
CREATE TABLE org_concurrency_config (
    config_id TEXT PRIMARY KEY DEFAULT 'default',
    max_concurrent_goals INTEGER NOT NULL DEFAULT 1,
    updated_by TEXT NOT NULL,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 1.12 execution_event
CREATE TABLE execution_event (
    event_id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL REFERENCES project(project_id),
    task_id TEXT REFERENCES task(task_id),
    run_id TEXT REFERENCES work_graph_run(run_id),
    node_exec_id TEXT REFERENCES work_node_execution(node_exec_id),
    event_type TEXT NOT NULL CHECK (event_type IN (
        'task_status_changed', 'node_status_changed', 'artifact_created',
        'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated',
        'log_appended', 'quota_wait_started'
    )),
    payload TEXT NOT NULL,
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at);
CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at);

-- 1.13 execution_log_file
CREATE TABLE execution_log_file (
    log_file_id TEXT PRIMARY KEY,
    node_exec_id TEXT NOT NULL UNIQUE REFERENCES work_node_execution(node_exec_id),
    project_id TEXT NOT NULL REFERENCES project(project_id),
    file_path TEXT NOT NULL,
    format TEXT NOT NULL CHECK (format IN ('jsonl', 'raw_text')),
    size_bytes INTEGER NOT NULL DEFAULT 0,
    status TEXT NOT NULL CHECK (status IN ('active', 'closed', 'purged')),
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    purged_at TEXT
);

CREATE INDEX idx_execution_log_file_project ON execution_log_file(project_id);

-- 1.14 log_retention_config
CREATE TABLE log_retention_config (
    config_id TEXT PRIMARY KEY DEFAULT 'default',
    retention_days INTEGER NOT NULL DEFAULT 14,
    max_total_size_mb INTEGER,
    updated_by TEXT NOT NULL,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 1.15 deployment (PH5, U-034, 2026-08-02 -- see docs/done/supervisor-auto-rollback-design.md)
CREATE TABLE deployment (
    deployment_id TEXT PRIMARY KEY,
    git_commit_hash TEXT NOT NULL,
    previous_commit_hash TEXT NOT NULL,
    status TEXT NOT NULL CHECK (status IN (
        'staged', 'candidate_starting', 'health_check_running', 'health_check_passed',
        'health_check_failed', 'cutover_in_progress', 'live', 'monitoring',
        'anomaly_detected', 'rollback_in_progress', 'rolled_back', 'rollback_failed',
        'blocked_pending_human', 'monitoring_window_clean', 'finalized'
    )),
    candidate_port INTEGER,
    candidate_db_path TEXT,
    -- Not in supervisor-auto-rollback-design.md §3's field table (found
    -- missing while building U-037's c_deploy_supervisor_stage): the
    -- checkout directory a candidate is actually running from has to be
    -- persisted somewhere, or a Deploy Supervisor restart between staging
    -- and cutover would lose track of it entirely -- the same class of gap
    -- REQ-06 exists to prevent everywhere else. Once a deployment reaches
    -- 'live'/'finalized', this same directory IS what the next deployment's
    -- `previous_instance_dir` bootstraps from (see deploy_stage.py).
    candidate_instance_dir TEXT,
    started_at TEXT,
    cutover_at TEXT,
    monitoring_closed_at TEXT,
    rollback_at TEXT,
    rollback_reason TEXT,
    previous_instance_dir TEXT NOT NULL
);

-- only one non-terminal deployment at a time (01-M 1.15's own constraint) --
-- expression index on a constant, same "global singleton while active" idiom
-- as a partial unique index scoped by a real column, just with no column to
-- scope by here (there's exactly one graphlemon self-target, not one per key)
CREATE UNIQUE INDEX ux_deployment_active ON deployment((1))
    WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human');

-- 1.16 deploy_health_signal (PH5, U-034, 2026-08-02)
CREATE TABLE deploy_health_signal (
    signal_id TEXT PRIMARY KEY,
    deployment_id TEXT NOT NULL REFERENCES deployment(deployment_id),
    sampled_at TEXT NOT NULL,
    signal_type TEXT NOT NULL CHECK (signal_type IN (
        'process_alive', 'http_health', 'canary_task', 'validator_approval_rate'
    )),
    status TEXT NOT NULL CHECK (status IN ('ok', 'degraded', 'fail')),
    detail TEXT
);

CREATE INDEX idx_deploy_health_signal_deployment_sampled ON deploy_health_signal(deployment_id, sampled_at);
"""


def connect(db_path=":memory:", check_same_thread=True):
    """Open a sqlite3 connection with foreign-key enforcement turned on.

    SQLite disables FK enforcement by default per-connection; every caller needs
    this pragma, so it lives here rather than being repeated at every call site.

    check_same_thread=False is for callers like backend_server.py that share
    one connection across a ThreadingHTTPServer's per-request threads -- the
    caller is then responsible for serializing access itself (a lock), since
    turning this off only disables sqlite3's own thread-identity check, it
    doesn't make concurrent use of one connection object safe by itself.
    """
    conn = sqlite3.connect(db_path, check_same_thread=check_same_thread)
    conn.execute("PRAGMA foreign_keys = ON")
    return conn


def create_schema(conn):
    """Create all 17 tables (15 from U-001 + deployment/deploy_health_signal
    from U-034, PH5) + indexes on an open connection."""
    conn.executescript(SCHEMA_SQL)
    conn.commit()


def migrate_expand_artifact_types(conn):
    """2026-08-06: adds test_dataset/test_script/validator_benchmark/
    validator_test_dataset/validator_test_script to artifact.artifact_type's
    CHECK enum on an ALREADY-POPULATED database -- SQLite has no
    `ALTER TABLE ... MODIFY CONSTRAINT`, so this is the standard "create a
    new table with the wanted constraint, copy every row, drop the old
    table, rename" dance. No other table has a foreign key into `artifact`
    (checked), so this doesn't need to touch anything beyond the table
    itself and its one index.

    Idempotent and safe to call unconditionally (including against a
    freshly create_schema()'d database, which already has the expanded
    constraint) -- checks the live constraint text first and no-ops if it's
    already expanded, so callers never need to special-case "is this a
    fresh or an upgraded database".
    """
    row = conn.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='artifact'").fetchone()
    if row is None or "'test_dataset'" in row[0]:
        return
    conn.executescript(
        """
        CREATE TABLE artifact_new (
            artifact_id TEXT PRIMARY KEY,
            artifact_type TEXT NOT NULL CHECK (artifact_type IN (
                'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report',
                'test_dataset', 'test_script',
                'validator_benchmark', 'validator_test_dataset', 'validator_test_script'
            )),
            task_id TEXT NOT NULL REFERENCES task(task_id),
            produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
            file_path TEXT NOT NULL,
            git_commit_sha TEXT NOT NULL,
            depends_on_artifact_ids TEXT,
            validation_status TEXT NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending', 'passed', 'failed')),
            created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
        );
        INSERT INTO artifact_new SELECT * FROM artifact;
        DROP TABLE artifact;
        ALTER TABLE artifact_new RENAME TO artifact;
        CREATE UNIQUE INDEX ux_artifact_task_type_commit ON artifact(task_id, artifact_type, git_commit_sha);
        """
    )
    conn.commit()