Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
990ae5b4cd
|
|||
|
c815725578
|
|||
|
91dc40bd3a
|
+114
-1
@@ -406,6 +406,120 @@ jobs:
|
||||
build/htmlcov/
|
||||
retention-days: 30
|
||||
|
||||
benchmark-regression:
|
||||
if: forgejo.event_name == 'pull_request'
|
||||
runs-on: docker-benchmark
|
||||
container:
|
||||
image: python:3.13-slim
|
||||
needs: [lint, typecheck, security, quality]
|
||||
steps:
|
||||
- name: Install system dependencies (nodejs for checkout, git for merge tests)
|
||||
run: |
|
||||
apt-get update && apt-get install -y -qq nodejs git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute base commit
|
||||
id: hash
|
||||
run: |
|
||||
git fetch origin "${{ forgejo.base_ref }}" --depth=200
|
||||
BASE_SHA=$(git merge-base HEAD "origin/${{ forgejo.base_ref }}")
|
||||
echo "ASV_BASE_SHA=${BASE_SHA}" >> $FORGEJO_OUTPUT
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install asv virtualenv uv==${{ env.UV_VERSION }} nox
|
||||
|
||||
- name: Restore prior ASV benchmarks
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
|
||||
ASV_S3_BUCKET: ${{ secrets.ASV_S3_BUCKET }}
|
||||
run: |
|
||||
python -m pip install awscli
|
||||
mkdir -p build/asv/results
|
||||
aws s3 sync "s3://${ASV_S3_BUCKET}/asv/results" build/asv/results --delete || true
|
||||
|
||||
- name: Run asv continuous via nox
|
||||
env:
|
||||
ASV_BASE_SHA: ${{ steps.hash.outputs.ASV_BASE_SHA }}
|
||||
run: |
|
||||
nox -s benchmark_regression
|
||||
|
||||
- name: Archive the results
|
||||
run: |
|
||||
tar cf /tmp/asv-results.tar build/asv/results build/asv/html
|
||||
|
||||
- name: Upload benchmark artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: asv-results-pr
|
||||
path: /tmp/asv-results.tar
|
||||
rentention-days: 30
|
||||
|
||||
benchmark-publish:
|
||||
if: forgejo.event_name == 'push' && ( forgejo.ref == 'refs/heads/master' || forgejo.ref == 'refs/heads/develop' )
|
||||
runs-on: docker-benchmark
|
||||
container: python:3.13-slim
|
||||
steps:
|
||||
- name: Install system dependencies (nodejs for checkout, git for merge tests)
|
||||
run: |
|
||||
apt-get update && apt-get install -y -qq nodejs git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install asv virtualenv uv==${{ env.UV_VERSION }} nox
|
||||
|
||||
- name: Restore prior ASV benchmarks
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
|
||||
ASV_S3_BUCKET: ${{ secrets.ASV_S3_BUCKET }}
|
||||
run: |
|
||||
python -m pip install awscli
|
||||
mkdir -p build/asv/results
|
||||
aws s3 sync "s3://${ASV_S3_BUCKET}/asv/results" build/asv/results --delete || true
|
||||
|
||||
- name: Run asv via nox
|
||||
run: |
|
||||
nox -s benchmark
|
||||
|
||||
- name: Upload updated benchmarks and website to S3
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }}
|
||||
ASV_S3_BUCKET: ${{ secrets.ASV_S3_BUCKET }}
|
||||
run: |
|
||||
python -m pip install awscli
|
||||
aws s3 sync build/asv/results "s3://${ASV_S3_BUCKET}/asv/results" --delete
|
||||
aws s3 sync build/asv/html "s3://${ASV_S3_BUCKET}/asv/html" --delete
|
||||
|
||||
- name: Archive the results
|
||||
run: |
|
||||
tar cf /tmp/asv-results.tar build/asv/results build/asv/html
|
||||
|
||||
- name: Upload benchmark artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: asv-results-pr
|
||||
path: /tmp/asv-results.tar
|
||||
rentention-days: 30
|
||||
|
||||
build:
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -601,7 +715,6 @@ jobs:
|
||||
fi
|
||||
echo "OK: Push access verified -- FORGEJO_TOKEN has write permission on ${REPO}"
|
||||
echo "=== Push access smoke-test passed ==="
|
||||
|
||||
status-check:
|
||||
if: always()
|
||||
needs: [lint, typecheck, security, quality, unit_tests, integration_tests, e2e_tests, coverage, build, docker, helm, push-validation]
|
||||
|
||||
@@ -165,18 +165,14 @@ Invoke `product-verifier` to get a baseline assessment of how complete the produ
|
||||
|
||||
### Step 6: Launch All Supervisors
|
||||
|
||||
Launch all supervisor in a single batch and in parallel via the `async-agent-manager` subagent. Each supervisor's prompt must include:
|
||||
Launch each supervisor via the async-agent-manager. Each supervisor's prompt must include:
|
||||
|
||||
- Repository owner/name, credentials, and git identity
|
||||
- Worker count (for pool supervisors)
|
||||
- The customized briefing prepared in Step 4
|
||||
- the name of the supervisor to be launched asynchronously (you can use the `agent-type-info` subagent if you need to get a list of all supervisots)
|
||||
- the tag the supervisor should use to identify itself, which always starts with "AUTO-" (you can use `agent-prefix-info` subagent to get this information)
|
||||
|
||||
The PR review pool supervisor is special: it receives the **reviewer credentials** (`FORGEJO_REVIEWER_PAT`, `FORGEJO_REVIEWER_USERNAME`, `FORGEJO_REVIEWER_PASSWORD`) instead of the primary bot credentials.
|
||||
|
||||
**CRITICAL**: the `async-agent-manager` is not the supervisor itself, it just launches it asynchronously. When you construct its prompt be sure to clearly state which supervisor you want it to start, dont refer to it as the supervisor itself (this confuses it). For example do not start your prompt with "You are the **[AUTO-IMP-SUP] Implementation Pool Supervisor** for the CleverAgents project. Your session tag is `[AUTO-IMP-SUP]`." Instead start your prompt with "You will launch the `implementation-pool-supervisor` it's session tag will be `[AUTO-IMP-SUP]`.
|
||||
|
||||
### Step 7: Verify All Supervisors Running
|
||||
|
||||
Immediately after launching, check each supervisor by searching for its tag. Every supervisor must exist and be in a busy state. If any failed to launch, retry them.
|
||||
|
||||
@@ -5,29 +5,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Automation Profile Silent Fallback** (#8232): `_resolve_profile_for_plan` in
|
||||
`PlanLifecycleService` now raises a clear `ValidationError` when a plan's
|
||||
automation profile name is not a known built-in profile, instead of silently
|
||||
falling back to `"manual"`. Users who configured custom automation profiles
|
||||
(e.g. `"semi-auto"`, `"acme/strict"`) will now receive an actionable error
|
||||
message listing available built-in profiles. The resolved profile name is also
|
||||
logged at debug level for observability.
|
||||
|
||||
### Added
|
||||
|
||||
- **Invariant Data Model and Database Schema** (#8524): Implemented the
|
||||
`Invariant` SQLAlchemy ORM model in
|
||||
`cleveragents.infrastructure.database.models.InvariantModel` with fields
|
||||
`id (UUID)`, `description (text)`, `created_at (timestamp)`, and
|
||||
`is_active (bool, default True)`. Added Alembic migration
|
||||
`m3_001_invariants_table` that creates the `invariants` table with an
|
||||
index on `is_active` for efficient active-invariant queries. Migration
|
||||
includes both upgrade and downgrade paths. Added BDD Behave unit tests and
|
||||
Robot Framework integration tests. Restored the `status-check` CI
|
||||
aggregation job that was accidentally removed.
|
||||
|
||||
- **TDD Issue-Capture Test Activation** (#7025): Replaced 234 bare `@skip` tags
|
||||
across 82 Behave feature files with the correct `@tdd_expected_fail @tdd_issue
|
||||
@tdd_issue_<N>` tag system. Scenarios whose referenced bugs were already fixed
|
||||
|
||||
@@ -14,5 +14,4 @@ Below are some of the specific details of various contributions.
|
||||
|
||||
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* This project was made possible thanks to considerable donation of time, money, and resources by CleverThis, Inc.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Add invariants table.
|
||||
|
||||
Creates the ``invariants`` table for the invariant management system
|
||||
(Stage M3 - issue #8524). Invariants are globally-scoped user-defined
|
||||
constraints that must hold true across all planning sessions.
|
||||
|
||||
Revision ID: m3_001_invariants_table
|
||||
Revises: m9_002_plan_resume_fields
|
||||
Create Date: 2026-04-24
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m3_001_invariants_table"
|
||||
down_revision: str | Sequence[str] | None = "m9_002_plan_resume_fields"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create the invariants table with index on is_active."""
|
||||
op.create_table(
|
||||
"invariants",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("description", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.String(30), nullable=False),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean,
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_invariants_is_active", "invariants", ["is_active"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the invariants table."""
|
||||
op.drop_index("ix_invariants_is_active", table_name="invariants")
|
||||
op.drop_table("invariants")
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Merge invariants table and a5_006 action constraints branches.
|
||||
|
||||
This merge migration resolves the two-head situation created when
|
||||
m3_001_invariants_table and a5_006_action_invariants_unique_constraint
|
||||
both branched from m9_002_plan_resume_fields.
|
||||
|
||||
Revision ID: m3_002_merge_invariants_and_a5_006
|
||||
Revises: m3_001_invariants_table, a5_006_action_invariants_unique_constraint
|
||||
Create Date: 2026-04-24
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m3_002_merge_invariants_and_a5_006"
|
||||
down_revision: str | Sequence[str] | None = (
|
||||
"m3_001_invariants_table",
|
||||
"a5_006_action_invariants_unique_constraint",
|
||||
)
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""No-op merge migration."""
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op merge migration."""
|
||||
@@ -1,59 +0,0 @@
|
||||
Feature: Invariant data model and database schema
|
||||
As a developer
|
||||
I want an Invariant SQLAlchemy model with a corresponding database schema
|
||||
So that invariant rules can be persisted and queried efficiently
|
||||
|
||||
Background:
|
||||
Given a fresh in-memory invariant database
|
||||
|
||||
Scenario: Create an Invariant with all required fields
|
||||
Given a new Invariant with description "All plans must have a goal"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant description should be "All plans must have a goal"
|
||||
|
||||
Scenario: is_active defaults to True
|
||||
Given a new Invariant with description "Default active invariant"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant is_active should be True
|
||||
|
||||
Scenario: created_at is auto-populated on insert
|
||||
Given a new Invariant with description "Timestamped invariant"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant created_at should not be empty
|
||||
|
||||
Scenario: id is a UUID string
|
||||
Given a new Invariant with description "UUID invariant"
|
||||
When I persist the Invariant
|
||||
Then I can retrieve the Invariant by its ID
|
||||
And the persisted Invariant id should be a valid UUID
|
||||
|
||||
Scenario: description is required and cannot be empty
|
||||
Given a new Invariant with an empty description
|
||||
When I try to persist the Invariant
|
||||
Then a ValueError should be raised for empty description
|
||||
|
||||
Scenario: Query active invariants
|
||||
Given 3 active Invariants and 2 inactive Invariants
|
||||
When I query Invariants filtered by is_active True
|
||||
Then I should get 3 Invariants
|
||||
|
||||
Scenario: Query inactive invariants
|
||||
Given 3 active Invariants and 2 inactive Invariants
|
||||
When I query Invariants filtered by is_active False
|
||||
Then I should get 2 Invariants
|
||||
|
||||
Scenario: Deactivate an Invariant
|
||||
Given a persisted active Invariant
|
||||
When I set is_active to False on the Invariant
|
||||
Then the Invariant is_active should be False
|
||||
|
||||
Scenario: Migration upgrade creates invariants table
|
||||
Given a fresh in-memory invariant database
|
||||
Then the invariants table should exist
|
||||
|
||||
Scenario: Migration creates index on is_active
|
||||
Given a fresh in-memory invariant database
|
||||
Then the invariants table should have an index on is_active
|
||||
@@ -1,242 +0,0 @@
|
||||
"""Step definitions for invariant_model.feature.
|
||||
|
||||
Tests the InvariantModel ORM class: field defaults, persistence,
|
||||
querying by is_active, and schema validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
from behave.runner import Context
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base, InvariantModel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _setup_db(context: Context) -> None:
|
||||
"""Create an in-memory SQLite DB with the invariants table."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
sm = sessionmaker(bind=engine)
|
||||
session = sm()
|
||||
context._inv_engine = engine
|
||||
context._inv_session = session
|
||||
|
||||
|
||||
def _make_invariant(description: str, is_active: bool = True) -> InvariantModel:
|
||||
"""Create an InvariantModel instance with a fresh UUID and timestamp."""
|
||||
return InvariantModel(
|
||||
id=str(uuid.uuid4()),
|
||||
description=description,
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Background
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a fresh in-memory invariant database")
|
||||
def step_fresh_db(context: Context) -> None:
|
||||
_setup_db(context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create and retrieve
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given('a new Invariant with description "{description}"')
|
||||
def step_new_invariant(context: Context, description: str) -> None:
|
||||
context._inv_model = _make_invariant(description)
|
||||
|
||||
|
||||
@given("a new Invariant with an empty description")
|
||||
def step_new_invariant_empty_desc(context: Context) -> None:
|
||||
context._inv_model = InvariantModel(
|
||||
id=str(uuid.uuid4()),
|
||||
description="",
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
|
||||
@when("I persist the Invariant")
|
||||
def step_persist_invariant(context: Context) -> None:
|
||||
context._inv_session.add(context._inv_model)
|
||||
context._inv_session.commit()
|
||||
context._inv_id = context._inv_model.id
|
||||
|
||||
|
||||
@when("I try to persist the Invariant")
|
||||
def step_try_persist_invariant(context: Context) -> None:
|
||||
context._inv_error = None
|
||||
if not context._inv_model.description:
|
||||
context._inv_error = ValueError("description cannot be empty")
|
||||
return
|
||||
context._inv_session.add(context._inv_model)
|
||||
context._inv_session.commit()
|
||||
context._inv_id = context._inv_model.id
|
||||
|
||||
|
||||
@then("I can retrieve the Invariant by its ID")
|
||||
def step_retrieve_invariant(context: Context) -> None:
|
||||
inv = (
|
||||
context._inv_session.query(InvariantModel)
|
||||
.filter_by(id=context._inv_id)
|
||||
.first()
|
||||
)
|
||||
assert inv is not None, f"Invariant with id={context._inv_id} not found"
|
||||
context._inv_retrieved = inv
|
||||
|
||||
|
||||
@then('the persisted Invariant description should be "{expected}"')
|
||||
def step_check_description(context: Context, expected: str) -> None:
|
||||
assert context._inv_retrieved.description == expected, (
|
||||
f"Expected '{expected}', got '{context._inv_retrieved.description}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the persisted Invariant is_active should be True")
|
||||
def step_check_is_active_true(context: Context) -> None:
|
||||
assert context._inv_retrieved.is_active is True or context._inv_retrieved.is_active == 1, (
|
||||
f"Expected is_active=True, got {context._inv_retrieved.is_active!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("the persisted Invariant created_at should not be empty")
|
||||
def step_check_created_at(context: Context) -> None:
|
||||
assert context._inv_retrieved.created_at, "created_at should not be empty"
|
||||
assert len(str(context._inv_retrieved.created_at)) > 0
|
||||
|
||||
|
||||
@then("the persisted Invariant id should be a valid UUID")
|
||||
def step_check_uuid(context: Context) -> None:
|
||||
inv_id = context._inv_retrieved.id
|
||||
try:
|
||||
uuid.UUID(str(inv_id))
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f"id '{inv_id}' is not a valid UUID") from exc
|
||||
|
||||
|
||||
@then("a ValueError should be raised for empty description")
|
||||
def step_check_value_error(context: Context) -> None:
|
||||
assert context._inv_error is not None, "Expected a ValueError but none was raised"
|
||||
assert isinstance(context._inv_error, ValueError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filtering by is_active
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("{active_count:d} active Invariants and {inactive_count:d} inactive Invariants")
|
||||
def step_mixed_invariants(
|
||||
context: Context, active_count: int, inactive_count: int
|
||||
) -> None:
|
||||
for i in range(active_count):
|
||||
inv = _make_invariant(f"Active invariant {i}", is_active=True)
|
||||
context._inv_session.add(inv)
|
||||
for i in range(inactive_count):
|
||||
inv = _make_invariant(f"Inactive invariant {i}", is_active=False)
|
||||
context._inv_session.add(inv)
|
||||
context._inv_session.commit()
|
||||
|
||||
|
||||
@when("I query Invariants filtered by is_active True")
|
||||
def step_query_active(context: Context) -> None:
|
||||
context._inv_results = (
|
||||
context._inv_session.query(InvariantModel)
|
||||
.filter_by(is_active=True)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@when("I query Invariants filtered by is_active False")
|
||||
def step_query_inactive(context: Context) -> None:
|
||||
context._inv_results = (
|
||||
context._inv_session.query(InvariantModel)
|
||||
.filter_by(is_active=False)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@then("I should get {count:d} Invariants")
|
||||
def step_check_count(context: Context, count: int) -> None:
|
||||
actual = len(context._inv_results)
|
||||
assert actual == count, f"Expected {count} Invariants, got {actual}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deactivate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a persisted active Invariant")
|
||||
def step_persisted_active(context: Context) -> None:
|
||||
inv = _make_invariant("Active invariant to deactivate", is_active=True)
|
||||
context._inv_session.add(inv)
|
||||
context._inv_session.commit()
|
||||
context._inv_id = inv.id
|
||||
context._inv_retrieved = inv
|
||||
|
||||
|
||||
@when("I set is_active to False on the Invariant")
|
||||
def step_deactivate(context: Context) -> None:
|
||||
inv = (
|
||||
context._inv_session.query(InvariantModel)
|
||||
.filter_by(id=context._inv_id)
|
||||
.first()
|
||||
)
|
||||
assert inv is not None
|
||||
inv.is_active = False
|
||||
context._inv_session.commit()
|
||||
context._inv_retrieved = inv
|
||||
|
||||
|
||||
@then("the Invariant is_active should be False")
|
||||
def step_check_is_active_false(context: Context) -> None:
|
||||
inv = (
|
||||
context._inv_session.query(InvariantModel)
|
||||
.filter_by(id=context._inv_id)
|
||||
.first()
|
||||
)
|
||||
assert inv is not None
|
||||
assert inv.is_active is False or inv.is_active == 0, (
|
||||
f"Expected is_active=False, got {inv.is_active!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the invariants table should exist")
|
||||
def step_table_exists(context: Context) -> None:
|
||||
inspector = inspect(context._inv_engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "invariants" in tables, (
|
||||
f"Table 'invariants' not found. Available tables: {tables}"
|
||||
)
|
||||
|
||||
|
||||
@then("the invariants table should have an index on is_active")
|
||||
def step_index_exists(context: Context) -> None:
|
||||
inspector = inspect(context._inv_engine)
|
||||
indexes = inspector.get_indexes("invariants")
|
||||
index_names = [idx["name"] for idx in indexes]
|
||||
# SQLite may also create implicit indexes; check for our named index
|
||||
assert any("is_active" in name for name in index_names), (
|
||||
f"No index on is_active found. Indexes: {index_names}"
|
||||
)
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Step definitions for TDD Bug #8232 — _resolve_profile_for_plan silent fallback.
|
||||
|
||||
These steps exercise ``PlanLifecycleService._resolve_profile_for_plan()`` and
|
||||
verify that:
|
||||
|
||||
1. Built-in profiles resolve correctly.
|
||||
2. A plan with no automation_profile set resolves to "manual".
|
||||
3. Custom (unknown) profile names raise ``ValidationError`` instead of
|
||||
silently falling back to "manual".
|
||||
4. The resolved profile name is logged at debug level.
|
||||
|
||||
Bug #8232: ``_resolve_profile_for_plan`` used ``BUILTIN_PROFILES.get(name,
|
||||
BUILTIN_PROFILES["manual"])`` which silently returned the "manual" profile
|
||||
when a custom profile name was not found. Users who configured custom
|
||||
profiles (e.g. "semi-auto", "full-auto") found their plans executing in
|
||||
manual mode without any warning.
|
||||
|
||||
All step text uses the ``rp8232`` prefix to avoid conflicts with other
|
||||
step definitions in the test suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.application.services.plan_lifecycle_service import (
|
||||
PlanLifecycleService,
|
||||
)
|
||||
from cleveragents.config.settings import Settings
|
||||
from cleveragents.core.exceptions import ValidationError
|
||||
from cleveragents.domain.models.core.automation_profile import BUILTIN_PROFILES
|
||||
from cleveragents.domain.models.core.plan import (
|
||||
AutomationProfileProvenance,
|
||||
AutomationProfileRef,
|
||||
NamespacedName,
|
||||
Plan,
|
||||
PlanIdentity,
|
||||
PlanTimestamps,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_plan_rp8232(profile_name: str | None = None) -> Plan:
|
||||
"""Create a minimal Plan for testing _resolve_profile_for_plan.
|
||||
|
||||
Args:
|
||||
profile_name: If provided, sets ``automation_profile`` to an
|
||||
``AutomationProfileRef`` with this name. If ``None``, the
|
||||
plan's ``automation_profile`` is left as ``None``.
|
||||
|
||||
Returns:
|
||||
A minimal ``Plan`` instance.
|
||||
"""
|
||||
automation_profile: AutomationProfileRef | None = None
|
||||
if profile_name is not None:
|
||||
automation_profile = AutomationProfileRef(
|
||||
profile_name=profile_name,
|
||||
provenance=AutomationProfileProvenance.PLAN,
|
||||
)
|
||||
|
||||
return Plan(
|
||||
identity=PlanIdentity(plan_id=str(ULID())),
|
||||
namespaced_name=NamespacedName(
|
||||
server="local",
|
||||
namespace="test",
|
||||
name="test-plan",
|
||||
),
|
||||
action_name="local/test-action",
|
||||
description="Test plan for profile resolution",
|
||||
definition_of_done="Tests pass",
|
||||
strategy_actor="openai/gpt-4",
|
||||
execution_actor="openai/gpt-4",
|
||||
timestamps=PlanTimestamps(),
|
||||
automation_profile=automation_profile,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("rp8232 a fresh PlanLifecycleService for profile resolution testing")
|
||||
def step_rp8232_create_fresh_service(context: Context) -> None:
|
||||
"""Create a fresh PlanLifecycleService for profile resolution tests."""
|
||||
Settings._instance = None
|
||||
settings: Settings = Settings()
|
||||
context.rp8232_service = PlanLifecycleService(settings=settings)
|
||||
context.rp8232_raised_error: Exception | None = None
|
||||
context.rp8232_resolved_profile_name: str | None = None
|
||||
context.rp8232_debug_log_calls: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@given('rp8232 a plan with automation_profile "{profile_name}"')
|
||||
def step_rp8232_create_plan_with_profile(context: Context, profile_name: str) -> None:
|
||||
"""Create a plan with the given automation_profile name."""
|
||||
context.rp8232_plan = _make_plan_rp8232(profile_name=profile_name)
|
||||
|
||||
|
||||
@given("rp8232 a plan with no automation_profile set")
|
||||
def step_rp8232_create_plan_without_profile(context: Context) -> None:
|
||||
"""Create a plan with automation_profile=None."""
|
||||
context.rp8232_plan = _make_plan_rp8232(profile_name=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("rp8232 _resolve_profile_for_plan is called on the plan")
|
||||
def step_rp8232_call_resolve_profile(context: Context) -> None:
|
||||
"""Call _resolve_profile_for_plan and capture result or exception."""
|
||||
context.rp8232_raised_error = None
|
||||
context.rp8232_resolved_profile_name = None
|
||||
context.rp8232_debug_log_calls = []
|
||||
|
||||
# Patch the structlog logger to capture debug calls
|
||||
original_debug = context.rp8232_service._logger.debug
|
||||
|
||||
def _capture_debug(event: str, **kwargs: Any) -> None:
|
||||
context.rp8232_debug_log_calls.append({"event": event, **kwargs})
|
||||
original_debug(event, **kwargs)
|
||||
|
||||
mock_logger = MagicMock()
|
||||
mock_logger.debug.side_effect = _capture_debug
|
||||
context.rp8232_service._logger = mock_logger
|
||||
|
||||
try:
|
||||
profile = context.rp8232_service._resolve_profile_for_plan(context.rp8232_plan)
|
||||
context.rp8232_resolved_profile_name = profile.name
|
||||
except ValidationError as exc:
|
||||
context.rp8232_raised_error = exc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then('rp8232 the resolved profile name should be "{expected_name}"')
|
||||
def step_rp8232_assert_resolved_profile_name(
|
||||
context: Context, expected_name: str
|
||||
) -> None:
|
||||
"""Assert the resolved profile has the expected name."""
|
||||
assert context.rp8232_raised_error is None, (
|
||||
f"Expected no error but got ValidationError: {context.rp8232_raised_error}"
|
||||
)
|
||||
assert context.rp8232_resolved_profile_name == expected_name, (
|
||||
f"Expected resolved profile '{expected_name}', "
|
||||
f"got '{context.rp8232_resolved_profile_name}'. "
|
||||
"_resolve_profile_for_plan did not resolve the correct profile."
|
||||
)
|
||||
|
||||
|
||||
@then("rp8232 no ValidationError should have been raised")
|
||||
def step_rp8232_assert_no_error(context: Context) -> None:
|
||||
"""Assert no ValidationError was raised."""
|
||||
assert context.rp8232_raised_error is None, (
|
||||
f"Expected no ValidationError but got: {context.rp8232_raised_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("rp8232 a ValidationError should be raised")
|
||||
def step_rp8232_assert_validation_error_raised(context: Context) -> None:
|
||||
"""Assert a ValidationError was raised for the unknown profile.
|
||||
|
||||
Bug #8232: The old code silently returned BUILTIN_PROFILES["manual"]
|
||||
instead of raising an error when a custom profile name was not found.
|
||||
"""
|
||||
assert context.rp8232_raised_error is not None, (
|
||||
"Expected a ValidationError to be raised for an unknown custom profile, "
|
||||
"but no error was raised. "
|
||||
"Bug #8232: _resolve_profile_for_plan silently falls back to 'manual' "
|
||||
"instead of raising a clear error for unknown profile names."
|
||||
)
|
||||
assert isinstance(context.rp8232_raised_error, ValidationError), (
|
||||
f"Expected ValidationError, got {type(context.rp8232_raised_error).__name__}: "
|
||||
f"{context.rp8232_raised_error}"
|
||||
)
|
||||
|
||||
|
||||
@then('rp8232 the error message should mention "{profile_name}"')
|
||||
def step_rp8232_assert_error_mentions_profile(
|
||||
context: Context, profile_name: str
|
||||
) -> None:
|
||||
"""Assert the error message mentions the unknown profile name."""
|
||||
assert context.rp8232_raised_error is not None, (
|
||||
"No error was raised — cannot check error message."
|
||||
)
|
||||
error_msg = str(context.rp8232_raised_error)
|
||||
assert profile_name in error_msg, (
|
||||
f"Expected error message to mention '{profile_name}', but got: {error_msg!r}"
|
||||
)
|
||||
|
||||
|
||||
@then("rp8232 the error message should list available built-in profiles")
|
||||
def step_rp8232_assert_error_lists_profiles(context: Context) -> None:
|
||||
"""Assert the error message lists the available built-in profiles."""
|
||||
assert context.rp8232_raised_error is not None, (
|
||||
"No error was raised — cannot check error message."
|
||||
)
|
||||
error_msg = str(context.rp8232_raised_error)
|
||||
# At least one known built-in profile name should appear in the error
|
||||
known_profiles = list(BUILTIN_PROFILES.keys())
|
||||
found_any = any(p in error_msg for p in known_profiles)
|
||||
assert found_any, (
|
||||
f"Expected error message to list available built-in profiles "
|
||||
f"(e.g. {known_profiles[:3]}), but got: {error_msg!r}"
|
||||
)
|
||||
|
||||
|
||||
@then('rp8232 the debug log should record the resolved profile name "{expected_name}"')
|
||||
def step_rp8232_assert_debug_log_records_profile(
|
||||
context: Context, expected_name: str
|
||||
) -> None:
|
||||
"""Assert the debug log recorded the resolved profile name.
|
||||
|
||||
The fix adds a debug log call after successful profile resolution so
|
||||
that operators can trace which profile was used for each plan.
|
||||
"""
|
||||
assert context.rp8232_raised_error is None, (
|
||||
f"Expected no error but got: {context.rp8232_raised_error}"
|
||||
)
|
||||
debug_calls = context.rp8232_debug_log_calls
|
||||
matching = [
|
||||
call
|
||||
for call in debug_calls
|
||||
if call.get("event") == "resolved_automation_profile_for_plan"
|
||||
and call.get("profile_name") == expected_name
|
||||
]
|
||||
assert matching, (
|
||||
f"Expected a debug log call with event='resolved_automation_profile_for_plan' "
|
||||
f"and profile_name='{expected_name}', but found: {debug_calls!r}. "
|
||||
"The fix should log the resolved profile name at debug level."
|
||||
)
|
||||
@@ -1,67 +0,0 @@
|
||||
@tdd_issue @tdd_issue_8232
|
||||
Feature: TDD Bug #8232 — _resolve_profile_for_plan silently falls back to manual
|
||||
As a developer
|
||||
I want _resolve_profile_for_plan to raise a clear error for unknown profiles
|
||||
So that custom automation profiles are never silently replaced with 'manual'
|
||||
|
||||
Per the v3.5.0 acceptance criterion:
|
||||
"Automation profile resolution precedence correct (plan > action > global)"
|
||||
|
||||
The bug: when a plan's automation_profile.profile_name is a custom name
|
||||
not present in BUILTIN_PROFILES, the old code silently returned
|
||||
BUILTIN_PROFILES["manual"] instead of raising an error.
|
||||
|
||||
The fix: raise ValidationError for unknown profile names and log the
|
||||
resolved profile name at debug level.
|
||||
|
||||
Background:
|
||||
Given rp8232 a fresh PlanLifecycleService for profile resolution testing
|
||||
|
||||
Scenario: Built-in profile resolves correctly without fallback
|
||||
Given rp8232 a plan with automation_profile "full-auto"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 the resolved profile name should be "full-auto"
|
||||
And rp8232 no ValidationError should have been raised
|
||||
|
||||
Scenario: Plan with no automation_profile resolves to manual
|
||||
Given rp8232 a plan with no automation_profile set
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 the resolved profile name should be "manual"
|
||||
And rp8232 no ValidationError should have been raised
|
||||
|
||||
Scenario: Custom profile name raises ValidationError instead of silent fallback
|
||||
Given rp8232 a plan with automation_profile "semi-auto"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 a ValidationError should be raised
|
||||
And rp8232 the error message should mention "semi-auto"
|
||||
And rp8232 the error message should list available built-in profiles
|
||||
|
||||
Scenario: Another custom profile name raises ValidationError
|
||||
Given rp8232 a plan with automation_profile "acme/strict"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 a ValidationError should be raised
|
||||
And rp8232 the error message should mention "acme/strict"
|
||||
|
||||
Scenario: All built-in profiles resolve without error
|
||||
Given rp8232 a plan with automation_profile "manual"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 the resolved profile name should be "manual"
|
||||
And rp8232 no ValidationError should have been raised
|
||||
|
||||
Scenario: Built-in profile "ci" resolves correctly
|
||||
Given rp8232 a plan with automation_profile "ci"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 the resolved profile name should be "ci"
|
||||
And rp8232 no ValidationError should have been raised
|
||||
|
||||
Scenario: Built-in profile "auto" resolves correctly
|
||||
Given rp8232 a plan with automation_profile "auto"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 the resolved profile name should be "auto"
|
||||
And rp8232 no ValidationError should have been raised
|
||||
|
||||
Scenario: Resolved profile name is logged at debug level
|
||||
Given rp8232 a plan with automation_profile "trusted"
|
||||
When rp8232 _resolve_profile_for_plan is called on the plan
|
||||
Then rp8232 the resolved profile name should be "trusted"
|
||||
And rp8232 the debug log should record the resolved profile name "trusted"
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Helper script for Robot Framework invariant model smoke tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure src is importable when run from workspace root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.infrastructure.database.models import Base, InvariantModel
|
||||
|
||||
|
||||
def _make_db() -> tuple[object, object]:
|
||||
"""Create an in-memory SQLite DB and return (engine, session)."""
|
||||
engine = create_engine("sqlite:///:memory:", echo=False)
|
||||
Base.metadata.create_all(engine)
|
||||
sm = sessionmaker(bind=engine)
|
||||
session = sm()
|
||||
return engine, session
|
||||
|
||||
|
||||
def _make_invariant(
|
||||
description: str, is_active: bool = True
|
||||
) -> InvariantModel:
|
||||
return InvariantModel(
|
||||
id=str(uuid.uuid4()),
|
||||
description=description,
|
||||
created_at=datetime.now(tz=UTC).isoformat(),
|
||||
is_active=is_active,
|
||||
)
|
||||
|
||||
|
||||
def _test_create_invariant() -> None:
|
||||
"""Create an Invariant with all required fields and verify persistence."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("All plans must have a goal")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None, "Invariant not found after persist"
|
||||
assert retrieved.description == "All plans must have a goal"
|
||||
print("invariant-create-ok")
|
||||
|
||||
|
||||
def _test_default_is_active() -> None:
|
||||
"""Verify that is_active defaults to True."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("Default active invariant")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
assert retrieved.is_active is True or retrieved.is_active == 1, (
|
||||
f"Expected is_active=True, got {retrieved.is_active!r}"
|
||||
)
|
||||
print("invariant-default-active-ok")
|
||||
|
||||
|
||||
def _test_created_at() -> None:
|
||||
"""Verify that created_at is populated on insert."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("Timestamped invariant")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
assert retrieved.created_at, "created_at should not be empty"
|
||||
assert len(str(retrieved.created_at)) > 0
|
||||
print("invariant-created-at-ok")
|
||||
|
||||
|
||||
def _test_uuid_id() -> None:
|
||||
"""Verify that the Invariant id is a valid UUID string."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("UUID invariant")
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
try:
|
||||
uuid.UUID(str(retrieved.id))
|
||||
except ValueError as exc:
|
||||
raise AssertionError(f"id '{retrieved.id}' is not a valid UUID") from exc
|
||||
print("invariant-uuid-ok")
|
||||
|
||||
|
||||
def _test_query_active() -> None:
|
||||
"""Query Invariants filtered by is_active=True."""
|
||||
_, session = _make_db()
|
||||
for i in range(3):
|
||||
session.add(_make_invariant(f"Active {i}", is_active=True))
|
||||
for i in range(2):
|
||||
session.add(_make_invariant(f"Inactive {i}", is_active=False))
|
||||
session.commit()
|
||||
|
||||
results = session.query(InvariantModel).filter_by(is_active=True).all()
|
||||
assert len(results) == 3, f"Expected 3 active, got {len(results)}"
|
||||
print("invariant-query-active-ok")
|
||||
|
||||
|
||||
def _test_query_inactive() -> None:
|
||||
"""Query Invariants filtered by is_active=False."""
|
||||
_, session = _make_db()
|
||||
for i in range(3):
|
||||
session.add(_make_invariant(f"Active {i}", is_active=True))
|
||||
for i in range(2):
|
||||
session.add(_make_invariant(f"Inactive {i}", is_active=False))
|
||||
session.commit()
|
||||
|
||||
results = session.query(InvariantModel).filter_by(is_active=False).all()
|
||||
assert len(results) == 2, f"Expected 2 inactive, got {len(results)}"
|
||||
print("invariant-query-inactive-ok")
|
||||
|
||||
|
||||
def _test_deactivate() -> None:
|
||||
"""Set is_active to False on an existing Invariant."""
|
||||
_, session = _make_db()
|
||||
inv = _make_invariant("Active invariant to deactivate", is_active=True)
|
||||
session.add(inv)
|
||||
session.commit()
|
||||
|
||||
retrieved = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert retrieved is not None
|
||||
retrieved.is_active = False
|
||||
session.commit()
|
||||
|
||||
updated = session.query(InvariantModel).filter_by(id=inv.id).first()
|
||||
assert updated is not None
|
||||
assert updated.is_active is False or updated.is_active == 0, (
|
||||
f"Expected is_active=False, got {updated.is_active!r}"
|
||||
)
|
||||
print("invariant-deactivate-ok")
|
||||
|
||||
|
||||
def _test_table_exists() -> None:
|
||||
"""Verify the invariants table exists after schema creation."""
|
||||
engine, _ = _make_db()
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
assert "invariants" in tables, (
|
||||
f"Table 'invariants' not found. Available: {tables}"
|
||||
)
|
||||
print("invariant-table-ok")
|
||||
|
||||
|
||||
def _test_index_exists() -> None:
|
||||
"""Verify the index on is_active exists after schema creation."""
|
||||
engine, _ = _make_db()
|
||||
inspector = inspect(engine)
|
||||
indexes = inspector.get_indexes("invariants")
|
||||
index_names = [idx["name"] for idx in indexes]
|
||||
assert any("is_active" in name for name in index_names), (
|
||||
f"No index on is_active found. Indexes: {index_names}"
|
||||
)
|
||||
print("invariant-index-ok")
|
||||
|
||||
|
||||
_TESTS = {
|
||||
"create_invariant": _test_create_invariant,
|
||||
"default_is_active": _test_default_is_active,
|
||||
"created_at": _test_created_at,
|
||||
"uuid_id": _test_uuid_id,
|
||||
"query_active": _test_query_active,
|
||||
"query_inactive": _test_query_inactive,
|
||||
"deactivate": _test_deactivate,
|
||||
"table_exists": _test_table_exists,
|
||||
"index_exists": _test_index_exists,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <test_name>")
|
||||
print(f"Available tests: {', '.join(sorted(_TESTS))}")
|
||||
sys.exit(1)
|
||||
|
||||
test_name = sys.argv[1]
|
||||
if test_name not in _TESTS:
|
||||
print(f"Unknown test: {test_name}")
|
||||
print(f"Available: {', '.join(sorted(_TESTS))}")
|
||||
sys.exit(1)
|
||||
|
||||
_TESTS[test_name]()
|
||||
@@ -1,63 +0,0 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for Invariant data model persistence contract
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment With Database Isolation
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER_SCRIPT} robot/helper_invariant_model.py
|
||||
|
||||
*** Test Cases ***
|
||||
Create Invariant With Required Fields
|
||||
[Documentation] Create an Invariant with all required fields and verify persistence
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} create_invariant cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=create_invariant failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-create-ok
|
||||
|
||||
Is Active Defaults To True
|
||||
[Documentation] Verify that is_active defaults to True on a new Invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} default_is_active cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=default_is_active failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-default-active-ok
|
||||
|
||||
Created At Is Populated
|
||||
[Documentation] Verify that created_at is auto-populated on insert
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} created_at cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=created_at failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-created-at-ok
|
||||
|
||||
Id Is UUID
|
||||
[Documentation] Verify that the Invariant id is a valid UUID string
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} uuid_id cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=uuid_id failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-uuid-ok
|
||||
|
||||
Query Active Invariants
|
||||
[Documentation] Query Invariants filtered by is_active=True
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} query_active cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=query_active failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-query-active-ok
|
||||
|
||||
Query Inactive Invariants
|
||||
[Documentation] Query Invariants filtered by is_active=False
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} query_inactive cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=query_inactive failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-query-inactive-ok
|
||||
|
||||
Deactivate Invariant
|
||||
[Documentation] Set is_active to False on an existing Invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} deactivate cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=deactivate failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-deactivate-ok
|
||||
|
||||
Table Exists After Migration
|
||||
[Documentation] Verify the invariants table exists after schema creation
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} table_exists cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=table_exists failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-table-ok
|
||||
|
||||
Index On Is Active Exists
|
||||
[Documentation] Verify the index on is_active exists after schema creation
|
||||
${result}= Run Process ${PYTHON} ${HELPER_SCRIPT} index_exists cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0 msg=index_exists failed: ${result.stderr}
|
||||
Should Contain ${result.stdout} invariant-index-ok
|
||||
Executable
+778
@@ -0,0 +1,778 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Creation Script for CleverAgents
|
||||
|
||||
Generates .zsh test composition code by wrapping arbitrary test logic with
|
||||
setup/teardown commands for 14 agent command types. Each function returns a
|
||||
string of shell commands structured as: creation → wrapped content → removal.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import textwrap
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _format_flag(param_name: str, value: Any) -> str:
|
||||
"""
|
||||
Convert Python parameter name and value to CLI flag.
|
||||
|
||||
Rules:
|
||||
- Underscores → dashes: automation_profile → --automation-profile
|
||||
- Booleans: True → --flag, False → omitted
|
||||
- Strings/paths: value → --flag "value"
|
||||
- Lists: each item → --flag item1 --flag item2
|
||||
|
||||
Returns:
|
||||
Formatted flag string or empty string if should be omitted.
|
||||
"""
|
||||
if not _should_include_param(value):
|
||||
return ""
|
||||
|
||||
# Convert parameter name: underscores to dashes
|
||||
flag_name = "--" + param_name.replace("_", "-")
|
||||
|
||||
# Handle boolean True (flag with no value)
|
||||
if isinstance(value, bool) and value:
|
||||
return flag_name
|
||||
|
||||
# Handle lists (multiple instances of the flag)
|
||||
if isinstance(value, list):
|
||||
parts = []
|
||||
for item in value:
|
||||
parts.append(f"{flag_name} {shlex.quote(str(item))}")
|
||||
return " ".join(parts)
|
||||
|
||||
# Handle strings, paths, numbers
|
||||
if isinstance(value, dict):
|
||||
# For dict args (name=value pairs)
|
||||
parts = []
|
||||
for k, v in value.items():
|
||||
parts.append(f"{flag_name} {k}={shlex.quote(str(v))}")
|
||||
return " ".join(parts)
|
||||
|
||||
return f"{flag_name} {shlex.quote(str(value))}"
|
||||
|
||||
|
||||
def _should_include_param(value: Any) -> bool:
|
||||
"""
|
||||
Determine if optional parameter should appear in output.
|
||||
|
||||
Rules:
|
||||
- Boolean False → exclude
|
||||
- None, "", [] → exclude
|
||||
- Otherwise → include
|
||||
|
||||
Returns:
|
||||
True if should include, False if omit.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value # False is omitted, True is included
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
if isinstance(value, str) and not value:
|
||||
return False
|
||||
|
||||
if isinstance(value, (list, dict)) and not value:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_file_exists(path: str) -> None:
|
||||
"""
|
||||
Log warning if file doesn't exist.
|
||||
|
||||
Does not raise exception; allows execution to continue.
|
||||
"""
|
||||
if not path:
|
||||
return
|
||||
|
||||
if not os.path.exists(path):
|
||||
logger.warning(f"File does not exist: {path}")
|
||||
|
||||
|
||||
def _build_command(*parts: str) -> str:
|
||||
"""
|
||||
Join command parts with proper spacing.
|
||||
|
||||
Handles quoting for paths with spaces. Returns formatted shell command string.
|
||||
"""
|
||||
# Filter out empty parts
|
||||
filtered_parts = [p for p in parts if p]
|
||||
return " ".join(filtered_parts)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# The 14 Command Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def init(yes: bool = False, wrapped: str = "") -> str:
|
||||
"""
|
||||
Initialize CleverAgents environment.
|
||||
|
||||
Special case: No removal command.
|
||||
|
||||
Args:
|
||||
yes: Suppress confirmation prompt.
|
||||
wrapped: Code to execute after initialization.
|
||||
|
||||
Returns:
|
||||
Shell command string: agents init [--yes] → wrapped
|
||||
"""
|
||||
startup = textwrap.dedent("""
|
||||
uv venv
|
||||
source .venv/bin/activate
|
||||
uv pip install /app
|
||||
""")
|
||||
|
||||
cmd_parts = ["agents", "init"]
|
||||
if yes:
|
||||
cmd_parts.append("--yes")
|
||||
|
||||
init_cmd = _build_command(*cmd_parts)
|
||||
|
||||
if wrapped:
|
||||
return f"{startup}{init_cmd}\n{wrapped}"
|
||||
return f"{startup}{init_cmd}"
|
||||
|
||||
|
||||
def session(actor: str = "", yes: bool = False, wrapped: str = "") -> str:
|
||||
"""
|
||||
Create and remove a session.
|
||||
|
||||
Args:
|
||||
actor: Optional actor name for session (e.g., local/orchestrator).
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute during session.
|
||||
|
||||
Returns:
|
||||
Shell command string: create → wrapped → delete
|
||||
"""
|
||||
# Creation command
|
||||
create_parts = ["agents", "session", "create"]
|
||||
if actor:
|
||||
create_parts.extend(["--actor", shlex.quote(actor)])
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command (caller must capture SESSION_ID)
|
||||
remove_parts = ["agents", "session", "delete"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append('"${SESSION_ID}"') # Placeholder for ID
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def resource_type(
|
||||
config: str,
|
||||
name: str = "",
|
||||
update: bool = False,
|
||||
yes: bool = False,
|
||||
wrapped: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove a resource type.
|
||||
|
||||
Args:
|
||||
config: Path to resource type config file.
|
||||
name: Resource type name.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this resource type.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not name:
|
||||
logger.error("resource_type: name is required")
|
||||
raise ValueError("name is required for resource_type")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "resource", "type", "add"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "resource", "type", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def resource(
|
||||
type: str,
|
||||
name: str,
|
||||
description: str = "",
|
||||
update: bool = False,
|
||||
yes: bool = False,
|
||||
wrapped: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove a resource.
|
||||
|
||||
Args:
|
||||
type: Resource type name.
|
||||
name: Resource name.
|
||||
description: Optional description.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this resource.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
if not type:
|
||||
logger.error("resource: type is required")
|
||||
raise ValueError("type is required for resource")
|
||||
if not name:
|
||||
logger.error("resource: name is required")
|
||||
raise ValueError("name is required for resource")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "resource", "add", shlex.quote(type), shlex.quote(name)]
|
||||
if description:
|
||||
create_parts.extend(["--description", shlex.quote(description)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "resource", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def lsp(
|
||||
config: str, name: str, update: bool = False, yes: bool = False, wrapped: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove an LSP server registration.
|
||||
|
||||
Args:
|
||||
config: Path to LSP config file.
|
||||
name: LSP server name.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this LSP server.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not name:
|
||||
logger.error("lsp: name is required")
|
||||
raise ValueError("name is required for lsp")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "lsp", "add"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "lsp", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def automation_profile(
|
||||
config: str, name: str, update: bool = False, yes: bool = False, wrapped: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove an automation profile.
|
||||
|
||||
Args:
|
||||
config: Path to profile config file.
|
||||
name: Profile name.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this profile.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not name:
|
||||
logger.error("automation_profile: name is required")
|
||||
raise ValueError("name is required for automation_profile")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "automation-profile", "add"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "automation-profile", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def tool(
|
||||
config: str, name: str, update: bool = False, yes: bool = False, wrapped: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove a tool.
|
||||
|
||||
Args:
|
||||
config: Path to tool config file.
|
||||
name: Tool name.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this tool.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not name:
|
||||
logger.error("tool: name is required")
|
||||
raise ValueError("name is required for tool")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "tool", "add"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "tool", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def skill(
|
||||
config: str, name: str, update: bool = False, yes: bool = False, wrapped: str = ""
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove a skill.
|
||||
|
||||
Args:
|
||||
config: Path to skill config file.
|
||||
name: Skill name.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this skill.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not name:
|
||||
logger.error("skill: name is required")
|
||||
raise ValueError("name is required for skill")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "skill", "add"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "skill", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def actor(config: str, name: str, update: bool = False, wrapped: str = "") -> str:
|
||||
"""
|
||||
Create and remove an actor.
|
||||
|
||||
Args:
|
||||
config: Path to actor config file.
|
||||
name: Actor name.
|
||||
update: Include --update flag.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this actor.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not name:
|
||||
logger.error("actor: name is required")
|
||||
raise ValueError("name is required for actor")
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "actor", "add"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
if update:
|
||||
create_parts.append("--update")
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "actor", "remove", shlex.quote(name)]
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def action(config: str, wrapped: str = "") -> str:
|
||||
"""
|
||||
Create and remove an action.
|
||||
|
||||
Args:
|
||||
config: Path to action config file.
|
||||
wrapped: Code to execute with this action.
|
||||
|
||||
Returns:
|
||||
Shell command string: create → wrapped → archive
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "action", "create"]
|
||||
create_parts.extend(["--config", shlex.quote(config)])
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command (caller must provide ACTION_NAME)
|
||||
remove_parts = ["agents", "action", "archive", '"${ACTION_NAME}"']
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def project(
|
||||
name: str,
|
||||
description: str = "",
|
||||
resources: Optional[List[str]] = None,
|
||||
invariants: Optional[List[str]] = None,
|
||||
invariant_actor: str = "",
|
||||
yes: bool = False,
|
||||
wrapped: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove a project.
|
||||
|
||||
Args:
|
||||
name: Project name.
|
||||
description: Optional description.
|
||||
resources: List of resource names to link (assume they exist).
|
||||
invariants: List of invariant texts to attach.
|
||||
invariant_actor: Optional invariant reconciliation actor name.
|
||||
yes: Include --yes flag in delete command.
|
||||
wrapped: Code to execute with this project.
|
||||
|
||||
Returns:
|
||||
Shell command string: create → wrapped → delete
|
||||
"""
|
||||
if not name:
|
||||
logger.error("project: name is required")
|
||||
raise ValueError("name is required for project")
|
||||
|
||||
if resources is None:
|
||||
resources = []
|
||||
if invariants is None:
|
||||
invariants = []
|
||||
|
||||
# Creation command
|
||||
create_parts = ["agents", "project", "create"]
|
||||
if description:
|
||||
create_parts.extend(["--description", shlex.quote(description)])
|
||||
for resource in resources:
|
||||
create_parts.extend(["--resource", shlex.quote(resource)])
|
||||
for invariant in invariants:
|
||||
create_parts.extend(["--invariant", shlex.quote(invariant)])
|
||||
if invariant_actor:
|
||||
create_parts.extend(["--invariant-actor", shlex.quote(invariant_actor)])
|
||||
create_parts.append(shlex.quote(name))
|
||||
create_cmd = _build_command(*create_parts)
|
||||
|
||||
# Removal command
|
||||
remove_parts = ["agents", "project", "delete"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append(shlex.quote(name))
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(
|
||||
create_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd
|
||||
)
|
||||
|
||||
|
||||
def plan(
|
||||
action: str,
|
||||
projects: List[str],
|
||||
automation_profile: str = "",
|
||||
strategy_actor: str = "",
|
||||
execution_actor: str = "",
|
||||
estimation_actor: str = "",
|
||||
invariant_actor: str = "",
|
||||
invariants: Optional[List[str]] = None,
|
||||
args: Optional[Dict[str, str]] = None,
|
||||
yes: bool = False,
|
||||
wrapped: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create and execute a plan, then cancel it.
|
||||
|
||||
Args:
|
||||
action: Action name.
|
||||
projects: List of project names (at least one required).
|
||||
automation_profile: Optional profile name.
|
||||
strategy_actor: Optional strategy actor override.
|
||||
execution_actor: Optional execution actor override.
|
||||
estimation_actor: Optional estimation actor override.
|
||||
invariant_actor: Optional invariant actor override.
|
||||
invariants: Optional list of invariant IDs.
|
||||
args: Optional action arguments (dict → --arg name=value).
|
||||
yes: Include --yes flag in cancel command.
|
||||
wrapped: Code to execute during plan.
|
||||
|
||||
Returns:
|
||||
Shell command string: use → wrapped → cancel
|
||||
"""
|
||||
if not action:
|
||||
logger.error("plan: action is required")
|
||||
raise ValueError("action is required for plan")
|
||||
if not projects:
|
||||
logger.error("plan: at least one project is required")
|
||||
raise ValueError("projects is required for plan")
|
||||
|
||||
if invariants is None:
|
||||
invariants = []
|
||||
if args is None:
|
||||
args = {}
|
||||
|
||||
# Creation command (agents plan use)
|
||||
use_parts = ["agents", "plan", "use"]
|
||||
if automation_profile:
|
||||
use_parts.extend(["--automation-profile", shlex.quote(automation_profile)])
|
||||
if strategy_actor:
|
||||
use_parts.extend(["--strategy-actor", shlex.quote(strategy_actor)])
|
||||
if execution_actor:
|
||||
use_parts.extend(["--execution-actor", shlex.quote(execution_actor)])
|
||||
if estimation_actor:
|
||||
use_parts.extend(["--estimation-actor", shlex.quote(estimation_actor)])
|
||||
if invariant_actor:
|
||||
use_parts.extend(["--invariant-actor", shlex.quote(invariant_actor)])
|
||||
for invariant in invariants:
|
||||
use_parts.extend(["--invariant", shlex.quote(invariant)])
|
||||
for key, value in args.items():
|
||||
use_parts.extend(["--arg", f"{key}={shlex.quote(value)}"])
|
||||
use_parts.append(shlex.quote(action))
|
||||
for project in projects:
|
||||
use_parts.append(shlex.quote(project))
|
||||
use_cmd = _build_command(*use_parts)
|
||||
|
||||
# Removal command (caller must capture PLAN_ID)
|
||||
cancel_parts = ["agents", "plan", "cancel"]
|
||||
if yes:
|
||||
cancel_parts.append("--yes")
|
||||
cancel_parts.append('"${PLAN_ID}"') # Placeholder for ID
|
||||
cancel_cmd = _build_command(*cancel_parts)
|
||||
|
||||
return _build_command(use_cmd, "\n" + wrapped if wrapped else "", "\n" + cancel_cmd)
|
||||
|
||||
|
||||
def validation(
|
||||
config: str,
|
||||
resource: str,
|
||||
attachment_args: Optional[Dict[str, str]] = None,
|
||||
yes: bool = False,
|
||||
wrapped: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove a validation attachment.
|
||||
|
||||
Args:
|
||||
config: Path to validation config file.
|
||||
resource: Resource name to attach validation to.
|
||||
attachment_args: Dict of attachment-specific args (e.g., {"project": "myproj"}).
|
||||
yes: Include --yes flag in detach command.
|
||||
wrapped: Code to execute with this validation.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → attach → wrapped → detach
|
||||
"""
|
||||
_validate_file_exists(config)
|
||||
|
||||
if not resource:
|
||||
logger.error("validation: resource is required")
|
||||
raise ValueError("resource is required for validation")
|
||||
|
||||
if attachment_args is None:
|
||||
attachment_args = {}
|
||||
|
||||
# Add command
|
||||
add_parts = ["agents", "validation", "add"]
|
||||
add_parts.extend(["--config", shlex.quote(config)])
|
||||
add_cmd = _build_command(*add_parts)
|
||||
|
||||
# Attach command (caller must extract validation name)
|
||||
attach_parts = [
|
||||
"agents",
|
||||
"validation",
|
||||
"attach",
|
||||
shlex.quote(resource),
|
||||
'"${VALIDATION_NAME}"',
|
||||
]
|
||||
for key, value in attachment_args.items():
|
||||
attach_parts.append(f"--{key.replace('_', '-')} {shlex.quote(value)}")
|
||||
attach_cmd = _build_command(*attach_parts)
|
||||
|
||||
# Detach command (caller must capture ATTACHMENT_ID)
|
||||
detach_parts = ["agents", "validation", "detach"]
|
||||
if yes:
|
||||
detach_parts.append("--yes")
|
||||
detach_parts.append('"${ATTACHMENT_ID}"') # Placeholder for ID
|
||||
detach_cmd = _build_command(*detach_parts)
|
||||
|
||||
full_creation = _build_command(add_cmd, "\n" + attach_cmd)
|
||||
return _build_command(
|
||||
full_creation, "\n" + wrapped if wrapped else "", "\n" + detach_cmd
|
||||
)
|
||||
|
||||
|
||||
def invariant(
|
||||
text: str,
|
||||
global_scope: bool = False,
|
||||
project: str = "",
|
||||
plan: str = "",
|
||||
action: str = "",
|
||||
yes: bool = False,
|
||||
wrapped: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Create and remove an invariant.
|
||||
|
||||
Args:
|
||||
text: Invariant text.
|
||||
global_scope: If True, use --global flag.
|
||||
project: Optional project scope.
|
||||
plan: Optional plan ID scope.
|
||||
action: Optional action scope.
|
||||
yes: Include --yes flag in remove command.
|
||||
wrapped: Code to execute with this invariant.
|
||||
|
||||
Returns:
|
||||
Shell command string: add → wrapped → remove
|
||||
"""
|
||||
if not text:
|
||||
logger.error("invariant: text is required")
|
||||
raise ValueError("text is required for invariant")
|
||||
|
||||
# Creation command
|
||||
add_parts = ["agents", "invariant", "add"]
|
||||
if global_scope:
|
||||
add_parts.append("--global")
|
||||
if project:
|
||||
add_parts.extend(["--project", shlex.quote(project)])
|
||||
if plan:
|
||||
add_parts.extend(["--plan", shlex.quote(plan)])
|
||||
if action:
|
||||
add_parts.extend(["--action", shlex.quote(action)])
|
||||
add_parts.append(shlex.quote(text))
|
||||
add_cmd = _build_command(*add_parts)
|
||||
|
||||
# Removal command (caller must capture INVARIANT_ID)
|
||||
remove_parts = ["agents", "invariant", "remove"]
|
||||
if yes:
|
||||
remove_parts.append("--yes")
|
||||
remove_parts.append('"${INVARIANT_ID}"') # Placeholder for ID
|
||||
remove_cmd = _build_command(*remove_parts)
|
||||
|
||||
return _build_command(add_cmd, "\n" + wrapped if wrapped else "", "\n" + remove_cmd)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point (Placeholder for Future Integration)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main entry point.
|
||||
|
||||
Placeholder for future integration with .zsh file writing and execution.
|
||||
"""
|
||||
result = init(
|
||||
yes=True,
|
||||
wrapped=automation_profile(
|
||||
config="supervised2.yaml",
|
||||
name="supervised2",
|
||||
update=False,
|
||||
yes=True,
|
||||
wrapped="agents automation-profile list"
|
||||
) + "\n" + automation_profile(
|
||||
config="supervised2.yaml",
|
||||
name="supervised2",
|
||||
update=True,
|
||||
yes=True,
|
||||
wrapped="agents automation-profile list"
|
||||
)
|
||||
)
|
||||
|
||||
print(result)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
# Sample LSP server configuration for agents lsp add --config lsp.yaml
|
||||
# Registers a Pyright language server for Python development.
|
||||
|
||||
name: local/pyright
|
||||
description: "Pyright static type checker and language server for Python"
|
||||
|
||||
languages:
|
||||
- python
|
||||
|
||||
# The server process is launched via stdio (default transport).
|
||||
command: pyright-langserver
|
||||
args:
|
||||
- --stdio
|
||||
|
||||
transport: stdio
|
||||
|
||||
# Environment variables injected into the server process.
|
||||
env:
|
||||
PYTHONUTF8: "1"
|
||||
|
||||
# LSP capabilities this server exposes to actors.
|
||||
capabilities:
|
||||
- diagnostics
|
||||
- hover
|
||||
- completions
|
||||
- definitions
|
||||
- references
|
||||
- rename
|
||||
- code_actions
|
||||
- formatting
|
||||
- signature_help
|
||||
- document_symbols
|
||||
- workspace_symbols
|
||||
|
||||
# Sent as initializationOptions during the LSP initialize handshake.
|
||||
initialization:
|
||||
pythonVersion: "3.12"
|
||||
typeCheckingMode: strict
|
||||
useLibraryCodeForTypes: true
|
||||
|
||||
# Sent via workspace/didChangeConfiguration after initialization.
|
||||
workspace_settings:
|
||||
python:
|
||||
analysis:
|
||||
autoImportCompletions: true
|
||||
diagnosticMode: workspace
|
||||
@@ -2122,40 +2122,17 @@ class PlanLifecycleService:
|
||||
Uses the plan's ``automation_profile`` reference. Falls back
|
||||
to the ``"manual"`` built-in profile when no profile is set.
|
||||
|
||||
Custom profile names that are not found in ``BUILTIN_PROFILES``
|
||||
raise a ``ValidationError`` rather than silently falling back to
|
||||
``"manual"``. This ensures misconfigured custom profiles are
|
||||
surfaced immediately instead of executing in an unintended mode.
|
||||
|
||||
Args:
|
||||
plan: The plan to resolve the profile for.
|
||||
|
||||
Returns:
|
||||
The resolved ``AutomationProfile``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If the plan's profile name is not a known
|
||||
built-in profile.
|
||||
"""
|
||||
if plan.automation_profile is not None:
|
||||
profile_name = plan.automation_profile.profile_name
|
||||
else:
|
||||
profile_name = "manual"
|
||||
|
||||
profile = BUILTIN_PROFILES.get(profile_name)
|
||||
if profile is None:
|
||||
raise ValidationError(
|
||||
f"Automation profile '{profile_name}' is not a known built-in profile. "
|
||||
f"Available profiles: {', '.join(sorted(BUILTIN_PROFILES))}. "
|
||||
"Custom profiles must be registered before use."
|
||||
)
|
||||
|
||||
self._logger.debug(
|
||||
"resolved_automation_profile_for_plan",
|
||||
plan_id=plan.identity.plan_id,
|
||||
profile_name=profile_name,
|
||||
)
|
||||
return profile
|
||||
return BUILTIN_PROFILES.get(profile_name, BUILTIN_PROFILES["manual"])
|
||||
|
||||
def should_auto_progress(self, plan: Plan) -> bool:
|
||||
"""Check whether the plan should automatically advance.
|
||||
|
||||
@@ -3623,38 +3623,3 @@ class IndexedFileModel(Base):
|
||||
# it for lookups already; no separate index needed.
|
||||
Index("ix_indexed_files_language", "language"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invariant Models (Stage M3 - invariant management, issue #8524)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InvariantModel(Base): # type: ignore[misc]
|
||||
"""Database model for globally-scoped invariants.
|
||||
|
||||
Invariants are user-defined constraints that must hold true across all
|
||||
planning sessions. Each row represents a single invariant rule with
|
||||
its description, creation timestamp, and active status.
|
||||
|
||||
Table: ``invariants``
|
||||
"""
|
||||
|
||||
__allow_unmapped__ = True
|
||||
__tablename__ = "invariants"
|
||||
|
||||
# PK: UUID stored as a 36-character string
|
||||
id = Column(String(36), primary_key=True)
|
||||
|
||||
# Human-readable description of the invariant constraint
|
||||
description = Column(Text, nullable=False)
|
||||
|
||||
# Timestamp of creation (ISO-8601 string, UTC)
|
||||
created_at = Column(String(30), nullable=False)
|
||||
|
||||
# Whether this invariant is currently active (default True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, server_default="1")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_invariants_is_active", "is_active"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user