Commit 1a31a2ad8171f0114def7b37da038f2a3de0273b

Authored by bboonnee
1 parent fc94ae8b

U-001: 01-M physical SQLite schema (15 platform entities)

Implements docs/sdd/01-M-模型设计.md §1.0-1.14 as stdlib sqlite3 DDL in src/
(deployment/deploy_health_signal §1.15-1.16 are PH5/U-034, deliberately
excluded). Covers all entities' fields, enum CHECK constraints, FKs, and
the two non-trivial unique indexes (work_graph_run's per-(task_id,org_node)
active-run partial index, artifact's (task_id,artifact_type,git_commit_sha)
composite key).

Per user decision (2026-07-30): bug_record.root_cause_category excludes
'core_engine' for now -- that value is PH5-scoped and development-plan.md
assigns adding it to U-034, not U-001; a test locks this in.

14 stdlib unittest cases in src/test_schema.py cover table creation and the
Done criterion's named constraint classes (unique/FK/enum), all passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
src/__init__.py 0 → 100644
src/schema.py 0 → 100644
  1 +"""SQLite schema for graphlemon's own structured storage.
  2 +
  3 +U-001 scope: the 15 platform entities from docs/sdd/01-M-模型设计.md §1.0-1.14
  4 +(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16) are PH5-only and are
  5 +deliberately not created here -- see CLAUDE.md "PH5 is not in scope yet" and
  6 +development-plan.md U-034.
  7 +
  8 +Interpretive conventions used below, where 01-M leaves the physical type implicit:
  9 +- Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type.
  10 +- A `*_at` column that records "when this row was created" (or, for skill_iteration,
  11 + when the row's one-shot event happened) is NOT NULL DEFAULT CURRENT_TIMESTAMP.
  12 + A `*_at` column that records a later lifecycle event which may not have happened
  13 + yet (started_at, completed_at, purged_at, ...) is nullable with no default -- the
  14 + application sets it only when that event actually occurs.
  15 +- JSON-typed columns (graph_manifest, nodes, edges, payload, ...) are stored as TEXT;
  16 + (de)serialization is an application-layer concern, not enforced by this schema.
  17 +- task.assigned_org_node is documented as "not null, fk" but there is no standalone
  18 + org-node lookup table to reference (planner/coder/validator only exist as entries
  19 + inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum
  20 + instead, matching work_graph_run.org_node / skill.owner_org_node / agent_profile.org_node.
  21 +- bug_record.root_cause_category intentionally excludes 'core_engine' for now: that
  22 + value is PH5-scoped (05 §5.8) and development-plan.md assigns adding it to U-034,
  23 + not U-001 (confirmed with user, 2026-07-30).
  24 +- bug_record and skill have no created_at in 01-M (skill is Git-versioned, so git log
  25 + covers "when"; bug_record has no such backing and this looks like a possible spec
  26 + gap, but adding an undocumented column is out of scope for U-001 -- flagged, not fixed).
  27 +"""
  28 +
  29 +import sqlite3
  30 +
  31 +TABLE_NAMES = (
  32 + "project",
  33 + "org_graph_definition",
  34 + "skill",
  35 + "task",
  36 + "work_graph_run",
  37 + "work_node_execution",
  38 + "artifact",
  39 + "bug_record",
  40 + "skill_iteration",
  41 + "agent_profile",
  42 + "budget_guardrail_config",
  43 + "org_concurrency_config",
  44 + "execution_event",
  45 + "execution_log_file",
  46 + "log_retention_config",
  47 +)
  48 +
  49 +SCHEMA_SQL = """
  50 +-- 1.0 project
  51 +CREATE TABLE project (
  52 + project_id TEXT PRIMARY KEY,
  53 + name TEXT NOT NULL,
  54 + target_folder_path TEXT NOT NULL UNIQUE,
  55 + has_git_repo INTEGER NOT NULL CHECK (has_git_repo IN (0, 1)),
  56 + graphlemon_subdir_initialized INTEGER NOT NULL DEFAULT 0
  57 + CHECK (graphlemon_subdir_initialized IN (0, 1)),
  58 + status TEXT NOT NULL CHECK (status IN ('active', 'archived')),
  59 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  60 +);
  61 +
  62 +-- 1.1 org_graph_definition
  63 +CREATE TABLE org_graph_definition (
  64 + graph_id TEXT PRIMARY KEY,
  65 + version TEXT NOT NULL,
  66 + nodes TEXT NOT NULL,
  67 + edges TEXT NOT NULL,
  68 + file_path TEXT NOT NULL,
  69 + status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'deprecated'))
  70 +);
  71 +
  72 +-- 1.7 skill (created before task/work_node_execution: work_node_execution.skill_ref
  73 +-- references it)
  74 +CREATE TABLE skill (
  75 + skill_id TEXT PRIMARY KEY,
  76 + owner_org_node TEXT NOT NULL CHECK (owner_org_node IN ('planner', 'coder', 'validator')),
  77 + file_path TEXT NOT NULL,
  78 + current_git_commit_sha TEXT NOT NULL,
  79 + source TEXT NOT NULL CHECK (source IN ('migrated_from_hermes', 'new', 'auto_iterated')),
  80 + migrated_from_profile TEXT CHECK (migrated_from_profile IN ('code', 'planning', 'qa')),
  81 + status TEXT NOT NULL CHECK (status IN ('active', 'archived'))
  82 +);
  83 +
  84 +-- 1.2 task
  85 +CREATE TABLE task (
  86 + task_id TEXT PRIMARY KEY,
  87 + project_id TEXT NOT NULL REFERENCES project(project_id),
  88 + goal_text TEXT NOT NULL,
  89 + task_type TEXT NOT NULL CHECK (task_type IN ('goal', 'bug_fix')),
  90 + origin_ref TEXT,
  91 + created_via TEXT NOT NULL DEFAULT 'api' CHECK (created_via IN ('api', 'human_console')),
  92 + status TEXT NOT NULL CHECK (status IN (
  93 + 'queued', 'planning_brd_prd_sdd', 'planning_failed',
  94 + 'coding', 'coding_failed', 'validating',
  95 + 'routed_fast_lane', 'routed_full_replan',
  96 + 'blocked_pending_human', 'done'
  97 + )),
  98 + route TEXT CHECK (route IN ('fast_lane', 'full_replan')),
  99 + loop_round INTEGER NOT NULL DEFAULT 0,
  100 + blocked_reason TEXT,
  101 + assigned_org_node TEXT NOT NULL CHECK (assigned_org_node IN ('planner', 'coder', 'validator')),
  102 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  103 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  104 +);
  105 +
  106 +CREATE INDEX idx_task_status ON task(status);
  107 +CREATE INDEX idx_task_assigned_org_node ON task(assigned_org_node);
  108 +CREATE INDEX idx_task_project_id ON task(project_id);
  109 +
  110 +-- 1.3 work_graph_run
  111 +CREATE TABLE work_graph_run (
  112 + run_id TEXT PRIMARY KEY,
  113 + task_id TEXT NOT NULL REFERENCES task(task_id),
  114 + org_node TEXT NOT NULL CHECK (org_node IN ('planner', 'coder', 'validator')),
  115 + graph_manifest TEXT NOT NULL,
  116 + orchestration_script_ref TEXT,
  117 + status TEXT NOT NULL CHECK (status IN (
  118 + 'planning', 'running', 'merging', 'verifying',
  119 + 'reflecting', 'blocked', 'done', 'failed'
  120 + )),
  121 + started_at TEXT,
  122 + completed_at TEXT,
  123 + result_artifact_refs TEXT
  124 +);
  125 +
  126 +-- only one active (non-terminal) run per (task_id, org_node) at a time
  127 +CREATE UNIQUE INDEX ux_work_graph_run_active ON work_graph_run(task_id, org_node)
  128 + WHERE status IN ('planning', 'running', 'merging', 'verifying');
  129 +
  130 +-- 1.4 work_node_execution
  131 +CREATE TABLE work_node_execution (
  132 + node_exec_id TEXT PRIMARY KEY,
  133 + run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
  134 + node_id TEXT NOT NULL,
  135 + subagent_type TEXT NOT NULL,
  136 + skill_ref TEXT NOT NULL REFERENCES skill(skill_id),
  137 + input_schema_ref TEXT NOT NULL,
  138 + output_schema_ref TEXT NOT NULL,
  139 + input_payload TEXT NOT NULL,
  140 + output_payload TEXT,
  141 + model_tier TEXT NOT NULL DEFAULT 'standard' CHECK (model_tier IN ('cheap', 'standard', 'premium')),
  142 + status TEXT NOT NULL CHECK (status IN (
  143 + 'pending', 'running', 'reflecting', 'retrying',
  144 + 'waiting_for_quota', 'blocked', 'done', 'failed'
  145 + )),
  146 + retry_count INTEGER NOT NULL DEFAULT 0,
  147 + reflection_notes TEXT,
  148 + git_worktree_ref TEXT,
  149 + quota_wait_started_at TEXT,
  150 + quota_limit_type TEXT CHECK (quota_limit_type IN ('session', 'weekly', 'opus'))
  151 +);
  152 +
  153 +-- 1.5 artifact
  154 +CREATE TABLE artifact (
  155 + artifact_id TEXT PRIMARY KEY,
  156 + artifact_type TEXT NOT NULL CHECK (artifact_type IN (
  157 + 'brd', 'prd', 'sdd', 'benchmark', 'bug_record', 'code_diff', 'test_report'
  158 + )),
  159 + task_id TEXT NOT NULL REFERENCES task(task_id),
  160 + produced_by_run_id TEXT NOT NULL REFERENCES work_graph_run(run_id),
  161 + file_path TEXT NOT NULL,
  162 + git_commit_sha TEXT NOT NULL,
  163 + depends_on_artifact_ids TEXT,
  164 + validation_status TEXT NOT NULL DEFAULT 'pending' CHECK (validation_status IN ('pending', 'passed', 'failed')),
  165 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  166 +);
  167 +
  168 +CREATE UNIQUE INDEX ux_artifact_task_type_commit ON artifact(task_id, artifact_type, git_commit_sha);
  169 +
  170 +-- 1.6 bug_record (skill_iteration_suggestion_id forward-references skill_iteration,
  171 +-- created further below -- SQLite does not validate FK target existence at CREATE
  172 +-- TABLE time, only at DML time, so table creation order here is fine)
  173 +CREATE TABLE bug_record (
  174 + bug_id TEXT PRIMARY KEY,
  175 + task_id TEXT REFERENCES task(task_id),
  176 + source TEXT NOT NULL CHECK (source IN ('validator', 'human')),
  177 + reported_by TEXT,
  178 + description TEXT NOT NULL,
  179 + repro_steps TEXT,
  180 + root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect')),
  181 + root_cause_detail TEXT,
  182 + related_artifact_ids TEXT,
  183 + suggested_route TEXT CHECK (suggested_route IN ('fast_lane', 'full_replan')),
  184 + skill_iteration_suggestion_id TEXT REFERENCES skill_iteration(iteration_id),
  185 + status TEXT NOT NULL CHECK (status IN ('open', 'triaging', 'routed', 'resolved'))
  186 +);
  187 +
  188 +-- 1.8 skill_iteration
  189 +CREATE TABLE skill_iteration (
  190 + iteration_id TEXT PRIMARY KEY,
  191 + skill_id TEXT NOT NULL REFERENCES skill(skill_id),
  192 + triggered_by_bug_id TEXT NOT NULL REFERENCES bug_record(bug_id),
  193 + before_git_commit_sha TEXT NOT NULL,
  194 + after_git_commit_sha TEXT NOT NULL,
  195 + change_summary TEXT NOT NULL,
  196 + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  197 + rollback_of_iteration_id TEXT REFERENCES skill_iteration(iteration_id)
  198 +);
  199 +
  200 +-- 1.9 agent_profile
  201 +CREATE TABLE agent_profile (
  202 + org_node TEXT PRIMARY KEY CHECK (org_node IN ('planner', 'coder', 'validator')),
  203 + soul_file_path TEXT NOT NULL,
  204 + agents_file_path TEXT,
  205 + migrated_from_profile TEXT NOT NULL CHECK (migrated_from_profile IN ('planning', 'code', 'qa')),
  206 + current_git_commit_sha TEXT NOT NULL
  207 +);
  208 +
  209 +-- 1.10 budget_guardrail_config
  210 +CREATE TABLE budget_guardrail_config (
  211 + config_id TEXT PRIMARY KEY DEFAULT 'default',
  212 + scope TEXT NOT NULL CHECK (scope IN ('global', 'per_task', 'per_day')),
  213 + limit_type TEXT NOT NULL CHECK (limit_type IN ('token_count', 'call_count')),
  214 + limit_value INTEGER NOT NULL,
  215 + current_usage INTEGER NOT NULL DEFAULT 0,
  216 + on_exceed_action TEXT NOT NULL DEFAULT 'pause_and_notify',
  217 + updated_by TEXT NOT NULL,
  218 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  219 +);
  220 +
  221 +-- 1.11 org_concurrency_config
  222 +CREATE TABLE org_concurrency_config (
  223 + config_id TEXT PRIMARY KEY DEFAULT 'default',
  224 + max_concurrent_goals INTEGER NOT NULL DEFAULT 1,
  225 + updated_by TEXT NOT NULL,
  226 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  227 +);
  228 +
  229 +-- 1.12 execution_event
  230 +CREATE TABLE execution_event (
  231 + event_id TEXT PRIMARY KEY,
  232 + project_id TEXT NOT NULL REFERENCES project(project_id),
  233 + task_id TEXT REFERENCES task(task_id),
  234 + run_id TEXT REFERENCES work_graph_run(run_id),
  235 + node_exec_id TEXT REFERENCES work_node_execution(node_exec_id),
  236 + event_type TEXT NOT NULL CHECK (event_type IN (
  237 + 'task_status_changed', 'node_status_changed', 'artifact_created',
  238 + 'bug_recorded', 'loop_routed', 'budget_updated', 'skill_iterated',
  239 + 'log_appended', 'quota_wait_started'
  240 + )),
  241 + payload TEXT NOT NULL,
  242 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  243 +);
  244 +
  245 +CREATE INDEX idx_execution_event_project_created ON execution_event(project_id, created_at);
  246 +CREATE INDEX idx_execution_event_task_created ON execution_event(task_id, created_at);
  247 +
  248 +-- 1.13 execution_log_file
  249 +CREATE TABLE execution_log_file (
  250 + log_file_id TEXT PRIMARY KEY,
  251 + node_exec_id TEXT NOT NULL UNIQUE REFERENCES work_node_execution(node_exec_id),
  252 + project_id TEXT NOT NULL REFERENCES project(project_id),
  253 + file_path TEXT NOT NULL,
  254 + format TEXT NOT NULL CHECK (format IN ('jsonl', 'raw_text')),
  255 + size_bytes INTEGER NOT NULL DEFAULT 0,
  256 + status TEXT NOT NULL CHECK (status IN ('active', 'closed', 'purged')),
  257 + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  258 + purged_at TEXT
  259 +);
  260 +
  261 +CREATE INDEX idx_execution_log_file_project ON execution_log_file(project_id);
  262 +
  263 +-- 1.14 log_retention_config
  264 +CREATE TABLE log_retention_config (
  265 + config_id TEXT PRIMARY KEY DEFAULT 'default',
  266 + retention_days INTEGER NOT NULL DEFAULT 14,
  267 + max_total_size_mb INTEGER,
  268 + updated_by TEXT NOT NULL,
  269 + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
  270 +);
  271 +"""
  272 +
  273 +
  274 +def connect(db_path=":memory:"):
  275 + """Open a sqlite3 connection with foreign-key enforcement turned on.
  276 +
  277 + SQLite disables FK enforcement by default per-connection; every caller needs
  278 + this pragma, so it lives here rather than being repeated at every call site.
  279 + """
  280 + conn = sqlite3.connect(db_path)
  281 + conn.execute("PRAGMA foreign_keys = ON")
  282 + return conn
  283 +
  284 +
  285 +def create_schema(conn):
  286 + """Create all 15 U-001 tables (+ indexes) on an open connection."""
  287 + conn.executescript(SCHEMA_SQL)
  288 + conn.commit()
