From 55972ca243cdf71e3c3f272da3a5f4ce25c0a37d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 1 May 2026 23:40:34 +0000 Subject: [PATCH] Fix promotion audit sequencing Co-authored-by: Gottam Sai Bharath --- src/flightdeck/cli/main.py | 13 ++++++++++-- src/flightdeck/storage.py | 10 +++------- tests/test_doctor.py | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/flightdeck/cli/main.py b/src/flightdeck/cli/main.py index 7dfb301..4f2b23c 100644 --- a/src/flightdeck/cli/main.py +++ b/src/flightdeck/cli/main.py @@ -16,8 +16,17 @@ from flightdeck.config import DEFAULT_CONFIG_FILENAME, load_config, write_default_config from flightdeck.doctor import run_doctor from flightdeck.ledger import diff_releases, parse_window -from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseArtifact, ReleaseRecord, RunEvent -from flightdeck.storage import Storage, utc_now +from flightdeck.models import ( + Policy, + PolicyResult, + PricingTable, + PromotionRecord, + ReleaseArtifact, + ReleaseRecord, + RunEvent, + utc_now, +) +from flightdeck.storage import Storage def read_release_artifact(path: Path) -> ReleaseArtifact: diff --git a/src/flightdeck/storage.py b/src/flightdeck/storage.py index 36b53af..d3bf355 100644 --- a/src/flightdeck/storage.py +++ b/src/flightdeck/storage.py @@ -5,16 +5,12 @@ import sqlite3 from contextlib import contextmanager from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime from pathlib import Path from typing import Any, Iterable from uuid import uuid4 -from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseRecord, RunEvent - - -def utc_now() -> datetime: - return datetime.now(timezone.utc) +from flightdeck.models import Policy, PolicyResult, PricingTable, PromotionRecord, ReleaseRecord, RunEvent, utc_now def ensure_parent_dir(db_path: str) -> None: @@ -506,7 +502,7 @@ def _insert_release_action_conn(conn: sqlite3.Connection, record: PromotionRecor ) def insert_promotion_record(self, record: PromotionRecord) -> None: - with self.connect() as conn: + with self.transaction() as conn: self._insert_release_action_conn(conn, record) def commit_promotion(self, record: PromotionRecord, *, new_promoted_release_id: str) -> None: diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 834e699..84d7fd7 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -7,6 +7,8 @@ from click.testing import CliRunner from flightdeck.cli.main import cli +from flightdeck.models import PolicyResult, PromotionRecord +from flightdeck.storage import Storage from tests.test_spine import write_events, write_policy, write_pricing, write_release @@ -107,6 +109,45 @@ def test_doctor_fails_on_audit_seq_gap(tmp_path: Path, monkeypatch) -> None: assert "audit_seq" in res.output.lower() +def test_insert_promotion_record_uses_immediate_transaction(tmp_path: Path) -> None: + storage = Storage(str(tmp_path / "flightdeck.db")) + storage.migrate() + with storage.connect() as conn: + conn.execute( + """ + INSERT INTO releases + (release_id, agent_id, version, environment, checksum, artifact_json, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ("rel_1", "agent_support", "1", "local", "sha256:abc", "{}", "2026-05-01T00:00:00+00:00"), + ) + + record = PromotionRecord( + action_id="act_1", + action="promote", + actor="tester", + release_id="rel_1", + agent_id="agent_support", + environment="local", + reason="test", + policy_result=PolicyResult(passed=True), + created_at=datetime.now(tz=timezone.utc), + ) + + competing_conn = storage.connect() + try: + competing_conn.execute("BEGIN IMMEDIATE;") + try: + storage.insert_promotion_record(record) + except sqlite3.OperationalError as exc: + assert "database is locked" in str(exc) + else: + raise AssertionError("insert_promotion_record did not request an immediate write lock") + finally: + competing_conn.rollback() + competing_conn.close() + + def test_doctor_fails_when_promoted_release_missing(tmp_path: Path, monkeypatch) -> None: monkeypatch.chdir(tmp_path) runner = CliRunner()