Capture all successful state reads with hashes and session-local IDs. Record action intent before POST, retain accepted/rejected/unknown results, and link subsequent observations. Preserve legacy feeds and finalize each invocation synchronously.
138 lines
6.4 KiB
Python
138 lines
6.4 KiB
Python
"""Session-local journal for observations and runner-supplied action calls."""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import time
|
|
from datetime import datetime, timezone
|
|
import uuid
|
|
|
|
|
|
def timestamp() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
class SessionRecorder:
|
|
"""A journal is authoritative; root JSONL files are compatibility feeds.
|
|
|
|
An attempt records intent before the POST. Neither acceptance nor a later
|
|
observation proves that the expected game effect occurred.
|
|
"""
|
|
|
|
def __init__(self, root: str | Path, *, dry_run: bool = False):
|
|
self.root = Path(root)
|
|
self.session_id = f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex}"
|
|
self.directory = self.root / "sessions" / self.session_id
|
|
self.directory.mkdir(parents=True, exist_ok=False)
|
|
(self.directory / "observations").mkdir()
|
|
self.events = self.directory / "events.jsonl"
|
|
self.started = timestamp()
|
|
self.dry_run = dry_run
|
|
self.step = 0
|
|
self.sources = {"code": 0, "jev": 0, "fallback": 0}
|
|
self.exit_code = 1
|
|
self.stop_reason = "interrupted"
|
|
self.summary: dict = {}
|
|
self.sequence = self.observations = self.proposals = self.attempts = 0
|
|
self.observation_id: str | None = None
|
|
self.awaiting_observation: str | None = None
|
|
|
|
@staticmethod
|
|
def _append(path: Path, row: dict, *, durable: bool = False) -> None:
|
|
line = json.dumps(row, sort_keys=True, allow_nan=False) + "\n"
|
|
with path.open("a", encoding="utf-8") as stream:
|
|
stream.write(line)
|
|
if durable:
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
|
|
def write(self, record: dict, *, mirror: bool = True, durable: bool = False) -> dict:
|
|
self.sequence += 1
|
|
row = {**record, "schema_version": 1, "session": self.session_id,
|
|
"sequence": self.sequence, "recorded_at": timestamp(),
|
|
"ts": time.strftime("%H:%M:%S")}
|
|
self._append(self.events, row, durable=durable)
|
|
if mirror:
|
|
self._append(self.root / "decisions.jsonl", row)
|
|
return row
|
|
|
|
def observe(self, observation: dict, *, step: int, phase: str) -> str:
|
|
self.step = step
|
|
self.observations += 1
|
|
observation_id = f"{self.session_id}:o{self.observations:06d}"
|
|
path = self.directory / "observations" / f"{self.observations:06d}.json"
|
|
data = (json.dumps(observation, sort_keys=True, allow_nan=False) + "\n").encode("utf-8")
|
|
with path.open("xb") as stream:
|
|
stream.write(data)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
self.observation_id = observation_id
|
|
self.write({"event": "observation", "observation_id": observation_id,
|
|
"step": step, "phase": phase, "state_type": observation.get("state_type"),
|
|
"path": str(path.relative_to(self.root)), "sha256": hashlib.sha256(data).hexdigest(),
|
|
"after_attempt_id": self.awaiting_observation}, mirror=False)
|
|
self.awaiting_observation = None
|
|
return observation_id
|
|
|
|
def propose(self, record: dict) -> str:
|
|
self.proposals += 1
|
|
proposal_id = f"{self.session_id}:p{self.proposals:06d}"
|
|
self.write({**record, "event": "proposal", "proposal_id": proposal_id,
|
|
"observation_id": self.observation_id}, mirror=False)
|
|
return proposal_id
|
|
|
|
def disposition(self, proposal_id: str, status: str) -> None:
|
|
self.write({"event": "proposal_status", "proposal_id": proposal_id,
|
|
"status": status, "step": self.step}, mirror=False)
|
|
|
|
def execute(self, proposal_id: str, decision, act):
|
|
"""Flush intent before invoking the transport. Never retry here."""
|
|
if self.dry_run:
|
|
raise RuntimeError("cannot execute actions in a dry-run recording")
|
|
self.attempts += 1
|
|
attempt_id = f"{self.session_id}:a{self.attempts:06d}"
|
|
self.disposition(proposal_id, "execute")
|
|
self.write({"event": "action_attempt", "attempt_id": attempt_id,
|
|
"proposal_id": proposal_id, "observation_id": self.observation_id,
|
|
"step": self.step, "action": decision.action, "params": decision.params},
|
|
mirror=False, durable=True)
|
|
self.awaiting_observation = attempt_id
|
|
try:
|
|
result = act(decision.action, **decision.params)
|
|
except BaseException as exc:
|
|
self.write({"event": "action_result", "attempt_id": attempt_id,
|
|
"step": self.step, "outcome": "unknown",
|
|
"error": f"{type(exc).__name__}: {str(exc)[:200]}"}, mirror=False)
|
|
raise
|
|
self.write({"event": "action_result", "attempt_id": attempt_id, "step": self.step,
|
|
"outcome": "accepted" if result.ok else "rejected",
|
|
"message": result.message, "response": getattr(result, "raw", None)}, mirror=False)
|
|
return result
|
|
|
|
def finish(self, code: int, reason: str) -> int:
|
|
self.exit_code, self.stop_reason = code, reason
|
|
return code
|
|
|
|
def __enter__(self):
|
|
self.write({"event": "session_start", "dry_run": self.dry_run}, mirror=False)
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, traceback):
|
|
if exc is not None:
|
|
self.exit_code = 130 if isinstance(exc, KeyboardInterrupt) else 1
|
|
self.stop_reason = "interrupted" if isinstance(exc, KeyboardInterrupt) else "exception"
|
|
self.summary["error"] = f"{type(exc).__name__}: {str(exc)[:200]}"
|
|
row = {**self.summary, "schema_version": 1, "session": self.session_id,
|
|
"started": self.started, "ended": timestamp(), "steps": self.step,
|
|
"sources": self.sources, "dry_run": self.dry_run,
|
|
"exit_code": self.exit_code, "stop_reason": self.stop_reason,
|
|
"recording_path": str(self.events.relative_to(self.root)),
|
|
"awaiting_observation_after_attempt": self.awaiting_observation}
|
|
self.write({**row, "event": "session_end", "step": self.step, "reason": self.stop_reason}, durable=True)
|
|
with (self.directory / "session.json").open("x", encoding="utf-8") as stream:
|
|
json.dump(row, stream, sort_keys=True, allow_nan=False)
|
|
stream.write("\n")
|
|
self._append(self.root / "sessions.jsonl", row)
|
|
return False
|