diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb446f..4743e83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3] - 2026-07-24 + +### Fixed + +- `register_run` now upserts a dashboard `runs` row so Admin list/detail show the run + without a separate `create_run`. Ledger spend overlays `cost_micros`; halt/clear + keep the dashboard row in sync. + ## [0.1.2] - 2026-07-24 ### Added diff --git a/docs/architecture.md b/docs/architecture.md index a00ef1b..cd012c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,8 @@ graph TD UI: POST /v1/tasks (task only; no run_id) ├─ instrument_app binds RequestContext (service, intent, provider, model) ├─ with tokenops_run(client=…): → register-or-join + SpanContext + governance - ├─ store/client.create_run(RunRecord status="running") + │ register_run also upserts a dashboard runs row (status="running") + ├─ optional create_run/update_run for richer status/cost/steps (not required for visibility) │ ├─ agent.run(…, complete_fn=wrap_complete(bound.…)) │ pre_call → worst-case / concurrency detectors diff --git a/pyproject.toml b/pyproject.toml index a6c3284..eab75f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agent-tokenops" -version = "0.1.2" +version = "0.1.3" description = "TokenOps control plane and SDK for run-aware agent token governance" readme = "README.md" requires-python = ">=3.10" diff --git a/src/tokenops/__init__.py b/src/tokenops/__init__.py index 0d35c1c..efbddd4 100644 --- a/src/tokenops/__init__.py +++ b/src/tokenops/__init__.py @@ -4,7 +4,7 @@ load_env() -__version__ = "0.1.2" +__version__ = "0.1.3" def init() -> None: diff --git a/src/tokenops/control/store.py b/src/tokenops/control/store.py index b77b837..588a26d 100644 --- a/src/tokenops/control/store.py +++ b/src/tokenops/control/store.py @@ -32,6 +32,7 @@ from collections.abc import Callable from typing import Any, TypeVar +from tokenops.control.ledger import LIFETIME, RUN_TOTAL_BUDGET from tokenops.control.models import ( BudgetSpec, PolicyInstance, @@ -377,6 +378,18 @@ def register_run(self, reg: RunRegistration) -> RunRegistration: (reg.run_id, reg.intent, json.dumps(reg.user_dims), reg.mode.value, time.time()), ) self._db.commit() + # Dashboard reads ``runs``; registration alone must make the run visible. + # Explicit create_run/update_run remain for richer status/cost/steps (REPLACE-safe). + agent = reg.intent or "agent" + self.create_run( + RunRecord( + run_id=reg.run_id, + agent=agent, + status="running", + dims=dict(reg.user_dims), + task=reg.intent or None, + ) + ) return reg @_locked @@ -484,7 +497,7 @@ def update_run(self, run_id: str, **fields) -> None: @_locked def get_run(self, run_id: str) -> RunRecord | None: row = self._db.execute("SELECT * FROM runs WHERE run_id=?", (run_id,)).fetchone() - return _run(row) if row else None + return self._run_with_ledger_cost(row) if row else None @_locked def list_runs(self, *, problematic_only: bool = False, limit: int = 200) -> list[RunRecord]: @@ -492,7 +505,19 @@ def list_runs(self, *, problematic_only: bool = False, limit: int = 200) -> list if problematic_only: sql += " WHERE status IN ('halted','throttled','error')" sql += " ORDER BY started_at DESC LIMIT ?" - return [_run(r) for r in self._db.execute(sql, (limit,))] + return [self._run_with_ledger_cost(r) for r in self._db.execute(sql, (limit,))] + + def _run_with_ledger_cost(self, row: sqlite3.Row) -> RunRecord: + """Build a RunRecord; prefer ``__run_total__`` ledger spend when present.""" + rec = _run(row) + spent_row = self._db.execute( + "SELECT spent_micros FROM ledger_spent " + "WHERE budget_id=? AND segment_key=? AND period=?", + (RUN_TOTAL_BUDGET.budget_id, f"run:{rec.run_id}", LIFETIME), + ).fetchone() + if spent_row is not None: + rec.cost_micros = int(spent_row[0]) + return rec @_locked def run_tag_keys(self, *, limit: int = 500) -> list[str]: @@ -581,6 +606,11 @@ def ledger_mark_halted(self, run_id: str, reason: str = "") -> None: "halt_reason=COALESCE(excluded.halt_reason, ledger_halt.halt_reason)", (run_id, reason or None), ) + # Keep dashboard row in sync when present; ignore missing runs rows. + self._db.execute( + "UPDATE runs SET status='halted', halt_reason=? WHERE run_id=?", + (reason or None, run_id), + ) self._db.commit() @_locked @@ -606,6 +636,11 @@ def ledger_clear_halt(self, run_id: str) -> None: "ON CONFLICT(run_id) DO UPDATE SET halted=0, halt_reason=NULL", (run_id,), ) + # Clear halt_reason only — do not invent a completed/running status. + self._db.execute( + "UPDATE runs SET halt_reason=NULL WHERE run_id=?", + (run_id,), + ) self._db.commit() # ---- trajectory hint index -------------------------------------------- # diff --git a/tests/test_store.py b/tests/test_store.py index b541f02..32a0b65 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -6,7 +6,14 @@ from conftest import toy_price from tokenops.control import build_governor -from tokenops.control.models import BudgetSpec, PolicyInstance, RunRecord, Segment +from tokenops.control.ledger import LIFETIME, RUN_TOTAL_BUDGET +from tokenops.control.models import ( + BudgetSpec, + PolicyInstance, + RunRecord, + RunRegistration, + Segment, +) from tokenops.control.store import Store @@ -77,6 +84,59 @@ def test_run_records_and_problematic_filter(store): assert problematic[0].halt_reason == "budget exhausted" +def test_register_run_creates_dashboard_row(store): + """Registration alone must surface on the Admin Dashboard (list_runs / get_run).""" + store.register_run( + RunRegistration( + run_id="reg-only", + intent="summarize", + user_dims={"team": "growth"}, + ) + ) + got = store.get_run("reg-only") + assert got is not None + assert got.agent == "summarize" + assert got.status == "running" + assert got.task == "summarize" + assert got.dims == {"team": "growth"} + assert got.cost_micros == 0 + listed = store.list_runs() + assert [r.run_id for r in listed] == ["reg-only"] + + store.ledger_add_spent(RUN_TOTAL_BUDGET.budget_id, "run:reg-only", LIFETIME, 12_345) + assert store.get_run("reg-only").cost_micros == 12_345 + assert store.list_runs()[0].cost_micros == 12_345 + + # Explicit create_run after register remains REPLACE-safe. + store.create_run( + RunRecord( + run_id="reg-only", + agent="summarize", + status="completed", + cost_micros=99, + dims={"team": "growth"}, + task="summarize", + ) + ) + after = store.get_run("reg-only") + assert after.status == "completed" + assert after.cost_micros == 12_345 # ledger still overlays + + store.ledger_mark_halted("reg-only", reason="budget exhausted") + halted = store.get_run("reg-only") + assert halted.status == "halted" + assert halted.halt_reason == "budget exhausted" + + store.ledger_clear_halt("reg-only") + cleared = store.get_run("reg-only") + assert cleared.halt_reason is None + assert cleared.status == "halted" # clear does not invent a new status + + store.register_run(RunRegistration(run_id="no-intent")) + assert store.get_run("no-intent").agent == "agent" + assert store.get_run("no-intent").task is None + + def test_seed_default_governance_if_empty(tmp_path, monkeypatch): monkeypatch.delenv("TOKENOPS_SKIP_GOVERNANCE_SEED", raising=False) s = Store(str(tmp_path / "seed.db"), auto_seed=False)