feat(decision): add decision persistence layer #131

Merged
hamza.khyari merged 2 commits from feature/m4-decision-persistence into master 2026-02-26 14:25:27 +00:00
12 changed files with 2362 additions and 1 deletions
+4
View File
@@ -11,6 +11,10 @@
- Fixed `examples/actors/graph_workflow.yaml` to use `actor_ref` instead of deprecated `actor_path`.
- Added Robot smoke test for loading hierarchical actor YAML via `ActorLoader.discover()`.
- Added decision persistence layer with DecisionRepository, DecisionModel, Alembic migration,
tree queries (BFS traversal, path-to-root), superseded lookup, and ordered decision path
retrieval. Includes Behave BDD scenarios, Robot Framework integration tests, and ASV
benchmarks. (#171)
- Added token/cost tracking, budget enforcement (per-plan and per-day), provider fallback
selection with capability filtering, and cost metadata for plan execution. New config keys
`budget_per_plan`, `budget_per_day`, and `fallback_providers` control spending limits and
+1
View File
@@ -2,6 +2,7 @@
* Jeffrey Phillips Freeman <jeffrey.freeman@syncleus.com>
* Brent E. Edwards <brent.edwards@cleverthis.com>
* Hamza Khyari <hamza.khyari@cleverthis.com>
* Luis Mendes <luis.p.mendes@gmail.com>
# Details
+153
View File
@@ -0,0 +1,153 @@
"""Add decision tree tables.
Creates the ``decisions`` and ``decision_dependencies`` tables for
the decision tree subsystem (Stage M3.2).
Revision ID: m4_001_decision_tables
Revises: d0_002_merge_changeset_and_locks
Create Date: 2026-02-23
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "m4_001_decision_tables"
down_revision: str | None = "d0_002_merge_changeset_and_locks"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create decisions and decision_dependencies tables."""
# -- decisions ----------------------------------------------------------
op.create_table(
"decisions",
sa.Column("decision_id", sa.String(26), nullable=False),
sa.Column(
"plan_id",
sa.String(26),
sa.ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"parent_decision_id",
sa.String(26),
sa.ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("sequence_number", sa.Integer, nullable=False),
sa.Column("decision_type", sa.String(30), nullable=False),
sa.Column("question", sa.Text, nullable=False),
sa.Column("chosen_option", sa.Text, nullable=False),
sa.Column("alternatives_considered_json", sa.Text, nullable=True),
sa.Column("confidence_score", sa.Float, nullable=True),
sa.Column("context_snapshot_json", sa.Text, nullable=False),
sa.Column("rationale", sa.Text, nullable=True),
sa.Column("actor_reasoning", sa.Text, nullable=True),
sa.Column("downstream_decision_ids_json", sa.Text, nullable=True),
sa.Column("downstream_plan_ids_json", sa.Text, nullable=True),
sa.Column("artifacts_produced_json", sa.Text, nullable=True),
sa.Column("created_at", sa.String(30), nullable=False),
sa.Column(
"is_correction",
sa.Boolean,
nullable=False,
server_default=sa.text("0"),
),
sa.Column(
"corrects_decision_id",
sa.String(26),
sa.ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("correction_reason", sa.Text, nullable=True),
sa.Column(
"superseded_by",
sa.String(26),
sa.ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
),
sa.PrimaryKeyConstraint("decision_id"),
sa.CheckConstraint(
"decision_type IN ("
"'prompt_definition', 'invariant_enforced', "
"'strategy_choice', 'implementation_choice', "
"'resource_selection', 'subplan_spawn', "
"'subplan_parallel_spawn', 'tool_invocation', "
"'error_recovery', 'validation_response', "
"'user_intervention')",
name="ck_decisions_type",
),
sa.CheckConstraint(
"confidence_score IS NULL OR "
"(confidence_score >= 0.0 AND confidence_score <= 1.0)",
name="ck_decisions_confidence",
),
)
op.create_index("ix_decisions_plan", "decisions", ["plan_id"])
op.create_index("ix_decisions_parent", "decisions", ["parent_decision_id"])
op.create_index("ix_decisions_type", "decisions", ["decision_type"])
op.create_index("ix_decisions_created", "decisions", ["created_at"])
op.create_index(
"ix_decisions_plan_seq", "decisions", ["plan_id", "sequence_number"]
)
# -- decision_dependencies ----------------------------------------------
op.create_table(
"decision_dependencies",
sa.Column(
"source_decision_id",
sa.String(26),
sa.ForeignKey("decisions.decision_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"target_decision_id",
sa.String(26),
sa.ForeignKey("decisions.decision_id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"relationship_type",
sa.String(30),
nullable=False,
server_default="influences",
),
sa.Column("created_at", sa.String(30), nullable=False),
sa.PrimaryKeyConstraint("source_decision_id", "target_decision_id"),
sa.CheckConstraint(
"source_decision_id != target_decision_id",
name="ck_decision_deps_no_self_loop",
),
)
op.create_index(
"ix_decision_deps_source",
"decision_dependencies",
["source_decision_id"],
)
op.create_index(
"ix_decision_deps_target",
"decision_dependencies",
["target_decision_id"],
)
def downgrade() -> None:
"""Drop decision_dependencies and decisions tables."""
# Drop decision_dependencies first (FK to decisions)
op.drop_index("ix_decision_deps_target", table_name="decision_dependencies")
op.drop_index("ix_decision_deps_source", table_name="decision_dependencies")
op.drop_table("decision_dependencies")
# Drop decisions
op.drop_index("ix_decisions_plan_seq", table_name="decisions")
op.drop_index("ix_decisions_created", table_name="decisions")
op.drop_index("ix_decisions_type", table_name="decisions")
op.drop_index("ix_decisions_parent", table_name="decisions")
op.drop_index("ix_decisions_plan", table_name="decisions")
op.drop_table("decisions")
+246
View File
@@ -0,0 +1,246 @@
"""ASV benchmarks for DecisionRepository persistence operations.
Measures create, get, get_by_plan, get_tree, and list_by_type performance.
"""
from __future__ import annotations
import sys
from datetime import UTC, datetime
from pathlib import Path
try:
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.decision import (
Decision,
DecisionType,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
DecisionRepository,
LifecyclePlanRepository,
)
except ModuleNotFoundError:
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.decision import (
Decision,
DecisionType,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
DecisionRepository,
LifecyclePlanRepository,
)
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
_PLAN_ID = "01HV00000000000000BENCH001"
def _make_db():
"""Create an in-memory SQLite DB with all tables and prerequisites."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
sm = sessionmaker(bind=engine)
session = sm()
factory = lambda: session # noqa: E731
# Action prerequisite
action_repo = ActionRepository(session_factory=factory)
action = Action(
namespaced_name=NamespacedName.parse("local/bench-decision-action"),
description="Benchmark action",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
state=ActionState.AVAILABLE,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
action_repo.create(action)
session.commit()
# Plan prerequisite
plan_repo = LifecyclePlanRepository(session_factory=factory)
now = datetime(2026, 3, 1, tzinfo=UTC)
plan = Plan(
identity=PlanIdentity(plan_id=_PLAN_ID, attempt=1),
namespaced_name=NamespacedName(namespace="local", name="bench-plan"),
action_name="local/bench-decision-action",
description="Benchmark plan",
definition_of_done="Done",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.PROCESSING,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by="bench",
tags=[],
reusable=True,
read_only=False,
)
plan_repo.create(plan)
session.commit()
return session, factory
def _make_decision(seq=0, parent_id=None, dtype=DecisionType.PROMPT_DEFINITION):
return Decision(
plan_id=_PLAN_ID,
parent_decision_id=parent_id,
sequence_number=seq,
decision_type=dtype,
question="Benchmark question?",
chosen_option="Benchmark option",
)
class TimeDecisionCreate:
"""Benchmark decision creation."""
timeout = 60
def setup(self):
self.session, self.factory = _make_db()
self.repo = DecisionRepository(session_factory=self.factory)
self._seq = 100
def time_create_single(self):
self._seq += 1
d = _make_decision(seq=self._seq, dtype=DecisionType.STRATEGY_CHOICE)
self.repo.create(d)
self.session.commit()
class TimeDecisionGet:
"""Benchmark decision retrieval."""
timeout = 60
def setup(self):
self.session, self.factory = _make_db()
self.repo = DecisionRepository(session_factory=self.factory)
d = _make_decision()
self.repo.create(d)
self.session.commit()
self.decision_id = d.decision_id
def time_get_by_id(self):
self.repo.get(self.decision_id)
class TimeDecisionGetByPlan:
"""Benchmark get_by_plan with 50 pre-seeded decisions."""
timeout = 60
def setup(self):
self.session, self.factory = _make_db()
self.repo = DecisionRepository(session_factory=self.factory)
root = _make_decision(seq=0)
self.repo.create(root)
root_id = root.decision_id
for i in range(1, 50):
d = _make_decision(
seq=i,
parent_id=root_id,
dtype=DecisionType.STRATEGY_CHOICE,
)
self.repo.create(d)
self.session.commit()
def time_get_by_plan(self):
self.repo.get_by_plan(_PLAN_ID)
class TimeDecisionTree:
"""Benchmark get_tree with a 3-level tree."""
timeout = 60
def setup(self):
self.session, self.factory = _make_db()
self.repo = DecisionRepository(session_factory=self.factory)
root = _make_decision(seq=0)
self.repo.create(root)
self.root_id = root.decision_id
seq = 1
for _ in range(3):
child = _make_decision(
seq=seq,
parent_id=root.decision_id,
dtype=DecisionType.STRATEGY_CHOICE,
)
self.repo.create(child)
for _ in range(2):
seq += 1
gc = _make_decision(
seq=seq,
parent_id=child.decision_id,
dtype=DecisionType.IMPLEMENTATION_CHOICE,
)
self.repo.create(gc)
seq += 1
self.session.commit()
def time_get_tree(self):
self.repo.get_tree(self.root_id)
class TimeDecisionListByType:
"""Benchmark list_by_type with pre-seeded typed decisions."""
timeout = 60
def setup(self):
self.session, self.factory = _make_db()
self.repo = DecisionRepository(session_factory=self.factory)
root = _make_decision(seq=0)
self.repo.create(root)
root_id = root.decision_id
for i in range(1, 20):
d = _make_decision(
seq=i,
parent_id=root_id,
dtype=DecisionType.STRATEGY_CHOICE,
)
self.repo.create(d)
for i in range(20, 30):
d = _make_decision(
seq=i,
parent_id=root_id,
dtype=DecisionType.IMPLEMENTATION_CHOICE,
)
self.repo.create(d)
self.session.commit()
def time_list_strategy_choices(self):
self.repo.list_by_type(_PLAN_ID, "strategy_choice")
def time_list_implementation_choices(self):
self.repo.list_by_type(_PLAN_ID, "implementation_choice")
+76
View File
@@ -230,6 +230,82 @@ tracking, and optional compiled metadata.
---
## Decision Tree Tables
The decision tree subsystem consists of two tables introduced in Stage M3.2:
| Table | Model | Description |
|--------------------------|--------------------------|----------------------------------|
| `decisions` | `DecisionModel` | Decision tree nodes |
| `decision_dependencies` | `DecisionDependencyModel` | Decision influence DAG edges |
### decisions
Primary key: `decision_id` (26-char ULID).
FK to `v3_plans.plan_id` (CASCADE delete). Self-referential FKs for
`parent_decision_id`, `corrects_decision_id`, and `superseded_by` (SET NULL).
| Column | Type | Constraints | Description |
|--------------------------------|-------------|---------------------------------------|-----------------------------------------|
| `decision_id` | String(26) | PRIMARY KEY | ULID identifier |
| `plan_id` | String(26) | NOT NULL, FK -> v3_plans CASCADE | Parent plan reference |
| `parent_decision_id` | String(26) | FK -> decisions SET NULL | Parent decision in tree |
| `sequence_number` | Integer | NOT NULL | Monotonic order within plan |
| `decision_type` | String(30) | NOT NULL, CHECK (11 enum values) | Classification of the decision |
| `question` | Text | NOT NULL | What question was being answered |
| `chosen_option` | Text | NOT NULL | The option that was chosen |
| `alternatives_considered_json` | Text | | JSON array of alternative options |
| `confidence_score` | Float | CHECK (0.0-1.0 or NULL) | Confidence in the decision |
| `context_snapshot_json` | Text | NOT NULL | JSON blob of ContextSnapshot |
| `rationale` | Text | | Human-readable rationale |
| `actor_reasoning` | Text | | Raw LLM reasoning trace |
| `downstream_decision_ids_json` | Text | | JSON array of dependent decision ULIDs |
| `downstream_plan_ids_json` | Text | | JSON array of spawned plan ULIDs |
| `artifacts_produced_json` | Text | | JSON array of ArtifactRef objects |
| `created_at` | String(30) | NOT NULL | ISO-8601 timestamp |
| `is_correction` | Boolean | NOT NULL, DEFAULT FALSE | Whether this is a correction |
| `corrects_decision_id` | String(26) | FK -> decisions SET NULL | Decision being corrected |
| `correction_reason` | Text | | Why the correction was made |
| `superseded_by` | String(26) | FK -> decisions SET NULL | Decision that supersedes this one |
**Check constraints:**
- `ck_decisions_type`: `decision_type IN ('prompt_definition', 'invariant_enforced', 'strategy_choice', 'implementation_choice', 'resource_selection', 'subplan_spawn', 'subplan_parallel_spawn', 'tool_invocation', 'error_recovery', 'validation_response', 'user_intervention')`
- `ck_decisions_confidence`: `confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)`
**Indexes:**
- `ix_decisions_plan` on `(plan_id)`
- `ix_decisions_parent` on `(parent_decision_id)`
- `ix_decisions_type` on `(decision_type)`
- `ix_decisions_created` on `(created_at)`
- `ix_decisions_plan_seq` on `(plan_id, sequence_number)`
### decision_dependencies
Composite primary key: `(source_decision_id, target_decision_id)`.
Both columns FK to `decisions.decision_id` with `CASCADE` delete.
Stores `relationship_type` (default `influences`) and a self-loop
check constraint.
| Column | Type | Constraints | Description |
|-----------------------|-------------|----------------------------------------|---------------------------------|
| `source_decision_id` | String(26) | PK, FK -> decisions CASCADE | Decision that influences |
| `target_decision_id` | String(26) | PK, FK -> decisions CASCADE | Decision that is influenced |
| `relationship_type` | String(30) | NOT NULL, DEFAULT 'influences' | Type of influence relationship |
| `created_at` | String(30) | NOT NULL | ISO-8601 timestamp |
**Check constraints:**
- `ck_decision_deps_no_self_loop`: `source_decision_id != target_decision_id`
**Indexes:**
- `ix_decision_deps_source` on `(source_decision_id)`
- `ix_decision_deps_target` on `(target_decision_id)`
---
## Migration
- **Revision**: `c1_001_tool_registry`
+149
View File
@@ -0,0 +1,149 @@
Feature: Decision persistence via DecisionRepository
As a developer
I want decisions to persist correctly through the DecisionRepository
So that the decision tree is durable across operations
Background:
Given a fresh in-memory decision persistence database
And a prerequisite action "local/decision-action" exists for decisions
And a prerequisite plan exists for decisions
# ------------------------------------------------------------------
# Create and retrieve
# ------------------------------------------------------------------
Scenario: Create a root decision and retrieve it by ID
Given a new root decision with sequence 0
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision type should be "prompt_definition"
And the persisted decision should be a root
Scenario: Create a child decision and retrieve it
Given a persisted root decision
And a new child decision of type "strategy_choice" with sequence 1
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision should not be a root
And the persisted decision parent should match the root
Scenario: Persisted decision preserves all scalar fields
Given a new root decision with all fields populated
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision question should match
And the persisted decision chosen_option should match
And the persisted decision confidence_score should be 0.85
And the persisted decision rationale should match
# ------------------------------------------------------------------
# JSON round-trip
# ------------------------------------------------------------------
Scenario: Alternatives considered round-trip through JSON
Given a new decision with 3 alternatives
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision should have 3 alternatives
Scenario: Context snapshot round-trip through JSON
Given a new decision with a populated context snapshot
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision context hash should be "sha256:persist-test"
And the persisted decision context should have 2 resources
Scenario: Artifacts produced round-trip through JSON
Given a new decision with 2 artifacts
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision should have 2 artifacts
Scenario: Downstream IDs round-trip through JSON
Given a new decision with downstream IDs
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision should have downstream decision IDs
And the persisted decision should have downstream plan IDs
# ------------------------------------------------------------------
# Get by plan
# ------------------------------------------------------------------
Scenario: Get decisions by plan returns ordered results
Given 3 persisted decisions for the same plan
When I get decisions by plan ID
Then I should get 3 decisions in sequence order
# ------------------------------------------------------------------
# Get tree (BFS)
# ------------------------------------------------------------------
Scenario: Get tree returns root and descendants in BFS order
Given a persisted decision tree with 3 levels
When I get the decision tree from the root
Then the tree should have 4 decisions
And the first decision in the tree should be the root
# ------------------------------------------------------------------
# Get path to root
# ------------------------------------------------------------------
Scenario: Get path to root walks from leaf to root
Given a persisted decision tree with 3 levels
When I get the path from a leaf to root
Then the path should start with the leaf
And the path should end with the root
# ------------------------------------------------------------------
# Correction and superseded
# ------------------------------------------------------------------
Scenario: Create a correction decision
Given a persisted root decision
And a correction decision that corrects the root
When I persist the decision via the decision repository
Then I can retrieve the decision by its ID
And the persisted decision is_correction should be true
Scenario: Update superseded_by on an existing decision
Given a persisted root decision
When I update the root decision superseded_by to a new ULID
Then the root decision should be marked as superseded
Scenario: Get superseded decisions for a plan
Given a persisted root decision that is superseded
When I get superseded decisions for the plan
Then I should get 1 superseded decision
# ------------------------------------------------------------------
# List by type
# ------------------------------------------------------------------
Scenario: List decisions by type filters correctly
Given 2 persisted decisions of type "strategy_choice"
And 1 persisted decision of type "implementation_choice"
When I list decisions by type "strategy_choice"
Then I should get 2 decisions of that type
# ------------------------------------------------------------------
# Delete
# ------------------------------------------------------------------
Scenario: Delete a decision by ID
Given a persisted root decision
When I delete the root decision
Then the root decision should no longer exist
Scenario: Delete a non-existent decision returns false
When I try to delete a non-existent decision
Then the decision delete result should be false
# ------------------------------------------------------------------
# Duplicate detection
# ------------------------------------------------------------------
Scenario: Creating a duplicate decision raises DuplicateDecisionError
Given a persisted root decision
When I try to persist the same decision again
Then a DuplicateDecisionError should be raised
@@ -0,0 +1,608 @@
"""Step definitions for decision_persistence.feature.
Tests the DecisionRepository CRUD operations, JSON round-trips,
tree traversal, correction workflows, and constraint enforcement.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from ulid import ULID
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.decision import (
ArtifactRef,
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
DecisionRepository,
DuplicateDecisionError,
LifecyclePlanRepository,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PLAN_ID = "01HV000000000000000000DP01"
def _setup_db(context: Context) -> None:
"""Create an in-memory SQLite DB and attach repos."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
sm = sessionmaker(bind=engine)
session = sm()
context._dp_engine = engine
context._dp_session = session
context._dp_factory = lambda: session
context._dp_repo = DecisionRepository(session_factory=context._dp_factory)
context._dp_action_repo = ActionRepository(session_factory=context._dp_factory)
context._dp_plan_repo = LifecyclePlanRepository(
session_factory=context._dp_factory,
)
def _make_decision(
plan_id: str = _PLAN_ID,
parent_decision_id: str | None = None,
sequence_number: int = 0,
decision_type: DecisionType = DecisionType.PROMPT_DEFINITION,
question: str = "What approach should we take?",
chosen_option: str = "Build a REST API",
confidence_score: float | None = None,
context_snapshot: ContextSnapshot | None = None,
alternatives_considered: list[str] | None = None,
artifacts_produced: list[ArtifactRef] | None = None,
downstream_decision_ids: list[str] | None = None,
downstream_plan_ids: list[str] | None = None,
rationale: str = "",
is_correction: bool = False,
corrects_decision_id: str | None = None,
correction_reason: str | None = None,
superseded_by: str | None = None,
) -> Decision:
kwargs: dict[str, Any] = {
"plan_id": plan_id,
"sequence_number": sequence_number,
"decision_type": decision_type,
"question": question,
"chosen_option": chosen_option,
"rationale": rationale,
"is_correction": is_correction,
}
if parent_decision_id is not None:
kwargs["parent_decision_id"] = parent_decision_id
if confidence_score is not None:
kwargs["confidence_score"] = confidence_score
if context_snapshot is not None:
kwargs["context_snapshot"] = context_snapshot
if alternatives_considered is not None:
kwargs["alternatives_considered"] = alternatives_considered
if artifacts_produced is not None:
kwargs["artifacts_produced"] = artifacts_produced
if downstream_decision_ids is not None:
kwargs["downstream_decision_ids"] = downstream_decision_ids
if downstream_plan_ids is not None:
kwargs["downstream_plan_ids"] = downstream_plan_ids
if corrects_decision_id is not None:
kwargs["corrects_decision_id"] = corrects_decision_id
if correction_reason is not None:
kwargs["correction_reason"] = correction_reason
if superseded_by is not None:
kwargs["superseded_by"] = superseded_by
return Decision(**kwargs)
def _create_prerequisite_action(context: Context) -> None:
ns = NamespacedName.parse("local/decision-action")
action = Action(
namespaced_name=ns,
description="Prerequisite action for decision tests",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
state=ActionState.AVAILABLE,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
context._dp_action_repo.create(action)
context._dp_session.commit()
def _create_prerequisite_plan(context: Context) -> None:
now = datetime(2026, 3, 1, tzinfo=UTC)
plan = Plan(
identity=PlanIdentity(plan_id=_PLAN_ID, attempt=1),
namespaced_name=NamespacedName(namespace="local", name="decision-plan"),
action_name="local/decision-action",
description="Decision test plan",
definition_of_done="All assertions pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.PROCESSING,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by="test-decision",
tags=[],
reusable=True,
read_only=False,
)
context._dp_plan_repo.create(plan)
context._dp_session.commit()
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a fresh in-memory decision persistence database")
def step_fresh_db(context: Context) -> None:
_setup_db(context)
@given('a prerequisite action "{action_name}" exists for decisions')
def step_prereq_action(context: Context, action_name: str) -> None:
_create_prerequisite_action(context)
@given("a prerequisite plan exists for decisions")
def step_prereq_plan(context: Context) -> None:
_create_prerequisite_plan(context)
# ---------------------------------------------------------------------------
# Create and retrieve
# ---------------------------------------------------------------------------
@given("a new root decision with sequence {seq:d}")
def step_new_root(context: Context, seq: int) -> None:
context._dp_decision = _make_decision(sequence_number=seq)
@given("a persisted root decision")
def step_persisted_root(context: Context) -> None:
d = _make_decision()
context._dp_repo.create(d)
context._dp_session.commit()
context._dp_root_decision = d
context._dp_root_id = d.decision_id
@given('a new child decision of type "{dtype}" with sequence {seq:d}')
def step_new_child(context: Context, dtype: str, seq: int) -> None:
context._dp_decision = _make_decision(
parent_decision_id=context._dp_root_id,
sequence_number=seq,
decision_type=DecisionType(dtype),
)
@given("a new root decision with all fields populated")
def step_new_full_root(context: Context) -> None:
context._dp_decision = _make_decision(
question="Full question?",
chosen_option="Full option",
confidence_score=0.85,
rationale="Thorough analysis",
alternatives_considered=["A", "B"],
context_snapshot=ContextSnapshot(
hot_context_hash="sha256:full",
hot_context_ref="store://full",
relevant_resources=[ResourceRef(resource_id=str(ULID()))],
actor_state_ref="checkpoint://full",
),
)
@when("I persist the decision via the decision repository")
def step_persist_decision(context: Context) -> None:
context._dp_repo.create(context._dp_decision)
context._dp_session.commit()
@then("I can retrieve the decision by its ID")
def step_retrieve_by_id(context: Context) -> None:
d = context._dp_repo.get(context._dp_decision.decision_id)
assert d is not None, "Decision not found"
context._dp_retrieved = d
@then('the persisted decision type should be "{dtype}"')
def step_check_type(context: Context, dtype: str) -> None:
assert str(context._dp_retrieved.decision_type) == dtype
@then("the persisted decision should be a root")
def step_check_is_root(context: Context) -> None:
assert context._dp_retrieved.is_root
@then("the persisted decision should not be a root")
def step_check_not_root(context: Context) -> None:
assert not context._dp_retrieved.is_root
@then("the persisted decision parent should match the root")
def step_check_parent(context: Context) -> None:
assert context._dp_retrieved.parent_decision_id == context._dp_root_id
@then("the persisted decision question should match")
def step_check_question(context: Context) -> None:
assert context._dp_retrieved.question == context._dp_decision.question
@then("the persisted decision chosen_option should match")
def step_check_chosen(context: Context) -> None:
assert context._dp_retrieved.chosen_option == context._dp_decision.chosen_option
@then("the persisted decision confidence_score should be {score}")
def step_check_confidence(context: Context, score: str) -> None:
expected = float(score)
assert context._dp_retrieved.confidence_score == expected, (
f"Expected {expected}, got {context._dp_retrieved.confidence_score}"
)
@then("the persisted decision rationale should match")
def step_check_rationale(context: Context) -> None:
assert context._dp_retrieved.rationale == context._dp_decision.rationale
# ---------------------------------------------------------------------------
# JSON round-trip
# ---------------------------------------------------------------------------
@given("a new decision with {count:d} alternatives")
def step_new_with_alts(context: Context, count: int) -> None:
alts = [f"Alternative {i}" for i in range(count)]
context._dp_decision = _make_decision(alternatives_considered=alts)
@then("the persisted decision should have {count:d} alternatives")
def step_check_alt_count(context: Context, count: int) -> None:
assert len(context._dp_retrieved.alternatives_considered) == count
@given("a new decision with a populated context snapshot")
def step_new_with_snapshot(context: Context) -> None:
snap = ContextSnapshot(
hot_context_hash="sha256:persist-test",
hot_context_ref="store://persist-test",
relevant_resources=[
ResourceRef(resource_id=str(ULID()), path="src/main.py"),
ResourceRef(resource_id=str(ULID())),
],
actor_state_ref="checkpoint://persist",
)
context._dp_decision = _make_decision(context_snapshot=snap)
@then('the persisted decision context hash should be "{expected}"')
def step_check_ctx_hash(context: Context, expected: str) -> None:
assert context._dp_retrieved.context_snapshot.hot_context_hash == expected
@then("the persisted decision context should have {count:d} resources")
def step_check_ctx_resources(context: Context, count: int) -> None:
assert len(context._dp_retrieved.context_snapshot.relevant_resources) == count
@given("a new decision with {count:d} artifacts")
def step_new_with_artifacts(context: Context, count: int) -> None:
arts = [
ArtifactRef(artifact_path=f"src/file_{i}.py", artifact_type="file")
for i in range(count)
]
context._dp_decision = _make_decision(artifacts_produced=arts)
@then("the persisted decision should have {count:d} artifacts")
def step_check_artifact_count(context: Context, count: int) -> None:
assert len(context._dp_retrieved.artifacts_produced) == count
@given("a new decision with downstream IDs")
def step_new_with_downstream(context: Context) -> None:
context._dp_decision = _make_decision(
downstream_decision_ids=[str(ULID()), str(ULID())],
downstream_plan_ids=[str(ULID())],
)
@then("the persisted decision should have downstream decision IDs")
def step_check_downstream_decisions(context: Context) -> None:
assert len(context._dp_retrieved.downstream_decision_ids) > 0
@then("the persisted decision should have downstream plan IDs")
def step_check_downstream_plans(context: Context) -> None:
assert len(context._dp_retrieved.downstream_plan_ids) > 0
# ---------------------------------------------------------------------------
# Get by plan
# ---------------------------------------------------------------------------
@given("{count:d} persisted decisions for the same plan")
def step_n_decisions(context: Context, count: int) -> None:
for i in range(count):
dt = DecisionType.PROMPT_DEFINITION if i == 0 else DecisionType.STRATEGY_CHOICE
parent = None if i == 0 else context._dp_first_id
d = _make_decision(
sequence_number=i,
decision_type=dt,
parent_decision_id=parent,
)
context._dp_repo.create(d)
if i == 0:
context._dp_first_id = d.decision_id
context._dp_session.commit()
@when("I get decisions by plan ID")
def step_get_by_plan(context: Context) -> None:
context._dp_plan_decisions = context._dp_repo.get_by_plan(_PLAN_ID)
@then("I should get {count:d} decisions in sequence order")
def step_check_plan_decisions(context: Context, count: int) -> None:
decisions = context._dp_plan_decisions
assert len(decisions) == count, f"Expected {count}, got {len(decisions)}"
for i in range(len(decisions) - 1):
assert decisions[i].sequence_number <= decisions[i + 1].sequence_number
# ---------------------------------------------------------------------------
# Get tree
# ---------------------------------------------------------------------------
@given("a persisted decision tree with 3 levels")
def step_decision_tree(context: Context) -> None:
# Root (level 0)
root = _make_decision(sequence_number=0)
context._dp_repo.create(root)
context._dp_tree_root_id = root.decision_id
# Level 1 — two children
child1 = _make_decision(
parent_decision_id=root.decision_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
)
context._dp_repo.create(child1)
child2 = _make_decision(
parent_decision_id=root.decision_id,
sequence_number=2,
decision_type=DecisionType.STRATEGY_CHOICE,
)
context._dp_repo.create(child2)
# Level 2 — one grandchild under child1
grandchild = _make_decision(
parent_decision_id=child1.decision_id,
sequence_number=3,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
)
context._dp_repo.create(grandchild)
context._dp_tree_leaf_id = grandchild.decision_id
context._dp_session.commit()
@when("I get the decision tree from the root")
def step_get_tree(context: Context) -> None:
context._dp_tree = context._dp_repo.get_tree(context._dp_tree_root_id)
@then("the tree should have {count:d} decisions")
def step_check_tree_count(context: Context, count: int) -> None:
assert len(context._dp_tree) == count, (
f"Expected {count}, got {len(context._dp_tree)}"
)
@then("the first decision in the tree should be the root")
def step_check_tree_root(context: Context) -> None:
assert context._dp_tree[0].decision_id == context._dp_tree_root_id
# ---------------------------------------------------------------------------
# Get path to root
# ---------------------------------------------------------------------------
@when("I get the path from a leaf to root")
def step_get_path(context: Context) -> None:
context._dp_path = context._dp_repo.get_path_to_root(
context._dp_tree_leaf_id,
)
@then("the path should start with the leaf")
def step_check_path_leaf(context: Context) -> None:
assert context._dp_path[0].decision_id == context._dp_tree_leaf_id
@then("the path should end with the root")
def step_check_path_root(context: Context) -> None:
assert context._dp_path[-1].decision_id == context._dp_tree_root_id
# ---------------------------------------------------------------------------
# Correction and superseded
# ---------------------------------------------------------------------------
@given("a correction decision that corrects the root")
def step_correction_decision(context: Context) -> None:
context._dp_decision = _make_decision(
parent_decision_id=context._dp_root_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
is_correction=True,
corrects_decision_id=context._dp_root_id,
correction_reason="Root was suboptimal",
)
@then("the persisted decision is_correction should be true")
def step_check_is_correction(context: Context) -> None:
assert context._dp_retrieved.is_correction
@when("I update the root decision superseded_by to a new ULID")
def step_update_superseded(context: Context) -> None:
new_id = str(ULID())
context._dp_superseding_id = new_id
context._dp_repo.update_superseded_by(context._dp_root_id, new_id)
context._dp_session.commit()
@then("the root decision should be marked as superseded")
def step_check_superseded(context: Context) -> None:
d = context._dp_repo.get(context._dp_root_id)
assert d is not None
assert d.is_superseded
assert d.superseded_by == context._dp_superseding_id
@given("a persisted root decision that is superseded")
def step_superseded_root(context: Context) -> None:
d = _make_decision(superseded_by=str(ULID()))
context._dp_repo.create(d)
context._dp_session.commit()
context._dp_root_id = d.decision_id
@when("I get superseded decisions for the plan")
def step_get_superseded(context: Context) -> None:
context._dp_superseded = context._dp_repo.get_superseded(_PLAN_ID)
@then("I should get {count:d} superseded decision")
def step_check_superseded_count(context: Context, count: int) -> None:
assert len(context._dp_superseded) == count
# ---------------------------------------------------------------------------
# List by type
# ---------------------------------------------------------------------------
@given('{count:d} persisted decisions of type "{dtype}"')
def step_n_typed_decisions(context: Context, count: int, dtype: str) -> None:
dt = DecisionType(dtype)
for i in range(count):
# Use sequence offset to avoid collision with any prior root
seq = 10 + i
parent = None
if dt != DecisionType.PROMPT_DEFINITION:
# Need a root first
if not hasattr(context, "_dp_type_root_id"):
root = _make_decision(sequence_number=9)
context._dp_repo.create(root)
context._dp_session.commit()
context._dp_type_root_id = root.decision_id
parent = context._dp_type_root_id
d = _make_decision(
parent_decision_id=parent,
sequence_number=seq,
decision_type=dt,
)
context._dp_repo.create(d)
context._dp_session.commit()
@given('{count:d} persisted decision of type "{dtype}"')
def step_one_typed_decision(context: Context, count: int, dtype: str) -> None:
step_n_typed_decisions(context, count, dtype)
@when('I list decisions by type "{dtype}"')
def step_list_by_type(context: Context, dtype: str) -> None:
context._dp_typed = context._dp_repo.list_by_type(_PLAN_ID, dtype)
@then("I should get {count:d} decisions of that type")
def step_check_typed_count(context: Context, count: int) -> None:
assert len(context._dp_typed) == count, (
f"Expected {count}, got {len(context._dp_typed)}"
)
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
@when("I delete the root decision")
def step_delete_root(context: Context) -> None:
context._dp_delete_result = context._dp_repo.delete(context._dp_root_id)
context._dp_session.commit()
@then("the root decision should no longer exist")
def step_check_deleted(context: Context) -> None:
assert context._dp_delete_result is True
d = context._dp_repo.get(context._dp_root_id)
assert d is None
@when("I try to delete a non-existent decision")
def step_delete_missing(context: Context) -> None:
context._dp_delete_result = context._dp_repo.delete(str(ULID()))
@then("the decision delete result should be false")
def step_check_delete_false(context: Context) -> None:
assert context._dp_delete_result is False
# ---------------------------------------------------------------------------
# Duplicate detection
# ---------------------------------------------------------------------------
@when("I try to persist the same decision again")
def step_persist_duplicate(context: Context) -> None:
try:
context._dp_repo.create(context._dp_root_decision)
context._dp_session.commit()
context._dp_dup_error = None
except DuplicateDecisionError as exc:
context._dp_session.rollback()
context._dp_dup_error = exc
@then("a DuplicateDecisionError should be raised")
def step_check_duplicate(context: Context) -> None:
assert context._dp_dup_error is not None
assert isinstance(context._dp_dup_error, DuplicateDecisionError)
+65
View File
@@ -0,0 +1,65 @@
*** Settings ***
Documentation Smoke tests for decision persistence via DecisionRepository
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_decision_persistence.py
*** Test Cases ***
Create And Retrieve Root Decision
[Documentation] Create a root prompt_definition decision, persist, retrieve
[Tags] database decision persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create-retrieve cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} create-retrieve-ok
Create Child Decision With Parent
[Documentation] Create a child strategy_choice under a root decision
[Tags] database decision persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create-child cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} create-child-ok
JSON Fields Round-Trip
[Documentation] Persist alternatives, context snapshot, artifacts and verify round-trip
[Tags] database decision json persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} json-roundtrip cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} json-roundtrip-ok
Get Decisions By Plan
[Documentation] Retrieve all decisions for a plan in sequence order
[Tags] database decision query persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} get-by-plan cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} get-by-plan-ok
Decision Tree BFS Traversal
[Documentation] Build a 3-level tree and retrieve via BFS
[Tags] database decision tree persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} tree-bfs cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} tree-bfs-ok
Path To Root Traversal
[Documentation] Walk from a leaf decision up to the root
[Tags] database decision tree persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} path-to-root cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} path-to-root-ok
Superseded By Update
[Documentation] Mark a decision as superseded and verify
[Tags] database decision correction persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} superseded cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} superseded-ok
Delete Decision
[Documentation] Delete a decision and verify it is gone
[Tags] database decision persistence
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} delete cwd=${WORKSPACE} timeout=30s
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} delete-ok
+380
View File
@@ -0,0 +1,380 @@
"""Helper script for Robot Framework decision persistence smoke tests.
Usage:
python robot/helper_decision_persistence.py <subcommand>
Subcommands:
create-retrieve Create + retrieve a root decision
create-child Create a child under a root
json-roundtrip Verify JSON fields survive persistence
get-by-plan Get all decisions for a plan in order
tree-bfs Build a 3-level tree, retrieve via BFS
path-to-root Walk from leaf to root
superseded Mark a decision as superseded
delete Delete a decision
"""
from __future__ import annotations
import sys
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
# Ensure src is importable when run from workspace root
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from collections.abc import Callable
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from ulid import ULID
from cleveragents.domain.models.core.action import (
Action,
ActionState,
)
from cleveragents.domain.models.core.decision import (
ArtifactRef,
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
from cleveragents.domain.models.core.plan import (
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
DecisionRepository,
LifecyclePlanRepository,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_PLAN_ID = "01HV000000000000000000RP01"
def _setup() -> tuple[Session, Callable[[], Session]]:
"""Create an in-memory SQLite DB with all tables."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
sm = sessionmaker(bind=engine)
session = sm()
factory: Callable[[], Session] = lambda: session # noqa: E731
return session, factory
def _create_prerequisites(session: Session, factory: Callable[[], Session]) -> None:
"""Create the action + plan needed to satisfy FK constraints."""
action_repo = ActionRepository(session_factory=factory)
action = Action(
namespaced_name=NamespacedName.parse("local/robot-decision-action"),
description="Robot prerequisite action",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
state=ActionState.AVAILABLE,
created_at=datetime(2026, 1, 1, tzinfo=UTC),
updated_at=datetime(2026, 1, 1, tzinfo=UTC),
)
action_repo.create(action)
session.commit()
plan_repo = LifecyclePlanRepository(session_factory=factory)
now = datetime(2026, 3, 1, tzinfo=UTC)
plan = Plan(
identity=PlanIdentity(plan_id=_PLAN_ID, attempt=1),
namespaced_name=NamespacedName(namespace="local", name="robot-plan"),
action_name="local/robot-decision-action",
description="Robot decision test plan",
definition_of_done="All tests pass",
phase=PlanPhase.STRATEGIZE,
processing_state=ProcessingState.PROCESSING,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(created_at=now, updated_at=now),
created_by="robot-test",
tags=[],
reusable=True,
read_only=False,
)
plan_repo.create(plan)
session.commit()
def _make_decision(
plan_id: str = _PLAN_ID,
parent_decision_id: str | None = None,
sequence_number: int = 0,
decision_type: DecisionType = DecisionType.PROMPT_DEFINITION,
**kwargs: Any,
) -> Decision:
return Decision(
plan_id=plan_id,
parent_decision_id=parent_decision_id,
sequence_number=sequence_number,
decision_type=decision_type,
question=kwargs.get("question", "What approach?"),
chosen_option=kwargs.get("chosen_option", "Build API"),
**{k: v for k, v in kwargs.items() if k not in ("question", "chosen_option")},
)
# ---------------------------------------------------------------------------
# Subcommands
# ---------------------------------------------------------------------------
def _create_retrieve() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
d = _make_decision()
repo.create(d)
session.commit()
got = repo.get(d.decision_id)
assert got is not None, "decision not found"
assert got.decision_id == d.decision_id
assert got.is_root
assert str(got.decision_type) == "prompt_definition"
print("create-retrieve-ok")
def _create_child() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
root = _make_decision()
repo.create(root)
session.commit()
child = _make_decision(
parent_decision_id=root.decision_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
)
repo.create(child)
session.commit()
got = repo.get(child.decision_id)
assert got is not None
assert not got.is_root
assert got.parent_decision_id == root.decision_id
print("create-child-ok")
def _json_roundtrip() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
snap = ContextSnapshot(
hot_context_hash="sha256:robot-test",
hot_context_ref="store://robot",
relevant_resources=[
ResourceRef(resource_id=str(ULID()), path="src/main.py"),
ResourceRef(resource_id=str(ULID())),
],
actor_state_ref="checkpoint://robot",
)
d = _make_decision(
alternatives_considered=["Flask", "Django", "Express"],
confidence_score=0.92,
context_snapshot=snap,
artifacts_produced=[
ArtifactRef(artifact_path="src/api.py", artifact_type="file"),
ArtifactRef(artifact_path="patches/fix.patch", artifact_type="patch"),
],
downstream_decision_ids=[str(ULID())],
downstream_plan_ids=[str(ULID()), str(ULID())],
)
repo.create(d)
session.commit()
got = repo.get(d.decision_id)
assert got is not None
assert len(got.alternatives_considered) == 3
assert got.confidence_score == 0.92
assert got.context_snapshot.hot_context_hash == "sha256:robot-test"
assert len(got.context_snapshot.relevant_resources) == 2
assert len(got.artifacts_produced) == 2
assert len(got.downstream_decision_ids) == 1
assert len(got.downstream_plan_ids) == 2
print("json-roundtrip-ok")
def _get_by_plan() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
first_id: str | None = None
for i in range(5):
dt = DecisionType.PROMPT_DEFINITION if i == 0 else DecisionType.STRATEGY_CHOICE
parent = None if i == 0 else first_id
d = _make_decision(
parent_decision_id=parent,
sequence_number=i,
decision_type=dt,
)
repo.create(d)
if i == 0:
first_id = d.decision_id
session.commit()
results = repo.get_by_plan(_PLAN_ID)
assert len(results) == 5
for i in range(len(results) - 1):
assert results[i].sequence_number <= results[i + 1].sequence_number
print("get-by-plan-ok")
def _tree_bfs() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
root = _make_decision(sequence_number=0)
repo.create(root)
c1 = _make_decision(
parent_decision_id=root.decision_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
)
repo.create(c1)
c2 = _make_decision(
parent_decision_id=root.decision_id,
sequence_number=2,
decision_type=DecisionType.STRATEGY_CHOICE,
)
repo.create(c2)
gc = _make_decision(
parent_decision_id=c1.decision_id,
sequence_number=3,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
)
repo.create(gc)
session.commit()
tree = repo.get_tree(root.decision_id)
assert len(tree) == 4, f"expected 4, got {len(tree)}"
assert tree[0].decision_id == root.decision_id
print("tree-bfs-ok")
def _path_to_root() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
root = _make_decision(sequence_number=0)
repo.create(root)
child = _make_decision(
parent_decision_id=root.decision_id,
sequence_number=1,
decision_type=DecisionType.STRATEGY_CHOICE,
)
repo.create(child)
leaf = _make_decision(
parent_decision_id=child.decision_id,
sequence_number=2,
decision_type=DecisionType.IMPLEMENTATION_CHOICE,
)
repo.create(leaf)
session.commit()
path = repo.get_path_to_root(leaf.decision_id)
assert len(path) == 3
assert path[0].decision_id == leaf.decision_id
assert path[-1].decision_id == root.decision_id
print("path-to-root-ok")
def _superseded() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
d = _make_decision()
repo.create(d)
session.commit()
new_id = str(ULID())
repo.update_superseded_by(d.decision_id, new_id)
session.commit()
got = repo.get(d.decision_id)
assert got is not None
assert got.is_superseded
assert got.superseded_by == new_id
print("superseded-ok")
def _delete() -> None:
session, factory = _setup()
_create_prerequisites(session, factory)
repo = DecisionRepository(session_factory=factory)
d = _make_decision()
repo.create(d)
session.commit()
result = repo.delete(d.decision_id)
session.commit()
assert result is True
got = repo.get(d.decision_id)
assert got is None
# Delete non-existent
result2 = repo.delete(str(ULID()))
assert result2 is False
print("delete-ok")
# ---------------------------------------------------------------------------
# Dispatcher
# ---------------------------------------------------------------------------
_COMMANDS = {
"create-retrieve": _create_retrieve,
"create-child": _create_child,
"json-roundtrip": _json_roundtrip,
"get-by-plan": _get_by_plan,
"tree-bfs": _tree_bfs,
"path-to-root": _path_to_root,
"superseded": _superseded,
"delete": _delete,
}
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit(f"Usage: {sys.argv[0]} <{'|'.join(_COMMANDS)}>")
command = sys.argv[1]
handler = _COMMANDS.get(command)
if handler is None:
raise SystemExit(f"Unknown command: {command}")
handler()
if __name__ == "__main__":
main()
@@ -19,6 +19,8 @@ Alembic migrations.
| ``resource_edges`` | ``ResourceEdgeModel`` | Resource DAG edges |
| ``ns_projects`` | ``NamespacedProjectModel`` | Namespaced projects |
| ``project_resource_links`` | ``ProjectResourceLinkModel`` | Project-resource |
| ``decisions`` | ``DecisionModel`` | Decision tree nodes |
| ``decision_dependencies`` | ``DecisionDependencyModel`` | Decision DAG edges |
Based on ADR-007 (Repository Pattern) and Phase 0 discovery.
Includes spec-aligned lifecycle models per Stage A5
@@ -2438,6 +2440,289 @@ class LockModel(Base): # type: ignore[misc]
)
# ---------------------------------------------------------------------------
# Decision models (Stage M3.2)
# ---------------------------------------------------------------------------
class DecisionModel(Base): # type: ignore[misc]
"""Database model for decision tree nodes.
Maps to the ``decisions`` table. Each row represents a single
choice point recorded during the Strategize or Execute phase of a
plan's lifecycle.
JSON-as-Text columns
--------------------
``alternatives_considered_json``, ``context_snapshot_json``,
``downstream_decision_ids_json``, ``downstream_plan_ids_json``, and
``artifacts_produced_json`` are stored as ``Text`` and parsed with
:func:`json.loads` / :func:`json.dumps`.
"""
__allow_unmapped__ = True
__tablename__ = "decisions"
# --- Identity ---
decision_id = Column(String(26), primary_key=True)
plan_id = Column(
String(26),
ForeignKey("v3_plans.plan_id", ondelete="CASCADE"),
nullable=False,
)
parent_decision_id = Column(
String(26),
ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
)
sequence_number = Column(Integer, nullable=False)
# --- Classification ---
decision_type = Column(String(30), nullable=False)
# --- The decision itself ---
question = Column(Text, nullable=False)
chosen_option = Column(Text, nullable=False)
alternatives_considered_json = Column(Text, nullable=True)
confidence_score = Column(Float, nullable=True)
# --- Context snapshot (JSON blob) ---
context_snapshot_json = Column(Text, nullable=False)
# --- Rationale ---
rationale = Column(Text, nullable=True, default="")
actor_reasoning = Column(Text, nullable=True)
# --- Downstream impact (JSON arrays) ---
downstream_decision_ids_json = Column(Text, nullable=True)
downstream_plan_ids_json = Column(Text, nullable=True)
artifacts_produced_json = Column(Text, nullable=True)
# --- Timestamps ---
created_at = Column(String(30), nullable=False)
# --- Correction metadata ---
is_correction = Column(Boolean, nullable=False, default=False)
corrects_decision_id = Column(
String(26),
ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
)
correction_reason = Column(Text, nullable=True)
superseded_by = Column(
String(26),
ForeignKey("decisions.decision_id", ondelete="SET NULL"),
nullable=True,
)
# --- Relationships ---
parent_decision = relationship(
"DecisionModel",
remote_side="DecisionModel.decision_id",
foreign_keys=[parent_decision_id],
)
__table_args__ = (
CheckConstraint(
"decision_type IN ("
"'prompt_definition', 'invariant_enforced', "
"'strategy_choice', 'implementation_choice', "
"'resource_selection', 'subplan_spawn', "
"'subplan_parallel_spawn', 'tool_invocation', "
"'error_recovery', 'validation_response', "
"'user_intervention')",
name="ck_decisions_type",
),
CheckConstraint(
"confidence_score IS NULL OR "
"(confidence_score >= 0.0 AND confidence_score <= 1.0)",
name="ck_decisions_confidence",
),
Index("ix_decisions_plan", "plan_id"),
Index("ix_decisions_parent", "parent_decision_id"),
Index("ix_decisions_type", "decision_type"),
Index("ix_decisions_created", "created_at"),
Index("ix_decisions_plan_seq", "plan_id", "sequence_number"),
)
# --- Domain conversion ---
def to_domain(self) -> Any:
"""Convert to a domain :class:`Decision` instance."""
from cleveragents.domain.models.core.decision import (
ArtifactRef,
ContextSnapshot,
Decision,
DecisionType,
ResourceRef,
)
# Deserialise JSON-as-text columns
alt_raw = cast(str, self.alternatives_considered_json or "[]")
alternatives: list[str] = json.loads(alt_raw)
snapshot_raw: dict[str, Any] = json.loads(
cast(str, self.context_snapshot_json),
)
relevant_resources = [
ResourceRef(**r) for r in snapshot_raw.get("relevant_resources", [])
]
context_snapshot = ContextSnapshot(
hot_context_hash=snapshot_raw.get("hot_context_hash", ""),
hot_context_ref=snapshot_raw.get("hot_context_ref", ""),
relevant_resources=relevant_resources,
actor_state_ref=snapshot_raw.get("actor_state_ref", ""),
)
dd_raw = cast(str, self.downstream_decision_ids_json or "[]")
downstream_decision_ids: list[str] = json.loads(dd_raw)
dp_raw = cast(str, self.downstream_plan_ids_json or "[]")
downstream_plan_ids: list[str] = json.loads(dp_raw)
artifacts_produced: list[ArtifactRef] = []
ap_raw = cast(str, self.artifacts_produced_json or "[]")
for a in json.loads(ap_raw):
artifacts_produced.append(ArtifactRef(**a))
# Parse created_at
created_at = datetime.fromisoformat(cast(str, self.created_at))
return Decision(
decision_id=cast(str, self.decision_id),
plan_id=cast(str, self.plan_id),
parent_decision_id=cast(str | None, self.parent_decision_id),
sequence_number=cast(int, self.sequence_number),
decision_type=DecisionType(cast(str, self.decision_type)),
question=cast(str, self.question),
chosen_option=cast(str, self.chosen_option),
alternatives_considered=alternatives,
confidence_score=cast(float | None, self.confidence_score),
context_snapshot=context_snapshot,
rationale=cast(str, self.rationale or ""),
actor_reasoning=cast(str | None, self.actor_reasoning),
downstream_decision_ids=downstream_decision_ids,
downstream_plan_ids=downstream_plan_ids,
artifacts_produced=artifacts_produced,
created_at=created_at,
is_correction=bool(self.is_correction),
corrects_decision_id=cast(str | None, self.corrects_decision_id),
correction_reason=cast(str | None, self.correction_reason),
superseded_by=cast(str | None, self.superseded_by),
)
@classmethod
def from_domain(cls, decision: Any) -> DecisionModel:
"""Create a model instance from a domain :class:`Decision`."""
# Serialise JSON-as-text columns
alternatives_json: str | None = None
if decision.alternatives_considered:
alternatives_json = json.dumps(decision.alternatives_considered)
# Serialise context snapshot
snapshot = decision.context_snapshot
snapshot_dict: dict[str, Any] = {
"hot_context_hash": snapshot.hot_context_hash,
"hot_context_ref": snapshot.hot_context_ref,
"relevant_resources": [
{"resource_id": r.resource_id, "path": r.path}
for r in snapshot.relevant_resources
],
"actor_state_ref": snapshot.actor_state_ref,
}
context_snapshot_json = json.dumps(snapshot_dict)
downstream_decision_ids_json: str | None = None
if decision.downstream_decision_ids:
downstream_decision_ids_json = json.dumps(
decision.downstream_decision_ids,
)
downstream_plan_ids_json: str | None = None
if decision.downstream_plan_ids:
downstream_plan_ids_json = json.dumps(decision.downstream_plan_ids)
artifacts_produced_json: str | None = None
if decision.artifacts_produced:
artifacts_produced_json = json.dumps(
[
{
"artifact_path": a.artifact_path,
"artifact_type": a.artifact_type,
}
for a in decision.artifacts_produced
],
)
# Format created_at as ISO-8601
created_at_str = decision.created_at.isoformat()
return cls(
decision_id=decision.decision_id,
plan_id=decision.plan_id,
parent_decision_id=decision.parent_decision_id,
sequence_number=decision.sequence_number,
decision_type=(
decision.decision_type.value
if hasattr(decision.decision_type, "value")
else str(decision.decision_type)
),
question=decision.question,
chosen_option=decision.chosen_option,
alternatives_considered_json=alternatives_json,
confidence_score=decision.confidence_score,
context_snapshot_json=context_snapshot_json,
rationale=decision.rationale,
actor_reasoning=decision.actor_reasoning,
downstream_decision_ids_json=downstream_decision_ids_json,
downstream_plan_ids_json=downstream_plan_ids_json,
artifacts_produced_json=artifacts_produced_json,
created_at=created_at_str,
is_correction=decision.is_correction,
corrects_decision_id=decision.corrects_decision_id,
correction_reason=decision.correction_reason,
superseded_by=decision.superseded_by,
)
class DecisionDependencyModel(Base): # type: ignore[misc]
"""Database model for the decision dependency DAG.
Maps to the ``decision_dependencies`` table. Each row represents
an influence edge from one decision to another (the *source*
decision influenced or produced the *target* decision).
"""
__allow_unmapped__ = True
__tablename__ = "decision_dependencies"
source_decision_id = Column(
String(26),
ForeignKey("decisions.decision_id", ondelete="CASCADE"),
primary_key=True,
)
target_decision_id = Column(
String(26),
ForeignKey("decisions.decision_id", ondelete="CASCADE"),
primary_key=True,
)
relationship_type = Column(
String(30),
nullable=False,
default="influences",
)
created_at = Column(String(30), nullable=False)
__table_args__ = (
CheckConstraint(
"source_decision_id != target_decision_id",
name="ck_decision_deps_no_self_loop",
),
Index("ix_decision_deps_source", "source_decision_id"),
Index("ix_decision_deps_target", "target_decision_id"),
)
# Database initialization functions
def init_database(database_url: str = "sqlite:///.cleveragents/db.sqlite") -> Any:
"""Initialize the database.
@@ -11,6 +11,7 @@ Provides database-backed repositories following the session-factory pattern
| ``ActionRepository`` | CRUD for actions + arguments/invariants |
| ``NamespacedProjectRepository``| CRUD for namespaced projects |
| ``ProjectResourceLinkRepository``| Project-resource link management |
| ``DecisionRepository`` | CRUD for decision tree nodes |
## Session-Factory Pattern
@@ -43,6 +44,8 @@ with uow:
| ``PlanNotFoundError`` | Plan ID not found |
| ``ProjectNotFoundError``| Project not found |
| ``DuplicateLinkError`` | Project-resource link already exists |
| ``DuplicateDecisionError``| Decision ID already exists |
| ``DecisionNotFoundError``| Decision ID not found |
Based on ADR-007 (Repository Pattern) and ADR-033 (Retry Patterns).
"""
@@ -51,10 +54,11 @@ from __future__ import annotations
import json
import warnings
from collections import deque
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, ClassVar, cast
from typing import TYPE_CHECKING, Any, ClassVar, cast
from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
from sqlalchemy.exc import IntegrityError, OperationalError
@@ -81,6 +85,7 @@ from cleveragents.infrastructure.database.models import (
ChangeModel,
ContextModel,
DebugAttemptModel,
DecisionModel,
LifecycleActionModel,
LifecyclePlanModel,
NamespacedProjectModel,
@@ -103,6 +108,9 @@ from cleveragents.infrastructure.database.models import (
ValidationAttachmentModel,
)
if TYPE_CHECKING:
from cleveragents.domain.models.core.decision import Decision
class ProjectRepository:
"""Repository for project persistence."""
@@ -4570,3 +4578,375 @@ class SkillRepository:
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(f"Failed to delete skill {name}: {exc}") from exc
# ---------------------------------------------------------------------------
# Decision repository (Stage M3.2)
# ---------------------------------------------------------------------------
class DuplicateDecisionError(DatabaseError):
"""Raised when creating a decision with an ID that already exists."""
def __init__(self, decision_id: str) -> None:
super().__init__(f"Decision '{decision_id}' already exists")
self.decision_id = decision_id
class DecisionNotFoundError(DatabaseError):
"""Raised when a decision cannot be found."""
def __init__(self, decision_id: str) -> None:
super().__init__(f"Decision '{decision_id}' not found")
self.decision_id = decision_id
class DecisionRepository:
"""Repository for decision tree node persistence.
Uses the session-factory pattern (ADR-007). All mutating methods
flush but do **not** commit; the caller or a :class:`UnitOfWork`
wrapper is responsible for committing the transaction.
"""
def __init__(self, session_factory: Callable[[], Session]) -> None:
"""Initialise with a callable that returns a SQLAlchemy Session."""
self._session_factory = session_factory
def _session(self) -> Session:
"""Convenience helper to obtain a session."""
return self._session_factory()
# --- CREATE ------------------------------------------------------------
@database_retry
def create(self, decision: Decision) -> Decision:
"""Persist a new decision.
Args:
decision: Domain :class:`Decision` instance.
Returns:
The same domain decision (pass-through for fluent API).
Raises:
DuplicateDecisionError: If the ``decision_id`` already exists.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
db_model = DecisionModel.from_domain(decision)
session.add(db_model)
session.flush()
return decision
except IntegrityError as exc:
session.rollback()
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
raise DuplicateDecisionError(
str(decision.decision_id),
) from exc
raise DatabaseError(
f"Failed to create decision: {exc}",
) from exc
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(
f"Failed to create decision: {exc}",
) from exc
# --- GET ---------------------------------------------------------------
@database_retry
def get(self, decision_id: str) -> Decision | None:
"""Retrieve a single decision by ID.
Args:
decision_id: ULID of the decision.
Returns:
Domain :class:`Decision` or ``None`` if not found.
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
row = (
session.query(DecisionModel).filter_by(decision_id=decision_id).first()
)
if row is None:
return None
return row.to_domain()
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get decision {decision_id}: {exc}",
) from exc
# --- GET BY PLAN -------------------------------------------------------
@database_retry
def get_by_plan(self, plan_id: str) -> list[Decision]:
"""Retrieve all decisions for a plan, ordered by sequence number.
Args:
plan_id: ULID of the plan.
Returns:
List of domain :class:`Decision` instances.
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
rows = (
session.query(DecisionModel)
.filter_by(plan_id=plan_id)
.order_by(DecisionModel.sequence_number)
.all()
)
return [r.to_domain() for r in rows]
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get decisions for plan {plan_id}: {exc}",
) from exc
# --- GET TREE ----------------------------------------------------------
@database_retry
def get_tree(self, root_decision_id: str) -> list[Decision]:
"""Retrieve the full decision tree rooted at *root_decision_id*.
Performs a breadth-first walk using the ``parent_decision_id``
relationship. Returns the root followed by its descendants in
BFS order.
Args:
root_decision_id: ULID of the root decision.
Returns:
List of domain :class:`Decision` instances (BFS order).
Raises:
DecisionNotFoundError: If the root decision does not exist.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
# Fetch root
root_row = (
session.query(DecisionModel)
.filter_by(decision_id=root_decision_id)
.first()
)
if root_row is None:
raise DecisionNotFoundError(root_decision_id)
# BFS walk
result: list[Decision] = [root_row.to_domain()]
bfs_queue: deque[str] = deque([root_decision_id])
while bfs_queue:
parent_id = bfs_queue.popleft()
children = (
session.query(DecisionModel)
.filter_by(parent_decision_id=parent_id)
.order_by(DecisionModel.sequence_number)
.all()
)
for child in children:
result.append(child.to_domain())
bfs_queue.append(cast(str, child.decision_id))
return result
except DecisionNotFoundError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get decision tree {root_decision_id}: {exc}",
) from exc
# --- GET PATH TO ROOT --------------------------------------------------
@database_retry
def get_path_to_root(self, decision_id: str) -> list[Decision]:
"""Walk from *decision_id* up to the tree root.
Returns a list starting with the given decision and ending
with the root (``parent_decision_id is None``).
Args:
decision_id: ULID of the starting decision.
Returns:
List of domain :class:`Decision` instances (leaf root).
Raises:
DecisionNotFoundError: If the starting decision is missing.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
path: list[Decision] = []
current_id: str | None = decision_id
visited: set[str] = set()
while current_id is not None:
if current_id in visited:
break # safety: avoid cycles
visited.add(current_id)
row = (
session.query(DecisionModel)
.filter_by(decision_id=current_id)
.first()
)
if row is None:
if not path:
raise DecisionNotFoundError(decision_id)
break
path.append(row.to_domain())
current_id = cast(str | None, row.parent_decision_id)
return path
except DecisionNotFoundError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get path to root from {decision_id}: {exc}",
) from exc
# --- GET SUPERSEDED ----------------------------------------------------
@database_retry
def get_superseded(self, plan_id: str) -> list[Decision]:
"""Retrieve all superseded decisions for a plan.
Args:
plan_id: ULID of the plan.
Returns:
List of domain :class:`Decision` instances that have been
superseded (``superseded_by IS NOT NULL``).
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
rows = (
session.query(DecisionModel)
.filter(
DecisionModel.plan_id == plan_id,
DecisionModel.superseded_by.isnot(None),
)
.order_by(DecisionModel.sequence_number)
.all()
)
return [r.to_domain() for r in rows]
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to get superseded decisions for plan {plan_id}: {exc}",
) from exc
# --- UPDATE SUPERSEDED BY ----------------------------------------------
@database_retry
def update_superseded_by(
self,
decision_id: str,
new_decision_id: str,
) -> Decision:
"""Mark a decision as superseded by another.
Args:
decision_id: ULID of the decision to supersede.
new_decision_id: ULID of the replacement decision.
Returns:
Updated domain :class:`Decision`.
Raises:
DecisionNotFoundError: If *decision_id* does not exist.
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
row = (
session.query(DecisionModel).filter_by(decision_id=decision_id).first()
)
if row is None:
raise DecisionNotFoundError(decision_id)
row.superseded_by = new_decision_id # type: ignore[assignment]
session.flush()
return row.to_domain()
except DecisionNotFoundError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(
f"Failed to update superseded_by for {decision_id}: {exc}",
) from exc
# --- LIST BY TYPE ------------------------------------------------------
@database_retry
def list_by_type(
self,
plan_id: str,
decision_type: str,
) -> list[Decision]:
"""Retrieve decisions of a specific type for a plan.
Args:
plan_id: ULID of the plan.
decision_type: String value of the :class:`DecisionType` enum.
Returns:
List of domain :class:`Decision` instances.
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
rows = (
session.query(DecisionModel)
.filter(
DecisionModel.plan_id == plan_id,
DecisionModel.decision_type == decision_type,
)
.order_by(DecisionModel.sequence_number)
.all()
)
return [r.to_domain() for r in rows]
except (OperationalError, SQLAlchemyDatabaseError) as exc:
raise DatabaseError(
f"Failed to list decisions by type {decision_type}: {exc}",
) from exc
# --- DELETE -------------------------------------------------------------
@database_retry
def delete(self, decision_id: str) -> bool:
"""Delete a decision by ID.
Args:
decision_id: ULID of the decision to delete.
Returns:
``True`` if the decision was deleted, ``False`` if not found.
Raises:
DatabaseError: On transient or unexpected DB errors.
"""
session = self._session()
try:
row = (
session.query(DecisionModel).filter_by(decision_id=decision_id).first()
)
if row is None:
return False
session.delete(row)
session.flush()
return True
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
raise DatabaseError(
f"Failed to delete decision {decision_id}: {exc}",
) from exc
@@ -19,6 +19,7 @@ from cleveragents.infrastructure.database.repositories import (
ChangeRepository,
ContextRepository,
DebugAttemptRepository,
DecisionRepository,
LifecyclePlanRepository,
PlanRepository,
ProjectRepository,
@@ -184,6 +185,7 @@ class UnitOfWorkContext:
self._actors: ActorRepository | None = None
self._actions: ActionRepository | None = None
self._lifecycle_plans: LifecyclePlanRepository | None = None
self._decisions: DecisionRepository | None = None
def _session_factory(self) -> Session:
"""Return the transaction's session for factory-pattern repositories."""
@@ -259,6 +261,18 @@ class UnitOfWorkContext:
self._actors = ActorRepository(self._session)
return self._actors
@property
def decisions(self) -> DecisionRepository:
"""Get decision repository for this transaction.
Uses the session-factory pattern required by DecisionRepository.
"""
if self._decisions is None:
self._decisions = DecisionRepository(
session_factory=self._session_factory,
)
return self._decisions
def add(self, entity: Any) -> None:
"""Add an entity to the session.