from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path import pytest from gyxx_flow.core.context import RunContext from gyxx_flow.core.layout import DataLayout from gyxx_flow.core.locks import LockManager, ResourceBusyError from gyxx_flow.core.records import RunJournal def _context() -> RunContext: return RunContext.create( "shop.weekly", "2026-07-27", now=datetime(2026, 7, 27, 4, 0, tzinfo=timezone.utc), random_suffix="abc123", ) def test_run_journal_tracks_steps_and_final_status(tmp_path: Path) -> None: context = _context() journal = RunJournal.create(DataLayout(tmp_path), context) journal.start_step("collect.jd", attempt=1) journal.finish_step("collect.jd", status="success", exit_code=0) journal.start_step("publish.feishu", attempt=1) journal.finish_step( "publish.feishu", status="failed", exit_code=3, error="credential binding missing", ) journal.finalize("failed", error="publish.feishu failed") payload = json.loads(journal.path.read_text(encoding="utf-8")) assert payload["run_id"] == context.run_id assert payload["status"] == "failed" assert payload["steps"]["collect.jd"]["status"] == "success" assert payload["steps"]["publish.feishu"]["exit_code"] == 3 assert payload["steps"]["publish.feishu"]["error"] == "credential binding missing" assert payload["error"] == "publish.feishu failed" assert payload["trace"]["paths"]["run"].startswith("runs/") assert payload["trace"]["paths"]["log"].startswith("logs/") assert payload["trace"]["paths"]["evidence"].startswith("data/evidence/") def test_run_journal_records_deduplicated_trace_references(tmp_path: Path) -> None: journal = RunJournal.create(DataLayout(tmp_path), _context()) journal.record_input("artifact:raw-001") journal.record_input("artifact:raw-001") journal.record_output("artifact:normalized-001") journal.record_external_write("outbox:msg-001") payload = json.loads(journal.path.read_text(encoding="utf-8")) assert payload["trace"]["inputs"] == ["artifact:raw-001"] assert payload["trace"]["outputs"] == ["artifact:normalized-001"] assert payload["trace"]["external_writes"] == ["outbox:msg-001"] def test_run_journal_rejects_invalid_transitions(tmp_path: Path) -> None: journal = RunJournal.create(DataLayout(tmp_path), _context()) with pytest.raises(ValueError, match="not running"): journal.finish_step("collect.jd", status="success") journal.start_step("collect.jd", attempt=1) with pytest.raises(ValueError, match="already running"): journal.start_step("collect.jd", attempt=2) def test_named_resource_lock_blocks_concurrent_owner_and_releases(tmp_path: Path) -> None: manager = LockManager(tmp_path) with manager.acquire("browser:xingtu", owner="run-001") as first: assert first.path.exists() with pytest.raises(ResourceBusyError, match="browser:xingtu"): with manager.acquire("browser:xingtu", owner="run-002", timeout_seconds=0): pass assert not first.path.exists() with manager.acquire("browser:xingtu", owner="run-002", timeout_seconds=0) as second: metadata = json.loads(second.path.read_text(encoding="utf-8")) assert metadata["resource"] == "browser:xingtu" assert metadata["owner"] == "run-002" @pytest.mark.parametrize("resource", ["", "../state", "resource\nname"]) def test_named_resource_lock_rejects_unsafe_name(tmp_path: Path, resource: str) -> None: manager = LockManager(tmp_path) with pytest.raises(ValueError, match="resource"): with manager.acquire(resource, owner="run-001"): pass