... ...
src/test_schema.py 0 → 100644
  1 +"""U-001 tests: all 15 tables build, and the constraints named in the Unit's
  2 +Done criterion (unique / foreign key / enum) are actually enforced.
  3 +
  4 +Run: python3 -m unittest src.test_schema -v
  5 +"""
  6 +
  7 +import sqlite3
  8 +import unittest
  9 +
  10 +from src import schema
  11 +
  12 +
  13 +class SchemaCreationTest(unittest.TestCase):
  14 + def setUp(self):
  15 + self.conn = schema.connect(":memory:")
  16 + schema.create_schema(self.conn)
  17 +
  18 + def tearDown(self):
  19 + self.conn.close()
  20 +
  21 + def test_all_15_tables_created(self):
  22 + rows = self.conn.execute(
  23 + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
  24 + ).fetchall()
  25 + actual = {row[0] for row in rows}
  26 + self.assertEqual(actual, set(schema.TABLE_NAMES))
  27 + self.assertEqual(len(schema.TABLE_NAMES), 15)
  28 +
  29 + def test_schema_is_idempotent_to_reopen_on_disk(self):
  30 + # not required by the Done criterion, but a schema that only works once
  31 + # against :memory: would be a trap for every later Unit that opens a
  32 + # real db file -- cheap to check now.
  33 + import os
  34 + import tempfile
  35 +
  36 + fd, path = tempfile.mkstemp(suffix=".sqlite3")
  37 + os.close(fd)
  38 + os.remove(path)
  39 + try:
  40 + conn = schema.connect(path)
  41 + schema.create_schema(conn)
  42 + conn.close()
  43 + conn2 = schema.connect(path)
  44 + rows = conn2.execute(
  45 + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
  46 + ).fetchall()
  47 + conn2.close()
  48 + self.assertEqual({r[0] for r in rows}, set(schema.TABLE_NAMES))
  49 + finally:
  50 + if os.path.exists(path):
  51 + os.remove(path)
  52 +
  53 +
  54 +class ConstraintTestBase(unittest.TestCase):
  55 + def setUp(self):
  56 + self.conn = schema.connect(":memory:")
  57 + schema.create_schema(self.conn)
  58 +
  59 + def tearDown(self):
  60 + self.conn.close()
  61 +
  62 + def insert_project(self, project_id="proj-1", target_folder_path="/tmp/proj-1"):
  63 + self.conn.execute(
  64 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  65 + "VALUES (?, ?, ?, 1, 'active')",
  66 + (project_id, "Project One", target_folder_path),
  67 + )
  68 + self.conn.commit()
  69 + return project_id
  70 +
  71 + def insert_task(self, task_id="task-1", project_id="proj-1", status="queued"):
  72 + self.conn.execute(
  73 + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) "
  74 + "VALUES (?, ?, 'implement snake game', 'goal', ?, 'planner')",
  75 + (task_id, project_id, status),
  76 + )
  77 + self.conn.commit()
  78 + return task_id
  79 +
  80 + def insert_skill(self, skill_id="skill-1"):
  81 + self.conn.execute(
  82 + "INSERT INTO skill (skill_id, owner_org_node, file_path, current_git_commit_sha, source, status) "
  83 + "VALUES (?, 'coder', 'skills/skill-1/', 'abc123', 'new', 'active')",
  84 + (skill_id,),
  85 + )
  86 + self.conn.commit()
  87 + return skill_id
  88 +
  89 + def insert_work_graph_run(self, run_id="run-1", task_id="task-1", org_node="coder", status="planning"):
  90 + self.conn.execute(
  91 + "INSERT INTO work_graph_run (run_id, task_id, org_node, graph_manifest, status) "
  92 + "VALUES (?, ?, ?, '{}', ?)",
  93 + (run_id, task_id, org_node, status),
  94 + )
  95 + self.conn.commit()
  96 + return run_id
  97 +
  98 +
  99 +class ProjectUniqueConstraintTest(ConstraintTestBase):
  100 + def test_duplicate_target_folder_path_rejected(self):
  101 + self.insert_project("proj-1", "/tmp/shared-path")
  102 + with self.assertRaises(sqlite3.IntegrityError):
  103 + self.conn.execute(
  104 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  105 + "VALUES ('proj-2', 'Project Two', '/tmp/shared-path', 1, 'active')"
  106 + )
  107 +
  108 + def test_has_git_repo_boolean_check_rejects_out_of_range_value(self):
  109 + with self.assertRaises(sqlite3.IntegrityError):
  110 + self.conn.execute(
  111 + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) "
  112 + "VALUES ('proj-1', 'P', '/tmp/p', 2, 'active')"
  113 + )
  114 +
  115 +
  116 +class WorkNodeExecutionEnumConstraintTest(ConstraintTestBase):
  117 + def setUp(self):
  118 + super().setUp()
  119 + self.insert_project()
  120 + self.insert_task()
  121 + self.insert_skill()
  122 + self.insert_work_graph_run()
  123 +
  124 + def _insert_node_exec(self, status):
  125 + self.conn.execute(
  126 + "INSERT INTO work_node_execution "
  127 + "(node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  128 + " input_schema_ref, output_schema_ref, input_payload, status) "
  129 + "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', ?)",
  130 + (status,),
  131 + )
  132 +
  133 + def test_valid_status_accepted(self):
  134 + self._insert_node_exec("waiting_for_quota")
  135 + self.conn.commit()
  136 + row = self.conn.execute(
  137 + "SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'"
  138 + ).fetchone()
  139 + self.assertEqual(row[0], "waiting_for_quota")
  140 +
  141 + def test_invalid_status_rejected(self):
  142 + with self.assertRaises(sqlite3.IntegrityError):
  143 + self._insert_node_exec("not_a_real_status")
  144 +
  145 +
  146 +class ForeignKeyEnforcementTest(ConstraintTestBase):
  147 + def test_task_rejects_unknown_project_id(self):
  148 + with self.assertRaises(sqlite3.IntegrityError):
  149 + self.insert_task(task_id="task-1", project_id="does-not-exist")
  150 +
  151 + def test_work_node_execution_rejects_unknown_skill_ref(self):
  152 + self.insert_project()
  153 + self.insert_task()
  154 + self.insert_work_graph_run()
  155 + with self.assertRaises(sqlite3.IntegrityError):
  156 + self.conn.execute(
  157 + "INSERT INTO work_node_execution "
  158 + "(node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  159 + " input_schema_ref, output_schema_ref, input_payload, status) "
  160 + "VALUES ('node-1', 'run-1', 'n1', 'coder', 'no-such-skill', 'in.json', 'out.json', '{}', 'pending')"
  161 + )
  162 +
  163 +
  164 +class WorkGraphRunActiveUniqueIndexTest(ConstraintTestBase):
  165 + """01-M 1.3: only one active (planning/running/merging/verifying) run per
  166 + (task_id, org_node) at a time -- terminal-status runs don't count."""
  167 +
  168 + def setUp(self):
  169 + super().setUp()
  170 + self.insert_project()
  171 + self.insert_task()
  172 +
  173 + def test_second_active_run_for_same_task_and_org_node_rejected(self):
  174 + self.insert_work_graph_run("run-1", "task-1", "coder", status="running")
  175 + with self.assertRaises(sqlite3.IntegrityError):
  176 + self.insert_work_graph_run("run-2", "task-1", "coder", status="planning")
  177 +
  178 + def test_second_run_allowed_once_first_is_terminal(self):
  179 + self.insert_work_graph_run("run-1", "task-1", "coder", status="done")
  180 + self.insert_work_graph_run("run-2", "task-1", "coder", status="planning")
  181 + count = self.conn.execute(
  182 + "SELECT COUNT(*) FROM work_graph_run WHERE task_id = 'task-1' AND org_node = 'coder'"
  183 + ).fetchone()[0]
  184 + self.assertEqual(count, 2)
  185 +
  186 +
  187 +class ArtifactUniqueConstraintTest(ConstraintTestBase):
  188 + def setUp(self):
  189 + super().setUp()
  190 + self.insert_project()
  191 + self.insert_task()
  192 + self.insert_work_graph_run()
  193 +
  194 + def _insert_artifact(self, artifact_id):
  195 + self.conn.execute(
  196 + "INSERT INTO artifact (artifact_id, artifact_type, task_id, produced_by_run_id, "
  197 + "file_path, git_commit_sha) VALUES (?, 'sdd', 'task-1', 'run-1', 'sdd.md', 'sha-1')",
  198 + (artifact_id,),
  199 + )
  200 +
  201 + def test_duplicate_task_type_commit_rejected(self):
  202 + self._insert_artifact("artifact-1")
  203 + self.conn.commit()
  204 + with self.assertRaises(sqlite3.IntegrityError):
  205 + self._insert_artifact("artifact-2")
  206 +
  207 +
  208 +class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase):
  209 + def setUp(self):
  210 + super().setUp()
  211 + self.insert_project()
  212 + self.insert_task()
  213 + self.insert_skill()
  214 + self.insert_work_graph_run()
  215 + self.conn.execute(
  216 + "INSERT INTO work_node_execution "
  217 + "(node_exec_id, run_id, node_id, subagent_type, skill_ref, "
  218 + " input_schema_ref, output_schema_ref, input_payload, status) "
  219 + "VALUES ('node-1', 'run-1', 'n1', 'coder', 'skill-1', 'in.json', 'out.json', '{}', 'running')"
  220 + )
  221 + self.conn.commit()
  222 +
  223 + def test_second_log_file_for_same_node_exec_rejected(self):
  224 + self.conn.execute(
  225 + "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, status) "
  226 + "VALUES ('log-1', 'node-1', 'proj-1', '/logs/node-1.jsonl', 'jsonl', 'active')"
  227 + )
  228 + self.conn.commit()
  229 + with self.assertRaises(sqlite3.IntegrityError):
  230 + self.conn.execute(
  231 + "INSERT INTO execution_log_file (log_file_id, node_exec_id, project_id, file_path, format, status) "
  232 + "VALUES ('log-2', 'node-1', 'proj-1', '/logs/node-1-again.jsonl', 'jsonl', 'active')"
  233 + )
  234 +
  235 +
  236 +class BugRecordRootCauseCategoryTest(ConstraintTestBase):
  237 + """core_engine is PH5-scoped (U-034); U-001 deliberately does not accept it yet
  238 + (confirmed with user, 2026-07-30) -- this test locks that decision in so a
  239 + future edit can't silently widen the enum back out ahead of U-034."""
  240 +
  241 + def _insert_bug(self, root_cause_category):
  242 + self.conn.execute(
  243 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, status) "
  244 + "VALUES ('bug-1', 'validator', 'wall collision misjudged', ?, 'open')",
  245 + (root_cause_category,),
  246 + )
  247 +
  248 + def test_pre_ph5_categories_accepted(self):
  249 + for category in ("planning", "coder", "skill_defect"):
  250 + with self.subTest(category=category):
  251 + conn = schema.connect(":memory:")
  252 + schema.create_schema(conn)
  253 + conn.execute(
  254 + "INSERT INTO bug_record (bug_id, source, description, root_cause_category, status) "
  255 + "VALUES ('bug-1', 'validator', 'x', ?, 'open')",
  256 + (category,),
  257 + )
  258 + conn.commit()
  259 + conn.close()
  260 +
  261 + def test_core_engine_rejected_before_u034(self):
  262 + with self.assertRaises(sqlite3.IntegrityError):
  263 + self._insert_bug("core_engine")
  264 +
  265 +
  266 +if __name__ == "__main__":
  267 + unittest.main()
... ...