debugbrief 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,361 @@
1
+ """Session lifecycle and persistence.
2
+
3
+ The canonical live record for an active session is its file under
4
+ ``.debugbrief/sessions/<id>.json``; it is rewritten immediately after every
5
+ event so a crash never loses captured work. ``active_session.json`` is a small
6
+ pointer to the currently-active session and is removed on a clean ``end``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ from . import git_utils
14
+ from .command_runner import RunResult
15
+ from .models import (
16
+ COMMAND_STATUS_ERROR,
17
+ COMMAND_STATUS_FAILED,
18
+ COMMAND_STATUS_TIMED_OUT,
19
+ CommandData,
20
+ Event,
21
+ FileChange,
22
+ Session,
23
+ SessionStatus,
24
+ )
25
+ from .paths import ProjectPaths
26
+ from .redaction import redact_text
27
+ from .utils import atomic_write_json, now_iso8601, read_json
28
+
29
+
30
+ class SessionError(Exception):
31
+ """Raised for expected, user-facing session errors."""
32
+
33
+
34
+ class SessionManager:
35
+ def __init__(self, paths: ProjectPaths) -> None:
36
+ self.paths = paths
37
+
38
+ # Active-pointer handling -------------------------------------------------
39
+ def _read_active_pointer(self) -> Optional[Dict[str, Any]]:
40
+ pointer_path = self.paths.active_session_file
41
+ if not pointer_path.exists():
42
+ return None
43
+ try:
44
+ data = read_json(pointer_path)
45
+ except (ValueError, OSError) as exc:
46
+ raise SessionError(
47
+ f"active_session.json exists but could not be read ({exc}). "
48
+ "Inspect or remove .debugbrief/active_session.json to recover."
49
+ ) from exc
50
+ if not isinstance(data, dict) or "session_id" not in data:
51
+ raise SessionError(
52
+ "active_session.json is malformed. Remove "
53
+ ".debugbrief/active_session.json to recover."
54
+ )
55
+ return data
56
+
57
+ def _write_active_pointer(self, session: Session) -> None:
58
+ atomic_write_json(
59
+ self.paths.active_session_file,
60
+ {
61
+ "session_id": session.session_id,
62
+ "title": session.title,
63
+ "status": session.status,
64
+ "started_at": session.timestamps.start,
65
+ "session_file": str(
66
+ self.paths.session_file(session.session_id)
67
+ ),
68
+ },
69
+ )
70
+
71
+ def _clear_active_pointer(self) -> None:
72
+ pointer_path = self.paths.active_session_file
73
+ try:
74
+ if pointer_path.exists():
75
+ pointer_path.unlink()
76
+ except OSError as exc: # pragma: no cover - defensive
77
+ raise SessionError(
78
+ f"Could not clear active_session.json ({exc}). Remove it manually."
79
+ ) from exc
80
+
81
+ def has_active(self) -> bool:
82
+ return self.paths.active_session_file.exists()
83
+
84
+ # Session persistence -----------------------------------------------------
85
+ def save_session(self, session: Session) -> None:
86
+ self._recompute_counts(session)
87
+ atomic_write_json(
88
+ self.paths.session_file(session.session_id), session.to_dict()
89
+ )
90
+
91
+ def load_session_file(self, session_id: str) -> Session:
92
+ path = self.paths.session_file(session_id)
93
+ if not path.exists():
94
+ raise SessionError(f"Session file not found for id {session_id}.")
95
+ try:
96
+ return Session.from_dict(read_json(path))
97
+ except (ValueError, OSError) as exc:
98
+ raise SessionError(f"Could not read session {session_id}: {exc}") from exc
99
+
100
+ def load_active(self) -> Optional[Session]:
101
+ """Return the active Session, or None if no session is active.
102
+
103
+ Raises SessionError if the pointer exists but the underlying session
104
+ file is missing/unreadable (an interrupted/inconsistent state).
105
+ """
106
+ pointer = self._read_active_pointer()
107
+ if pointer is None:
108
+ return None
109
+ session_id = pointer["session_id"]
110
+ path = self.paths.session_file(session_id)
111
+ if not path.exists():
112
+ raise SessionError(
113
+ "active_session.json points to a missing session file "
114
+ f"({session_id}). The session looks interrupted. Remove "
115
+ ".debugbrief/active_session.json to recover."
116
+ )
117
+ return self.load_session_file(session_id)
118
+
119
+ def require_active(self, action: str) -> Session:
120
+ session = self.load_active()
121
+ if session is None:
122
+ raise SessionError(
123
+ f"No active DebugBrief session. Cannot {action}. "
124
+ 'Start one with: debugbrief start "<title>"'
125
+ )
126
+ return session
127
+
128
+ # Lifecycle ---------------------------------------------------------------
129
+ def start(self, title: str) -> Session:
130
+ if self.has_active():
131
+ existing = self._read_active_pointer() or {}
132
+ raise SessionError(
133
+ "A DebugBrief session is already active"
134
+ + (f" ({existing.get('title')!r})." if existing.get("title") else ".")
135
+ + " End it with: debugbrief end --mode pr|handoff|incident, "
136
+ "or check it with: debugbrief status"
137
+ )
138
+
139
+ clean_title = title.strip()
140
+ if not clean_title:
141
+ raise SessionError("Session title must not be empty.")
142
+
143
+ self.paths.ensure_directories()
144
+ git_state = git_utils.capture_state(self.paths.project_root, initial=True)
145
+
146
+ session = Session(
147
+ title=clean_title,
148
+ project_root=str(self.paths.project_root),
149
+ git=git_state,
150
+ )
151
+ session.timestamps.start = now_iso8601()
152
+
153
+ # Record an initial snapshot event for an honest timeline.
154
+ session.events.append(
155
+ Event.snapshot(
156
+ {
157
+ "phase": "start",
158
+ "git": git_state.to_dict(),
159
+ },
160
+ session.timestamps.start,
161
+ )
162
+ )
163
+
164
+ self.save_session(session)
165
+ self._write_active_pointer(session)
166
+ return session
167
+
168
+ def auto_start(self, seed_text: str) -> Session:
169
+ """Start a session with a title derived from the time and ``seed_text``.
170
+
171
+ Used when ``run`` or ``note`` is invoked with no active session, so a
172
+ capture is never silently dropped.
173
+ """
174
+ from .utils import utc_now
175
+
176
+ first_line = ""
177
+ for line in (seed_text or "").strip().splitlines():
178
+ if line.strip():
179
+ first_line = line.strip()
180
+ break
181
+ snippet = first_line[:60] if first_line else "debug session"
182
+ stamp = utc_now().strftime("%Y-%m-%d %H:%M")
183
+ return self.start(f"Auto session {stamp}: {snippet}")
184
+
185
+ def add_note(self, text: str) -> Session:
186
+ session = self.require_active("add a note")
187
+ clean = text.strip()
188
+ if not clean:
189
+ raise SessionError("Note text must not be empty.")
190
+ # Notes are persisted to the session JSON and surfaced in reports, so a
191
+ # secret pasted into a note (an env var, a log line) must be scrubbed
192
+ # before it ever reaches disk, the same as captured command output.
193
+ clean, n_redacted = redact_text(clean)
194
+ note_event = Event.note(clean, now_iso8601())
195
+ if n_redacted:
196
+ note_event.data["redacted"] = True
197
+ session.events.append(note_event)
198
+ self.save_session(session)
199
+ self._write_active_pointer(session)
200
+ return session
201
+
202
+ def record_command(self, result: RunResult) -> Session:
203
+ session = self.require_active("run a command")
204
+ # Best-effort, lightweight git snapshot at the moment of the command so
205
+ # later reports can correlate file changes with what happened. Safe and
206
+ # silent outside a repo.
207
+ if session.git.is_repo:
208
+ cwd = self.paths.project_root
209
+ result.command_data.git_head = git_utils.current_short_sha(cwd)
210
+ result.command_data.git_changed_files = git_utils.changed_files(cwd)
211
+ session.events.append(
212
+ Event.command(result.command_data, result.command_data.started_at)
213
+ )
214
+ if result.error_message and (result.errored or result.timed_out):
215
+ session.add_warning(result.error_message, now_iso8601())
216
+ self.save_session(session)
217
+ self._write_active_pointer(session)
218
+ return session
219
+
220
+ def end(self, mode: str, report_format: str = "md") -> Session:
221
+ # Local imports avoid an import cycle with the reporters package.
222
+ from .reporters import render_report, render_report_json
223
+
224
+ session = self.require_active("end the session")
225
+
226
+ # Capture final Git state, preserving the initial SHA.
227
+ final_state = git_utils.capture_state(self.paths.project_root, initial=False)
228
+ session.git.final_sha = final_state.final_sha
229
+ session.git.branch = final_state.branch
230
+ session.git.detached_head = final_state.detached_head
231
+ session.git.is_repo = final_state.is_repo
232
+ if final_state.repo_root:
233
+ session.git.repo_root = final_state.repo_root
234
+
235
+ session.timestamps.end = now_iso8601()
236
+ session.status = SessionStatus.COMPLETED.value
237
+
238
+ session.events.append(
239
+ Event.snapshot(
240
+ {"phase": "end", "git": session.git.to_dict()},
241
+ session.timestamps.end,
242
+ )
243
+ )
244
+
245
+ self._finalize_summary(session)
246
+ self.save_session(session)
247
+
248
+ from .utils import write_text
249
+
250
+ if report_format in ("md", "both"):
251
+ report_text = render_report(session, mode)
252
+ write_text(self.paths.report_file(session.session_id, mode), report_text)
253
+ if report_format in ("json", "both"):
254
+ import json
255
+
256
+ payload = render_report_json(session, mode)
257
+ write_text(
258
+ self.paths.report_json_file(session.session_id, mode),
259
+ json.dumps(payload, indent=2) + "\n",
260
+ )
261
+
262
+ # Only clear the active pointer after the report is safely written.
263
+ self._clear_active_pointer()
264
+ return session
265
+
266
+ def cancel(self) -> Session:
267
+ """Discard the active session without writing a report.
268
+
269
+ The session file is kept on disk with status ABANDONED, so nothing is
270
+ silently deleted; it simply never becomes a brief.
271
+ """
272
+ session = self.require_active("cancel the session")
273
+ session.status = SessionStatus.ABANDONED.value
274
+ session.timestamps.end = now_iso8601()
275
+ self.save_session(session)
276
+ self._clear_active_pointer()
277
+ return session
278
+
279
+ # Status ------------------------------------------------------------------
280
+ def build_status(self) -> Dict[str, Any]:
281
+ """Return a structured status payload for the CLI to render."""
282
+ pointer = self._read_active_pointer()
283
+ if pointer is None:
284
+ return {"active": False}
285
+
286
+ session_id = pointer.get("session_id", "")
287
+ path = self.paths.session_file(session_id)
288
+ if not path.exists():
289
+ return {
290
+ "active": True,
291
+ "interrupted": True,
292
+ "session_id": session_id,
293
+ "title": pointer.get("title"),
294
+ "reason": "Session file is missing.",
295
+ }
296
+
297
+ session = self.load_session_file(session_id)
298
+ self._recompute_counts(session)
299
+ interrupted = session.status != SessionStatus.ACTIVE.value
300
+ return {
301
+ "active": True,
302
+ "interrupted": interrupted,
303
+ "session_id": session.session_id,
304
+ "title": session.title,
305
+ "status": session.status,
306
+ "project_root": session.project_root,
307
+ "start": session.timestamps.start,
308
+ "notes_count": session.summary.notes_count,
309
+ "commands_count": session.summary.commands_count,
310
+ "failed_commands_count": session.summary.failed_commands_count,
311
+ "branch": session.git.branch,
312
+ "detached_head": session.git.detached_head,
313
+ "is_repo": session.git.is_repo,
314
+ "warnings": list(session.warnings),
315
+ }
316
+
317
+ # Internal helpers --------------------------------------------------------
318
+ def _recompute_counts(self, session: Session) -> None:
319
+ commands = session.command_events()
320
+ notes = session.note_events()
321
+ failed = 0
322
+ for event in commands:
323
+ status = (event.data.get("classification") or {}).get("status")
324
+ if status in (
325
+ COMMAND_STATUS_FAILED,
326
+ COMMAND_STATUS_TIMED_OUT,
327
+ COMMAND_STATUS_ERROR,
328
+ ):
329
+ failed += 1
330
+ session.summary.notes_count = len(notes)
331
+ session.summary.commands_count = len(commands)
332
+ session.summary.failed_commands_count = failed
333
+
334
+ def _finalize_summary(self, session: Session) -> None:
335
+ self._recompute_counts(session)
336
+
337
+ tests_run: List[str] = []
338
+ for event in session.command_events():
339
+ data = CommandData.from_dict(event.data)
340
+ if data.classification.is_test:
341
+ tests_run.append(data.command)
342
+ session.summary.tests_run = tests_run
343
+
344
+ if session.git.is_repo:
345
+ pairs = git_utils.name_status(self.paths.project_root)
346
+ session.summary.file_changes = [
347
+ FileChange(status=label, path=path) for label, path in pairs
348
+ ]
349
+ session.summary.modified_files = [path for _label, path in pairs]
350
+ added, deleted = git_utils.shortstat(self.paths.project_root)
351
+ session.summary.lines_added = added
352
+ session.summary.lines_deleted = deleted
353
+ else:
354
+ session.summary.file_changes = []
355
+ session.summary.modified_files = []
356
+ session.summary.lines_added = 0
357
+ session.summary.lines_deleted = 0
358
+
359
+ # The explicit-run capture model captures exactly what was run through
360
+ # DebugBrief; there is no silent gap to report.
361
+ session.summary.command_capture_status = "full"
@@ -0,0 +1,78 @@
1
+ """Read-only helpers for enumerating and resolving stored sessions.
2
+
3
+ Used by the ``list`` and ``show`` commands. None of these require an active
4
+ session, and they never mutate state.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import List, Optional, Tuple
10
+
11
+ from .models import Session
12
+ from .paths import ProjectPaths
13
+ from .reporters import VALID_MODES, build_context
14
+ from .utils import parse_iso8601, read_json
15
+
16
+
17
+ def _start_seconds(session: Session) -> float:
18
+ start = session.timestamps.start
19
+ if not start:
20
+ return 0.0
21
+ try:
22
+ return parse_iso8601(start).timestamp()
23
+ except (ValueError, TypeError):
24
+ return 0.0
25
+
26
+
27
+ def load_all_sessions(paths: ProjectPaths) -> List[Session]:
28
+ """Load every stored session, most recent first (by start time).
29
+
30
+ Unreadable session files are skipped silently so a single corrupt file
31
+ cannot break listing.
32
+ """
33
+ sessions_dir = paths.sessions_dir
34
+ if not sessions_dir.is_dir():
35
+ return []
36
+ sessions: List[Session] = []
37
+ for path in sessions_dir.glob("*.json"):
38
+ if not path.is_file():
39
+ continue
40
+ try:
41
+ sessions.append(Session.from_dict(read_json(path)))
42
+ except (ValueError, OSError, TypeError):
43
+ continue
44
+ sessions.sort(key=lambda s: (_start_seconds(s), s.session_id), reverse=True)
45
+ return sessions
46
+
47
+
48
+ def report_modes_for(paths: ProjectPaths, session_id: str) -> List[str]:
49
+ """Return the report modes that have been generated for ``session_id``."""
50
+ modes = []
51
+ for mode in VALID_MODES:
52
+ if paths.report_file(session_id, mode).exists():
53
+ modes.append(mode)
54
+ return modes
55
+
56
+
57
+ def is_verified(session: Session) -> bool:
58
+ """True if at least one verification command passed during the session."""
59
+ return len(build_context(session).verification_commands) > 0
60
+
61
+
62
+ def resolve_session_id(
63
+ paths: ProjectPaths, prefix: str
64
+ ) -> Tuple[Optional[str], List[str]]:
65
+ """Resolve a (possibly short) session id prefix to a full id.
66
+
67
+ Returns (resolved_id, matches). ``resolved_id`` is set only when exactly one
68
+ session id matches; otherwise it is None and ``matches`` lists all candidate
69
+ ids (empty when there is no match, multiple when the prefix is ambiguous).
70
+ """
71
+ clean = prefix.strip()
72
+ ids = [s.session_id for s in load_all_sessions(paths)]
73
+ if clean in ids:
74
+ return clean, [clean]
75
+ matches = [sid for sid in ids if sid.startswith(clean)]
76
+ if len(matches) == 1:
77
+ return matches[0], matches
78
+ return None, matches
debugbrief/utils.py ADDED
@@ -0,0 +1,151 @@
1
+ """Small shared helpers: timestamps, output truncation, and atomic JSON I/O.
2
+
3
+ These helpers are deliberately dependency-free and side-effect minimal so the
4
+ rest of the package can rely on consistent, testable behavior.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import tempfile
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Tuple
16
+
17
+ # Default preview limits for captured command output. We store previews, not
18
+ # unbounded logs, so a single noisy command can never balloon a session file.
19
+ DEFAULT_STDOUT_PREVIEW_LIMIT = 4000
20
+ DEFAULT_STDERR_PREVIEW_LIMIT = 4000
21
+
22
+
23
+ def utc_now() -> datetime:
24
+ """Return the current time as a timezone-aware UTC datetime."""
25
+ return datetime.now(timezone.utc)
26
+
27
+
28
+ def to_iso8601(moment: datetime) -> str:
29
+ """Serialize a datetime to an ISO8601 UTC string ending in 'Z'.
30
+
31
+ Naive datetimes are assumed to already be UTC.
32
+ """
33
+ if moment.tzinfo is None:
34
+ moment = moment.replace(tzinfo=timezone.utc)
35
+ moment = moment.astimezone(timezone.utc)
36
+ # Use millisecond precision; drop the '+00:00' offset in favor of 'Z'.
37
+ return moment.isoformat(timespec="milliseconds").replace("+00:00", "Z")
38
+
39
+
40
+ def now_iso8601() -> str:
41
+ """Convenience: current UTC time as an ISO8601 string."""
42
+ return to_iso8601(utc_now())
43
+
44
+
45
+ def parse_iso8601(value: str) -> datetime:
46
+ """Parse an ISO8601 string (possibly ending in 'Z') into a UTC datetime."""
47
+ normalized = value.strip()
48
+ if normalized.endswith("Z"):
49
+ normalized = normalized[:-1] + "+00:00"
50
+ parsed = datetime.fromisoformat(normalized)
51
+ if parsed.tzinfo is None:
52
+ parsed = parsed.replace(tzinfo=timezone.utc)
53
+ return parsed.astimezone(timezone.utc)
54
+
55
+
56
+ def human_duration(seconds: float) -> str:
57
+ """Render a duration in seconds as a compact ``1h 2m 3s`` style string."""
58
+ total = int(round(seconds))
59
+ if total < 0:
60
+ total = 0
61
+ hours, remainder = divmod(total, 3600)
62
+ minutes, secs = divmod(remainder, 60)
63
+ if hours:
64
+ return f"{hours}h {minutes}m {secs}s"
65
+ if minutes:
66
+ return f"{minutes}m {secs}s"
67
+ return f"{secs}s"
68
+
69
+
70
+ def truncate_text(text: str, limit: int) -> Tuple[str, bool]:
71
+ """Truncate ``text`` to ``limit`` characters, keeping the head and tail.
72
+
73
+ Returns a tuple of (possibly truncated text, was_truncated). A ``limit`` of
74
+ zero or negative is treated as "no limit".
75
+
76
+ When the text is longer than ``limit`` we keep a small head and a larger
77
+ tail with an elision marker in between. The decisive output of a debugging
78
+ run (tracebacks, assertions, the final build error) lands at the end, so the
79
+ tail gets the larger share: the head is the first ``limit // 3`` characters
80
+ and the tail is the remaining budget. The kept original content totals
81
+ ``limit`` characters; the marker is added on top.
82
+ """
83
+ if text is None:
84
+ return "", False
85
+ if limit is None or limit <= 0:
86
+ return text, False
87
+ if len(text) <= limit:
88
+ return text, False
89
+ head_len = limit // 3
90
+ tail_len = limit - head_len
91
+ omitted = len(text) - limit
92
+ marker = f"\n... [{omitted} characters omitted] ...\n"
93
+ head = text[:head_len]
94
+ tail = text[len(text) - tail_len:]
95
+ return head + marker + tail, True
96
+
97
+
98
+ def atomic_write_json(path: Path, data: Any) -> None:
99
+ """Write ``data`` as JSON to ``path`` atomically.
100
+
101
+ The data is written to a temporary file in the same directory and then
102
+ renamed into place, so a crash mid-write cannot corrupt an existing file.
103
+ """
104
+ path = Path(path)
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ fd, tmp_name = tempfile.mkstemp(
107
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
108
+ )
109
+ try:
110
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
111
+ json.dump(data, handle, indent=2, ensure_ascii=False, sort_keys=False)
112
+ handle.write("\n")
113
+ handle.flush()
114
+ os.fsync(handle.fileno())
115
+ os.replace(tmp_name, str(path))
116
+ except BaseException:
117
+ # Best-effort cleanup of the temp file on any failure.
118
+ try:
119
+ if os.path.exists(tmp_name):
120
+ os.unlink(tmp_name)
121
+ except OSError:
122
+ pass
123
+ raise
124
+
125
+
126
+ def read_json(path: Path) -> Any:
127
+ """Read and parse JSON from ``path``."""
128
+ with open(path, encoding="utf-8") as handle:
129
+ return json.load(handle)
130
+
131
+
132
+ def write_text(path: Path, text: str) -> None:
133
+ """Write ``text`` to ``path``, creating parent directories as needed."""
134
+ path = Path(path)
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+ with open(path, "w", encoding="utf-8") as handle:
137
+ handle.write(text)
138
+
139
+
140
+ def is_supported_platform() -> bool:
141
+ """Return True on Unix-like platforms (Linux, macOS, BSD).
142
+
143
+ V1 explicitly does not support Windows / PowerShell.
144
+ """
145
+ return os.name == "posix"
146
+
147
+
148
+ def eprint(*args: Any, **kwargs: Any) -> None:
149
+ """Print to stderr."""
150
+ kwargs.setdefault("file", sys.stderr)
151
+ print(*args, **kwargs)