Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Session lifecycle invariants ([#15](https://github.com/ARPAHLS/aura/issues/15))** — strict closed-session errors (`SessionClosedError`, `SessionAlreadyOpenError`); `export=False` builds in-memory `summary` and `audit_report` on `SessionRun`; atomic summary + OTel commit on export; frozen `declared_rules` / `open_snapshot_hash` at open (runtime rule merges via skill bind still apply to constraints); `trace_id` on summary export.

- **Documentation sweep ([#14](https://github.com/ARPAHLS/aura/issues/14))** — `docs/INDEX.md` three-tier entry (Start / Build / Decide + Optional vision); demoted narrative, three-rings, aura-levels, field-services; refreshed architecture, concepts, stack-position, field-services shipped vs planned; fixed stale v0.2 voice in getting-started and concepts.

## [0.3.4] - 2026-08-26
Expand Down
6 changes: 6 additions & 0 deletions aura/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from aura.api import (
AgentHandle,
ApprovalRequired,
ExportError,
SessionClosedError,
SessionNotOpenError,
SessionRun,
agent,
configure,
Expand All @@ -23,4 +26,7 @@
"AgentHandle",
"SessionRun",
"ApprovalRequired",
"SessionClosedError",
"SessionNotOpenError",
"ExportError",
]
44 changes: 34 additions & 10 deletions aura/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@
from aura.agents.profile import AgentProfile
from aura.agents.registry import AgentRegistry
from aura.config import configure as _configure, get_config
from aura.core.conformance import ConformanceEngine
from aura.core.conformance import ConformanceEngine, ConformanceReport
from aura.core.constraints import ApprovalRequired
from aura.core.errors import ExportError, SessionClosedError, SessionNotOpenError
from aura.core.session import Session, SessionMode
from aura.exporters.jsonl import export_session
from aura.exporters.jsonl import build_session_summary, export_session


@dataclass
Expand All @@ -23,6 +24,9 @@ class SessionRun:

_session: Session
exports: dict[str, str] = field(default_factory=dict)
summary: dict[str, Any] | None = None
audit_report: dict[str, Any] | None = None
conformance: ConformanceReport | None = None

@property
def session_id(self) -> str:
Expand All @@ -32,6 +36,10 @@ def session_id(self) -> str:
def aura_id(self) -> str:
return self._session.profile.aura_id

@property
def trace_id(self) -> str | None:
return self._session.trace_id

def emit(self, kind: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
return self._session.emit(kind, payload)

Expand Down Expand Up @@ -61,6 +69,26 @@ def current_session() -> SessionRun | None:
return _current_run.get()


def _finalize_session_run(run: SessionRun, session: Session, *, do_export: bool) -> None:
"""Build in-memory receipt; optionally commit export artifacts."""
if not session.spine:
return

report = ConformanceEngine().summarize(
session.spine,
session.declared_rules,
session.open_snapshot_hash,
sequencer_spec=session.sequencer_spec or session.profile.sequencer,
)
run.conformance = report
run.summary = build_session_summary(session, conformance=report)
audit = run.summary.get("audit_report")
run.audit_report = audit if isinstance(audit, dict) else None

if do_export:
run.exports = export_session(session, get_config().sessions_dir(), conformance=report)


@dataclass
class AgentHandle:
profile: AgentProfile
Expand All @@ -86,14 +114,7 @@ def session(
_current_run.reset(token)
session.close()
do_export = export if export is not None else cfg.values.get("export_on_close", True)
if do_export and session.spine:
report = ConformanceEngine().summarize(
session.spine,
session.rules,
session.snapshot_hash,
sequencer_spec=session.sequencer_spec or session.profile.sequencer,
)
run.exports = export_session(session, cfg.sessions_dir(), conformance=report)
_finalize_session_run(run, session, do_export=do_export)


def _build_session(
Expand Down Expand Up @@ -164,5 +185,8 @@ def list_agents(include_archived: bool = False) -> list[AgentProfile]:
"AgentHandle",
"SessionRun",
"ApprovalRequired",
"SessionClosedError",
"SessionNotOpenError",
"ExportError",
"current_session",
]
40 changes: 40 additions & 0 deletions aura/core/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Session and export lifecycle errors."""


class AuraSessionError(Exception):
"""Base class for session lifecycle errors."""


class SessionNotOpenError(AuraSessionError):
"""Operation requires an open session."""

def __init__(self, session_id: str | None = None) -> None:
self.session_id = session_id
sid = session_id or "unknown"
super().__init__(f"Session not open: {sid}")


class SessionClosedError(AuraSessionError):
"""Session is closed; further mutations are rejected."""

def __init__(self, session_id: str | None = None) -> None:
self.session_id = session_id
sid = session_id or "unknown"
super().__init__(f"Session already closed: {sid}")


class SessionAlreadyOpenError(AuraSessionError):
"""Session open() was called more than once on the same handle."""

def __init__(self, session_id: str | None = None) -> None:
self.session_id = session_id
sid = session_id or "unknown"
super().__init__(f"Session already open: {sid}")


class ExportError(Exception):
"""Atomic session export failed; summary and OTel artifacts were not committed."""

def __init__(self, session_id: str, message: str) -> None:
self.session_id = session_id
super().__init__(f"Export failed for {session_id}: {message}")
74 changes: 68 additions & 6 deletions aura/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

from __future__ import annotations

import copy
import json
import uuid
from dataclasses import dataclass, field
from enum import Enum
from hashlib import sha256
from pathlib import Path
from typing import Any
import json
import uuid

from aura.agents.profile import AgentProfile
from aura.core.errors import (
SessionAlreadyOpenError,
SessionClosedError,
SessionNotOpenError,
)
from aura.core.constraints import (
ApprovalRequired,
ConstraintContext,
Expand All @@ -28,6 +34,11 @@ class SessionMode(str, Enum):
CONTINUOUS = "continuous"


_IMMUTABLE_AFTER_OPEN = frozenset(
{"session_id", "spine", "profile", "mode", "_declared_rules", "_open_snapshot_hash"}
)


@dataclass
class Session:
"""One runtime activation of an agent."""
Expand All @@ -48,11 +59,42 @@ class Session:
_closed: bool = False
_log_path: Path | None = None
_goal_reached: bool = False
_declared_rules: list[dict[str, Any]] = field(default_factory=list)
_open_snapshot_hash: str | None = None

def __setattr__(self, name: str, value: Any) -> None:
if (
name
not in {
"_open",
"_closed",
"_goal_reached",
"state",
"_approved",
"_observers",
"rules",
"snapshot_hash",
}
and getattr(self, "_open", False)
and name in _IMMUTABLE_AFTER_OPEN
):
raise AttributeError(f"{name} is immutable after session open")
object.__setattr__(self, name, value)

def _ensure_active(self) -> None:
if self._closed:
raise SessionClosedError(self.session_id)
if not self._open or not self.spine:
raise SessionNotOpenError(self.session_id)

def open(self, sessions_dir: Path) -> None:
if self._closed:
raise SessionClosedError(self.session_id)
if self._open:
return
raise SessionAlreadyOpenError(self.session_id)
self.snapshot_hash = _snapshot_hash(self.profile, self.rules)
self._open_snapshot_hash = self.snapshot_hash
self._declared_rules = copy.deepcopy(self.rules)
self._log_path = sessions_dir / f"{self.session_id}.jsonl"
self.spine = AuditSpine(
session_id=self.session_id,
Expand Down Expand Up @@ -107,14 +149,19 @@ def _attach_profile_observers(self) -> None:

def close(self, reason: str = "normal") -> dict[str, Any]:
if self._closed:
return self.state.get("_summary", {})
raise SessionClosedError(self.session_id)
if not self._open:
raise SessionNotOpenError(self.session_id)
if self.spine:
self.emit("session.close", {"reason": reason, "goal_reached": self._goal_reached})
self._closed = True
self._open = False
return {
"session_id": self.session_id,
"log_path": str(self._log_path) if self._log_path else None,
"trace_id": self.trace_id,
"snapshot_hash": self.snapshot_hash,
"open_snapshot_hash": self.open_snapshot_hash,
}

def emit(
Expand All @@ -124,8 +171,7 @@ def emit(
*,
step_id: str | None = None,
) -> dict[str, Any]:
if not self.spine:
raise RuntimeError("Session not open")
self._ensure_active()
ctx = ConstraintContext(
event_kind=kind,
payload=dict(payload or {}),
Expand Down Expand Up @@ -186,6 +232,7 @@ def require_approval(
"""Gate helper — log approval requirement and raise."""
if request_id in self._approved:
return
self._ensure_active()
if self.spine:
self.spine.append(
"constraint.approval_required",
Expand All @@ -200,6 +247,7 @@ def require_approval(
raise ApprovalRequired(request_id, message, rule)

def approve(self, request_id: str, *, principal: str | None = None) -> None:
self._ensure_active()
self._approved.add(request_id)
if self.spine:
payload: dict[str, Any] = {"request_id": request_id}
Expand Down Expand Up @@ -235,6 +283,20 @@ def log_path(self) -> Path | None:
def is_open(self) -> bool:
return self._open and not self._closed

@property
def trace_id(self) -> str | None:
return self.spine.trace_id if self.spine else None

@property
def declared_rules(self) -> list[dict[str, Any]]:
if self._declared_rules:
return self._declared_rules
return self.rules

@property
def open_snapshot_hash(self) -> str | None:
return self._open_snapshot_hash or self.snapshot_hash


def _snapshot_hash(profile: AgentProfile, rules: list[dict[str, Any]]) -> str:
blob = json.dumps(
Expand Down
4 changes: 2 additions & 2 deletions aura/exporters/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Session exporters."""

from aura.exporters.jsonl import export_session
from aura.exporters.jsonl import build_session_summary, export_session

__all__ = ["export_session"]
__all__ = ["build_session_summary", "export_session"]
Loading
Loading