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
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:
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user