| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | PPT Master - Automatic Workflow Transcript |
| 4 | |
| 5 | Internal runtime helper that records a project-scoped Python tool's command |
| 6 | envelope and material text outcomes in an existing project workflow log. |
| 7 | |
| 8 | Dependencies: |
| 9 | None (standard library only) |
| 10 | """ |
| 11 | |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import atexit |
| 15 | import json |
| 16 | import os |
| 17 | import sys |
| 18 | import threading |
| 19 | import time |
| 20 | import uuid |
| 21 | from collections import deque |
| 22 | from collections.abc import Iterable |
| 23 | from datetime import datetime, timezone |
| 24 | from pathlib import Path |
| 25 | from typing import TextIO |
| 26 | |
| 27 | WORKFLOW_LOG_RELATIVE_PATH = Path("validation/workflow.log") |
| 28 | PROJECT_PATH_ENV = "PPT_MASTER_PROJECT_PATH" |
| 29 | DISABLE_TRANSCRIPT_ENV = "PPT_MASTER_DISABLE_WORKFLOW_TRANSCRIPT" |
| 30 | _EXCLUDED_ENTRYPOINTS = {"workflow_log.py"} |
| 31 | _CRITICAL_MARKERS = ("[ERROR]", "[FAIL]") |
| 32 | _ALWAYS_RETAIN_MARKERS = ( |
| 33 | "[POSTFLIGHT]", |
| 34 | "[PPTX]", |
| 35 | "[REPORT]", |
| 36 | "[Done]", |
| 37 | "[OK] Done", |
| 38 | ) |
| 39 | _SUMMARY_MARKER = "[SUMMARY]" |
| 40 | _SUMMARY_STOP_MARKERS = ("[TIP]",) |
| 41 | _WARNING_MARKERS = ("[WARN]", "[WARNING]") |
| 42 | _OK_MARKER = "[OK]" |
| 43 | _WARNING_SAMPLE_LIMIT = 4 |
| 44 | _OK_SAMPLE_LIMIT = 1 |
| 45 | _STDERR_SAMPLE_LIMIT = 8 |
| 46 | _STDERR_TAIL_LIMIT = 4 |
| 47 | _CRITICAL_CONTEXT_LIMIT = 4 |
| 48 | _SUMMARY_CONTEXT_LIMIT = 12 |
| 49 | _ACTIVE_TRANSCRIPT: _AutomaticTranscript | None = None |
| 50 | |
| 51 | |
| 52 | def _utc_timestamp() -> str: |
| 53 | """Return a compact UTC timestamp.""" |
| 54 | return ( |
| 55 | datetime.now(timezone.utc) |
| 56 | .isoformat(timespec="milliseconds") |
| 57 | .replace("+00:00", "Z") |
| 58 | ) |
| 59 | |
| 60 | |
| 61 | def _candidate_directory(value: str) -> Path | None: |
| 62 | """Resolve one CLI value to an existing directory candidate.""" |
| 63 | if not value or value.startswith("-") or "://" in value: |
| 64 | return None |
| 65 | try: |
| 66 | raw_path = Path(value) |
| 67 | except (OSError, ValueError): |
| 68 | return None |
| 69 | if not raw_path.exists() and raw_path.parent == Path("."): |
| 70 | return None |
| 71 | path = raw_path |
| 72 | if not path.is_absolute(): |
| 73 | path = Path.cwd() / path |
| 74 | while not path.exists() and path != path.parent: |
| 75 | path = path.parent |
| 76 | if path.is_file(): |
| 77 | path = path.parent |
| 78 | return path.resolve() |
| 79 | |
| 80 | |
| 81 | def _find_project_root(argv: list[str]) -> Path | None: |
| 82 | """Find the first ancestor that already owns a workflow log.""" |
| 83 | explicit_project = os.environ.get(PROJECT_PATH_ENV, "").strip() |
| 84 | values = [explicit_project, *argv[1:], str(Path.cwd())] |
| 85 | for value in values: |
| 86 | directory = _candidate_directory(value) |
| 87 | if directory is None: |
| 88 | continue |
| 89 | for candidate in (directory, *directory.parents): |
| 90 | if (candidate / WORKFLOW_LOG_RELATIVE_PATH).is_file(): |
| 91 | return candidate |
| 92 | return None |
| 93 | |
| 94 | |
| 95 | class _AutomaticTranscript: |
| 96 | """Append a bounded command/outcome audit without changing the owning tool.""" |
| 97 | |
| 98 | def __init__( |
| 99 | self, |
| 100 | handle: TextIO, |
| 101 | original_stderr: TextIO, |
| 102 | argv: list[str], |
| 103 | ) -> None: |
| 104 | self.handle = handle |
| 105 | self.original_stderr = original_stderr |
| 106 | self.argv = argv |
| 107 | self.run_id = uuid.uuid4().hex[:12] |
| 108 | self.started_clock = time.monotonic() |
| 109 | self.pending = {"stdout": "", "stderr": ""} |
| 110 | self.observed_lines = 0 |
| 111 | self.retained_lines = 0 |
| 112 | self.omitted_lines = 0 |
| 113 | self.warning_samples = 0 |
| 114 | self.ok_samples = 0 |
| 115 | self.stderr_samples = 0 |
| 116 | self.stderr_tail: deque[str] = deque(maxlen=_STDERR_TAIL_LIMIT) |
| 117 | self.critical_context_remaining = 0 |
| 118 | self.summary_context_remaining = 0 |
| 119 | self.current_record_stream: str | None = None |
| 120 | self.lock = threading.RLock() |
| 121 | self.enabled = True |
| 122 | |
| 123 | def _disable(self, exc: OSError | UnicodeError | ValueError) -> None: |
| 124 | """Stop transcript writes without changing tool execution.""" |
| 125 | self.enabled = False |
| 126 | try: |
| 127 | self.original_stderr.write( |
| 128 | f"[WARN] Workflow audit recording stopped: {exc}\n" |
| 129 | ) |
| 130 | self.original_stderr.flush() |
| 131 | except (OSError, ValueError): |
| 132 | pass |
| 133 | |
| 134 | def _write(self, text: str) -> None: |
| 135 | """Append and flush one complete audit record.""" |
| 136 | if not self.enabled: |
| 137 | return |
| 138 | try: |
| 139 | self.handle.write(text) |
| 140 | self.handle.flush() |
| 141 | except (OSError, UnicodeError, ValueError) as exc: |
| 142 | self._disable(exc) |
| 143 | |
| 144 | def start(self) -> None: |
| 145 | """Write the Python-command envelope.""" |
| 146 | self._write( |
| 147 | f"\n=== {_utc_timestamp()} PYTHON run={self.run_id} ===\n" |
| 148 | f"cwd: {Path.cwd()}\n" |
| 149 | f"argv: {json.dumps(self.argv, ensure_ascii=False)}\n" |
| 150 | ) |
| 151 | |
| 152 | @staticmethod |
| 153 | def _is_decoration(line: str) -> bool: |
| 154 | """Return whether a non-empty line is only visual separator glyphs.""" |
| 155 | stripped = line.strip() |
| 156 | return bool(stripped) and set(stripped) <= {"-", "=", "_"} |
| 157 | |
| 158 | def _should_retain(self, stream_name: str, line: str) -> bool: |
| 159 | """Select a bounded set of explicit outcomes for the cold audit log.""" |
| 160 | stripped = line.strip() |
| 161 | if not stripped or self._is_decoration(stripped): |
| 162 | return False |
| 163 | |
| 164 | if _SUMMARY_MARKER in stripped: |
| 165 | self.summary_context_remaining = _SUMMARY_CONTEXT_LIMIT |
| 166 | return True |
| 167 | if any(marker in stripped for marker in _SUMMARY_STOP_MARKERS): |
| 168 | self.summary_context_remaining = 0 |
| 169 | return False |
| 170 | if self.summary_context_remaining > 0: |
| 171 | self.summary_context_remaining -= 1 |
| 172 | return True |
| 173 | if any(marker in stripped for marker in _CRITICAL_MARKERS): |
| 174 | self.critical_context_remaining = _CRITICAL_CONTEXT_LIMIT |
| 175 | return True |
| 176 | if self.critical_context_remaining > 0: |
| 177 | self.critical_context_remaining -= 1 |
| 178 | return True |
| 179 | if any(marker in stripped for marker in _ALWAYS_RETAIN_MARKERS): |
| 180 | return True |
| 181 | if any(marker in stripped for marker in _WARNING_MARKERS): |
| 182 | if self.warning_samples < _WARNING_SAMPLE_LIMIT: |
| 183 | self.warning_samples += 1 |
| 184 | return True |
| 185 | return False |
| 186 | if _OK_MARKER in stripped: |
| 187 | if self.ok_samples < _OK_SAMPLE_LIMIT: |
| 188 | self.ok_samples += 1 |
| 189 | return True |
| 190 | return False |
| 191 | if stream_name == "stderr" and self.stderr_samples < _STDERR_SAMPLE_LIMIT: |
| 192 | self.stderr_samples += 1 |
| 193 | return True |
| 194 | return False |
| 195 | |
| 196 | def _record_line(self, stream_name: str, line: str) -> None: |
| 197 | """Record one material logical line or account for its omission.""" |
| 198 | if not line.strip(): |
| 199 | return |
| 200 | self.observed_lines += 1 |
| 201 | if not self._should_retain(stream_name, line): |
| 202 | self.omitted_lines += 1 |
| 203 | if stream_name == "stderr" and not self._is_decoration(line): |
| 204 | self.stderr_tail.append(line) |
| 205 | return |
| 206 | if stream_name == "stderr": |
| 207 | self.stderr_tail.clear() |
| 208 | self.retained_lines += 1 |
| 209 | if self.current_record_stream != stream_name: |
| 210 | self._write(f"{stream_name}:\n") |
| 211 | self.current_record_stream = stream_name |
| 212 | self._write(f" {line}\n") |
| 213 | |
| 214 | def write_stream(self, stream_name: str, text: str) -> None: |
| 215 | """Buffer fragments and write complete logical lines.""" |
| 216 | if not text or not self.enabled: |
| 217 | return |
| 218 | with self.lock: |
| 219 | pending = self.pending[stream_name] + text |
| 220 | while "\n" in pending: |
| 221 | line, pending = pending.split("\n", 1) |
| 222 | self._record_line(stream_name, line) |
| 223 | self.pending[stream_name] = pending |
| 224 | |
| 225 | def flush_stream(self, stream_name: str) -> None: |
| 226 | """Flush a partial logical line when the owning stream flushes.""" |
| 227 | with self.lock: |
| 228 | if not self.enabled: |
| 229 | self.pending[stream_name] = "" |
| 230 | return |
| 231 | pending = self.pending[stream_name] |
| 232 | if pending: |
| 233 | self._record_line(stream_name, pending) |
| 234 | self.pending[stream_name] = "" |
| 235 | |
| 236 | def close(self) -> None: |
| 237 | """Flush pending output and close the command envelope.""" |
| 238 | with self.lock: |
| 239 | if not self.enabled: |
| 240 | return |
| 241 | self.flush_stream("stdout") |
| 242 | self.flush_stream("stderr") |
| 243 | if self.stderr_tail: |
| 244 | self._write("stderr-tail:\n") |
| 245 | for line in self.stderr_tail: |
| 246 | self._write(f" {line}\n") |
| 247 | self.retained_lines += len(self.stderr_tail) |
| 248 | self.omitted_lines -= len(self.stderr_tail) |
| 249 | elapsed_ms = int((time.monotonic() - self.started_clock) * 1000) |
| 250 | self._write( |
| 251 | f"=== {_utc_timestamp()} END run={self.run_id} " |
| 252 | f"elapsed_ms={elapsed_ms} output_lines={self.observed_lines} " |
| 253 | f"retained={self.retained_lines} omitted={self.omitted_lines} ===\n" |
| 254 | ) |
| 255 | self.enabled = False |
| 256 | try: |
| 257 | self.handle.close() |
| 258 | except (OSError, ValueError) as exc: |
| 259 | self._disable(exc) |
| 260 | |
| 261 | |
| 262 | class _TeeTextIO: |
| 263 | """Forward text writes while offering them to the audit recorder.""" |
| 264 | |
| 265 | def __init__( |
| 266 | self, |
| 267 | primary: TextIO, |
| 268 | transcript: _AutomaticTranscript, |
| 269 | stream_name: str, |
| 270 | ) -> None: |
| 271 | self.primary = primary |
| 272 | self.transcript = transcript |
| 273 | self.stream_name = stream_name |
| 274 | self.lock = threading.RLock() |
| 275 | |
| 276 | def write(self, text: str) -> int: |
| 277 | """Write to the original stream, then mirror the same text.""" |
| 278 | with self.lock: |
| 279 | written = self.primary.write(text) |
| 280 | self.transcript.write_stream(self.stream_name, text) |
| 281 | return written |
| 282 | |
| 283 | def writelines(self, lines: Iterable[str]) -> None: |
| 284 | """Forward a sequence through the ordinary write path.""" |
| 285 | for line in lines: |
| 286 | self.write(line) |
| 287 | |
| 288 | def flush(self) -> None: |
| 289 | """Flush the original stream without splitting a logical log line.""" |
| 290 | with self.lock: |
| 291 | self.primary.flush() |
| 292 | |
| 293 | def __getattr__(self, name: str) -> object: |
| 294 | """Delegate the remaining text-stream interface.""" |
| 295 | return getattr(self.primary, name) |
| 296 | |
| 297 | |
| 298 | def install_auto_transcript(argv: list[str] | None = None) -> Path | None: |
| 299 | """Record a project-scoped Python command when its project log exists.""" |
| 300 | global _ACTIVE_TRANSCRIPT |
| 301 | |
| 302 | effective_argv = list(sys.argv if argv is None else argv) |
| 303 | if _ACTIVE_TRANSCRIPT is not None: |
| 304 | return None |
| 305 | if os.environ.get(DISABLE_TRANSCRIPT_ENV): |
| 306 | return None |
| 307 | if Path(effective_argv[0]).name in _EXCLUDED_ENTRYPOINTS: |
| 308 | return None |
| 309 | |
| 310 | project_root = _find_project_root(effective_argv) |
| 311 | if project_root is None: |
| 312 | return None |
| 313 | log_path = project_root / WORKFLOW_LOG_RELATIVE_PATH |
| 314 | original_stdout = sys.stdout |
| 315 | original_stderr = sys.stderr |
| 316 | try: |
| 317 | handle = log_path.open( |
| 318 | "a", |
| 319 | encoding="utf-8", |
| 320 | errors="replace", |
| 321 | newline="", |
| 322 | ) |
| 323 | except OSError as exc: |
| 324 | try: |
| 325 | original_stderr.write( |
| 326 | f"[WARN] Workflow audit unavailable ({log_path}): {exc}\n" |
| 327 | ) |
| 328 | original_stderr.flush() |
| 329 | except (OSError, ValueError): |
| 330 | pass |
| 331 | return None |
| 332 | |
| 333 | transcript = _AutomaticTranscript( |
| 334 | handle, |
| 335 | original_stderr, |
| 336 | [sys.executable, *effective_argv], |
| 337 | ) |
| 338 | transcript.start() |
| 339 | _ACTIVE_TRANSCRIPT = transcript |
| 340 | sys.stdout = _TeeTextIO(original_stdout, transcript, "stdout") |
| 341 | sys.stderr = _TeeTextIO(original_stderr, transcript, "stderr") |
| 342 | atexit.register(transcript.close) |
| 343 | return log_path |
| 344 |