feat(db): rebaseline plan phase/state enums for Action and Apply terminal states
CI / lint (pull_request) Successful in 14s
CI / typecheck (pull_request) Successful in 27s
CI / security (pull_request) Successful in 22s
CI / quality (pull_request) Successful in 16s
CI / integration_tests (pull_request) Successful in 4m53s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 10m5s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 7m16s

This commit is contained in:
Jeffrey Phillips Freeman
2026-02-15 06:16:19 +00:00
parent d07e9903eb
commit 6183ee3230
9 changed files with 1015 additions and 25 deletions
@@ -0,0 +1,385 @@
"""Rebaseline plan phase/state enums for Action + Apply terminal states.
This migration updates the ``v3_plans`` table CHECK constraints to align with
the four-phase lifecycle: Action -> Strategize -> Execute -> Apply.
Changes:
- ``ck_v3_plans_phase``: add ``'action'``, remove ``'applied'``
- ``ck_v3_plans_state``: add ``'applied'`` and ``'constrained'``
- Default ``phase`` changes from ``'strategize'`` to ``'action'``
SQLite does not support ``ALTER TABLE ... DROP CONSTRAINT``, so we rebuild
the ``v3_plans`` table using the standard temp-table-copy-rename approach.
Child tables (``plan_projects``, ``plan_arguments``, ``plan_invariants``)
have their FKs re-pointed during the rebuild.
Revision ID: a5_005_rebaseline_plan_phases
Revises: b1_001_resource_registry
Create Date: 2026-02-15 12:00:00
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a5_005_rebaseline_plan_phases"
down_revision: str | Sequence[str] | None = "b1_001_resource_registry"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# ── Column lists (shared by upgrade/downgrade) ────────────────────────────
_V3_PLANS_COLUMNS = [
sa.Column("plan_id", sa.String(26), nullable=False),
sa.Column("parent_plan_id", sa.String(26), nullable=True),
sa.Column("root_plan_id", sa.String(26), nullable=True),
sa.Column("action_name", sa.String(255), nullable=False),
sa.Column("namespaced_name", sa.String(255), nullable=False),
sa.Column("namespace", sa.String(100), nullable=False),
# phase and processing_state are added dynamically
sa.Column("attempt", sa.Integer(), nullable=False, server_default=sa.text("1")),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("definition_of_done", sa.Text(), nullable=True),
sa.Column("strategy_actor", sa.String(255), nullable=True),
sa.Column("execution_actor", sa.String(255), nullable=True),
sa.Column("review_actor", sa.String(255), nullable=True),
sa.Column("apply_actor", sa.String(255), nullable=True),
sa.Column("estimation_actor", sa.String(255), nullable=True),
sa.Column("invariant_actor", sa.String(255), nullable=True),
sa.Column("automation_profile", sa.String(255), nullable=True),
sa.Column(
"automation_level", sa.String(30), nullable=False, server_default="manual"
),
sa.Column("reusable", sa.Boolean(), nullable=False, server_default=sa.text("1")),
sa.Column("read_only", sa.Boolean(), nullable=False, server_default=sa.text("0")),
sa.Column("inputs_schema_json", sa.Text(), nullable=True),
sa.Column("changeset_id", sa.String(26), nullable=True),
sa.Column("sandbox_refs_json", sa.Text(), nullable=True),
sa.Column("validation_summary_json", sa.Text(), nullable=True),
sa.Column("decision_root_id", sa.String(26), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("error_details_json", sa.Text(), nullable=True),
sa.Column("cost_estimate_usd", sa.Float(), nullable=True),
sa.Column("cost_actual_usd", sa.Float(), nullable=True),
sa.Column(
"token_count_input",
sa.Integer(),
nullable=True,
server_default=sa.text("0"),
),
sa.Column(
"token_count_output",
sa.Integer(),
nullable=True,
server_default=sa.text("0"),
),
sa.Column("created_by", sa.String(255), nullable=True),
sa.Column("tags_json", sa.Text(), nullable=False, server_default="[]"),
sa.Column("created_at", sa.String(30), nullable=False),
sa.Column("updated_at", sa.String(30), nullable=False),
sa.Column("completed_at", sa.String(30), nullable=True),
sa.Column("strategize_started_at", sa.String(30), nullable=True),
sa.Column("strategize_completed_at", sa.String(30), nullable=True),
sa.Column("execute_started_at", sa.String(30), nullable=True),
sa.Column("execute_completed_at", sa.String(30), nullable=True),
sa.Column("apply_started_at", sa.String(30), nullable=True),
sa.Column("applied_at", sa.String(30), nullable=True),
]
# Column names (excluding phase/processing_state which differ between versions)
_ALL_DATA_COLUMNS = (
"plan_id, parent_plan_id, root_plan_id, action_name, "
"namespaced_name, namespace, phase, processing_state, attempt, "
"description, definition_of_done, "
"strategy_actor, execution_actor, review_actor, apply_actor, "
"estimation_actor, invariant_actor, "
"automation_profile, automation_level, "
"reusable, read_only, inputs_schema_json, "
"changeset_id, sandbox_refs_json, validation_summary_json, decision_root_id, "
"error_message, error_details_json, "
"cost_estimate_usd, cost_actual_usd, "
"token_count_input, token_count_output, "
"created_by, tags_json, "
"created_at, updated_at, completed_at, "
"strategize_started_at, strategize_completed_at, "
"execute_started_at, execute_completed_at, "
"apply_started_at, applied_at"
)
# ── New (upgrade) constraints ──────────────────────────────────────────────
_NEW_PHASE_CK = "phase IN ('action', 'strategize', 'execute', 'apply')"
_NEW_STATE_CK = (
"processing_state IN "
"('queued', 'processing', 'errored', 'complete', 'cancelled', "
"'applied', 'constrained')"
)
_NEW_PHASE_DEFAULT = "action"
# ── Old (downgrade) constraints ────────────────────────────────────────────
_OLD_PHASE_CK = "phase IN ('strategize', 'execute', 'apply', 'applied')"
_OLD_STATE_CK = (
"processing_state IN ('queued', 'processing', 'errored', 'complete', 'cancelled')"
)
_OLD_PHASE_DEFAULT = "strategize"
def _rebuild_v3_plans(
phase_check: str,
state_check: str,
phase_default: str,
) -> None:
"""Rebuild v3_plans with new CHECK constraints (SQLite-safe).
Steps:
1. Create ``_v3_plans_new`` with updated constraints.
2. Copy all data from ``v3_plans``.
3. Drop child-table FKs (SQLite: drop+recreate child tables).
4. Drop old ``v3_plans``.
5. Rename ``_v3_plans_new`` → ``v3_plans``.
6. Recreate child tables pointing at the renamed table.
7. Recreate indexes.
"""
conn = op.get_bind()
dialect = conn.dialect.name
if dialect == "sqlite":
# Turn off FK enforcement during rebuild
conn.execute(sa.text("PRAGMA foreign_keys = OFF"))
# ── 1. Create temp table ──────────────────────────────────────────
columns: list[Any] = []
for col in _V3_PLANS_COLUMNS:
columns.append(col.copy())
# Insert phase and processing_state at the right position (after namespace)
phase_col: Any = sa.Column(
"phase", sa.String(20), nullable=False, server_default=phase_default
)
state_col: Any = sa.Column(
"processing_state", sa.String(20), nullable=False, server_default="queued"
)
# Insert after namespace (index 5 in the list)
columns.insert(6, phase_col)
columns.insert(7, state_col)
op.create_table(
"_v3_plans_new",
*columns,
sa.PrimaryKeyConstraint("plan_id"),
sa.ForeignKeyConstraint(
["parent_plan_id"],
["_v3_plans_new.plan_id"],
name="fk_v3_plans_parent",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["root_plan_id"],
["_v3_plans_new.plan_id"],
name="fk_v3_plans_root",
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["action_name"],
["actions.namespaced_name"],
name="fk_v3_plans_action",
ondelete="RESTRICT",
),
sa.CheckConstraint(phase_check, name="ck_v3_plans_phase"),
sa.CheckConstraint(state_check, name="ck_v3_plans_state"),
sa.CheckConstraint(
"automation_level IN ('manual', 'review_before_apply', 'full_automation')",
name="ck_v3_plans_automation",
),
)
# ── 2. Copy data ─────────────────────────────────────────────────
conn.execute(
sa.text(
f"INSERT INTO _v3_plans_new ({_ALL_DATA_COLUMNS}) "
f"SELECT {_ALL_DATA_COLUMNS} FROM v3_plans"
)
)
# ── 3. Drop child tables (they have FKs to v3_plans) ─────────────
# Save child data first
conn.execute(
sa.text("CREATE TABLE _plan_projects_bak AS SELECT * FROM plan_projects")
)
conn.execute(
sa.text("CREATE TABLE _plan_arguments_bak AS SELECT * FROM plan_arguments")
)
conn.execute(
sa.text("CREATE TABLE _plan_invariants_bak AS SELECT * FROM plan_invariants")
)
op.drop_table("plan_invariants")
op.drop_table("plan_arguments")
op.drop_table("plan_projects")
# ── 4. Drop indexes and old v3_plans ──────────────────────────────
for idx_name in [
"ix_v3_plans_phase_state",
"ix_v3_plans_namespace",
"ix_v3_plans_action",
"ix_v3_plans_created",
"ix_v3_plans_root",
"ix_v3_plans_parent",
"ix_v3_plans_state",
"ix_v3_plans_phase",
]:
op.drop_index(idx_name, table_name="v3_plans")
op.drop_table("v3_plans")
# ── 5. Rename temp → v3_plans ─────────────────────────────────────
op.rename_table("_v3_plans_new", "v3_plans")
# ── 6. Recreate child tables ──────────────────────────────────────
op.create_table(
"plan_projects",
sa.Column(
"plan_id",
sa.String(26),
sa.ForeignKey(
"v3_plans.plan_id",
ondelete="CASCADE",
name="fk_plan_projects_plan",
),
nullable=False,
),
sa.Column("project_name", sa.String(255), nullable=False),
sa.Column("alias", sa.String(100), nullable=True),
sa.Column(
"read_only", sa.Boolean(), nullable=False, server_default=sa.text("0")
),
sa.Column("created_at", sa.String(30), nullable=False),
sa.PrimaryKeyConstraint("plan_id", "project_name"),
)
op.create_index(
"ix_plan_projects_project", "plan_projects", ["project_name"], unique=False
)
op.create_table(
"plan_arguments",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"plan_id",
sa.String(26),
sa.ForeignKey(
"v3_plans.plan_id",
ondelete="CASCADE",
name="fk_plan_arguments_plan",
),
nullable=False,
),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("value_json", sa.Text(), nullable=True),
sa.Column("value_type", sa.String(20), nullable=False, server_default="string"),
sa.Column("position", sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("plan_id", "name", name="uq_plan_arguments_name"),
)
op.create_index(
"ix_plan_arguments_plan", "plan_arguments", ["plan_id"], unique=False
)
op.create_index(
"ix_plan_arguments_position",
"plan_arguments",
["plan_id", "position"],
unique=False,
)
op.create_table(
"plan_invariants",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"plan_id",
sa.String(26),
sa.ForeignKey(
"v3_plans.plan_id",
ondelete="CASCADE",
name="fk_plan_invariants_plan",
),
nullable=False,
),
sa.Column("invariant_text", sa.Text(), nullable=False),
sa.Column(
"source_scope",
sa.String(20),
nullable=False,
server_default="plan",
),
sa.Column("source_name", sa.String(255), nullable=True),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("created_at", sa.String(30), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"plan_id", "invariant_text", name="uq_plan_invariants_text"
),
sa.CheckConstraint(
"source_scope IN ('global', 'project', 'action', 'plan')",
name="ck_plan_invariants_scope",
),
)
op.create_index(
"ix_plan_invariants_plan", "plan_invariants", ["plan_id"], unique=False
)
op.create_index(
"ix_plan_invariants_position",
"plan_invariants",
["plan_id", "position"],
unique=False,
)
# ── 7. Restore child data ─────────────────────────────────────────
conn.execute(sa.text("INSERT INTO plan_projects SELECT * FROM _plan_projects_bak"))
conn.execute(
sa.text("INSERT INTO plan_arguments SELECT * FROM _plan_arguments_bak")
)
conn.execute(
sa.text("INSERT INTO plan_invariants SELECT * FROM _plan_invariants_bak")
)
conn.execute(sa.text("DROP TABLE _plan_projects_bak"))
conn.execute(sa.text("DROP TABLE _plan_arguments_bak"))
conn.execute(sa.text("DROP TABLE _plan_invariants_bak"))
# ── 8. Recreate v3_plans indexes ──────────────────────────────────
op.create_index("ix_v3_plans_phase", "v3_plans", ["phase"], unique=False)
op.create_index("ix_v3_plans_state", "v3_plans", ["processing_state"], unique=False)
op.create_index("ix_v3_plans_parent", "v3_plans", ["parent_plan_id"], unique=False)
op.create_index("ix_v3_plans_root", "v3_plans", ["root_plan_id"], unique=False)
op.create_index("ix_v3_plans_created", "v3_plans", ["created_at"], unique=False)
op.create_index("ix_v3_plans_action", "v3_plans", ["action_name"], unique=False)
op.create_index("ix_v3_plans_namespace", "v3_plans", ["namespace"], unique=False)
op.create_index(
"ix_v3_plans_phase_state",
"v3_plans",
["phase", "processing_state"],
unique=False,
)
if dialect == "sqlite":
conn.execute(sa.text("PRAGMA foreign_keys = ON"))
def upgrade() -> None:
"""Rebaseline phase/state constraints for Action phase + Apply terminals."""
_rebuild_v3_plans(
phase_check=_NEW_PHASE_CK,
state_check=_NEW_STATE_CK,
phase_default=_NEW_PHASE_DEFAULT,
)
def downgrade() -> None:
"""Revert to pre-rebaseline phase/state constraints."""
_rebuild_v3_plans(
phase_check=_OLD_PHASE_CK,
state_check=_OLD_STATE_CK,
phase_default=_OLD_PHASE_DEFAULT,
)
+153
View File
@@ -0,0 +1,153 @@
"""ASV benchmarks for plan phase rebaseline migration (a5_005).
Measures:
- Schema creation time with rebaselined constraints
- Plan insert throughput with Action phase
- Plan insert throughput with Apply terminal states
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
LifecycleActionModel,
LifecyclePlanModel,
)
def _now_iso() -> str:
return datetime.now(tz=UTC).isoformat()
def _make_ulid(n: int) -> str:
return str(n).zfill(26)
class SchemaCreationWithRebaseline:
"""Benchmark schema creation including rebaselined v3_plans constraints."""
def time_create_all_tables(self) -> None:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
engine.dispose()
class PlanInsertActionPhase:
"""Benchmark inserting plans with phase='action' (new phase)."""
def setup(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session_factory = sessionmaker(bind=self.engine)
# Create parent action
session = self.session_factory()
action = LifecycleActionModel()
action.namespaced_name = "bench/phase-action"
action.namespace = "bench"
action.name = "phase-action"
action.description = "Benchmark action"
action.definition_of_done = "Done"
action.strategy_actor = "bench/s"
action.execution_actor = "bench/e"
action.state = "available"
action.tags_json = "[]"
action.created_at = _now_iso()
action.updated_at = _now_iso()
session.add(action)
session.commit()
session.close()
def teardown(self) -> None:
self.engine.dispose()
def time_insert_100_action_phase_plans(self) -> None:
session = self.session_factory()
now = _now_iso()
for i in range(100):
plan = LifecyclePlanModel()
plan.plan_id = _make_ulid(i)
plan.action_name = "bench/phase-action"
plan.namespaced_name = f"bench/plan-{i}"
plan.namespace = "bench"
plan.phase = "action"
plan.processing_state = "queued"
plan.description = f"Benchmark plan {i}"
plan.tags_json = "[]"
plan.created_at = now
plan.updated_at = now
session.add(plan)
session.commit()
session.close()
class PlanInsertApplyTerminalStates:
"""Benchmark inserting plans with Apply terminal states."""
def setup(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine)
self.session_factory = sessionmaker(bind=self.engine)
session = self.session_factory()
action = LifecycleActionModel()
action.namespaced_name = "bench/terminal-action"
action.namespace = "bench"
action.name = "terminal-action"
action.description = "Benchmark terminal action"
action.definition_of_done = "Done"
action.strategy_actor = "bench/s"
action.execution_actor = "bench/e"
action.state = "available"
action.tags_json = "[]"
action.created_at = _now_iso()
action.updated_at = _now_iso()
session.add(action)
session.commit()
session.close()
def teardown(self) -> None:
self.engine.dispose()
def time_insert_50_applied_plans(self) -> None:
session = self.session_factory()
now = _now_iso()
for i in range(50):
plan = LifecyclePlanModel()
plan.plan_id = _make_ulid(1000 + i)
plan.action_name = "bench/terminal-action"
plan.namespaced_name = f"bench/applied-{i}"
plan.namespace = "bench"
plan.phase = "apply"
plan.processing_state = "applied"
plan.description = f"Applied plan {i}"
plan.tags_json = "[]"
plan.created_at = now
plan.updated_at = now
session.add(plan)
session.commit()
session.close()
def time_insert_50_constrained_plans(self) -> None:
session = self.session_factory()
now = _now_iso()
for i in range(50):
plan = LifecyclePlanModel()
plan.plan_id = _make_ulid(2000 + i)
plan.action_name = "bench/terminal-action"
plan.namespaced_name = f"bench/constrained-{i}"
plan.namespace = "bench"
plan.phase = "apply"
plan.processing_state = "constrained"
plan.description = f"Constrained plan {i}"
plan.tags_json = "[]"
plan.created_at = now
plan.updated_at = now
session.add(plan)
session.commit()
session.close()
+3 -2
View File
@@ -132,7 +132,7 @@ hierarchical subplan trees via self-referencing foreign keys.
| `action_name` | String(255) | No | -- | FK to `actions.namespaced_name` (RESTRICT) |
| `namespaced_name` | String(255) | No | -- | Plan's own namespaced name |
| `namespace` | String(100) | No | -- | Extracted namespace |
| `phase` | String(20) | No | `'strategize'` | Current lifecycle phase |
| `phase` | String(20) | No | `'action'` | Current lifecycle phase |
| `processing_state` | String(20) | No | `'queued'` | Current processing state |
| `attempt` | Integer | No | `1` | Retry attempt counter |
| `description` | Text | No | -- | Rendered description (from action template + args) |
@@ -144,7 +144,7 @@ hierarchical subplan trees via self-referencing foreign keys.
| `estimation_actor` | String(255) | Yes | NULL | Estimation actor |
| `invariant_actor` | String(255) | Yes | NULL | Invariant reconciliation actor |
| `automation_profile` | String(255) | Yes | NULL | Resolved automation profile name |
| `automation_level` | String(30) | No | `'manual'` | `manual`, `review_before_apply`, `full_automation` |
| `automation_level` | String(30) | No | `'manual'` | **Legacy.** `manual`, `review_before_apply`, `full_automation`. Retained for backward compatibility; prefer `automation_profile`. |
| `reusable` | Boolean | No | `1` | Copied from action |
| `read_only` | Boolean | No | `0` | Copied from action |
| `inputs_schema_json` | Text | Yes | NULL | JSON Schema dict (copied from action) |
@@ -410,6 +410,7 @@ resources (PK: resource_id)
| `a5_003_spec_aligned_actions` | Drops `lifecycle_plans` and `actions_v3`; creates `actions`, `action_invariants`, `action_arguments` |
| `a5_004_spec_aligned_plans` | Creates `v3_plans`, `plan_projects`, `plan_arguments`, `plan_invariants` |
| `b1_001_resource_registry` | Creates `resource_types`, `resources`, `resource_edges` |
| `a5_005_rebaseline_plan_phases` | Rebaselines `v3_plans` phase/state CHECK constraints: adds `action` phase, removes `applied` phase, adds `applied`/`constrained` processing states, changes default phase to `action` |
## Source Location
+32
View File
@@ -0,0 +1,32 @@
@database @migration @plan_phase_rebaseline
Feature: Plan phase/state constraint rebaseline
As the system operator
I want the v3_plans table to accept the Action phase and Apply terminal states
So that plans can follow the four-phase lifecycle: Action -> Strategize -> Execute -> Apply
Background:
Given the plan phase rebaseline database is initialized
Scenario: Phase constraint accepts 'action' phase
When I insert a plan with phase "action" and state "queued"
Then the plan should be persisted successfully with phase "action"
Scenario: Phase constraint rejects 'applied' as a phase
When I try to insert a plan with phase "applied" and state "queued"
Then the insert should fail with a constraint violation
Scenario: Processing state accepts 'applied' state
When I insert a plan with phase "apply" and state "applied"
Then the plan should be persisted successfully with state "applied"
Scenario: Processing state accepts 'constrained' state
When I insert a plan with phase "apply" and state "constrained"
Then the plan should be persisted successfully with state "constrained"
Scenario: Default phase is 'action'
When I insert a plan using default phase
Then the rebaselined plan phase should be "action"
Scenario: Phase constraint rejects legacy 'applied' phase value
When I try to insert a plan with phase "applied" and state "complete"
Then the insert should fail with a constraint violation
@@ -0,0 +1,217 @@
"""Step definitions for plan phase/state constraint rebaseline tests."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from behave import given, then, when
from sqlalchemy import create_engine, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.infrastructure.database.models import (
Base,
LifecycleActionModel,
LifecyclePlanModel,
)
_VALID_ULID_PLAN = "01HV000000000000000000PH01"
_VALID_ULID_COUNTER = 0
def _next_ulid() -> str:
global _VALID_ULID_COUNTER
_VALID_ULID_COUNTER += 1
suffix = str(_VALID_ULID_COUNTER).zfill(4)
return f"01HV0000000000000000PH{suffix}"
def _now_iso() -> str:
return datetime.now(tz=UTC).isoformat()
def _ensure_action(session: Session) -> None:
"""Ensure a parent action exists for FK references."""
existing = (
session.query(LifecycleActionModel)
.filter_by(namespaced_name="local/phase-test-action")
.first()
)
if existing is not None:
return
action = LifecycleActionModel()
action.namespaced_name = "local/phase-test-action"
action.namespace = "local"
action.name = "phase-test-action"
action.description = "Test action for phase migration"
action.definition_of_done = "Done"
action.strategy_actor = "local/s"
action.execution_actor = "local/e"
action.state = "available"
action.tags_json = "[]"
action.created_at = _now_iso()
action.updated_at = _now_iso()
session.add(action)
session.commit()
@given("the plan phase rebaseline database is initialized")
def step_plan_phase_rebaseline_db_init(context: Any) -> None:
"""Set up an in-memory database with the schema including new constraints."""
engine = create_engine("sqlite:///:memory:")
# Enable FK enforcement
from sqlalchemy import event
@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection: Any, _: Any) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
session = factory()
_ensure_action(session)
context.phase_rebaseline_engine = engine
context.phase_rebaseline_session = session
@when('I insert a plan with phase "{phase}" and state "{state}"')
def step_insert_plan_with_phase_and_state(context: Any, phase: str, state: str) -> None:
"""Insert a plan with the specified phase and state."""
session: Session = context.phase_rebaseline_session
ulid = _next_ulid()
now = _now_iso()
plan = LifecyclePlanModel()
plan.plan_id = ulid
plan.action_name = "local/phase-test-action"
plan.namespaced_name = "local/phase-test-plan"
plan.namespace = "local"
plan.phase = phase
plan.processing_state = state
plan.description = "Test plan"
plan.tags_json = "[]"
plan.created_at = now
plan.updated_at = now
try:
session.add(plan)
session.commit()
context.phase_rebaseline_result = "ok"
context.phase_rebaseline_plan_id = ulid
except (IntegrityError, Exception) as exc:
session.rollback()
context.phase_rebaseline_result = "error"
context.phase_rebaseline_error = str(exc)
@when('I try to insert a plan with phase "{phase}" and state "{state}"')
def step_try_insert_plan_with_phase_and_state(
context: Any, phase: str, state: str
) -> None:
"""Try to insert a plan with the specified phase and state, expecting failure."""
session: Session = context.phase_rebaseline_session
ulid = _next_ulid()
now = _now_iso()
try:
session.execute(
text(
"INSERT INTO v3_plans "
"(plan_id, action_name, namespaced_name, namespace, "
"phase, processing_state, description, tags_json, "
"created_at, updated_at) "
"VALUES (:pid, :aname, :nname, :ns, :phase, :state, "
":desc, :tags, :cat, :uat)"
),
{
"pid": ulid,
"aname": "local/phase-test-action",
"nname": "local/try-plan",
"ns": "local",
"phase": phase,
"state": state,
"desc": "Test plan",
"tags": "[]",
"cat": now,
"uat": now,
},
)
session.commit()
context.phase_rebaseline_result = "ok"
except (IntegrityError, Exception) as exc:
session.rollback()
context.phase_rebaseline_result = "error"
context.phase_rebaseline_error = str(exc)
@when("I insert a plan using default phase")
def step_insert_plan_default_phase(context: Any) -> None:
"""Insert a plan without specifying a phase to test the ORM default."""
session: Session = context.phase_rebaseline_session
ulid = _next_ulid()
now = _now_iso()
# Use ORM to create plan without specifying phase — should default to 'action'
plan = LifecyclePlanModel()
plan.plan_id = ulid
plan.action_name = "local/phase-test-action"
plan.namespaced_name = "local/default-phase-plan"
plan.namespace = "local"
plan.description = "Default phase plan"
plan.tags_json = "[]"
plan.created_at = now
plan.updated_at = now
session.add(plan)
session.commit()
context.phase_rebaseline_plan_id = ulid
@then('the plan should be persisted successfully with phase "{phase}"')
def step_verify_plan_persisted_phase(context: Any, phase: str) -> None:
"""Verify the plan was persisted with the expected phase."""
assert context.phase_rebaseline_result == "ok", (
f"Expected ok, got {context.phase_rebaseline_result}"
)
session: Session = context.phase_rebaseline_session
plan = (
session.query(LifecyclePlanModel)
.filter_by(plan_id=context.phase_rebaseline_plan_id)
.one()
)
assert plan.phase == phase
@then('the plan should be persisted successfully with state "{state}"')
def step_verify_plan_persisted_state(context: Any, state: str) -> None:
"""Verify the plan was persisted with the expected state."""
assert context.phase_rebaseline_result == "ok", (
f"Expected ok, got {context.phase_rebaseline_result}"
)
session: Session = context.phase_rebaseline_session
plan = (
session.query(LifecyclePlanModel)
.filter_by(plan_id=context.phase_rebaseline_plan_id)
.one()
)
assert plan.processing_state == state
@then("the insert should fail with a constraint violation")
def step_verify_constraint_violation(context: Any) -> None:
"""Verify the insert failed."""
assert context.phase_rebaseline_result == "error", (
f"Expected error, got {context.phase_rebaseline_result}"
)
@then('the rebaselined plan phase should be "{phase}"')
def step_verify_rebaselined_plan_phase_default(context: Any, phase: str) -> None:
"""Verify the plan's phase matches the expected value."""
session: Session = context.phase_rebaseline_session
plan = (
session.query(LifecyclePlanModel)
.filter_by(plan_id=context.phase_rebaseline_plan_id)
.one()
)
assert plan.phase == phase, f"Expected phase '{phase}', got '{plan.phase}'"
+20 -20
View File
@@ -1634,26 +1634,26 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git branch -d feature/m1-db-actions`
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report` -- TOTAL 97%.
- [ ] **COMMIT (Owner: Jeff | Group: A5.beta | Branch: feature/m1-db-plan-phase-rebaseline | Planned: Day 7 | Expected: Day 9) - Commit message: "feat(db): rebaseline plan phase/state enums for Action and Apply terminal states"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
- [ ] Git [Jeff]: `git checkout -b feature/m1-db-plan-phase-rebaseline`
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Jeff]: Add Alembic migration that updates `v3_plans` phase/state constraints to include `action` phase and Apply terminal outcomes (`applied`, `constrained`, `errored`, `cancelled`), and removes `applied` as a phase.
- [ ] Code [Jeff]: Rebuild `v3_plans` table safely for SQLite (create temp table → copy → drop → rename) to update CHECK constraints and defaults without data loss.
- [ ] Code [Jeff]: Update ORM `LifecyclePlanModel` to accept Action phase + Apply terminal states; ensure serialization/deserialization maps updated enums.
- [ ] Code [Jeff]: Mark `automation_level` column as legacy (no new writes); keep nullable for backward compatibility until removal in A6.legacy cleanup.
- [ ] Docs [Jeff]: Update `docs/reference/database_schema.md` with the new phase/state constraints and legacy automation_level note.
- [ ] Tests (Behave) [Jeff]: Add migration scenarios asserting phase/state constraints accept Action + Apply terminal values and reject legacy `applied` phase.
- [ ] Tests (Robot) [Jeff]: Add Robot migration smoke test that inserts a plan row with `phase=action` and `processing_state=queued`.
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/plan_phase_migration_bench.py` for migration runtime baseline.
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [ ] Git [Jeff]: `git add .`
- [ ] Git [Jeff]: `git commit -m "feat(db): rebaseline plan phase/state enums for Action and Apply terminal states"`
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-db-plan-phase-rebaseline` to `master` with description "Rebaseline plan phase/state constraints for Action phase and Apply terminal outcomes, with migration + tests.".
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git branch -d feature/m1-db-plan-phase-rebaseline`
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [X] **COMMIT (Owner: Jeff | Group: A5.beta | Branch: feature/m1-db-plan-phase-rebaseline | Planned: Day 7 | Done: Day 7, February 15, 2026) - Commit message: "feat(db): rebaseline plan phase/state enums for Action and Apply terminal states"**
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git pull origin master`
- [X] Git [Jeff]: `git checkout -b feature/m1-db-plan-phase-rebaseline`
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [X] Code [Jeff]: Add Alembic migration that updates `v3_plans` phase/state constraints to include `action` phase and Apply terminal outcomes (`applied`, `constrained`, `errored`, `cancelled`), and removes `applied` as a phase.
- [X] Code [Jeff]: Rebuild `v3_plans` table safely for SQLite (create temp table → copy → drop → rename) to update CHECK constraints and defaults without data loss.
- [X] Code [Jeff]: Update ORM `LifecyclePlanModel` to accept Action phase + Apply terminal states; ensure serialization/deserialization maps updated enums.
- [X] Code [Jeff]: Mark `automation_level` column as legacy (no new writes); keep nullable for backward compatibility until removal in A6.legacy cleanup.
- [X] Docs [Jeff]: Update `docs/reference/database_schema.md` with the new phase/state constraints and legacy automation_level note.
- [X] Tests (Behave) [Jeff]: Add migration scenarios asserting phase/state constraints accept Action + Apply terminal values and reject legacy `applied` phase.
- [X] Tests (Robot) [Jeff]: Add Robot migration smoke test that inserts a plan row with `phase=action` and `processing_state=queued`.
- [X] Tests (ASV) [Jeff]: Add `benchmarks/plan_phase_migration_bench.py` for migration runtime baseline.
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
- [X] Git [Jeff]: `git add .`
- [X] Git [Jeff]: `git commit -m "feat(db): rebaseline plan phase/state enums for Action and Apply terminal states"`
- [X] Forgejo PR [Jeff]: Open PR from `feature/m1-db-plan-phase-rebaseline` to `master` with description "Rebaseline plan phase/state constraints for Action phase and Apply terminal outcomes, with migration + tests.".
- [X] Git [Jeff]: `git checkout master`
- [X] Git [Jeff]: `git branch -d feature/m1-db-plan-phase-rebaseline`
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
- [ ] **COMMIT (Owner: Jeff | Group: A5.delta | Branch: feature/m1-plan-repo | Planned: Day 8 | Expected: Day 10) - Commit message: "feat(repo): add lifecycle plan repository"**
- [ ] Git [Jeff]: `git checkout master`
- [ ] Git [Jeff]: `git pull origin master`
+157
View File
@@ -0,0 +1,157 @@
"""Helper script for plan phase migration Robot integration tests.
Tests that the rebaselined v3_plans schema accepts Action phase
and Apply terminal processing states.
"""
from __future__ import annotations
import sys
import tempfile
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy.orm import Session
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.plan import (
AutomationLevel,
NamespacedName,
Plan,
PlanIdentity,
PlanPhase,
PlanTimestamps,
ProcessingState,
)
from cleveragents.infrastructure.database.models import (
LifecycleActionModel,
LifecyclePlanModel,
init_database,
)
def _create_temp_db() -> tuple[Session, str]:
"""Create a temporary SQLite database for testing."""
tmp = tempfile.mktemp(suffix=".db")
db_url = f"sqlite:///{tmp}"
engine = init_database(db_url)
session = Session(bind=engine)
return session, tmp
def _ensure_action(session: Session) -> None:
"""Ensure a parent action exists for FK references."""
action = Action(
namespaced_name=NamespacedName.parse("local/phase-migration-action"),
description="Migration test 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),
)
db_model = LifecycleActionModel.from_domain(action)
session.add(db_model)
session.commit()
def _action_queued() -> None:
"""Test: insert plan with phase='action', processing_state='queued'."""
session, tmp = _create_temp_db()
try:
_ensure_action(session)
plan = Plan(
identity=PlanIdentity(plan_id="01HV000000000000000000MG01"),
action_name="local/phase-migration-action",
namespaced_name=NamespacedName.parse("local/phase-migration-action"),
description="Phase migration test plan",
phase=PlanPhase.ACTION,
processing_state=ProcessingState.QUEUED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(
created_at=datetime(2026, 2, 15, tzinfo=UTC),
updated_at=datetime(2026, 2, 15, tzinfo=UTC),
),
)
plan_db = LifecyclePlanModel.from_domain(
plan, action_name="local/phase-migration-action"
)
session.add(plan_db)
session.commit()
loaded = (
session.query(LifecyclePlanModel)
.filter_by(plan_id="01HV000000000000000000MG01")
.one()
)
assert loaded.phase == "action"
assert loaded.processing_state == "queued"
print("action-queued-ok")
finally:
session.close()
Path(tmp).unlink(missing_ok=True)
def _apply_applied() -> None:
"""Test: insert plan with phase='apply', processing_state='applied'."""
session, tmp = _create_temp_db()
try:
_ensure_action(session)
plan = Plan(
identity=PlanIdentity(plan_id="01HV000000000000000000MG02"),
action_name="local/phase-migration-action",
namespaced_name=NamespacedName.parse("local/phase-migration-action"),
description="Apply terminal test plan",
phase=PlanPhase.APPLY,
processing_state=ProcessingState.APPLIED,
automation_level=AutomationLevel.MANUAL,
strategy_actor="local/s",
execution_actor="local/e",
timestamps=PlanTimestamps(
created_at=datetime(2026, 2, 15, tzinfo=UTC),
updated_at=datetime(2026, 2, 15, tzinfo=UTC),
),
)
plan_db = LifecyclePlanModel.from_domain(
plan, action_name="local/phase-migration-action"
)
session.add(plan_db)
session.commit()
loaded = (
session.query(LifecyclePlanModel)
.filter_by(plan_id="01HV000000000000000000MG02")
.one()
)
assert loaded.phase == "apply"
assert loaded.processing_state == "applied"
print("apply-applied-ok")
finally:
session.close()
Path(tmp).unlink(missing_ok=True)
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit("Expected command argument")
command = sys.argv[1]
commands = {
"action-queued": _action_queued,
"apply-applied": _apply_applied,
}
if command not in commands:
raise SystemExit(f"Unknown command: {command}")
commands[command]()
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
*** Settings ***
Documentation Integration tests for plan phase/state rebaseline migration (a5_005).
... Verifies that the rebaselined v3_plans table accepts the Action phase
... and Apply terminal processing states.
Resource ${CURDIR}/common.resource
Suite Setup Setup Test Environment
Suite Teardown Cleanup Test Environment
*** Variables ***
${HELPER_SCRIPT} robot/helper_plan_phase_migration.py
*** Test Cases ***
Insert Plan With Action Phase And Queued State
[Documentation] Verify a plan with phase='action' and processing_state='queued' persists
[Tags] database migration plan_phase
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} action-queued cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} action-queued-ok
Insert Plan With Apply Phase And Applied State
[Documentation] Verify a plan with phase='apply' and processing_state='applied' persists
[Tags] database migration plan_phase
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} apply-applied cwd=${WORKSPACE}
Should Be Equal As Integers ${result.rc} 0
Should Contain ${result.stdout} apply-applied-ok
@@ -524,9 +524,13 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
namespaced_name = Column(String(255), nullable=False)
namespace = Column(String(100), nullable=False)
# Lifecycle phase and processing state (spec-aligned)
phase = Column(String(20), nullable=False, default="strategize")
processing_state = Column(String(20), nullable=False, default="queued")
# Lifecycle phase and processing state (spec-aligned, rebaselined in a5_005)
phase = Column(
String(20), nullable=False, default="action", server_default="action"
)
processing_state = Column(
String(20), nullable=False, default="queued", server_default="queued"
)
attempt = Column(Integer, nullable=False, default=1)
# Rendered description and DoD
@@ -543,6 +547,8 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
# Policy / profile
automation_profile = Column(Text, nullable=True)
# Legacy: automation_level is retained for backward compatibility.
# New code should use automation_profile. Will be removed in A6.legacy.
automation_level = Column(String(30), nullable=False, default="manual")
# Behavior
@@ -614,6 +620,20 @@ class LifecyclePlanModel(Base): # type: ignore[misc]
)
__table_args__ = (
CheckConstraint(
"phase IN ('action', 'strategize', 'execute', 'apply')",
name="ck_v3_plans_phase",
),
CheckConstraint(
"processing_state IN "
"('queued', 'processing', 'errored', 'complete', 'cancelled', "
"'applied', 'constrained')",
name="ck_v3_plans_state",
),
CheckConstraint(
"automation_level IN ('manual', 'review_before_apply', 'full_automation')",
name="ck_v3_plans_automation",
),
Index("ix_v3_plans_phase", "phase"),
Index("ix_v3_plans_state", "processing_state"),
Index("ix_v3_plans_parent", "parent_plan_id"),