Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b73be9f43 | |||
| f179b57e18 |
+1
-34
@@ -5,15 +5,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **`agents session list` now displays full 26-character session ULIDs** (#10970): The Rich table
|
||||
and Summary panel ("Most Recent" / "Oldest") previously showed only the first 8 characters of
|
||||
each session ULID. This made the output unusable for copy-paste into `session tell`,
|
||||
`session show`, `session delete`, and `session export`, all of which require the full
|
||||
26-character identifier. The full ULID is now displayed in all output formats (Rich, plain,
|
||||
JSON, YAML, table).
|
||||
|
||||
### Security
|
||||
|
||||
- **aiohttp upgraded to >=3.13.4 to remediate CVE-2026-34513 and CVE-2026-34515** (#1549, #1544):
|
||||
@@ -59,6 +50,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- `agents actor context clear` command to reset actor message history and
|
||||
state while preserving the underlying context directory via `ContextManager`
|
||||
(#6370).
|
||||
- **Quick Start Guide** (PR #9245): Added `docs/quickstart.md` with an end-to-end quick start guide covering prerequisites, installation, project creation, resource registration, plan/apply workflow, and troubleshooting. Updated `mkdocs.yml` navigation to include the Quick Start page.
|
||||
|
||||
- **Plan checkpoint management CLI commands** (#8683): Added `agents plan checkpoint-list <plan-id>` and `agents plan checkpoint-delete <checkpoint-id>` commands. Listing output now highlights checkpoint ID, type, created timestamp, reason, phase, and decision linkage with a concise field summary footer across rich/table/json/yaml formats. Deletion supports batch IDs, interactive confirmation (skip with `--yes`), and structured JSON/YAML responses for automation-friendly scripting.
|
||||
- **Invariant Remove CLI Command** (#8530): Implemented `agents invariant remove <id>` command that soft-deletes an invariant by ID. The command displays a confirmation prompt before removal (bypassable with `--yes`/`-y`), outputs the removed invariant ID on success, and shows a clear error message when the invariant ID does not exist. Supports `--format` flag for JSON and YAML output. Full BDD test coverage and Robot Framework integration tests included.
|
||||
@@ -87,31 +79,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`InvariantService.get_effective_invariants()` adds action_name param for 4-tier merge** (#3128):
|
||||
Added an `action_name: str | None = None` parameter to
|
||||
`InvariantService.get_effective_invariants()`, promoting the invariant precedence chain to
|
||||
four tiers (**plan > action > project > global**) instead of three. The method now filters
|
||||
action-scoped invariants by `action_name` (when provided) and passes them as the second tier
|
||||
to `merge_invariants()`. Updated docstrings, `InvariantScope` documentation, and
|
||||
`InvariantSet.merge()` + `merge_invariants()` signatures across the domain model, service layer,
|
||||
CLI help text, benchmarks, Robot Framework tests, and new BDD scenarios (12 scenarios across
|
||||
`features/invariant_action_scope_effective.feature`) covering all four-precedence paths,
|
||||
deduplication semantics, action-scope filtering, and list_invariants effective-mode
|
||||
passthrough. (#3329)
|
||||
|
||||
- **`LLMTraceRepository.save()` premature commit breaks UnitOfWork transactions** (#7505):
|
||||
Replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a
|
||||
dual-path implementation that respects the UnitOfWork (UoW) pattern. When an external
|
||||
session is provided (UoW mode), the method now calls only `session.flush()`, leaving
|
||||
transaction control to the caller. When no session is provided (standalone mode), the
|
||||
method creates its own session, flushes, commits, and closes it to ensure durable
|
||||
persistence. This eliminates three data-integrity violations: premature commit of outer
|
||||
UoW transactions, loss of rollback capability for subsequent failures, and a mismatch
|
||||
between the class docstring ("Callers are responsible for commit") and the implementation.
|
||||
Input validation for the `trace` argument was also added. Two new BDD scenarios verify
|
||||
the session contract: `Repository save() calls flush not commit` and `LLM trace rolled
|
||||
back when UnitOfWork transaction rolls back`.
|
||||
|
||||
- **git_tools._get_base_env() TOCTOU Race Condition** (#7619): Fixed a
|
||||
Time-Of-Check-To-Time-Of-Use race condition in `git_tools._get_base_env()`
|
||||
where two concurrent threads could both observe `_BASE_ENV is None`, both
|
||||
|
||||
@@ -29,6 +29,3 @@ Below are some of the specific details of various contributions.
|
||||
* HAL 9000 has contributed the git worktree TOCTOU race condition fix (PR #8178 / issue #7507): replaced the unsafe mkdtemp() + rmdir() pattern with a parent-directory approach to eliminate the race window in concurrent git worktree operations.
|
||||
* HAL 9000 has contributed the git_tools TOCTOU race condition fix (PR #8255 / issue #7619): eliminated the Time-Of-Check-To-Time-Of-Use race in `_get_base_env()` by adding double-checked locking with a module-level `threading.Lock`, preventing concurrent threads from writing conflicting environment snapshots.
|
||||
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
|
||||
* HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch.
|
||||
|
||||
* HAL 9000 has contributed the invariant action-scope 4-tier merge fix (PR #3329 / issue #3128): added `action_name` parameter to `InvariantService.get_effective_invariants()`, promoted the invariant precedence chain from 3-tier (plan > project > global) to 4-tier (plan > action > project > global), updated `merge_invariants()` and `InvariantSet.merge()` signatures, refreshed CLI documentation, benchmarks, Robot Framework tests, and added 13 BDD scenarios covering 4-tier precedence, deduplication semantics, and action-scope filtering.
|
||||
|
||||
@@ -74,7 +74,7 @@ class MergeSmallSuite:
|
||||
|
||||
def time_merge_small(self) -> None:
|
||||
"""Benchmark merge with ~13 invariants."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeMediumSuite:
|
||||
@@ -88,7 +88,7 @@ class MergeMediumSuite:
|
||||
|
||||
def time_merge_medium(self) -> None:
|
||||
"""Benchmark merge with ~50 invariants."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeLargeSuite:
|
||||
@@ -102,7 +102,7 @@ class MergeLargeSuite:
|
||||
|
||||
def time_merge_large(self) -> None:
|
||||
"""Benchmark merge with ~250 invariants."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeDeduplicationSuite:
|
||||
@@ -137,7 +137,7 @@ class MergeDeduplicationSuite:
|
||||
|
||||
def time_merge_dedup(self) -> None:
|
||||
"""Benchmark merge with 60 invariants, all duplicates."""
|
||||
merge_invariants(self.plan, [], self.project, self.global_invs)
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class InvariantSetMergeSuite:
|
||||
@@ -151,7 +151,7 @@ class InvariantSetMergeSuite:
|
||||
|
||||
def time_invariant_set_merge(self) -> None:
|
||||
"""Benchmark InvariantSet.merge()."""
|
||||
InvariantSet.merge(self.plan, [], self.project, self.global_invs)
|
||||
InvariantSet.merge(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class ServiceEffectiveSuite:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# Quick Start Guide
|
||||
|
||||
This quick start guide will walk you through creating a new project, registering a resource, running a plan, and applying changes with CleverAgents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+ and virtualenv
|
||||
- Git
|
||||
- A working CleverAgents installation (see development/testing.md for details)
|
||||
|
||||
## Install (local development)
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
## Create a new project
|
||||
|
||||
```bash
|
||||
# Create a new directory for your project
|
||||
mkdir my-project && cd my-project
|
||||
# Initialize a CleverAgents project (example command)
|
||||
cleveragents init --name my-project
|
||||
```
|
||||
|
||||
## Register a resource
|
||||
|
||||
Create a resource file under `resources/` (example YAML) and register it with the CLI or API.
|
||||
|
||||
## Plan and apply
|
||||
|
||||
```bash
|
||||
# Create a plan using an action on your project
|
||||
cleveragents plan use <action-name> --project my-project
|
||||
# List plans to find the plan ID
|
||||
cleveragents plan list
|
||||
# Review the plan, then apply it by plan ID
|
||||
cleveragents plan apply <plan-id>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues running the examples above, consult `docs/development/testing.md` and the project README for local development tips.
|
||||
@@ -72,8 +72,8 @@ The `rich` format renders a sessions table with columns: **ID**, **Name**, **Act
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| Total | Number of sessions |
|
||||
| Most Recent | Name or full ULID of the most recently updated session |
|
||||
| Oldest | Name or full ULID of the oldest session |
|
||||
| Most Recent | Name or truncated ID of the most recently updated session |
|
||||
| Oldest | Name or truncated ID of the oldest session |
|
||||
| Total Messages | Sum of messages across all sessions |
|
||||
| Storage | Estimated storage used |
|
||||
|
||||
@@ -85,7 +85,7 @@ Followed by a `✓ OK N sessions listed` success message.
|
||||
{
|
||||
"sessions": [
|
||||
{
|
||||
"id": "01HXYZ4M1Q3F0R0E5HR8K5T8A",
|
||||
"id": "01HXYZ...",
|
||||
"name": "my-session",
|
||||
"actor": "openai/gpt-4",
|
||||
"messages": 5,
|
||||
|
||||
@@ -296,13 +296,13 @@ $ python -m cleveragents session list
|
||||
┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
|
||||
┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
||||
│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
└──────────┴───────────┴────────┴──────────┴──────────────────┘
|
||||
|
||||
╭────────────────────────────────── Summary ───────────────────────────────────╮
|
||||
│ Total: 1 │
|
||||
│ Most Recent: 01KNKK4Q9GZ0TRR5B0NEJYGMWH │
|
||||
│ Oldest: 01KNKK4Q9GZ0TRR5B0NEJYGMWH │
|
||||
│ Most Recent: 01KNKK4Q │
|
||||
│ Oldest: 01KNKK4Q │
|
||||
│ Total Messages: 0 │
|
||||
│ Storage: 0 KB │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -311,7 +311,7 @@ $ python -m cleveragents session list
|
||||
```
|
||||
|
||||
**What's Happening:**
|
||||
The session list shows all sessions with their full ULID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions.
|
||||
The session list shows all sessions with their truncated ID, optional name, bound actor, message count, and last update time. The summary panel provides aggregate statistics across all sessions.
|
||||
|
||||
---
|
||||
|
||||
@@ -339,8 +339,8 @@ $ python -m cleveragents session list --format json
|
||||
],
|
||||
"summary": {
|
||||
"total": 1,
|
||||
"most_recent": "01KNKK4Q9GZ0TRR5B0NEJYGMWH",
|
||||
"oldest": "01KNKK4Q9GZ0TRR5B0NEJYGMWH",
|
||||
"most_recent": "01KNKK4Q",
|
||||
"oldest": "01KNKK4Q",
|
||||
"total_messages": 0,
|
||||
"storage": "0 KB"
|
||||
}
|
||||
@@ -469,7 +469,7 @@ $ python -m cleveragents session list
|
||||
┏━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
|
||||
┡━━━━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
||||
│ 01KNKK4Q9GZ0TRR5B0NEJYGMWH │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
│ 01KNKK4Q │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:07 │
|
||||
└──────────┴───────────┴────────┴──────────┴──────────────────┘
|
||||
✓ OK 1 sessions listed
|
||||
```
|
||||
|
||||
@@ -180,14 +180,14 @@ $ python -m cleveragents session list
|
||||
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
||||
┃ ID ┃ Name ┃ Actor ┃ Messages ┃ Updated ┃
|
||||
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
||||
│ 01HXYZ4M1Q3F0R0E5HR8K5T8A │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │
|
||||
│ 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │
|
||||
│ 01HXYZ4M │ (unnamed) │ openai/gpt-4o │ 3 │ 2026-04-07 09:22 │
|
||||
│ 01HXYZ3K │ (unnamed) │ (none) │ 0 │ 2026-04-07 09:15 │
|
||||
└──────────┴────────────┴────────────────┴──────────┴──────────────────┘
|
||||
|
||||
╭──────────────────────────────────────────── Summary ─────────────────────────────────────────────╮
|
||||
│ Total: 2 │
|
||||
│ Most Recent: 01HXYZ4M1Q3F0R0E5HR8K5T8A │
|
||||
│ Oldest: 01HXYZ3K9P2E9Q9D4GQ7J4S7Z │
|
||||
│ Most Recent: 01HXYZ4M │
|
||||
│ Oldest: 01HXYZ3K │
|
||||
│ Total Messages: 3 │
|
||||
│ Storage: 0 KB │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
@@ -196,8 +196,8 @@ $ python -m cleveragents session list
|
||||
```
|
||||
|
||||
**What's Happening:**
|
||||
The list command renders a Rich table with five columns: the full session
|
||||
ULID (26 characters), optional name, bound actor, message count, and
|
||||
The list command renders a Rich table with five columns: truncated ID (first
|
||||
8 characters for readability), optional name, bound actor, message count, and
|
||||
last update time. The **Summary** panel below shows aggregate statistics
|
||||
including total sessions, most recent, oldest, total message count, and
|
||||
storage used.
|
||||
@@ -229,8 +229,8 @@ $ python -m cleveragents session list --format json
|
||||
],
|
||||
"summary": {
|
||||
"total": 2,
|
||||
"most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A",
|
||||
"oldest": "01HXYZ3K9P2E9Q9D4GQ7J4S7Z",
|
||||
"most_recent": "01HXYZ4M",
|
||||
"oldest": "01HXYZ3K",
|
||||
"total_messages": 3,
|
||||
"storage": "0 KB"
|
||||
}
|
||||
@@ -773,7 +773,7 @@ $ python -m cleveragents session list
|
||||
✓ OK 2 sessions listed
|
||||
|
||||
$ python -m cleveragents session list --format json
|
||||
{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M1Q3F0R0E5HR8K5T8A", ...}}
|
||||
{"sessions": [...], "summary": {"total": 2, "most_recent": "01HXYZ4M", ...}}
|
||||
|
||||
$ python -m cleveragents session tell --session 01HXYZ4M1Q3F0R0E5HR8K5T8A "What is the capital of France?"
|
||||
user: What is the capital of France?
|
||||
|
||||
@@ -1723,8 +1723,8 @@ None.
|
||||
╭─ Sessions ───────────────────────────────────────────────────────────────────╮
|
||||
│ <span style="color: cyan; font-weight: 600;">ID</span> <span style="color: cyan; font-weight: 600;">Name</span> <span style="color: cyan; font-weight: 600;">Actor</span> <span style="color: cyan; font-weight: 600;">Messages</span> <span style="color: cyan; font-weight: 600;">Updated</span> │
|
||||
│ <span style="opacity: 0.7;">──────── ─────────────── ────────────────── ──────── ────────────────</span> │
|
||||
│ 01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44 │
|
||||
│ 01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11 │
|
||||
│ 01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44 │
|
||||
│ 01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
╭─ Summary ────────────────────╮
|
||||
@@ -1746,8 +1746,8 @@ None.
|
||||
Sessions
|
||||
ID Name Actor Messages Updated
|
||||
-------- --------------- ------------------ -------- ----------------
|
||||
01HXM2A61MQHZ4MRBAY3MPNJTN weekly-planning local/orchestrator 6 2026-02-08 12:44
|
||||
01HXM1F21MQHZ4MRBAY3MPNJTN refactor-sprint local/orchestrator 14 2026-02-07 18:11
|
||||
01HXM2A6 weekly-planning local/orchestrator 6 2026-02-08 12:44
|
||||
01HXM1F2 refactor-sprint local/orchestrator 14 2026-02-07 18:11
|
||||
|
||||
Summary
|
||||
Total: 2
|
||||
@@ -1769,14 +1769,14 @@ None.
|
||||
"data": {
|
||||
"sessions": [
|
||||
{
|
||||
"id": "01HXM2A61MQHZ4MRBAY3MPNJTN",
|
||||
"id": "01HXM2A6",
|
||||
"name": "weekly-planning",
|
||||
"actor": "local/orchestrator",
|
||||
"messages": 6,
|
||||
"updated": "2026-02-08T12:44:00Z"
|
||||
},
|
||||
{
|
||||
"id": "01HXM1F21MQHZ4MRBAY3MPNJTN",
|
||||
"id": "01HXM1F2",
|
||||
"name": "refactor-sprint",
|
||||
"actor": "local/orchestrator",
|
||||
"messages": 14,
|
||||
@@ -1804,12 +1804,12 @@ None.
|
||||
exit_code: 0
|
||||
data:
|
||||
sessions:
|
||||
- id: 01HXM2A61MQHZ4MRBAY3MPNJTN
|
||||
- id: 01HXM2A6
|
||||
name: weekly-planning
|
||||
actor: local/orchestrator
|
||||
messages: 6
|
||||
updated: "2026-02-08T12:44:00Z"
|
||||
- id: 01HXM1F21MQHZ4MRBAY3MPNJTN
|
||||
- id: 01HXM1F2
|
||||
name: refactor-sprint
|
||||
actor: local/orchestrator
|
||||
messages: 14
|
||||
|
||||
@@ -1714,12 +1714,6 @@ Feature: Consolidated Misc
|
||||
And the temporary connection should be closed afterward
|
||||
|
||||
|
||||
Scenario: get_current_revision uses check_same_thread=False for SQLite engines
|
||||
Given a migration runner configured for "sqlite:///:memory:"
|
||||
When I request the current revision from the database
|
||||
Then the SQLite engine for get_current_revision should use check_same_thread=False
|
||||
|
||||
|
||||
Scenario: File-based SQLite database directory is created if missing
|
||||
Given a migration runner configured for "sqlite:///tmp/test-db/mydb.db"
|
||||
When I initialize or upgrade a file-based SQLite database
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# BDD scenarios for issue #3128 — action-scoped invariants in get_effective_invariants()
|
||||
#
|
||||
# Verifies that InvariantService.get_effective_invariants() correctly includes
|
||||
# action-scoped invariants in the 4-tier precedence merge:
|
||||
# plan > action > project > global
|
||||
#
|
||||
# Also verifies that merge_invariants() and InvariantSet.merge() accept and
|
||||
# correctly process the action_invariants parameter.
|
||||
#
|
||||
# See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/3128
|
||||
|
||||
Feature: Action-scoped invariants included in effective invariant computation
|
||||
As a developer using InvariantService
|
||||
I want get_effective_invariants() to include action-scoped invariants
|
||||
So that the 4-tier precedence chain (plan > action > project > global) is fully enforced
|
||||
|
||||
# ================================================================
|
||||
# get_effective_invariants() — action_name parameter
|
||||
# ================================================================
|
||||
|
||||
Scenario: get_effective_invariants returns action-scoped invariants when action_name provided
|
||||
Given an invariant service with action and global invariants
|
||||
When I get effective invariants with action_name "deploy-service"
|
||||
Then the effective set should contain the action invariant "Do not modify public API"
|
||||
And the effective set should contain the global invariant "Log all changes"
|
||||
And the effective set should have 2 invariants
|
||||
|
||||
Scenario: get_effective_invariants includes all action invariants when action_name is None
|
||||
Given an invariant service with action and global invariants
|
||||
When I get effective invariants with no action_name
|
||||
Then the effective set should contain the action invariant "Do not modify public API"
|
||||
And the effective set should contain the global invariant "Log all changes"
|
||||
And the effective set should have 2 invariants
|
||||
|
||||
Scenario: get_effective_invariants with all four scopes returns all invariants
|
||||
Given an invariant service with invariants at all four scopes
|
||||
When I get effective invariants for plan "plan-001" action "deploy-service" and project "myapp"
|
||||
Then the effective set should have 4 invariants
|
||||
And the effective set should contain the plan invariant "Plan-level rule"
|
||||
And the effective set should contain the action invariant "Action-level rule"
|
||||
And the effective set should contain the project invariant "Project-level rule"
|
||||
And the effective set should contain the global invariant "Global-level rule"
|
||||
|
||||
Scenario: Action invariants have higher precedence than project invariants
|
||||
Given an invariant service with action and project invariants having the same text
|
||||
When I get effective invariants for plan "plan-001" action "deploy-service" and project "myapp"
|
||||
Then the effective set should have 1 invariant
|
||||
And the first effective invariant should have scope "action"
|
||||
|
||||
Scenario: Plan invariants have higher precedence than action invariants
|
||||
Given an invariant service with plan and action invariants having the same text
|
||||
When I get effective invariants for plan "plan-001" action "deploy-service" and project "myapp"
|
||||
Then the effective set should have 1 invariant
|
||||
And the first effective invariant should have scope "plan"
|
||||
|
||||
Scenario: get_effective_invariants filters action invariants by action_name
|
||||
Given an invariant service with two different action invariants
|
||||
When I get effective invariants with action_name "deploy-service"
|
||||
Then the effective set should contain the action invariant "Deploy constraint"
|
||||
And the effective set should not contain the action invariant "Build constraint"
|
||||
And the effective set should have 1 invariant
|
||||
|
||||
# ================================================================
|
||||
# list_invariants(effective=True) — action scope passthrough
|
||||
# ================================================================
|
||||
|
||||
Scenario: list_invariants with effective=True and action scope passes action_name
|
||||
Given an invariant service with action and global invariants
|
||||
When I list invariants with effective True and action scope "deploy-service"
|
||||
Then the listed effective set should contain the action invariant "Do not modify public API"
|
||||
And the listed effective set should contain the global invariant "Log all changes"
|
||||
|
||||
# ================================================================
|
||||
# merge_invariants() — 4-tier signature
|
||||
# ================================================================
|
||||
|
||||
Scenario: merge_invariants accepts action_invariants as second parameter
|
||||
Given I have plan action project and global invariant lists
|
||||
When I merge all four invariant tiers
|
||||
Then the merged result should have 4 invariants
|
||||
And the merged result should contain "Plan rule"
|
||||
And the merged result should contain "Action rule"
|
||||
And the merged result should contain "Project rule"
|
||||
And the merged result should contain "Global rule"
|
||||
|
||||
Scenario: merge_invariants deduplicates action invariants by text
|
||||
Given I have overlapping action and project invariant lists
|
||||
When I merge all four invariant tiers
|
||||
Then the merged result should have 1 invariant
|
||||
And the first merged invariant should have scope "action"
|
||||
|
||||
Scenario: merge_invariants with empty action list behaves like 3-tier merge
|
||||
Given I have plan project and global invariant lists with no action invariants
|
||||
When I merge all four invariant tiers with empty action list
|
||||
Then the merged result should have 3 invariants
|
||||
|
||||
# ================================================================
|
||||
# InvariantSet.merge() — 4-tier signature
|
||||
# ================================================================
|
||||
|
||||
Scenario: InvariantSet.merge accepts action_invariants as second parameter
|
||||
Given I have plan action project and global invariant lists
|
||||
When I merge all four tiers using InvariantSet
|
||||
Then the invariant set should have 4 invariants
|
||||
|
||||
Scenario: InvariantSet.merge with empty action list produces correct result
|
||||
Given I have plan project and global invariant lists with no action invariants
|
||||
When I merge all four tiers using InvariantSet with empty action list
|
||||
Then the invariant set should have 3 invariants
|
||||
@@ -44,28 +44,6 @@ Feature: Session CLI commands
|
||||
When I run session CLI list with --format json
|
||||
Then the session CLI JSON list entries should match the documented contract
|
||||
|
||||
Scenario: List sessions displays full 26-character ULIDs in Rich table
|
||||
Given there are mocked existing sessions
|
||||
When I run session CLI list
|
||||
Then the session CLI rich table should display full session ULIDs
|
||||
|
||||
Scenario: List sessions summary panel shows full ULIDs for unnamed sessions
|
||||
Given there are mocked existing sessions
|
||||
When I run session CLI list
|
||||
Then the session CLI summary panel should contain full session ULIDs
|
||||
|
||||
Scenario: List sessions summary panel shows session names for named sessions
|
||||
Given there are mocked existing named sessions
|
||||
When I run session CLI list
|
||||
Then the session CLI summary panel should show session names
|
||||
|
||||
Scenario: Full session ID from list output works with session tell
|
||||
Given there are mocked existing sessions
|
||||
When I run session CLI list
|
||||
And I capture the first session full ULID from the output
|
||||
And I run session CLI tell with the full session ID and prompt "Hello from list"
|
||||
Then the session CLI tell should succeed
|
||||
|
||||
# Show command tests
|
||||
Scenario: Show session with valid ID
|
||||
Given there is a mocked session with messages
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
"""Step definitions for invariant_action_scope_effective.feature.
|
||||
|
||||
Tests that InvariantService.get_effective_invariants() correctly includes
|
||||
action-scoped invariants in the 4-tier precedence merge:
|
||||
plan > action > project > global
|
||||
|
||||
Also tests that merge_invariants() and InvariantSet.merge() accept and
|
||||
correctly process the action_invariants parameter.
|
||||
|
||||
See: https://git.cleverthis.com/cleveragents/cleveragents-core/issues/3128
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantScope,
|
||||
InvariantSet,
|
||||
merge_invariants,
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# Givens — service setup
|
||||
# ================================================================
|
||||
|
||||
|
||||
@given("an invariant service with action and global invariants")
|
||||
def step_service_action_global(context: object) -> None:
|
||||
"""Set up a service with one action-scoped and one global invariant."""
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant(
|
||||
text="Do not modify public API",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Log all changes",
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
|
||||
|
||||
@given("an invariant service with invariants at all four scopes")
|
||||
def step_service_all_four_scopes(context: object) -> None:
|
||||
"""Set up a service with one invariant at each of the four scopes."""
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant(
|
||||
text="Plan-level rule",
|
||||
scope=InvariantScope.PLAN,
|
||||
source_name="plan-001",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Action-level rule",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Project-level rule",
|
||||
scope=InvariantScope.PROJECT,
|
||||
source_name="myapp",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Global-level rule",
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
|
||||
|
||||
@given("an invariant service with action and project invariants having the same text")
|
||||
def step_service_action_project_same_text(context: object) -> None:
|
||||
"""Set up a service where action and project invariants share the same text."""
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant(
|
||||
text="Shared constraint text",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Shared constraint text",
|
||||
scope=InvariantScope.PROJECT,
|
||||
source_name="myapp",
|
||||
)
|
||||
|
||||
|
||||
@given("an invariant service with plan and action invariants having the same text")
|
||||
def step_service_plan_action_same_text(context: object) -> None:
|
||||
"""Set up a service where plan and action invariants share the same text."""
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant(
|
||||
text="Shared constraint text",
|
||||
scope=InvariantScope.PLAN,
|
||||
source_name="plan-001",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Shared constraint text",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
|
||||
|
||||
@given("an invariant service with two different action invariants")
|
||||
def step_service_two_action_invariants(context: object) -> None:
|
||||
"""Set up a service with two action invariants from different actions."""
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant(
|
||||
text="Deploy constraint",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
context.service.add_invariant(
|
||||
text="Build constraint",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="build-service",
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Whens — get_effective_invariants calls
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when('I get effective invariants with action_name "{action_name}"')
|
||||
def step_get_effective_with_action(context: object, action_name: str) -> None:
|
||||
"""Call get_effective_invariants with the given action_name."""
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
action_name=action_name,
|
||||
)
|
||||
|
||||
|
||||
@when("I get effective invariants with no action_name")
|
||||
def step_get_effective_no_action(context: object) -> None:
|
||||
"""Call get_effective_invariants without an action_name."""
|
||||
context.effective = context.service.get_effective_invariants()
|
||||
|
||||
|
||||
@when(
|
||||
'I get effective invariants for plan "{plan_id}" action "{action_name}" and project "{project_name}"'
|
||||
)
|
||||
def step_get_effective_all_four(
|
||||
context: object, plan_id: str, action_name: str, project_name: str
|
||||
) -> None:
|
||||
"""Call get_effective_invariants with all four scope parameters."""
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
plan_id=plan_id,
|
||||
action_name=action_name,
|
||||
project_name=project_name,
|
||||
)
|
||||
|
||||
|
||||
@when('I get effective invariants with only action_name "{action_name}"')
|
||||
def step_get_effective_only_action(context: object, action_name: str) -> None:
|
||||
"""Call get_effective_invariants with only action_name (no plan or project)."""
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
action_name=action_name,
|
||||
)
|
||||
|
||||
|
||||
@when('I get effective invariants with action_name "{action_name}" for filtering')
|
||||
def step_get_effective_action_filter(context: object, action_name: str) -> None:
|
||||
"""Call get_effective_invariants to filter by specific action_name."""
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
action_name=action_name,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Whens — list_invariants with effective=True
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when('I list invariants with effective True and action scope "{action_name}"')
|
||||
def step_list_effective_action(context: object, action_name: str) -> None:
|
||||
"""Call list_invariants with effective=True and action scope."""
|
||||
context.listed_effective = context.service.list_invariants(
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name=action_name,
|
||||
effective=True,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Whens — merge_invariants() direct calls
|
||||
# ================================================================
|
||||
|
||||
|
||||
@given("I have plan action project and global invariant lists")
|
||||
def step_four_tier_lists(context: object) -> None:
|
||||
"""Create four separate invariant lists for merge testing."""
|
||||
context.plan_invs = [
|
||||
Invariant(text="Plan rule", scope=InvariantScope.PLAN, source_name="plan-001")
|
||||
]
|
||||
context.action_invs = [
|
||||
Invariant(
|
||||
text="Action rule",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
]
|
||||
context.project_invs = [
|
||||
Invariant(
|
||||
text="Project rule", scope=InvariantScope.PROJECT, source_name="myapp"
|
||||
)
|
||||
]
|
||||
context.global_invs = [
|
||||
Invariant(text="Global rule", scope=InvariantScope.GLOBAL, source_name="system")
|
||||
]
|
||||
|
||||
|
||||
@given("I have overlapping action and project invariant lists")
|
||||
def step_overlapping_action_project(context: object) -> None:
|
||||
"""Create action and project invariant lists with the same text."""
|
||||
context.plan_invs: list[Invariant] = []
|
||||
context.action_invs = [
|
||||
Invariant(
|
||||
text="Shared rule",
|
||||
scope=InvariantScope.ACTION,
|
||||
source_name="deploy-service",
|
||||
)
|
||||
]
|
||||
context.project_invs = [
|
||||
Invariant(text="Shared rule", scope=InvariantScope.PROJECT, source_name="myapp")
|
||||
]
|
||||
context.global_invs: list[Invariant] = []
|
||||
|
||||
|
||||
@given("I have plan project and global invariant lists with no action invariants")
|
||||
def step_three_tier_lists(context: object) -> None:
|
||||
"""Create three invariant lists (no action tier) for merge testing."""
|
||||
context.plan_invs = [
|
||||
Invariant(text="Plan rule", scope=InvariantScope.PLAN, source_name="plan-001")
|
||||
]
|
||||
context.action_invs: list[Invariant] = []
|
||||
context.project_invs = [
|
||||
Invariant(
|
||||
text="Project rule", scope=InvariantScope.PROJECT, source_name="myapp"
|
||||
)
|
||||
]
|
||||
context.global_invs = [
|
||||
Invariant(text="Global rule", scope=InvariantScope.GLOBAL, source_name="system")
|
||||
]
|
||||
|
||||
|
||||
@when("I merge all four invariant tiers")
|
||||
def step_merge_four_tiers(context: object) -> None:
|
||||
"""Call merge_invariants with all four tiers."""
|
||||
context.merged = merge_invariants(
|
||||
context.plan_invs,
|
||||
context.action_invs,
|
||||
context.project_invs,
|
||||
context.global_invs,
|
||||
)
|
||||
|
||||
|
||||
@when("I merge all four invariant tiers with empty action list")
|
||||
def step_merge_four_tiers_empty_action(context: object) -> None:
|
||||
"""Call merge_invariants with an empty action list."""
|
||||
context.merged = merge_invariants(
|
||||
context.plan_invs,
|
||||
[],
|
||||
context.project_invs,
|
||||
context.global_invs,
|
||||
)
|
||||
|
||||
|
||||
@when("I merge all four tiers using InvariantSet")
|
||||
def step_merge_invariant_set_four(context: object) -> None:
|
||||
"""Call InvariantSet.merge with all four tiers."""
|
||||
context.invariant_set = InvariantSet.merge(
|
||||
context.plan_invs,
|
||||
context.action_invs,
|
||||
context.project_invs,
|
||||
context.global_invs,
|
||||
)
|
||||
|
||||
|
||||
@when("I merge all four tiers using InvariantSet with empty action list")
|
||||
def step_merge_invariant_set_empty_action(context: object) -> None:
|
||||
"""Call InvariantSet.merge with an empty action list."""
|
||||
context.invariant_set = InvariantSet.merge(
|
||||
context.plan_invs,
|
||||
[],
|
||||
context.project_invs,
|
||||
context.global_invs,
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Thens — effective set assertions
|
||||
# ================================================================
|
||||
|
||||
|
||||
@then('the effective set should contain the action invariant "{text}"')
|
||||
def step_effective_contains_action(context: object, text: str) -> None:
|
||||
"""Assert the effective set contains an action invariant with the given text."""
|
||||
texts = [inv.text for inv in context.effective]
|
||||
assert text in texts, (
|
||||
f"Expected action invariant '{text}' in effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
@then('the effective set should not contain the action invariant "{text}"')
|
||||
def step_effective_not_contains_action(context: object, text: str) -> None:
|
||||
"""Assert the effective set does NOT contain an invariant with the given text."""
|
||||
texts = [inv.text for inv in context.effective]
|
||||
assert text not in texts, (
|
||||
f"Expected action invariant '{text}' to be absent from effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
@then('the effective set should contain the global invariant "{text}"')
|
||||
def step_effective_contains_global(context: object, text: str) -> None:
|
||||
"""Assert the effective set contains a global invariant with the given text."""
|
||||
texts = [inv.text for inv in context.effective]
|
||||
assert text in texts, (
|
||||
f"Expected global invariant '{text}' in effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
@then('the effective set should contain the plan invariant "{text}"')
|
||||
def step_effective_contains_plan(context: object, text: str) -> None:
|
||||
"""Assert the effective set contains a plan invariant with the given text."""
|
||||
texts = [inv.text for inv in context.effective]
|
||||
assert text in texts, (
|
||||
f"Expected plan invariant '{text}' in effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
@then('the effective set should contain the project invariant "{text}"')
|
||||
def step_effective_contains_project(context: object, text: str) -> None:
|
||||
"""Assert the effective set contains a project invariant with the given text."""
|
||||
texts = [inv.text for inv in context.effective]
|
||||
assert text in texts, (
|
||||
f"Expected project invariant '{text}' in effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
@then("the effective set should have {count:d} invariants")
|
||||
def step_effective_count(context: object, count: int) -> None:
|
||||
"""Assert the effective set has exactly the given number of invariants."""
|
||||
actual = len(context.effective)
|
||||
assert actual == count, f"Expected {count} effective invariants, got {actual}"
|
||||
|
||||
|
||||
@then("the effective set should have {count:d} invariant")
|
||||
def step_effective_count_singular(context: object, count: int) -> None:
|
||||
"""Assert the effective set has exactly the given number of invariants (singular)."""
|
||||
actual = len(context.effective)
|
||||
assert actual == count, f"Expected {count} effective invariant, got {actual}"
|
||||
|
||||
|
||||
@then('the first effective invariant should have scope "{scope}"')
|
||||
def step_first_effective_scope(context: object, scope: str) -> None:
|
||||
"""Assert the first invariant in the effective set has the given scope."""
|
||||
assert context.effective, "Effective set is empty"
|
||||
actual = context.effective[0].scope.value
|
||||
assert actual == scope, (
|
||||
f"Expected first effective invariant scope '{scope}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Thens — list_invariants effective assertions
|
||||
# ================================================================
|
||||
|
||||
|
||||
@then('the listed effective set should contain the action invariant "{text}"')
|
||||
def step_listed_effective_contains_action(context: object, text: str) -> None:
|
||||
"""Assert the listed effective set contains an action invariant with the given text."""
|
||||
texts = [inv.text for inv in context.listed_effective]
|
||||
assert text in texts, (
|
||||
f"Expected action invariant '{text}' in listed effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
@then('the listed effective set should contain the global invariant "{text}"')
|
||||
def step_listed_effective_contains_global(context: object, text: str) -> None:
|
||||
"""Assert the listed effective set contains a global invariant with the given text."""
|
||||
texts = [inv.text for inv in context.listed_effective]
|
||||
assert text in texts, (
|
||||
f"Expected global invariant '{text}' in listed effective set, got: {texts}"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Thens — merge_invariants() assertions
|
||||
# ================================================================
|
||||
|
||||
|
||||
@then("the merged result should have {count:d} invariants")
|
||||
def step_merged_count_four(context: object, count: int) -> None:
|
||||
"""Assert the merged result has exactly the given number of invariants."""
|
||||
actual = len(context.merged)
|
||||
assert actual == count, f"Expected {count} merged invariants, got {actual}"
|
||||
|
||||
|
||||
@then('the merged result should contain "{text}"')
|
||||
def step_merged_contains_text(context: object, text: str) -> None:
|
||||
"""Assert the merged result contains an invariant with the given text."""
|
||||
texts = [inv.text for inv in context.merged]
|
||||
assert text in texts, f"Expected '{text}' in merged result, got: {texts}"
|
||||
|
||||
|
||||
@then('the first merged invariant should have scope "{scope}"')
|
||||
def step_first_merged_scope(context: object, scope: str) -> None:
|
||||
"""Assert the first invariant in the merged result has the given scope."""
|
||||
assert context.merged, "Merged result is empty"
|
||||
actual = context.merged[0].scope.value
|
||||
assert actual == scope, (
|
||||
f"Expected first merged invariant scope '{scope}', got '{actual}'"
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Thens — InvariantSet.merge() assertions
|
||||
# ================================================================
|
||||
|
||||
|
||||
@then("the invariant set should have {count:d} invariant")
|
||||
def step_invariant_set_count_singular(context: object, count: int) -> None:
|
||||
"""Assert the InvariantSet has exactly the given number of invariants (singular)."""
|
||||
actual = len(context.invariant_set.invariants)
|
||||
assert actual == count, f"Expected {count} invariant in set, got {actual}"
|
||||
@@ -160,7 +160,6 @@ def step_plan_invariants_inactive(context):
|
||||
def step_merge(context):
|
||||
context.merged = merge_invariants(
|
||||
getattr(context, "plan_invariants", []),
|
||||
getattr(context, "action_invariants", []),
|
||||
getattr(context, "project_invariants", []),
|
||||
getattr(context, "global_invariants", []),
|
||||
)
|
||||
@@ -190,7 +189,6 @@ def step_merged_scope(context, idx, scope):
|
||||
def step_merge_invariant_set(context):
|
||||
inv_set = InvariantSet.merge(
|
||||
getattr(context, "plan_invariants", []),
|
||||
getattr(context, "action_invariants", []),
|
||||
getattr(context, "project_invariants", []),
|
||||
getattr(context, "global_invariants", []),
|
||||
)
|
||||
|
||||
@@ -809,9 +809,6 @@ class _BrokenSession:
|
||||
def rollback(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def query(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
raise SQLAlchemyDatabaseError("mock", {}, Exception("broken"))
|
||||
|
||||
@@ -988,10 +985,8 @@ def step_save_with_spy(context: Context) -> None:
|
||||
object.__setattr__(real_session, "flush", spy_flush)
|
||||
object.__setattr__(real_session, "commit", spy_commit)
|
||||
|
||||
# Pass the session explicitly to test the UoW path: save() must flush
|
||||
# but must NOT commit (the caller owns the transaction boundary).
|
||||
repo = LLMTraceRepository(session_factory=lambda: real_session)
|
||||
repo.save(context.trace, session=real_session)
|
||||
repo.save(context.trace)
|
||||
# Commit so the data is visible for subsequent queries
|
||||
object.__setattr__(real_session, "commit", original_commit)
|
||||
real_session.commit()
|
||||
@@ -1035,9 +1030,7 @@ def step_save_in_uow_rollback(context: Context) -> None:
|
||||
session = context.uow_session_factory()
|
||||
repo = LLMTraceRepository(session_factory=lambda: session)
|
||||
try:
|
||||
# Pass the session explicitly to use UoW mode: save() flushes but
|
||||
# does NOT commit, so the caller's rollback can undo the change.
|
||||
repo.save(context.trace, session=session)
|
||||
repo.save(context.trace)
|
||||
# Simulate a subsequent failure that triggers rollback
|
||||
raise RuntimeError("Simulated failure after save")
|
||||
except RuntimeError:
|
||||
|
||||
@@ -316,17 +316,6 @@ def step_then_temp_connection_closed(context) -> None:
|
||||
assert context.current_rev_fake_engine.connections[0].exit_called is True
|
||||
|
||||
|
||||
@then("the SQLite engine for get_current_revision should use check_same_thread=False")
|
||||
def step_then_get_current_revision_check_same_thread(context) -> None:
|
||||
_url, kwargs = context.current_rev_create_call
|
||||
assert "connect_args" in kwargs, (
|
||||
"Expected connect_args to be passed to create_engine for SQLite"
|
||||
)
|
||||
assert kwargs["connect_args"].get("check_same_thread") is False, (
|
||||
"Expected check_same_thread=False in connect_args for SQLite engine"
|
||||
)
|
||||
|
||||
|
||||
@when("I initialize or upgrade a file-based SQLite database")
|
||||
def step_when_init_file_based_sqlite(context) -> None:
|
||||
import shutil
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -161,34 +160,14 @@ def step_existing_sessions(context: Context) -> None:
|
||||
context.mock_service.list.return_value = sessions
|
||||
|
||||
|
||||
@given("there are mocked existing named sessions")
|
||||
def step_existing_named_sessions(context: Context) -> None:
|
||||
"""Set up mocked sessions with names for Summary panel name-display test."""
|
||||
sessions = [
|
||||
_make_session(
|
||||
session_id=_SESSION_ID,
|
||||
actor_name="openai/gpt-4",
|
||||
messages=[_make_message(sequence=0)],
|
||||
),
|
||||
_make_session(session_id=_SESSION_ID_2),
|
||||
]
|
||||
sessions[0].name = "weekly-planning"
|
||||
sessions[1].name = "refactor-sprint"
|
||||
context.mock_service.list.return_value = sessions
|
||||
|
||||
|
||||
@when("I run session CLI list")
|
||||
def step_list(context: Context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
session_app, ["list"], env={"COLUMNS": "200"}
|
||||
)
|
||||
context.result = context.runner.invoke(session_app, ["list"])
|
||||
|
||||
|
||||
@when("I run session CLI list with --format json")
|
||||
def step_list_json(context: Context) -> None:
|
||||
context.result = context.runner.invoke(
|
||||
session_app, ["list", "--format", "json"], env={"COLUMNS": "200"}
|
||||
)
|
||||
context.result = context.runner.invoke(session_app, ["list", "--format", "json"])
|
||||
|
||||
|
||||
@then("the session CLI should show all sessions in a table")
|
||||
@@ -197,125 +176,6 @@ def step_list_shows_table(context: Context) -> None:
|
||||
assert "Sessions" in context.result.output
|
||||
|
||||
|
||||
@then("the session CLI rich table should display full session ULIDs")
|
||||
def step_list_rich_table_full_ulids(context: Context) -> None:
|
||||
"""Verify the Rich table displays the full 26-character session ULIDs."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
# Restrict assertion to the table region (before the Summary panel) so
|
||||
# that a regression back to 8-char truncation is not masked by the full
|
||||
# ULID appearing elsewhere (e.g. in the Summary panel).
|
||||
summary_idx = output.find("Summary")
|
||||
table_output = output[:summary_idx] if summary_idx != -1 else output
|
||||
assert _SESSION_ID in table_output, (
|
||||
f"Full ULID {_SESSION_ID} not found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
assert _SESSION_ID_2 in table_output, (
|
||||
f"Full ULID {_SESSION_ID_2} not found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
# Negative guard: ensure the table contains full 26-character ULIDs,
|
||||
# not the old 8-character truncated form. A standalone 8-char prefix
|
||||
# (not as part of the full ULID) would indicate the [:8] slice was not
|
||||
# removed.
|
||||
table_without_full_ids = table_output.replace(_SESSION_ID, "").replace(
|
||||
_SESSION_ID_2, ""
|
||||
)
|
||||
assert _SESSION_ID[:8] not in table_without_full_ids, (
|
||||
f"Truncated 8-char ID {_SESSION_ID[:8]} found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
assert _SESSION_ID_2[:8] not in table_without_full_ids, (
|
||||
f"Truncated 8-char ID {_SESSION_ID_2[:8]} found in Rich table output:\n"
|
||||
f"{context.result.output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session CLI summary panel should contain full session ULIDs")
|
||||
def step_list_summary_full_ulids(context: Context) -> None:
|
||||
"""Verify the Summary panel shows full ULIDs for unnamed sessions."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
assert "Summary" in output, f"Summary panel not found in output:\n{output}"
|
||||
# Extract the Summary panel region to avoid a false pass from the Rich
|
||||
# table also containing the same full ULIDs.
|
||||
summary_idx = output.find("Summary")
|
||||
summary_output = output[summary_idx:]
|
||||
# Both Most Recent and Oldest entries should display full ULIDs when
|
||||
# sessions are unnamed. Asserting only one ID would allow a regression
|
||||
# that re-introduced [:8] on one fallback path while keeping the other
|
||||
# intact to pass the test undetected.
|
||||
assert _SESSION_ID in summary_output, (
|
||||
f"Full ULID {_SESSION_ID} not found in Summary panel:\n{output}"
|
||||
)
|
||||
assert _SESSION_ID_2 in summary_output, (
|
||||
f"Full ULID {_SESSION_ID_2} not found in Summary panel:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@then("the session CLI summary panel should show session names")
|
||||
def step_list_summary_shows_names(context: Context) -> None:
|
||||
"""Verify the Summary panel shows session names (not ULIDs) for named sessions."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
summary_idx = output.find("Summary")
|
||||
assert summary_idx != -1, f"Summary panel not found in output:\n{output}"
|
||||
summary_output = output[summary_idx:]
|
||||
assert "weekly-planning" in summary_output, (
|
||||
f"'weekly-planning' not found in Summary panel:\n{output}"
|
||||
)
|
||||
assert "refactor-sprint" in summary_output, (
|
||||
f"'refactor-sprint' not found in Summary panel:\n{output}"
|
||||
)
|
||||
# The Summary should NOT show the raw ULID when session names are present
|
||||
assert _SESSION_ID not in summary_output, (
|
||||
f"Full ULID {_SESSION_ID} unexpectedly found in Summary panel:\n{output}"
|
||||
)
|
||||
assert _SESSION_ID_2 not in summary_output, (
|
||||
f"Full ULID {_SESSION_ID_2} unexpectedly found in Summary panel:\n{output}"
|
||||
)
|
||||
|
||||
|
||||
@when("I capture the first session full ULID from the output")
|
||||
def step_capture_first_ulid(context: Context) -> None:
|
||||
"""Parse the first session's full ULID from the output and store it
|
||||
for subsequent steps (round-trip tell test)."""
|
||||
assert context.result.exit_code == 0
|
||||
output = context.result.output
|
||||
# Restrict the search to the table region (before the Summary panel)
|
||||
# so that a ULID appearing in the Summary panel is not accidentally
|
||||
# captured as the "first" session ID.
|
||||
summary_idx = output.find("Summary")
|
||||
search_region = output[:summary_idx] if summary_idx != -1 else output
|
||||
match = re.search(r"[0-9A-HJKMNP-TV-Z]{26}", search_region)
|
||||
assert match is not None, (
|
||||
f"No 26-character ULID found in table output:\n{search_region}"
|
||||
)
|
||||
context.full_session_id = match.group()
|
||||
assert len(context.full_session_id) == 26, (
|
||||
f"Parsed ULID '{context.full_session_id}' is not 26 characters"
|
||||
)
|
||||
# Sanity check: the captured ID should match one of the known fixture IDs
|
||||
assert context.full_session_id in (_SESSION_ID, _SESSION_ID_2), (
|
||||
f"Captured ULID '{context.full_session_id}' does not match any fixture ID"
|
||||
)
|
||||
|
||||
|
||||
@when('I run session CLI tell with the full session ID and prompt "{prompt}"')
|
||||
def step_tell_with_stored_ulid(context: Context, prompt: str) -> None:
|
||||
"""Run session tell using the full ULID stored from session list."""
|
||||
session_id = context.full_session_id
|
||||
context.mock_service.append_message.side_effect = [
|
||||
_make_message(MessageRole.USER, prompt, 0),
|
||||
_make_message(MessageRole.ASSISTANT, f"Acknowledged: {prompt}", 1),
|
||||
]
|
||||
context.result = context.runner.invoke(
|
||||
session_app,
|
||||
["tell", "--session", session_id, prompt],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Show
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+2
-1
@@ -61,6 +61,7 @@ nav:
|
||||
- Reference/Command Input & Sessions: tui/input-and-sessions.md
|
||||
- Configuration, Key Bindings & Integration: tui/configuration-and-integration.md
|
||||
- FAQ: faq.md
|
||||
- Quick Start: quickstart.md
|
||||
- Changelog: CHANGELOG.md
|
||||
- Contributing: CONTRIBUTING.md
|
||||
- Reference: reference/
|
||||
@@ -93,7 +94,7 @@ nav:
|
||||
- ADR-025 Observability & Logging: adr/ADR-025-observability-and-logging.md
|
||||
- ADR-026 Agent-to-Agent Protocol (A2A): adr/ADR-026-agent-client-protocol.md
|
||||
- ADR-027 Language Server Protocol (LSP) Integration: adr/ADR-027-language-server-protocol.md
|
||||
- ADR-028 Agent Skills Standard (AgentSkills.io): adr/ADR-028-agent-skills-standard.md
|
||||
- ADR-028 Skill/Tool Abstraction Definition: adr/ADR-028-agent-skills-standard.md
|
||||
- ADR-029 Model Context Protocol (MCP) Adoption: adr/ADR-029-model-context-protocol.md
|
||||
- ADR-030 Skill Abstraction Definition: adr/ADR-030-skill-abstraction-definition.md
|
||||
- ADR-031 Actor Abstraction Definition: adr/ADR-031-actor-abstraction-definition.md
|
||||
|
||||
@@ -867,7 +867,6 @@ def invariants_enforced_during_strategize() -> None:
|
||||
|
||||
merged = merge_invariants(
|
||||
plan_invariants=[plan_inv],
|
||||
action_invariants=[],
|
||||
project_invariants=[project_inv],
|
||||
global_invariants=[global_inv],
|
||||
)
|
||||
@@ -891,7 +890,6 @@ def invariants_enforced_during_strategize() -> None:
|
||||
|
||||
invariant_set = InvariantSet.merge(
|
||||
plan_invariants=[plan_inv],
|
||||
action_invariants=[],
|
||||
project_invariants=[project_inv],
|
||||
global_invariants=[global_inv],
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ a dict keyed by invariant ID.
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
Effective invariants are computed using plan > action > project > global order.
|
||||
Effective invariants are computed using plan > project > global order.
|
||||
See ``merge_invariants`` for de-duplication semantics.
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
@@ -124,7 +124,6 @@ class InvariantService:
|
||||
if effective:
|
||||
return self.get_effective_invariants(
|
||||
plan_id=source_name if scope == InvariantScope.PLAN else None,
|
||||
action_name=source_name if scope == InvariantScope.ACTION else None,
|
||||
project_name=source_name if scope == InvariantScope.PROJECT else None,
|
||||
)
|
||||
|
||||
@@ -168,19 +167,16 @@ class InvariantService:
|
||||
def get_effective_invariants(
|
||||
self,
|
||||
plan_id: str | None = None,
|
||||
action_name: str | None = None,
|
||||
project_name: str | None = None,
|
||||
) -> list[Invariant]:
|
||||
"""Return the merged precedence chain for a plan/action/project context.
|
||||
"""Return the merged precedence chain for a plan/project context.
|
||||
|
||||
Collects active invariants from each scope tier and merges them
|
||||
using plan > action > project > global precedence.
|
||||
using plan > project > global precedence.
|
||||
|
||||
Args:
|
||||
plan_id: Optional plan identifier to collect plan-scoped
|
||||
invariants.
|
||||
action_name: Optional action name to collect action-scoped
|
||||
invariants.
|
||||
project_name: Optional project name to collect project-scoped
|
||||
invariants.
|
||||
|
||||
@@ -195,12 +191,6 @@ class InvariantService:
|
||||
if inv.scope == InvariantScope.PLAN
|
||||
and (plan_id is None or inv.source_name == plan_id)
|
||||
]
|
||||
action_invs = [
|
||||
inv
|
||||
for inv in active
|
||||
if inv.scope == InvariantScope.ACTION
|
||||
and (action_name is None or inv.source_name == action_name)
|
||||
]
|
||||
project_invs = [
|
||||
inv
|
||||
for inv in active
|
||||
@@ -209,7 +199,7 @@ class InvariantService:
|
||||
]
|
||||
global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL]
|
||||
|
||||
return merge_invariants(plan_invs, action_invs, project_invs, global_invs)
|
||||
return merge_invariants(plan_invs, project_invs, global_invs)
|
||||
|
||||
def enforce_invariants(
|
||||
self,
|
||||
|
||||
@@ -176,7 +176,6 @@ def list_invariants(
|
||||
agents invariant list
|
||||
agents invariant list --global
|
||||
agents invariant list --effective --project myapp
|
||||
agents invariant list --effective --action deploy-service
|
||||
agents invariant list "data.*safe"
|
||||
"""
|
||||
try:
|
||||
|
||||
@@ -151,8 +151,8 @@ def _session_list_dict(sessions: list[Session]) -> dict[str, Any]:
|
||||
# Find most recent and oldest sessions
|
||||
if sessions:
|
||||
sorted_sessions = sorted(sessions, key=lambda x: x.updated_at, reverse=True)
|
||||
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id
|
||||
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id
|
||||
most_recent = sorted_sessions[0].name or sorted_sessions[0].session_id[:8]
|
||||
oldest = sorted_sessions[-1].name or sorted_sessions[-1].session_id[:8]
|
||||
else:
|
||||
most_recent = None
|
||||
oldest = None
|
||||
@@ -347,7 +347,7 @@ def list_sessions(
|
||||
|
||||
for s in sessions:
|
||||
table.add_row(
|
||||
s.session_id, # Full ULID for copy-paste compatibility with session tell
|
||||
s.session_id[:8], # Truncate ID for readability
|
||||
s.name or "(unnamed)",
|
||||
s.actor_name or "(none)",
|
||||
str(s.message_count),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Invariant domain models for CleverAgents.
|
||||
|
||||
Invariants are natural-language constraints on plan execution, scoped at
|
||||
global, project, action, or plan level. ACTION invariants participate
|
||||
directly in the 4-tier merge chain rather than being promoted to plan-level.
|
||||
The runtime precedence chain is:
|
||||
global, project, action, or plan level. When an action is used, its
|
||||
invariants are promoted to plan-level. The runtime precedence chain is:
|
||||
|
||||
plan > action > project > global
|
||||
plan > project > global
|
||||
|
||||
They are reconciled by the Invariant Reconciliation Actor at the start
|
||||
of Strategize and recorded as ``invariant_enforced`` decisions.
|
||||
@@ -16,17 +15,14 @@ of Strategize and recorded as ``invariant_enforced`` decisions.
|
||||
|------------|----------------------------------------------------|
|
||||
| ``GLOBAL`` | Applies to every plan in the system |
|
||||
| ``PROJECT``| Applies to plans targeting a specific project |
|
||||
| ``ACTION`` | Defined in an action template; between PLAN and PROJECT in precedence |
|
||||
| ``ACTION`` | Defined in an action template; promoted on ``use`` |
|
||||
| ``PLAN`` | Attached directly to a specific plan |
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
When computing the effective set of invariants for a plan, the merge
|
||||
order is **plan > action > project > global**. Action-scoped invariants
|
||||
sit between plan and project in the precedence chain — they override
|
||||
project and global constraints but are themselves overridden by
|
||||
plan-level constraints. Duplicate texts (case-insensitive) are
|
||||
de-duplicated, keeping the highest-precedence copy.
|
||||
order is **plan > project > global**. Duplicate texts (case-insensitive)
|
||||
are de-duplicated, keeping the highest-precedence copy.
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
"""
|
||||
@@ -43,9 +39,8 @@ from ulid import ULID
|
||||
class InvariantScope(StrEnum):
|
||||
"""Scope at which an invariant applies.
|
||||
|
||||
Precedence (highest to lowest): PLAN > ACTION > PROJECT > GLOBAL.
|
||||
ACTION invariants participate directly in the 4-tier merge chain,
|
||||
sitting between PLAN and PROJECT in precedence order.
|
||||
Precedence (highest to lowest): PLAN > PROJECT > GLOBAL.
|
||||
ACTION invariants are promoted to PLAN scope at ``plan use`` time.
|
||||
"""
|
||||
|
||||
GLOBAL = "global"
|
||||
@@ -142,11 +137,10 @@ class InvariantSet(BaseModel):
|
||||
def merge(
|
||||
cls,
|
||||
plan_invariants: list[Invariant],
|
||||
action_invariants: list[Invariant],
|
||||
project_invariants: list[Invariant],
|
||||
global_invariants: list[Invariant],
|
||||
) -> InvariantSet:
|
||||
"""Merge invariants respecting plan > action > project > global precedence.
|
||||
"""Merge invariants respecting plan > project > global precedence.
|
||||
|
||||
De-duplicates by text (case-insensitive), keeping the copy from
|
||||
the highest-precedence tier. Within each tier, source ordering
|
||||
@@ -154,8 +148,7 @@ class InvariantSet(BaseModel):
|
||||
|
||||
Args:
|
||||
plan_invariants: Plan-level invariants (highest precedence).
|
||||
action_invariants: Action-level invariants (second precedence).
|
||||
project_invariants: Project-level invariants (third precedence).
|
||||
project_invariants: Project-level invariants.
|
||||
global_invariants: Global-level invariants (lowest precedence).
|
||||
|
||||
Returns:
|
||||
@@ -163,12 +156,7 @@ class InvariantSet(BaseModel):
|
||||
"""
|
||||
return cls(
|
||||
invariants=tuple(
|
||||
merge_invariants(
|
||||
plan_invariants,
|
||||
action_invariants,
|
||||
project_invariants,
|
||||
global_invariants,
|
||||
)
|
||||
merge_invariants(plan_invariants, project_invariants, global_invariants)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -177,11 +165,10 @@ class InvariantSet(BaseModel):
|
||||
|
||||
def merge_invariants(
|
||||
plan_invariants: list[Invariant],
|
||||
action_invariants: list[Invariant],
|
||||
project_invariants: list[Invariant],
|
||||
global_invariants: list[Invariant],
|
||||
) -> list[Invariant]:
|
||||
"""Merge invariants implementing plan > action > project > global precedence.
|
||||
"""Merge invariants implementing plan > project > global precedence.
|
||||
|
||||
De-duplicates by text (case-insensitive). The first occurrence
|
||||
(from the highest-precedence tier) wins. Within each tier, the
|
||||
@@ -189,8 +176,7 @@ def merge_invariants(
|
||||
|
||||
Args:
|
||||
plan_invariants: Plan-level invariants (highest precedence).
|
||||
action_invariants: Action-level invariants (second precedence).
|
||||
project_invariants: Project-level invariants (third precedence).
|
||||
project_invariants: Project-level invariants.
|
||||
global_invariants: Global-level invariants (lowest precedence).
|
||||
|
||||
Returns:
|
||||
@@ -199,13 +185,7 @@ def merge_invariants(
|
||||
seen: set[str] = set()
|
||||
result: list[Invariant] = []
|
||||
|
||||
tiers = (
|
||||
plan_invariants,
|
||||
action_invariants,
|
||||
project_invariants,
|
||||
global_invariants,
|
||||
)
|
||||
for inv_list in tiers:
|
||||
for inv_list in (plan_invariants, project_invariants, global_invariants):
|
||||
for inv in inv_list:
|
||||
if not inv.active:
|
||||
continue
|
||||
|
||||
@@ -31,17 +31,6 @@ class LLMTraceRepository:
|
||||
|
||||
Uses the session-factory pattern: each public method obtains a
|
||||
session from the factory. Callers are responsible for commit.
|
||||
|
||||
When ``save()`` is called with an explicit ``session`` argument the
|
||||
repository operates in *UnitOfWork mode*: it flushes the change into
|
||||
the caller's transaction but does **not** commit or close the session.
|
||||
The caller (or the enclosing ``UnitOfWork``) is responsible for the
|
||||
final commit.
|
||||
|
||||
When ``save()`` is called without an explicit ``session`` argument the
|
||||
repository operates in *standalone mode*: it creates its own session
|
||||
from the factory, flushes, commits, and closes the session so that the
|
||||
trace is durably persisted even outside a ``UnitOfWork``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -57,26 +46,16 @@ class LLMTraceRepository:
|
||||
return self._sf()
|
||||
|
||||
@database_retry
|
||||
def save(self, trace: LLMTrace, session: Session | None = None) -> None:
|
||||
def save(self, trace: LLMTrace) -> None:
|
||||
"""Persist a single ``LLMTrace`` row.
|
||||
|
||||
Args:
|
||||
trace: The trace to persist. Must not be ``None``.
|
||||
session: Optional external SQLAlchemy session. When provided
|
||||
the repository flushes into the caller's transaction and
|
||||
does **not** commit or close the session (UnitOfWork mode).
|
||||
When omitted the repository creates its own session, commits,
|
||||
and closes it (standalone mode).
|
||||
trace: The trace to persist.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``trace`` is ``None``.
|
||||
DatabaseError: On unrecoverable persistence failure.
|
||||
"""
|
||||
if trace is None:
|
||||
raise ValueError("trace must not be None")
|
||||
|
||||
own_session = session is None
|
||||
s: Session = self._session() if own_session else session
|
||||
session = self._session()
|
||||
try:
|
||||
model = LLMTraceModel(
|
||||
trace_id=trace.trace_id,
|
||||
@@ -98,16 +77,11 @@ class LLMTraceRepository:
|
||||
error=trace.error,
|
||||
timestamp=trace.timestamp.isoformat(),
|
||||
)
|
||||
s.add(model)
|
||||
s.flush()
|
||||
if own_session:
|
||||
s.commit()
|
||||
session.add(model)
|
||||
session.flush()
|
||||
except (SQLAlchemyDatabaseError, OperationalError) as exc:
|
||||
s.rollback()
|
||||
session.rollback()
|
||||
raise DatabaseError(f"Failed to save LLM trace: {exc}") from exc
|
||||
finally:
|
||||
if own_session:
|
||||
s.close()
|
||||
|
||||
@database_retry
|
||||
def get(self, trace_id: str) -> LLMTrace | None:
|
||||
|
||||
@@ -154,13 +154,7 @@ class MigrationRunner:
|
||||
Returns:
|
||||
Current revision ID or None if no migrations have been applied
|
||||
"""
|
||||
if self.database_url.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
self.database_url,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
else:
|
||||
engine = create_engine(self.database_url)
|
||||
engine = create_engine(self.database_url)
|
||||
with engine.connect() as connection:
|
||||
context = MigrationContext.configure(connection)
|
||||
return context.get_current_revision()
|
||||
|
||||
Reference in New Issue
Block a user