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

1"""Lightweight, additive **command-activity** records for live observability. 

2 

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. 

7 

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. 

13 

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""" 

18 

19from __future__ import annotations 

20 

21import json 

22import re 

23from pathlib import Path 

24from typing import Any 

25 

26from . import config as cfg 

27from . import flows 

28 

29ACTIVITY_SCHEMA_VERSION = "keel.activity.v1" 

30RECORD_TYPE_ACTIVITY = "command_activity" 

31DEFAULT_ACTIVITY_DIR = ".keel/activity" 

32STATUSES = ("running", "done", "merged") 

33 

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._-]+") 

37 

38 

39class ActivityError(ValueError): 

40 """Raised when an activity record or path is malformed.""" 

41 

42 

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 } 

55 

56 

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" 

65 

66 

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 

80 

81 

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 

90 

91 

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" 

95 

96 

97def _phase_ids(command: str) -> tuple[str, ...]: 

98 return tuple(phase.id for phase in flows.flow_for(command)) 

99 

100 

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. 

112 

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 } 

134 

135 

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") 

153 

154 

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" 

159 

160 

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 

169 

170 

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")) 

177 

178 

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") 

184 

185 

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 

193 

194 

195def read_all_activity(dir_path: str | Path) -> list[dict[str, Any]]: 

196 """Every readable activity record in ``dir_path``, sorted by run_id. 

197 

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