Commit b6b495c8e51712ad0381408fd722aadb064ff7ef
1 parent
3dfcdd6a
U-034: deployment/deploy_health_signal tables + core_engine enum (PH5 start)
Adds the two Deploy Supervisor entities from 01-M §1.15/§1.16 (17 tables total): deployment (full 15-value blue-green state machine, per 01-M's own text rather than supervisor-auto-rollback-design.md §3's abbreviated summary table) and deploy_health_signal (append-only health/monitoring samples). ux_deployment_active enforces 01-M's "only one non-terminal deployment at a time" constraint via an expression-based unique index (CREATE UNIQUE INDEX ... ON deployment((1)) WHERE status NOT IN (...)) -- the same partial-unique-index idiom work_graph_run already uses, just with a constant instead of a real grouping column since there's exactly one graphlemon self-target. bug_record.root_cause_category's schema-level CHECK now accepts core_engine, but bug_record.py's own application-layer whitelist deliberately does not follow yet -- classifying a bug as core_engine is a file-path-based rule (05 §5.8), not something the Validator's existing 3-perspective judge_root_cause can produce, and wiring that classification + c_org_loop_route's routing is U-035's job. 392 tests green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
5 changed files
with
194 additions
and
22 deletions
docs/development-plan.md
| ... | ... | @@ -336,6 +336,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 |
| 336 | 336 | - **U-034 [Enabler]** 01-M 扩展:新增 `deployment`、`deploy_health_signal` 两个实体建表;`bug_record.root_cause_category` 新增 `core_engine` 枚举值 |
| 337 | 337 | - 依赖:U-001 |
| 338 | 338 | - Done:两张新表建表成功并通过约束校验;`bug_record` 现有记录/查询逻辑对新枚举值向后兼容(不破坏 U-015/U-018 已有测试) |
| 339 | + - **实现补充(2026-08-02)**:按 01-M §1.15/1.16(比 `supervisor-auto-rollback-design.md` §3 的概要表更完整,以 01-M 为准)在 `src/schema.py` 追加两张表;`deployment.status` 用 01-M 原文的完整 15 值状态机枚举(不是 §3 表格里省略过的简化版)。`ux_deployment_active` 用表达式索引 `CREATE UNIQUE INDEX ... ON deployment((1)) WHERE status NOT IN (...)` 实现"同一时刻只允许一条非终态 deployment"的全局单例约束——和 `work_graph_run` 现有的按 `(task_id, org_node)` 分组的 partial unique index 是同一个思路,只是这里没有天然的分组键(graphlemon 自己就一份,不是"每个 X 一条"),用常量表达式当分组键达到"全局只有一条"的效果,本地 sqlite3 验证过真实生效。`bug_record.root_cause_category` CHECK 约束加了 `core_engine`,但 `bug_record.py` 自己的应用层 `_VALID_ROOT_CAUSE_CATEGORIES` 白名单**刻意没有跟着放开**——"什么时候一个 bug 该归为 core_engine"是 05 §5.8 定义的按文件路径判断的规则(`.claude/agents/*.md`/Graph Engine 核心逻辑/01-M 自身 schema 变更),不是 Validator 现有三视角 `judge_root_cause` 能判断的东西(那三个视角硬编码只产出 planning/coder/skill_defect),这条分类逻辑连同 `c_org_loop_route` 的路由扩展一起留给 U-035,不是这个 Unit 该做的。`previous_commit_hash`/`previous_instance_dir` 在 01-M 里标了 not null,但第一次真正跑 Deploy Supervisor 时没有"上一条 finalized deployment"可取——这个引导期边界情况留给 U-037 的 `c_deploy_supervisor_stage` 处理(第一次调用时把当前正在跑的 commit/checkout 目录当成"previous"传进去),不是 schema 层该解决的,已在 `schema.py` docstring 里记录。 | |
| 339 | 340 | - 覆盖:`supervisor-auto-rollback-design.md` §3;`self-development-feasibility.md` 缺口 3 |
| 340 | 341 | |
| 341 | 342 | - **U-035 [Risk Gate]** self-target 场景 worktree 无条件强制规则(缺口 1)+ `root_cause_category=core_engine` 命中时路由到蓝绿部署流程而非 `fast_lane`(`c_org_loop_route` 扩展) | ... | ... |
docs/sdd/unit_matrix.json
| ... | ... | @@ -51,7 +51,7 @@ |
| 51 | 51 | {"id": "U-032", "phase": "PH4", "sub_phase": "PH4-S2", "name": "多 Goal 并发 + 长时间运行稳定性压测(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-031"], "coverage": {"model": ["org_concurrency_config"], "view": [], "controller": [], "process": [], "requirement": ["REQ-09"]}, "done": "并发数调高后多个独立 Goal 可同时稳定运行;发现并补上一个此前从未存在的结构性缺口——04-P/01-M/REQ-09 boundary 反复假设存在的自动调度器实际从未被建过,新增 src/scheduler.py(ThreadPoolExecutor + 每线程独立 sqlite 连接,避免复刻 U-027 的共享连接锁陷阱)真正实现,并顺带补上 e2e_pipeline.run_end_to_end() 自己文档过的缺口(PASS/FAIL 后从不调用 close_task_done/run_fix_loop,卡在 validating 不动)"}, |
| 52 | 52 | {"id": "U-033", "phase": "PH4", "sub_phase": "PH4-S3", "name": "A-2 假设收口(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-004"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": [], "assumption": ["A-2"]}, "done": "A-2 状态确认为 confirmed 或记录实际偏差;最终结论:维持 U-004 记录的\"部分成立\",PH1~PH4 积累的真实运行数据未暴露新偏差,偏差范围到 U-004 为止已完全摸清(Coder SOUL.md 三处实质性重写),不改判 confirmed,正式收口"}, |
| 53 | 53 | |
| 54 | - {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功;bug_record 新枚举值向后兼容 U-015/U-018 已有测试"}, | |
| 54 | + {"id": "U-034", "phase": "PH5", "sub_phase": "PH5-S1", "name": "01-M 扩展:deployment/deploy_health_signal + core_engine 枚举(见 development-plan.md 2026-08-02 实现补充)", "classification": "Enabler", "depends_on": ["U-001"], "coverage": {"model": ["deployment", "deploy_health_signal", "bug_record"], "view": [], "controller": [], "process": [], "requirement": []}, "done": "两张新表建表成功(共 17 张);bug_record 新枚举值向后兼容 U-015/U-018 已有测试;deployment 状态机用 01-M 1.15 完整 15 值枚举;ux_deployment_active 表达式索引强制全局单例非终态约束;bug_record.py 应用层白名单刻意未跟着放开 core_engine,classification+路由逻辑留给 U-035"}, | |
| 55 | 55 | {"id": "U-035", "phase": "PH5", "sub_phase": "PH5-S1", "name": "self-target worktree 无条件强制 + core_engine 路由到蓝绿部署", "classification": "Risk Gate", "depends_on": ["U-013", "U-016", "U-034"], "coverage": {"model": [], "view": [], "controller": ["c_org_loop_route"], "process": [], "requirement": ["REQ-03"]}, "done": "self-target 时无条件走 worktree;core_engine 分类的 bug 被路由到 PH5-S2/S3 而非 fast_lane;且改动落地后必须重跑 U-013/REQ-03(worktree 隔离)现有回归用例全部通过(2026-07-30 计划评审追加,确认未破坏 PH4 已建立的隔离保证)"}, |
| 56 | 56 | {"id": "U-036", "phase": "PH5", "sub_phase": "PH5-S2", "name": "supervisor/isolation 补齐单测 + Deploy Supervisor 进程骨架", "classification": "Enabler", "depends_on": [], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "四模块各有 test_*.py;Deploy Supervisor 骨架独立进程可启停,不受 Live/Candidate 崩溃影响"}, |
| 57 | 57 | {"id": "U-037", "phase": "PH5", "sub_phase": "PH5-S2", "name": "c_deploy_supervisor_stage", "classification": "Risk Gate", "depends_on": ["U-034", "U-036"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_stage"], "process": [], "requirement": []}, "done": "能生成 deployment 记录并拉起监听临时端口、连一次性 DB 副本的候选进程"}, | ... | ... |
src/schema.py
| 1 | 1 | """SQLite schema for graphlemon's own structured storage. |
| 2 | 2 | |
| 3 | 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. | |
| 4 | +(PH0-PH4). `deployment`/`deploy_health_signal` (§1.15/§1.16, added here in U-034, | |
| 5 | +2026-08-02) are PH5-only -- 17 tables total as of PH5. | |
| 7 | 6 | |
| 8 | 7 | Interpretive conventions used below, where 01-M leaves the physical type implicit: |
| 9 | 8 | - Booleans are stored as INTEGER CHECK(x IN (0, 1)) -- sqlite3 has no native bool type. |
| ... | ... | @@ -18,12 +17,18 @@ Interpretive conventions used below, where 01-M leaves the physical type implici |
| 18 | 17 | org-node lookup table to reference (planner/coder/validator only exist as entries |
| 19 | 18 | inside org_graph_definition.nodes, a JSON column) -- implemented as a CHECK enum |
| 20 | 19 | 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). | |
| 20 | +- bug_record.root_cause_category gained 'core_engine' in U-034 (2026-08-02, PH5) -- | |
| 21 | + 05 §5.8: self-target scenarios (`.claude/agents/*.md` identity file changes, Graph | |
| 22 | + Engine scheduler/executor core logic changes, this schema's own changes), routed by | |
| 23 | + `c_org_loop_route` to `p_deploy_supervisor_blue_green` instead of fast_lane/full_replan. | |
| 24 | 24 | - bug_record and skill have no created_at in 01-M (skill is Git-versioned, so git log |
| 25 | 25 | covers "when"; bug_record has no such backing and this looks like a possible spec |
| 26 | 26 | gap, but adding an undocumented column is out of scope for U-001 -- flagged, not fixed). |
| 27 | +- deployment.previous_commit_hash/previous_instance_dir are "not null" per 01-M 1.15, | |
| 28 | + but the very first deployment ever run has no prior `finalized` deployment to point | |
| 29 | + at -- that bootstrap case is c_deploy_supervisor_stage's problem (U-037), not this | |
| 30 | + schema's: it must supply the currently-running commit/checkout dir as the "previous" | |
| 31 | + values on that first call, not leave the schema-level NOT NULL as aspirational. | |
| 27 | 32 | """ |
| 28 | 33 | |
| 29 | 34 | import sqlite3 |
| ... | ... | @@ -44,6 +49,8 @@ TABLE_NAMES = ( |
| 44 | 49 | "execution_event", |
| 45 | 50 | "execution_log_file", |
| 46 | 51 | "log_retention_config", |
| 52 | + "deployment", | |
| 53 | + "deploy_health_signal", | |
| 47 | 54 | ) |
| 48 | 55 | |
| 49 | 56 | SCHEMA_SQL = """ |
| ... | ... | @@ -177,7 +184,7 @@ CREATE TABLE bug_record ( |
| 177 | 184 | reported_by TEXT, |
| 178 | 185 | description TEXT NOT NULL, |
| 179 | 186 | repro_steps TEXT, |
| 180 | - root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect')), | |
| 187 | + root_cause_category TEXT CHECK (root_cause_category IN ('planning', 'coder', 'skill_defect', 'core_engine')), | |
| 181 | 188 | root_cause_detail TEXT, |
| 182 | 189 | related_artifact_ids TEXT, |
| 183 | 190 | suggested_route TEXT CHECK (suggested_route IN ('fast_lane', 'full_replan')), |
| ... | ... | @@ -268,6 +275,48 @@ CREATE TABLE log_retention_config ( |
| 268 | 275 | updated_by TEXT NOT NULL, |
| 269 | 276 | updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 270 | 277 | ); |
| 278 | + | |
| 279 | +-- 1.15 deployment (PH5, U-034, 2026-08-02 -- see docs/supervisor-auto-rollback-design.md) | |
| 280 | +CREATE TABLE deployment ( | |
| 281 | + deployment_id TEXT PRIMARY KEY, | |
| 282 | + git_commit_hash TEXT NOT NULL, | |
| 283 | + previous_commit_hash TEXT NOT NULL, | |
| 284 | + status TEXT NOT NULL CHECK (status IN ( | |
| 285 | + 'staged', 'candidate_starting', 'health_check_running', 'health_check_passed', | |
| 286 | + 'health_check_failed', 'cutover_in_progress', 'live', 'monitoring', | |
| 287 | + 'anomaly_detected', 'rollback_in_progress', 'rolled_back', 'rollback_failed', | |
| 288 | + 'blocked_pending_human', 'monitoring_window_clean', 'finalized' | |
| 289 | + )), | |
| 290 | + candidate_port INTEGER, | |
| 291 | + candidate_db_path TEXT, | |
| 292 | + started_at TEXT, | |
| 293 | + cutover_at TEXT, | |
| 294 | + monitoring_closed_at TEXT, | |
| 295 | + rollback_at TEXT, | |
| 296 | + rollback_reason TEXT, | |
| 297 | + previous_instance_dir TEXT NOT NULL | |
| 298 | +); | |
| 299 | + | |
| 300 | +-- only one non-terminal deployment at a time (01-M 1.15's own constraint) -- | |
| 301 | +-- expression index on a constant, same "global singleton while active" idiom | |
| 302 | +-- as a partial unique index scoped by a real column, just with no column to | |
| 303 | +-- scope by here (there's exactly one graphlemon self-target, not one per key) | |
| 304 | +CREATE UNIQUE INDEX ux_deployment_active ON deployment((1)) | |
| 305 | + WHERE status NOT IN ('finalized', 'rolled_back', 'blocked_pending_human'); | |
| 306 | + | |
| 307 | +-- 1.16 deploy_health_signal (PH5, U-034, 2026-08-02) | |
| 308 | +CREATE TABLE deploy_health_signal ( | |
| 309 | + signal_id TEXT PRIMARY KEY, | |
| 310 | + deployment_id TEXT NOT NULL REFERENCES deployment(deployment_id), | |
| 311 | + sampled_at TEXT NOT NULL, | |
| 312 | + signal_type TEXT NOT NULL CHECK (signal_type IN ( | |
| 313 | + 'process_alive', 'http_health', 'canary_task', 'validator_approval_rate' | |
| 314 | + )), | |
| 315 | + status TEXT NOT NULL CHECK (status IN ('ok', 'degraded', 'fail')), | |
| 316 | + detail TEXT | |
| 317 | +); | |
| 318 | + | |
| 319 | +CREATE INDEX idx_deploy_health_signal_deployment_sampled ON deploy_health_signal(deployment_id, sampled_at); | |
| 271 | 320 | """ |
| 272 | 321 | |
| 273 | 322 | |
| ... | ... | @@ -289,6 +338,7 @@ def connect(db_path=":memory:", check_same_thread=True): |
| 289 | 338 | |
| 290 | 339 | |
| 291 | 340 | def create_schema(conn): |
| 292 | - """Create all 15 U-001 tables (+ indexes) on an open connection.""" | |
| 341 | + """Create all 17 tables (15 from U-001 + deployment/deploy_health_signal | |
| 342 | + from U-034, PH5) + indexes on an open connection.""" | |
| 293 | 343 | conn.executescript(SCHEMA_SQL) |
| 294 | 344 | conn.commit() | ... | ... |
src/test_bug_record.py
| ... | ... | @@ -60,9 +60,15 @@ class CreateBugRecordTest(BugRecordTestBase): |
| 60 | 60 | root_cause_detail="d", suggested_route="fast_lane", |
| 61 | 61 | ) |
| 62 | 62 | |
| 63 | - def test_core_engine_rejected_pre_ph5(self): | |
| 64 | - # core_engine is PH5-scoped (U-034) -- not accepted yet, matching the | |
| 65 | - # schema CHECK constraint's own exclusion (see src/schema.py) | |
| 63 | + def test_core_engine_rejected_pending_u035(self): | |
| 64 | + # U-034 (2026-08-02) widened the SCHEMA's own CHECK constraint to | |
| 65 | + # accept core_engine (see src/schema.py), but bug_record.py's own | |
| 66 | + # application-layer _VALID_ROOT_CAUSE_CATEGORIES deliberately still | |
| 67 | + # excludes it -- nothing yet determines WHEN a bug is core_engine | |
| 68 | + # (05 §5.8's file-path-based rule, not the Validator's 3-perspective | |
| 69 | + # judge_root_cause, which only ever produces planning/coder/ | |
| 70 | + # skill_defect); that classification logic + c_org_loop_route's | |
| 71 | + # routing to the blue-green flow is U-035's job, not this one's. | |
| 66 | 72 | with self.assertRaises(bug_record.InvalidRootCauseCategoryError): |
| 67 | 73 | bug_record.create_bug_record( |
| 68 | 74 | self.conn, task_id="task-1", description="x", root_cause_category="core_engine", | ... | ... |
src/test_schema.py
| 1 | -"""U-001 tests: all 15 tables build, and the constraints named in the Unit's | |
| 1 | +"""U-001 tests: all tables build, and the constraints named in the Unit's | |
| 2 | 2 | Done criterion (unique / foreign key / enum) are actually enforced. |
| 3 | +U-034 (PH5, 2026-08-02) added `deployment`/`deploy_health_signal` (17 tables | |
| 4 | +total) and widened `bug_record.root_cause_category` to accept `core_engine`. | |
| 3 | 5 | |
| 4 | 6 | Run: python3 -m unittest src.test_schema -v |
| 5 | 7 | """ |
| ... | ... | @@ -18,13 +20,13 @@ class SchemaCreationTest(unittest.TestCase): |
| 18 | 20 | def tearDown(self): |
| 19 | 21 | self.conn.close() |
| 20 | 22 | |
| 21 | - def test_all_15_tables_created(self): | |
| 23 | + def test_all_17_tables_created(self): | |
| 22 | 24 | rows = self.conn.execute( |
| 23 | 25 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" |
| 24 | 26 | ).fetchall() |
| 25 | 27 | actual = {row[0] for row in rows} |
| 26 | 28 | self.assertEqual(actual, set(schema.TABLE_NAMES)) |
| 27 | - self.assertEqual(len(schema.TABLE_NAMES), 15) | |
| 29 | + self.assertEqual(len(schema.TABLE_NAMES), 17) | |
| 28 | 30 | |
| 29 | 31 | def test_schema_is_idempotent_to_reopen_on_disk(self): |
| 30 | 32 | # not required by the Done criterion, but a schema that only works once |
| ... | ... | @@ -234,9 +236,10 @@ class ExecutionLogFileUniqueConstraintTest(ConstraintTestBase): |
| 234 | 236 | |
| 235 | 237 | |
| 236 | 238 | 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.""" | |
| 239 | + """core_engine was PH5-scoped and deliberately excluded pre-U-034 | |
| 240 | + (confirmed with user, 2026-07-30); U-034 (2026-08-02) widened the enum | |
| 241 | + to accept it -- 05 §5.8's self-target routing needs it as a real, | |
| 242 | + storable category, not just a documented intention.""" | |
| 240 | 243 | |
| 241 | 244 | def _insert_bug(self, root_cause_category): |
| 242 | 245 | self.conn.execute( |
| ... | ... | @@ -245,8 +248,8 @@ class BugRecordRootCauseCategoryTest(ConstraintTestBase): |
| 245 | 248 | (root_cause_category,), |
| 246 | 249 | ) |
| 247 | 250 | |
| 248 | - def test_pre_ph5_categories_accepted(self): | |
| 249 | - for category in ("planning", "coder", "skill_defect"): | |
| 251 | + def test_all_four_categories_accepted(self): | |
| 252 | + for category in ("planning", "coder", "skill_defect", "core_engine"): | |
| 250 | 253 | with self.subTest(category=category): |
| 251 | 254 | conn = schema.connect(":memory:") |
| 252 | 255 | schema.create_schema(conn) |
| ... | ... | @@ -258,9 +261,121 @@ class BugRecordRootCauseCategoryTest(ConstraintTestBase): |
| 258 | 261 | conn.commit() |
| 259 | 262 | conn.close() |
| 260 | 263 | |
| 261 | - def test_core_engine_rejected_before_u034(self): | |
| 264 | + def test_unknown_category_still_rejected(self): | |
| 262 | 265 | with self.assertRaises(sqlite3.IntegrityError): |
| 263 | - self._insert_bug("core_engine") | |
| 266 | + self._insert_bug("not_a_real_category") | |
| 267 | + | |
| 268 | + | |
| 269 | +class DeploymentEnumConstraintTest(ConstraintTestBase): | |
| 270 | + def _insert_deployment(self, deployment_id, status): | |
| 271 | + self.conn.execute( | |
| 272 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 273 | + "previous_instance_dir) VALUES (?, 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", | |
| 274 | + (deployment_id, status), | |
| 275 | + ) | |
| 276 | + | |
| 277 | + def test_every_state_machine_status_accepted(self): | |
| 278 | + statuses = ( | |
| 279 | + "staged", "candidate_starting", "health_check_running", "health_check_passed", | |
| 280 | + "health_check_failed", "cutover_in_progress", "live", "monitoring", | |
| 281 | + "anomaly_detected", "rollback_in_progress", "rolled_back", "rollback_failed", | |
| 282 | + "blocked_pending_human", "monitoring_window_clean", "finalized", | |
| 283 | + ) | |
| 284 | + for status in statuses: | |
| 285 | + with self.subTest(status=status): | |
| 286 | + conn = schema.connect(":memory:") | |
| 287 | + schema.create_schema(conn) | |
| 288 | + conn.execute( | |
| 289 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 290 | + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", | |
| 291 | + (status,), | |
| 292 | + ) | |
| 293 | + conn.commit() | |
| 294 | + conn.close() | |
| 295 | + | |
| 296 | + def test_invalid_status_rejected(self): | |
| 297 | + with self.assertRaises(sqlite3.IntegrityError): | |
| 298 | + self._insert_deployment("d1", "not_a_real_status") | |
| 299 | + | |
| 300 | + | |
| 301 | +class DeploymentActiveUniqueIndexTest(ConstraintTestBase): | |
| 302 | + """01-M 1.15: only one non-terminal deployment (not finalized/rolled_back/ | |
| 303 | + blocked_pending_human) at a time -- concurrent self-modifying deploys | |
| 304 | + would corrupt the single Live/previous_instance_dir bookkeeping.""" | |
| 305 | + | |
| 306 | + def _insert_deployment(self, deployment_id, status): | |
| 307 | + self.conn.execute( | |
| 308 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 309 | + "previous_instance_dir) VALUES (?, 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", | |
| 310 | + (deployment_id, status), | |
| 311 | + ) | |
| 312 | + | |
| 313 | + def test_second_active_deployment_rejected(self): | |
| 314 | + self._insert_deployment("d1", "staged") | |
| 315 | + self.conn.commit() | |
| 316 | + with self.assertRaises(sqlite3.IntegrityError): | |
| 317 | + self._insert_deployment("d2", "candidate_starting") | |
| 318 | + | |
| 319 | + def test_second_deployment_allowed_once_first_is_terminal(self): | |
| 320 | + for terminal_status in ("finalized", "rolled_back", "blocked_pending_human"): | |
| 321 | + with self.subTest(terminal_status=terminal_status): | |
| 322 | + conn = schema.connect(":memory:") | |
| 323 | + schema.create_schema(conn) | |
| 324 | + conn.execute( | |
| 325 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 326 | + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', ?, '/tmp/prev-instance')", | |
| 327 | + (terminal_status,), | |
| 328 | + ) | |
| 329 | + conn.commit() | |
| 330 | + conn.execute( | |
| 331 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 332 | + "previous_instance_dir) VALUES ('d2', 'sha-newer', 'sha-new', 'staged', '/tmp/prev-instance-2')" | |
| 333 | + ) | |
| 334 | + conn.commit() | |
| 335 | + count = conn.execute("SELECT COUNT(*) FROM deployment").fetchone()[0] | |
| 336 | + conn.close() | |
| 337 | + self.assertEqual(count, 2) | |
| 338 | + | |
| 339 | + | |
| 340 | +class DeployHealthSignalTest(ConstraintTestBase): | |
| 341 | + def setUp(self): | |
| 342 | + super().setUp() | |
| 343 | + self.conn.execute( | |
| 344 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | |
| 345 | + "previous_instance_dir) VALUES ('d1', 'sha-new', 'sha-old', 'health_check_running', " | |
| 346 | + "'/tmp/prev-instance')" | |
| 347 | + ) | |
| 348 | + self.conn.commit() | |
| 349 | + | |
| 350 | + def test_valid_signal_accepted(self): | |
| 351 | + self.conn.execute( | |
| 352 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status, detail) " | |
| 353 | + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'process_alive', 'ok', 'pid 1234 alive')" | |
| 354 | + ) | |
| 355 | + self.conn.commit() | |
| 356 | + row = self.conn.execute("SELECT status FROM deploy_health_signal WHERE signal_id = 'sig-1'").fetchone() | |
| 357 | + self.assertEqual(row[0], "ok") | |
| 358 | + | |
| 359 | + def test_invalid_signal_type_rejected(self): | |
| 360 | + with self.assertRaises(sqlite3.IntegrityError): | |
| 361 | + self.conn.execute( | |
| 362 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | |
| 363 | + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'not_a_real_signal', 'ok')" | |
| 364 | + ) | |
| 365 | + | |
| 366 | + def test_invalid_status_rejected(self): | |
| 367 | + with self.assertRaises(sqlite3.IntegrityError): | |
| 368 | + self.conn.execute( | |
| 369 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | |
| 370 | + "VALUES ('sig-1', 'd1', '2026-08-02T00:00:00Z', 'process_alive', 'not_a_real_status')" | |
| 371 | + ) | |
| 372 | + | |
| 373 | + def test_rejects_unknown_deployment_id(self): | |
| 374 | + with self.assertRaises(sqlite3.IntegrityError): | |
| 375 | + self.conn.execute( | |
| 376 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | |
| 377 | + "VALUES ('sig-1', 'no-such-deployment', '2026-08-02T00:00:00Z', 'process_alive', 'ok')" | |
| 378 | + ) | |
| 264 | 379 | |
| 265 | 380 | |
| 266 | 381 | if __name__ == "__main__": | ... | ... |