Coverage for src/keel/activity.py: 100%
106 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-16 18:07 +0000
1"""Lightweight, additive **command-activity** records for live observability.
3The resumable ``checkpoint`` is the ship backbone's own artifact (s0–s12) and is
4deliberately ship-shaped. Most other keel commands (``triage``, ``morning``,
5``pr-loop`` …) run in the main checkout, never write a checkpoint, and so are
6invisible to ``keel-visual``'s live board.
8This module adds a *separate, additive* channel: a per-run JSON record under
9``.keel/activity/`` that any command's adapter can stamp as it moves through its
10own flow phases (from :mod:`keel.flows`). It never touches the checkpoint
11contract. Records are keyed by ``run_id`` (one file each), so two commands in the
12same repo never clobber one another.
14Pure-core + thin I/O, mirroring :mod:`keel.checkpoint`: the builders/validators
15are deterministic (stable ordering, no wall-clock, no randomness); only
16read/write/remove touch the filesystem.
17"""
19from __future__ import annotations
21import json
22import re
23from pathlib import Path
24from typing import Any
26from . import config as cfg
27from . import flows
29ACTIVITY_SCHEMA_VERSION = "keel.activity.v1"
30RECORD_TYPE_ACTIVITY = "command_activity"
31DEFAULT_ACTIVITY_DIR = ".keel/activity"
32STATUSES = ("running", "done", "merged")
34# A run_id reduces to this slug for its filename; anything else is rejected so a
35# crafted run_id can never escape the activity directory.
36_RUN_ID_SLUG = re.compile(r"[^a-z0-9._-]+")
39class ActivityError(ValueError):
40 """Raised when an activity record or path is malformed."""
43def activity_contract_as_dict() -> dict[str, Any]:
44 """Return the stable activity-record contract consumed by adapters."""
45 return {
46 "schema_version": ACTIVITY_SCHEMA_VERSION,
47 "record_type": RECORD_TYPE_ACTIVITY,
48 "dir": DEFAULT_ACTIVITY_DIR,
49 "keyed_by": "run_id",
50 "statuses": list(STATUSES),
51 "additive": True,
52 "touches_checkpoint": False,
53 "phase_source": "keel.flows.flow_for(command)",
54 }
57def configured_activity_dir(config: cfg.ProjectConfig) -> tuple[str, str]:
58 """Return the configured activity directory and its source."""
59 pack = config.policy_pack or {}
60 reports = pack.get("reports") if isinstance(pack.get("reports"), dict) else {}
61 value = reports.get("activity")
62 if isinstance(value, str) and value.strip():
63 return value, "policy_pack.reports.activity"
64 return DEFAULT_ACTIVITY_DIR, "default"
67def resolve_dir(root: str | Path, config: cfg.ProjectConfig) -> Path:
68 """Resolve the activity directory under ``root`` and reject escapes."""
69 raw, _ = configured_activity_dir(config)
70 path = Path(raw)
71 if path.is_absolute():
72 raise ActivityError("activity dir must be relative to the project root")
73 root_path = Path(root).resolve()
74 resolved = (root_path / path).resolve()
75 try:
76 resolved.relative_to(root_path)
77 except ValueError as exc:
78 raise ActivityError("activity dir escapes the project root") from exc
79 return resolved
82def run_id_slug(run_id: str) -> str:
83 """Reduce a run_id to a safe filename stem (lowercase ``[a-z0-9._-]``)."""
84 if not isinstance(run_id, str) or not run_id.strip():
85 raise ActivityError("run_id must be a non-empty string")
86 slug = _RUN_ID_SLUG.sub("-", run_id.strip().lower()).strip("-.")
87 if not slug:
88 raise ActivityError("run_id has no usable characters")
89 return slug
92def record_path(root: str | Path, config: cfg.ProjectConfig, run_id: str) -> Path:
93 """Path of the activity record for ``run_id`` under ``root``."""
94 return resolve_dir(root, config) / f"{run_id_slug(run_id)}.json"
97def _phase_ids(command: str) -> tuple[str, ...]:
98 return tuple(phase.id for phase in flows.flow_for(command))
101def build_activity_record(
102 *,
103 command: str,
104 run_id: str,
105 phase: str,
106 status: str = "running",
107 issue: int | None = None,
108 pr: int | None = None,
109 note: str | None = None,
110) -> dict[str, Any]:
111 """Build one deterministic activity record, validating command + phase.
113 ``command`` must be a known :mod:`keel.flows` command and ``phase`` one of
114 that command's flow phase ids. ``status`` is ``running``, ``done`` or
115 ``merged`` (a real merge landed, distinct from a soft ``done``).
116 """
117 if not flows.is_known(command):
118 raise ActivityError(f"unknown command: {command!r}")
119 if phase not in _phase_ids(command):
120 raise ActivityError(f"phase {phase!r} is not a {command} flow phase")
121 if status not in STATUSES:
122 raise ActivityError(f"unsupported status: {status!r}")
123 return {
124 "schema_version": ACTIVITY_SCHEMA_VERSION,
125 "record_type": RECORD_TYPE_ACTIVITY,
126 "command": command,
127 "run_id": run_id,
128 "phase": phase,
129 "status": status,
130 "issue": issue,
131 "pr": pr,
132 "note": note,
133 }
136def validate_activity(record: Any) -> None:
137 """Validate the stable activity-record shape."""
138 if not isinstance(record, dict):
139 raise ActivityError("activity must be an object")
140 if record.get("schema_version") != ACTIVITY_SCHEMA_VERSION:
141 raise ActivityError("unsupported schema_version")
142 if record.get("record_type") != RECORD_TYPE_ACTIVITY:
143 raise ActivityError("unsupported record_type")
144 command = record.get("command")
145 if not isinstance(command, str) or not flows.is_known(command):
146 raise ActivityError("unsupported command")
147 if not isinstance(record.get("run_id"), str) or not record["run_id"].strip():
148 raise ActivityError("run_id must be a non-empty string")
149 if record.get("phase") not in _phase_ids(command):
150 raise ActivityError("unsupported phase")
151 if record.get("status") not in STATUSES:
152 raise ActivityError("unsupported status")
155def encode_activity(record: dict[str, Any]) -> str:
156 """Encode one activity record as stable JSON."""
157 validate_activity(record)
158 return json.dumps(record, indent=2, sort_keys=True) + "\n"
161def parse_activity(text: str) -> dict[str, Any]:
162 """Parse and validate one activity record."""
163 try:
164 record = json.loads(text)
165 except json.JSONDecodeError as exc:
166 raise ActivityError("invalid JSON") from exc
167 validate_activity(record)
168 return record
171def read_activity(path: str | Path) -> dict[str, Any] | None:
172 """Read one activity record; a missing file means no such run."""
173 activity_path = Path(path)
174 if not activity_path.exists():
175 return None
176 return parse_activity(activity_path.read_text(encoding="utf-8"))
179def write_activity(path: str | Path, record: dict[str, Any]) -> None:
180 """Write one validated activity record."""
181 activity_path = Path(path)
182 activity_path.parent.mkdir(parents=True, exist_ok=True)
183 activity_path.write_text(encode_activity(record), encoding="utf-8")
186def remove_activity(path: str | Path) -> bool:
187 """Delete an activity record. Returns ``True`` if a file was removed."""
188 activity_path = Path(path)
189 if not activity_path.exists():
190 return False
191 activity_path.unlink()
192 return True
195def read_all_activity(dir_path: str | Path) -> list[dict[str, Any]]:
196 """Every readable activity record in ``dir_path``, sorted by run_id.
198 Fail-soft: an unreadable or malformed file is skipped, never raised — one bad
199 record must not blank the board. The directory missing yields ``[]``.
200 """
201 directory = Path(dir_path)
202 if not directory.is_dir():
203 return []
204 records: list[dict[str, Any]] = []
205 for entry in sorted(directory.glob("*.json")):
206 try:
207 record = parse_activity(entry.read_text(encoding="utf-8"))
208 except (ActivityError, OSError):
209 continue
210 records.append(record)
211 return records