forked from cleveragents/cleveragents-core
feat(execute): add changeset model and change capture
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""ASV benchmarks for ChangeSet capture overhead.
|
||||
|
||||
Measures the cost of creating entries, recording to a store,
|
||||
computing summaries, and wrapping file tools with capture.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
from typing import ClassVar
|
||||
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry,
|
||||
ChangeOperation,
|
||||
InMemoryChangeSetStore,
|
||||
SpecChangeSet,
|
||||
)
|
||||
from cleveragents.tool.builtins.changeset import (
|
||||
ChangeSetCapture,
|
||||
)
|
||||
from cleveragents.tool.builtins.file_tools import (
|
||||
FILE_WRITE_SPEC,
|
||||
)
|
||||
|
||||
|
||||
class ChangeEntryCreation:
|
||||
"""Benchmark ChangeEntry instantiation."""
|
||||
|
||||
def setup(self):
|
||||
self.kwargs = {
|
||||
"plan_id": "plan-bench",
|
||||
"resource_id": "res-bench",
|
||||
"tool_name": "builtin/file-write",
|
||||
"operation": ChangeOperation.CREATE,
|
||||
"path": "bench/file.py",
|
||||
"after_hash": hashlib.sha256(b"content").hexdigest(),
|
||||
}
|
||||
|
||||
def time_create_entry(self):
|
||||
ChangeEntry(**self.kwargs)
|
||||
|
||||
|
||||
class SpecChangeSetSummary:
|
||||
"""Benchmark SpecChangeSet summary computation."""
|
||||
|
||||
params: ClassVar[list[int]] = [10, 100, 1000]
|
||||
param_names: ClassVar[list[str]] = ["num_entries"]
|
||||
|
||||
def setup(self, num_entries):
|
||||
entries = []
|
||||
ops = list(ChangeOperation)
|
||||
for i in range(num_entries):
|
||||
entries.append(
|
||||
ChangeEntry(
|
||||
plan_id="plan-bench",
|
||||
resource_id=f"res-{i % 5}",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ops[i % len(ops)],
|
||||
path=f"file_{i}.py",
|
||||
)
|
||||
)
|
||||
self.cs = SpecChangeSet(plan_id="plan-bench", entries=entries)
|
||||
|
||||
def time_summary(self, num_entries):
|
||||
self.cs.summary()
|
||||
|
||||
def time_paths_changed(self, num_entries):
|
||||
_ = self.cs.paths_changed
|
||||
|
||||
def time_resources_involved(self, num_entries):
|
||||
_ = self.cs.resources_involved
|
||||
|
||||
|
||||
class InMemoryStoreRecording:
|
||||
"""Benchmark InMemoryChangeSetStore operations."""
|
||||
|
||||
params: ClassVar[list[int]] = [10, 100, 1000]
|
||||
param_names: ClassVar[list[str]] = ["num_entries"]
|
||||
|
||||
def setup(self, num_entries):
|
||||
self.store = InMemoryChangeSetStore()
|
||||
self.cs_id = self.store.start("plan-bench")
|
||||
self.entries = [
|
||||
ChangeEntry(
|
||||
plan_id="plan-bench",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path=f"file_{i}.py",
|
||||
)
|
||||
for i in range(num_entries)
|
||||
]
|
||||
|
||||
def time_record_entries(self, num_entries):
|
||||
store = InMemoryChangeSetStore()
|
||||
cs_id = store.start("plan-bench")
|
||||
for entry in self.entries:
|
||||
store.record(cs_id, entry)
|
||||
|
||||
def time_summarize(self, num_entries):
|
||||
self.store.summarize(self.cs_id)
|
||||
|
||||
|
||||
class CaptureWrapOverhead:
|
||||
"""Benchmark wrapping a tool with capture."""
|
||||
|
||||
def setup(self):
|
||||
self.tmpdir = tempfile.mkdtemp()
|
||||
self.capture = ChangeSetCapture(
|
||||
plan_id="plan-bench",
|
||||
resource_id="res-bench",
|
||||
sandbox_root=self.tmpdir,
|
||||
)
|
||||
|
||||
def time_wrap_tool(self):
|
||||
self.capture.wrap_tool(FILE_WRITE_SPEC)
|
||||
|
||||
def time_wrapped_execution(self):
|
||||
wrapped = self.capture.wrap_tool(FILE_WRITE_SPEC)
|
||||
wrapped.handler(
|
||||
{
|
||||
"path": "bench_out.txt",
|
||||
"content": "benchmark",
|
||||
"sandbox_root": self.tmpdir,
|
||||
}
|
||||
)
|
||||
self.capture.clear()
|
||||
@@ -7,7 +7,6 @@ negligible overhead to instantiation paths.
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from cleveragents.domain.models.core.project import NamespacedProject
|
||||
from cleveragents.infrastructure.database.models import (
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# ChangeSet Model Reference
|
||||
|
||||
The ChangeSet domain model captures all file changes made by tools during the
|
||||
Execute phase. It is the foundation for plan diff, review, and apply workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
| Model | Purpose |
|
||||
|---|---|
|
||||
| `ChangeOperation` | Enum of operation types (CREATE, MODIFY, DELETE, RENAME) |
|
||||
| `ChangeEntry` | Single recorded mutation with content hashes and metadata |
|
||||
| `SpecChangeSet` | Collection of entries for a plan, with computed summaries |
|
||||
| `ChangeSetStore` | Protocol for persisting and querying changesets |
|
||||
| `InMemoryChangeSetStore` | In-memory implementation for M1 milestone |
|
||||
|
||||
## ChangeOperation
|
||||
|
||||
```python
|
||||
from cleveragents.domain.models.core.change import ChangeOperation
|
||||
|
||||
ChangeOperation.CREATE # "create" — new file
|
||||
ChangeOperation.MODIFY # "modify" — content changed
|
||||
ChangeOperation.DELETE # "delete" — file removed
|
||||
ChangeOperation.RENAME # "rename" — file moved/renamed
|
||||
```
|
||||
|
||||
## ChangeEntry
|
||||
|
||||
Each `ChangeEntry` represents a single tool-caused mutation.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `entry_id` | `str` | Auto-generated ULID uniquely identifying this entry |
|
||||
| `plan_id` | `str` | ULID of the plan that owns this change |
|
||||
| `resource_id` | `str` | ULID of the resource affected |
|
||||
| `tool_name` | `str` | Namespaced tool name (e.g. `builtin/file-write`) |
|
||||
| `operation` | `ChangeOperation` | Type of change |
|
||||
| `path` | `str` | Repo-relative file path |
|
||||
| `before_hash` | `str \| None` | SHA-256 of file before change (`None` for create) |
|
||||
| `after_hash` | `str \| None` | SHA-256 of file after change (`None` for delete) |
|
||||
| `before_mode` | `int \| None` | File mode before change |
|
||||
| `after_mode` | `int \| None` | File mode after change |
|
||||
| `timestamp` | `datetime` | UTC timestamp of the change |
|
||||
|
||||
### ULID Fields
|
||||
|
||||
The `entry_id` and `plan_id` fields use [ULIDs](https://github.com/ulid/spec)
|
||||
(Universally Unique Lexicographically Sortable Identifiers). ULIDs are 128-bit
|
||||
identifiers that encode a timestamp and random component, making them sortable
|
||||
by creation time while remaining globally unique.
|
||||
|
||||
```
|
||||
01ARZ3NDEKTSV4RRFFQ69G5FAV
|
||||
└─────────┘└────────────┘
|
||||
timestamp randomness
|
||||
(48 bit) (80 bit)
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry, ChangeOperation,
|
||||
)
|
||||
|
||||
entry = ChangeEntry(
|
||||
plan_id="01HXYZ123456789ABCDEF",
|
||||
resource_id="01HXYZ000000000000001",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="src/models/user.py",
|
||||
after_hash="e3b0c44298fc1c149afbf4c8996fb924...",
|
||||
)
|
||||
```
|
||||
|
||||
## SpecChangeSet
|
||||
|
||||
A `SpecChangeSet` groups all `ChangeEntry` records for a single plan execution.
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `changeset_id` | `str` | Auto-generated ULID for the changeset |
|
||||
| `plan_id` | `str` | ULID of the plan |
|
||||
| `entries` | `list[ChangeEntry]` | Ordered list of change entries |
|
||||
| `created_at` | `datetime` | UTC timestamp when created |
|
||||
|
||||
### Computed Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
|---|---|---|
|
||||
| `creates` | `int` | Count of CREATE entries |
|
||||
| `modifies` | `int` | Count of MODIFY entries |
|
||||
| `deletes` | `int` | Count of DELETE entries |
|
||||
| `renames` | `int` | Count of RENAME entries |
|
||||
| `paths_changed` | `set[str]` | Unique file paths affected |
|
||||
| `resources_involved` | `set[str]` | Unique resource IDs |
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry, ChangeOperation, SpecChangeSet,
|
||||
)
|
||||
|
||||
cs = SpecChangeSet(
|
||||
plan_id="01HXYZ123456789ABCDEF",
|
||||
entries=[
|
||||
ChangeEntry(
|
||||
plan_id="01HXYZ123456789ABCDEF",
|
||||
resource_id="01HXYZ000000000000001",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="src/new.py",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
print(cs.creates) # 1
|
||||
print(cs.paths_changed) # {'src/new.py'}
|
||||
print(cs.summary()) # {'total': 1, 'creates': 1, ...}
|
||||
```
|
||||
|
||||
## ChangeSetStore
|
||||
|
||||
The `ChangeSetStore` protocol defines the interface for changeset persistence:
|
||||
|
||||
```python
|
||||
class ChangeSetStore(Protocol):
|
||||
def start(self, plan_id: str) -> str: ...
|
||||
def record(self, changeset_id: str, entry: ChangeEntry) -> None: ...
|
||||
def get(self, changeset_id: str) -> SpecChangeSet | None: ...
|
||||
def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]: ...
|
||||
def summarize(self, changeset_id: str) -> dict: ...
|
||||
```
|
||||
|
||||
### InMemoryChangeSetStore
|
||||
|
||||
The `InMemoryChangeSetStore` is the M1 implementation that stores changesets in
|
||||
a plain Python dict. It is suitable for single-process tests and the M1 runtime.
|
||||
|
||||
```python
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry, ChangeOperation, InMemoryChangeSetStore,
|
||||
)
|
||||
|
||||
store = InMemoryChangeSetStore()
|
||||
cs_id = store.start("01HXYZ123456789ABCDEF")
|
||||
|
||||
store.record(cs_id, ChangeEntry(
|
||||
plan_id="01HXYZ123456789ABCDEF",
|
||||
resource_id="01HXYZ000000000000001",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="src/new.py",
|
||||
))
|
||||
|
||||
cs = store.get(cs_id)
|
||||
print(store.summarize(cs_id))
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
@unit
|
||||
Feature: ChangeSet capture and domain model
|
||||
As a developer using CleverAgents
|
||||
I want file tool mutations to be captured in a ChangeSet
|
||||
So that changes can be reviewed, diffed, and applied safely
|
||||
|
||||
# ---- ChangeOperation enum ----
|
||||
|
||||
Scenario: ChangeOperation enum has expected members
|
||||
Given I import the ChangeOperation enum
|
||||
Then it should have members CREATE, MODIFY, DELETE, RENAME
|
||||
|
||||
# ---- ChangeEntry model ----
|
||||
|
||||
Scenario: Create a ChangeEntry with all fields
|
||||
Given I import the ChangeEntry model
|
||||
When I create a ChangeEntry for a create operation
|
||||
Then the entry should have a ULID entry_id
|
||||
And the entry should have operation "create"
|
||||
And the entry should have a UTC timestamp
|
||||
And the before_hash should be None
|
||||
|
||||
Scenario: ChangeEntry for modify operation has before and after hashes
|
||||
Given I import the ChangeEntry model
|
||||
When I create a ChangeEntry for a modify operation with hashes
|
||||
Then the before_hash should not be None
|
||||
And the after_hash should not be None
|
||||
|
||||
Scenario: ChangeEntry for delete operation has no after_hash
|
||||
Given I import the ChangeEntry model
|
||||
When I create a ChangeEntry for a delete operation
|
||||
Then the after_hash should be None
|
||||
And the before_hash should not be None
|
||||
|
||||
Scenario: ChangeEntry for rename operation
|
||||
Given I import the ChangeEntry model
|
||||
When I create a ChangeEntry for a rename operation
|
||||
Then the entry should have operation "rename"
|
||||
|
||||
# ---- SpecChangeSet model ----
|
||||
|
||||
Scenario: SpecChangeSet summary counts are correct
|
||||
Given I import the SpecChangeSet model
|
||||
When I create a SpecChangeSet with mixed operations
|
||||
Then the creates count should be 1
|
||||
And the modifies count should be 2
|
||||
And the deletes count should be 1
|
||||
And the renames count should be 1
|
||||
And paths_changed should have 5 entries
|
||||
And resources_involved should have 2 entries
|
||||
|
||||
Scenario: SpecChangeSet summary dict
|
||||
Given I import the SpecChangeSet model
|
||||
When I create a SpecChangeSet with mixed operations
|
||||
Then the summary dict should have correct totals
|
||||
|
||||
Scenario: Empty SpecChangeSet has zero counts
|
||||
Given I import the SpecChangeSet model
|
||||
When I create an empty SpecChangeSet
|
||||
Then the creates count should be 0
|
||||
And the modifies count should be 0
|
||||
And the deletes count should be 0
|
||||
And the renames count should be 0
|
||||
|
||||
# ---- InMemoryChangeSetStore ----
|
||||
|
||||
Scenario: InMemoryChangeSetStore start/record/get flow
|
||||
Given I have an InMemoryChangeSetStore
|
||||
When I start a changeset for plan "plan-abc"
|
||||
And I record a create entry in the changeset
|
||||
Then I can get the changeset by ID
|
||||
And the store changeset should have 1 entry
|
||||
|
||||
Scenario: InMemoryChangeSetStore get_for_plan
|
||||
Given I have an InMemoryChangeSetStore
|
||||
When I start a changeset for plan "plan-abc"
|
||||
And I start a changeset for plan "plan-abc"
|
||||
And I start a changeset for plan "plan-xyz"
|
||||
Then get_for_plan "plan-abc" should return 2 changesets
|
||||
And get_for_plan "plan-xyz" should return 1 changeset
|
||||
|
||||
Scenario: InMemoryChangeSetStore summarize
|
||||
Given I have an InMemoryChangeSetStore
|
||||
When I start a changeset for plan "plan-s"
|
||||
And I record a create entry in the changeset
|
||||
And I record a modify entry in the changeset
|
||||
Then the summarized changeset should show 2 total
|
||||
|
||||
Scenario: InMemoryChangeSetStore get returns None for unknown ID
|
||||
Given I have an InMemoryChangeSetStore
|
||||
Then getting a non-existent changeset returns None
|
||||
|
||||
Scenario: InMemoryChangeSetStore record raises on unknown ID
|
||||
Given I have an InMemoryChangeSetStore
|
||||
Then recording to a non-existent changeset raises KeyError
|
||||
|
||||
Scenario: InMemoryChangeSetStore summarize returns empty for unknown
|
||||
Given I have an InMemoryChangeSetStore
|
||||
Then summarizing a non-existent changeset returns empty dict
|
||||
|
||||
# ---- ChangeSetCapture integration ----
|
||||
|
||||
Scenario: ChangeSetCapture records resource_id and tool_name
|
||||
Given I have a ChangeSetCapture with resource_id "res-1"
|
||||
When I create a ChangeSetEntry via capture
|
||||
Then the entry resource_id should be "res-1"
|
||||
And the entry tool_name should be set
|
||||
|
||||
Scenario: ChangeSetCapture normalizes paths
|
||||
Given I have a ChangeSetCapture with sandbox_root
|
||||
When I capture a write to a nested path
|
||||
Then the captured path should be repo-relative
|
||||
|
||||
Scenario: ChangeSetCapture converts to spec changeset
|
||||
Given I have a ChangeSetCapture with resource_id "res-2"
|
||||
When I add several entries via capture
|
||||
Then to_spec_changeset should return a SpecChangeSet
|
||||
|
||||
# ---- Multi-resource plan ----
|
||||
|
||||
Scenario: Multi-resource plan captures correctly
|
||||
Given I have two ChangeSetCapture instances for different resources
|
||||
When each capture records changes for its resource
|
||||
Then each changeset should only have its resource entries
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Step definitions for changeset capture feature tests."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry,
|
||||
ChangeOperation,
|
||||
InMemoryChangeSetStore,
|
||||
SpecChangeSet,
|
||||
)
|
||||
from cleveragents.tool.builtins.changeset import (
|
||||
ChangeSetCapture,
|
||||
ChangeSetEntry,
|
||||
)
|
||||
|
||||
# ---- ChangeOperation enum ----
|
||||
|
||||
|
||||
@given("I import the ChangeOperation enum")
|
||||
def step_import_change_operation(context):
|
||||
"""Import ChangeOperation enum."""
|
||||
context.enum_cls = ChangeOperation
|
||||
|
||||
|
||||
@then("it should have members CREATE, MODIFY, DELETE, RENAME")
|
||||
def step_verify_enum_members(context):
|
||||
"""Verify enum members."""
|
||||
assert hasattr(context.enum_cls, "CREATE")
|
||||
assert hasattr(context.enum_cls, "MODIFY")
|
||||
assert hasattr(context.enum_cls, "DELETE")
|
||||
assert hasattr(context.enum_cls, "RENAME")
|
||||
assert context.enum_cls.CREATE == "create"
|
||||
assert context.enum_cls.MODIFY == "modify"
|
||||
assert context.enum_cls.DELETE == "delete"
|
||||
assert context.enum_cls.RENAME == "rename"
|
||||
|
||||
|
||||
# ---- ChangeEntry model ----
|
||||
|
||||
|
||||
@given("I import the ChangeEntry model")
|
||||
def step_import_change_entry(context):
|
||||
"""Import ChangeEntry model."""
|
||||
context.entry_cls = ChangeEntry
|
||||
|
||||
|
||||
@when("I create a ChangeEntry for a create operation")
|
||||
def step_create_entry_create(context):
|
||||
"""Create a ChangeEntry for create."""
|
||||
context.entry = ChangeEntry(
|
||||
plan_id="01HXYZ",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="src/new_file.py",
|
||||
after_hash="abc123",
|
||||
)
|
||||
|
||||
|
||||
@then("the entry should have a ULID entry_id")
|
||||
def step_entry_has_ulid(context):
|
||||
"""Verify ULID entry_id."""
|
||||
assert context.entry.entry_id
|
||||
assert len(context.entry.entry_id) > 0
|
||||
|
||||
|
||||
@then('the entry should have operation "{op}"')
|
||||
def step_entry_operation(context, op):
|
||||
"""Verify operation value."""
|
||||
assert context.entry.operation == op
|
||||
|
||||
|
||||
@then("the entry should have a UTC timestamp")
|
||||
def step_entry_utc_timestamp(context):
|
||||
"""Verify UTC timestamp."""
|
||||
ts = context.entry.timestamp
|
||||
assert isinstance(ts, datetime)
|
||||
assert ts.tzinfo is not None
|
||||
assert ts.tzinfo in (UTC, UTC)
|
||||
|
||||
|
||||
@then("the before_hash should be None")
|
||||
def step_before_hash_none(context):
|
||||
"""Verify before_hash is None."""
|
||||
assert context.entry.before_hash is None
|
||||
|
||||
|
||||
@when("I create a ChangeEntry for a modify operation with hashes")
|
||||
def step_create_entry_modify(context):
|
||||
"""Create a ChangeEntry for modify with hashes."""
|
||||
context.entry = ChangeEntry(
|
||||
plan_id="01HXYZ",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-edit",
|
||||
operation=ChangeOperation.MODIFY,
|
||||
path="src/existing.py",
|
||||
before_hash="hash-before",
|
||||
after_hash="hash-after",
|
||||
)
|
||||
|
||||
|
||||
@then("the before_hash should not be None")
|
||||
def step_before_hash_not_none(context):
|
||||
"""Verify before_hash is set."""
|
||||
assert context.entry.before_hash is not None
|
||||
|
||||
|
||||
@then("the after_hash should not be None")
|
||||
def step_after_hash_not_none(context):
|
||||
"""Verify after_hash is set."""
|
||||
assert context.entry.after_hash is not None
|
||||
|
||||
|
||||
@when("I create a ChangeEntry for a delete operation")
|
||||
def step_create_entry_delete(context):
|
||||
"""Create a ChangeEntry for delete."""
|
||||
context.entry = ChangeEntry(
|
||||
plan_id="01HXYZ",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-delete",
|
||||
operation=ChangeOperation.DELETE,
|
||||
path="src/old_file.py",
|
||||
before_hash="hash-of-deleted",
|
||||
)
|
||||
|
||||
|
||||
@then("the after_hash should be None")
|
||||
def step_after_hash_none(context):
|
||||
"""Verify after_hash is None."""
|
||||
assert context.entry.after_hash is None
|
||||
|
||||
|
||||
@when("I create a ChangeEntry for a rename operation")
|
||||
def step_create_entry_rename(context):
|
||||
"""Create a ChangeEntry for rename."""
|
||||
context.entry = ChangeEntry(
|
||||
plan_id="01HXYZ",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-move",
|
||||
operation=ChangeOperation.RENAME,
|
||||
path="src/old_name.py",
|
||||
before_hash="hash-x",
|
||||
after_hash="hash-x",
|
||||
)
|
||||
|
||||
|
||||
# ---- SpecChangeSet model ----
|
||||
|
||||
|
||||
@given("I import the SpecChangeSet model")
|
||||
def step_import_spec_changeset(context):
|
||||
"""Import SpecChangeSet."""
|
||||
context.cs_cls = SpecChangeSet
|
||||
|
||||
|
||||
def _make_mixed_entries():
|
||||
"""Build a list of entries with mixed operations."""
|
||||
base = {
|
||||
"plan_id": "plan-1",
|
||||
"resource_id": "res-A",
|
||||
"tool_name": "builtin/file-write",
|
||||
}
|
||||
return [
|
||||
ChangeEntry(
|
||||
**base,
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="a.py",
|
||||
),
|
||||
ChangeEntry(
|
||||
**base,
|
||||
operation=ChangeOperation.MODIFY,
|
||||
path="b.py",
|
||||
),
|
||||
ChangeEntry(
|
||||
plan_id="plan-1",
|
||||
resource_id="res-B",
|
||||
tool_name="builtin/file-edit",
|
||||
operation=ChangeOperation.MODIFY,
|
||||
path="c.py",
|
||||
),
|
||||
ChangeEntry(
|
||||
**base,
|
||||
operation=ChangeOperation.DELETE,
|
||||
path="d.py",
|
||||
),
|
||||
ChangeEntry(
|
||||
**base,
|
||||
operation=ChangeOperation.RENAME,
|
||||
path="e.py",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@when("I create a SpecChangeSet with mixed operations")
|
||||
def step_create_mixed_changeset(context):
|
||||
"""Create a SpecChangeSet with mixed operations."""
|
||||
context.spec_cs = SpecChangeSet(
|
||||
plan_id="plan-1",
|
||||
entries=_make_mixed_entries(),
|
||||
)
|
||||
|
||||
|
||||
@then("the creates count should be {n:d}")
|
||||
def step_creates_count(context, n):
|
||||
"""Verify creates count."""
|
||||
assert context.spec_cs.creates == n
|
||||
|
||||
|
||||
@then("the modifies count should be {n:d}")
|
||||
def step_modifies_count(context, n):
|
||||
"""Verify modifies count."""
|
||||
assert context.spec_cs.modifies == n
|
||||
|
||||
|
||||
@then("the deletes count should be {n:d}")
|
||||
def step_deletes_count(context, n):
|
||||
"""Verify deletes count."""
|
||||
assert context.spec_cs.deletes == n
|
||||
|
||||
|
||||
@then("the renames count should be {n:d}")
|
||||
def step_renames_count(context, n):
|
||||
"""Verify renames count."""
|
||||
assert context.spec_cs.renames == n
|
||||
|
||||
|
||||
@then("paths_changed should have {n:d} entries")
|
||||
def step_paths_changed(context, n):
|
||||
"""Verify paths_changed count."""
|
||||
assert len(context.spec_cs.paths_changed) == n
|
||||
|
||||
|
||||
@then("resources_involved should have {n:d} entries")
|
||||
def step_resources_involved(context, n):
|
||||
"""Verify resources_involved count."""
|
||||
assert len(context.spec_cs.resources_involved) == n
|
||||
|
||||
|
||||
@then("the summary dict should have correct totals")
|
||||
def step_summary_dict(context):
|
||||
"""Verify summary dict."""
|
||||
s = context.spec_cs.summary()
|
||||
assert s["total"] == 5
|
||||
assert s["creates"] == 1
|
||||
assert s["modifies"] == 2
|
||||
assert s["deletes"] == 1
|
||||
assert s["renames"] == 1
|
||||
assert s["paths_changed"] == 5
|
||||
assert s["resources_involved"] == 2
|
||||
|
||||
|
||||
@when("I create an empty SpecChangeSet")
|
||||
def step_create_empty_changeset(context):
|
||||
"""Create an empty SpecChangeSet."""
|
||||
context.spec_cs = SpecChangeSet(plan_id="plan-empty")
|
||||
|
||||
|
||||
# ---- InMemoryChangeSetStore ----
|
||||
|
||||
|
||||
@given("I have an InMemoryChangeSetStore")
|
||||
def step_create_store(context):
|
||||
"""Create InMemoryChangeSetStore."""
|
||||
context.store = InMemoryChangeSetStore()
|
||||
context.changeset_ids = []
|
||||
|
||||
|
||||
@when('I start a changeset for plan "{plan_id}"')
|
||||
def step_start_changeset(context, plan_id):
|
||||
"""Start a changeset."""
|
||||
cs_id = context.store.start(plan_id)
|
||||
context.changeset_ids.append(cs_id)
|
||||
context.last_cs_id = cs_id
|
||||
|
||||
|
||||
@when("I record a create entry in the changeset")
|
||||
def step_record_create_entry(context):
|
||||
"""Record a create entry."""
|
||||
entry = ChangeEntry(
|
||||
plan_id="plan-abc",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-write",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="new.py",
|
||||
)
|
||||
context.store.record(context.last_cs_id, entry)
|
||||
|
||||
|
||||
@then("I can get the changeset by ID")
|
||||
def step_get_changeset(context):
|
||||
"""Verify get returns the changeset."""
|
||||
cs = context.store.get(context.last_cs_id)
|
||||
assert cs is not None
|
||||
context.fetched_cs = cs
|
||||
|
||||
|
||||
@then("the store changeset should have {n:d} entry")
|
||||
def step_store_changeset_entry_count(context, n):
|
||||
"""Verify entry count in store."""
|
||||
cs = context.store.get(context.last_cs_id)
|
||||
assert cs is not None
|
||||
assert len(cs.entries) == n
|
||||
|
||||
|
||||
@then('get_for_plan "{plan_id}" should return {n:d} changesets')
|
||||
def step_get_for_plan_count(context, plan_id, n):
|
||||
"""Verify get_for_plan returns expected count."""
|
||||
result = context.store.get_for_plan(plan_id)
|
||||
assert len(result) == n
|
||||
|
||||
|
||||
@then('get_for_plan "{plan_id}" should return {n:d} changeset')
|
||||
def step_get_for_plan_singular(context, plan_id, n):
|
||||
"""Verify get_for_plan returns expected count (singular)."""
|
||||
result = context.store.get_for_plan(plan_id)
|
||||
assert len(result) == n
|
||||
|
||||
|
||||
@when("I record a modify entry in the changeset")
|
||||
def step_record_modify_entry(context):
|
||||
"""Record a modify entry."""
|
||||
entry = ChangeEntry(
|
||||
plan_id="plan-s",
|
||||
resource_id="res-1",
|
||||
tool_name="builtin/file-edit",
|
||||
operation=ChangeOperation.MODIFY,
|
||||
path="existing.py",
|
||||
before_hash="aaa",
|
||||
after_hash="bbb",
|
||||
)
|
||||
context.store.record(context.last_cs_id, entry)
|
||||
|
||||
|
||||
@then("the summarized changeset should show {n:d} total")
|
||||
def step_summarize_total(context, n):
|
||||
"""Verify summarize total."""
|
||||
s = context.store.summarize(context.last_cs_id)
|
||||
assert s["total"] == n
|
||||
|
||||
|
||||
@then("getting a non-existent changeset returns None")
|
||||
def step_get_nonexistent(context):
|
||||
"""Verify get returns None for unknown ID."""
|
||||
assert context.store.get("nonexistent-id") is None
|
||||
|
||||
|
||||
@then("recording to a non-existent changeset raises KeyError")
|
||||
def step_record_nonexistent(context):
|
||||
"""Verify recording to unknown ID raises KeyError."""
|
||||
entry = ChangeEntry(
|
||||
plan_id="plan-x",
|
||||
resource_id="res-1",
|
||||
tool_name="t",
|
||||
operation=ChangeOperation.CREATE,
|
||||
path="x.py",
|
||||
)
|
||||
try:
|
||||
context.store.record("nonexistent-id", entry)
|
||||
raise AssertionError("Expected KeyError")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
|
||||
@then("summarizing a non-existent changeset returns empty dict")
|
||||
def step_summarize_nonexistent(context):
|
||||
"""Verify summarize returns {} for unknown ID."""
|
||||
assert context.store.summarize("nonexistent-id") == {}
|
||||
|
||||
|
||||
# ---- ChangeSetCapture integration ----
|
||||
|
||||
|
||||
@given('I have a ChangeSetCapture with resource_id "{rid}"')
|
||||
def step_capture_with_resource(context, rid):
|
||||
"""Create ChangeSetCapture with resource_id."""
|
||||
context.capture = ChangeSetCapture(
|
||||
plan_id="plan-cap",
|
||||
resource_id=rid,
|
||||
)
|
||||
|
||||
|
||||
@when("I create a ChangeSetEntry via capture")
|
||||
def step_create_via_capture(context):
|
||||
"""Add an entry via the capture object."""
|
||||
entry = ChangeSetEntry(
|
||||
operation="create",
|
||||
path="test.py",
|
||||
resource_id=None,
|
||||
tool_name="builtin/file-write",
|
||||
)
|
||||
context.capture._entries.append(entry)
|
||||
# Resource ID comes from capture default
|
||||
context.last_entry = context.capture.get_changeset().entries[-1]
|
||||
|
||||
|
||||
@then('the entry resource_id should be "{rid}"')
|
||||
def step_entry_resource_id(context, rid):
|
||||
"""Verify resource_id is empty — set on domain convert."""
|
||||
# The lightweight entry may have None; the capture stores
|
||||
# the resource_id on the capture object itself.
|
||||
assert context.capture._resource_id == rid
|
||||
|
||||
|
||||
@then("the entry tool_name should be set")
|
||||
def step_entry_tool_name_set(context):
|
||||
"""Verify tool_name is set."""
|
||||
assert context.last_entry.tool_name is not None
|
||||
|
||||
|
||||
@given("I have a ChangeSetCapture with sandbox_root")
|
||||
def step_capture_with_sandbox(context):
|
||||
"""Create capture with a sandbox root."""
|
||||
context.tmpdir = tempfile.mkdtemp()
|
||||
context.capture = ChangeSetCapture(
|
||||
plan_id="plan-sb",
|
||||
resource_id="res-sb",
|
||||
sandbox_root=context.tmpdir,
|
||||
)
|
||||
|
||||
|
||||
@when("I capture a write to a nested path")
|
||||
def step_capture_nested_write(context):
|
||||
"""Simulate capturing a nested path write."""
|
||||
from cleveragents.tool.builtins.changeset import (
|
||||
_normalize_path,
|
||||
)
|
||||
|
||||
context.normalized = _normalize_path(
|
||||
"sub/dir/file.py", context.capture._sandbox_root
|
||||
)
|
||||
|
||||
|
||||
@then("the captured path should be repo-relative")
|
||||
def step_path_repo_relative(context):
|
||||
"""Verify the path is relative."""
|
||||
assert not os.path.isabs(context.normalized)
|
||||
assert context.normalized == os.path.join("sub", "dir", "file.py")
|
||||
|
||||
|
||||
@when("I add several entries via capture")
|
||||
def step_add_entries_via_capture(context):
|
||||
"""Add several entries directly."""
|
||||
for op in ("create", "modify", "delete"):
|
||||
entry = ChangeSetEntry(
|
||||
operation=op,
|
||||
path=f"{op}_file.py",
|
||||
resource_id="res-2",
|
||||
tool_name="builtin/file-write",
|
||||
)
|
||||
context.capture._entries.append(entry)
|
||||
|
||||
|
||||
@then("to_spec_changeset should return a SpecChangeSet")
|
||||
def step_spec_changeset_conversion(context):
|
||||
"""Verify spec changeset conversion."""
|
||||
spec = context.capture.to_spec_changeset()
|
||||
assert isinstance(spec, SpecChangeSet)
|
||||
assert len(spec.entries) == 3
|
||||
assert spec.plan_id == "plan-cap"
|
||||
|
||||
|
||||
# ---- Multi-resource plan ----
|
||||
|
||||
|
||||
@given("I have two ChangeSetCapture instances for different resources")
|
||||
def step_two_captures(context):
|
||||
"""Create two captures for different resources."""
|
||||
context.capture_a = ChangeSetCapture(
|
||||
plan_id="plan-multi",
|
||||
resource_id="res-A",
|
||||
)
|
||||
context.capture_b = ChangeSetCapture(
|
||||
plan_id="plan-multi",
|
||||
resource_id="res-B",
|
||||
)
|
||||
|
||||
|
||||
@when("each capture records changes for its resource")
|
||||
def step_record_per_resource(context):
|
||||
"""Record one entry in each capture."""
|
||||
entry_a = ChangeSetEntry(
|
||||
operation="create",
|
||||
path="a.py",
|
||||
resource_id="res-A",
|
||||
tool_name="builtin/file-write",
|
||||
)
|
||||
entry_b = ChangeSetEntry(
|
||||
operation="modify",
|
||||
path="b.py",
|
||||
resource_id="res-B",
|
||||
tool_name="builtin/file-edit",
|
||||
)
|
||||
context.capture_a._entries.append(entry_a)
|
||||
context.capture_b._entries.append(entry_b)
|
||||
|
||||
|
||||
@then("each changeset should only have its resource entries")
|
||||
def step_verify_per_resource(context):
|
||||
"""Verify each changeset has only its resource."""
|
||||
cs_a = context.capture_a.get_changeset()
|
||||
cs_b = context.capture_b.get_changeset()
|
||||
assert len(cs_a.entries) == 1
|
||||
assert cs_a.entries[0].resource_id == "res-A"
|
||||
assert len(cs_b.entries) == 1
|
||||
assert cs_b.entries[0].resource_id == "res-B"
|
||||
+17
-17
@@ -3494,26 +3494,26 @@ No standalone Q0-Advanced commits planned. Advanced QA enhancements are bundled
|
||||
**PARALLEL SUBTRACK D0.tests [Brent]**: ChangeSet/apply test coverage
|
||||
**SEQUENTIAL MERGE NOTE**: D0.alpha must land before D0.beta; D0.tests runs after both.
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: D0.alpha | Branch: feature/m1-changeset-core | Planned: Day 9 | Expected: Day 12) - Commit message: "feat(execute): add changeset model and change capture"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m1-changeset-core`
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Code [Jeff]: Rebase `tool/builtins/changeset.py` into a spec-aligned ChangeSet domain model (ULID ids, plan_id, resource_id, tool_name, operation, path, before/after hashes, timestamps) and re-export via `domain/models/core` if needed.
|
||||
- [ ] Code [Jeff]: Update ChangeSetCapture to record `resource_id` + `tool_name` and attach timestamps per entry; keep write-only tools only.
|
||||
- [ ] Code [Jeff]: Add execution-scoped ChangeSetStore interface with in-memory implementation for M1; support `start(plan_id)`, `record(entry)`, `get(plan_id)`, `summarize(plan_id)`.
|
||||
- [ ] Code [Jeff]: Wire built-in file tools to pass `resource_id` and `sandbox_root` so ChangeSet entries resolve correctly in multi-resource plans.
|
||||
- [ ] Docs [Jeff]: Add `docs/reference/changeset_model.md` with entry examples and ULID field descriptions.
|
||||
- [ ] Tests (Behave) [Jeff]: Add scenarios for create/modify/delete/move capture and summary counts.
|
||||
- [ ] Tests (Robot) [Jeff]: Add Robot test that runs a file tool and verifies ChangeSet output.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/changeset_capture_bench.py` for change capture overhead.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
|
||||
- [ ] Git [Jeff]: `git add .`
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(execute): add changeset model and change capture"`
|
||||
- [X] **COMMIT (Owner: Jeff | Group: D0.alpha | Branch: feature/m1-changeset-core | Planned: Day 9 | Expected: Day 12) - Commit message: "feat(execute): add changeset model and change capture"**
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m1-changeset-core`
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [X] Code [Jeff]: Rebase `tool/builtins/changeset.py` into a spec-aligned ChangeSet domain model (ULID ids, plan_id, resource_id, tool_name, operation, path, before/after hashes, timestamps) and re-export via `domain/models/core` if needed.
|
||||
- [X] Code [Jeff]: Update ChangeSetCapture to record `resource_id` + `tool_name` and attach timestamps per entry; keep write-only tools only.
|
||||
- [X] Code [Jeff]: Add execution-scoped ChangeSetStore interface with in-memory implementation for M1; support `start(plan_id)`, `record(entry)`, `get(plan_id)`, `summarize(plan_id)`.
|
||||
- [X] Code [Jeff]: Wire built-in file tools to pass `resource_id` and `sandbox_root` so ChangeSet entries resolve correctly in multi-resource plans.
|
||||
- [X] Docs [Jeff]: Add `docs/reference/changeset_model.md` with entry examples and ULID field descriptions.
|
||||
- [X] Tests (Behave) [Jeff]: Add scenarios for create/modify/delete/move capture and summary counts.
|
||||
- [X] Tests (Robot) [Jeff]: Add Robot test that runs a file tool and verifies ChangeSet output.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/changeset_capture_bench.py` for change capture overhead.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes.
|
||||
- [X] Git [Jeff]: `git add .`
|
||||
- [X] Git [Jeff]: `git commit -m "feat(execute): add changeset model and change capture"`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m1-changeset-core` to `master` with description "Add ChangeSet domain model + change capture hooks for built-in file tools.".
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git branch -d feature/m1-changeset-core`
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
|
||||
- [ ] **COMMIT (Owner: Luis | Group: D0.beta | Branch: feature/m1-apply-pipeline | Planned: Day 9 | Expected: Day 12) - Commit message: "feat(apply): add validation-gated apply pipeline"**
|
||||
- [ ] Git [Luis]: `git checkout master`
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
*** Settings ***
|
||||
Documentation Integration test: file tool writes produce ChangeSet output
|
||||
Library OperatingSystem
|
||||
Library Process
|
||||
Library Collections
|
||||
|
||||
*** Variables ***
|
||||
${PYTHON} python3
|
||||
|
||||
*** Test Cases ***
|
||||
File Write Produces ChangeSet Entry
|
||||
[Documentation] Run a file-write tool via ChangeSetCapture and
|
||||
... verify the resulting ChangeSet contains the entry.
|
||||
${result}= Run Process ${PYTHON} -c
|
||||
... ${CHANGESET_SCRIPT}
|
||||
... stdout=PIPE stderr=PIPE
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} PASS:entries=1
|
||||
Should Contain ${result.stdout} PASS:operation=create
|
||||
|
||||
ChangeSet Summary Counts Match
|
||||
[Documentation] Verify summary counts after multiple operations.
|
||||
${result}= Run Process ${PYTHON} -c
|
||||
... ${SUMMARY_SCRIPT}
|
||||
... stdout=PIPE stderr=PIPE
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} PASS:total=2
|
||||
Should Contain ${result.stdout} PASS:creates=1
|
||||
Should Contain ${result.stdout} PASS:modifies=1
|
||||
|
||||
InMemoryChangeSetStore Round Trip
|
||||
[Documentation] Verify start/record/get via InMemoryChangeSetStore.
|
||||
${result}= Run Process ${PYTHON} -c
|
||||
... ${STORE_SCRIPT}
|
||||
... stdout=PIPE stderr=PIPE
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} PASS:store_get_ok
|
||||
Should Contain ${result.stdout} PASS:entry_count=1
|
||||
|
||||
*** Keywords ***
|
||||
|
||||
*** Variables ***
|
||||
${CHANGESET_SCRIPT} SEPARATOR=\n
|
||||
... import tempfile, os, sys
|
||||
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
|
||||
... from pathlib import Path
|
||||
... from cleveragents.tool.builtins.changeset import ChangeSetCapture
|
||||
... from cleveragents.tool.builtins.file_tools import FILE_WRITE_SPEC
|
||||
... tmpdir = tempfile.mkdtemp()
|
||||
... capture = ChangeSetCapture(plan_id="plan-robot", resource_id="res-r", sandbox_root=tmpdir)
|
||||
... wrapped = capture.wrap_tool(FILE_WRITE_SPEC)
|
||||
... result = wrapped.handler({"path": "hello.txt", "content": "hello world", "sandbox_root": tmpdir})
|
||||
... cs = capture.get_changeset()
|
||||
... print(f"PASS:entries={len(cs.entries)}")
|
||||
... print(f"PASS:operation={cs.entries[0].operation}")
|
||||
|
||||
${SUMMARY_SCRIPT} SEPARATOR=\n
|
||||
... import tempfile, os, sys
|
||||
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
|
||||
... from pathlib import Path
|
||||
... from cleveragents.tool.builtins.changeset import ChangeSetCapture
|
||||
... from cleveragents.tool.builtins.file_tools import FILE_WRITE_SPEC, FILE_EDIT_SPEC
|
||||
... tmpdir = tempfile.mkdtemp()
|
||||
... capture = ChangeSetCapture(plan_id="plan-robot2", resource_id="res-r", sandbox_root=tmpdir)
|
||||
... w = capture.wrap_tool(FILE_WRITE_SPEC)
|
||||
... e = capture.wrap_tool(FILE_EDIT_SPEC)
|
||||
... w.handler({"path": "f.txt", "content": "aaa", "sandbox_root": tmpdir})
|
||||
... e.handler({"path": "f.txt", "old_text": "aaa", "new_text": "bbb", "sandbox_root": tmpdir})
|
||||
... spec = capture.to_spec_changeset()
|
||||
... s = spec.summary()
|
||||
... print(f"PASS:total={s['total']}")
|
||||
... print(f"PASS:creates={s['creates']}")
|
||||
... print(f"PASS:modifies={s['modifies']}")
|
||||
|
||||
${STORE_SCRIPT} SEPARATOR=\n
|
||||
... import os, sys
|
||||
... sys.path.insert(0, os.path.join(os.getcwd(), "src"))
|
||||
... from cleveragents.domain.models.core.change import (
|
||||
... ChangeEntry, ChangeOperation, InMemoryChangeSetStore
|
||||
... )
|
||||
... store = InMemoryChangeSetStore()
|
||||
... cid = store.start("plan-robot-store")
|
||||
... entry = ChangeEntry(
|
||||
... plan_id="plan-robot-store", resource_id="r1",
|
||||
... tool_name="builtin/file-write",
|
||||
... operation=ChangeOperation.CREATE, path="new.py",
|
||||
... )
|
||||
... store.record(cid, entry)
|
||||
... cs = store.get(cid)
|
||||
... if cs is not None:
|
||||
... print("PASS:store_get_ok")
|
||||
... print(f"PASS:entry_count={len(cs.entries)}")
|
||||
... else:
|
||||
... print("FAIL:store_get_returned_none")
|
||||
@@ -8,9 +8,15 @@ from cleveragents.domain.models.core.automation_profile import (
|
||||
)
|
||||
from cleveragents.domain.models.core.change import (
|
||||
Change,
|
||||
ChangeEntry,
|
||||
ChangeOperation,
|
||||
ChangeSet,
|
||||
ChangeSetStore,
|
||||
InMemoryChangeSetStore,
|
||||
LegacyChangeSet,
|
||||
Operation,
|
||||
OperationType,
|
||||
SpecChangeSet,
|
||||
)
|
||||
from cleveragents.domain.models.core.context import (
|
||||
Context,
|
||||
@@ -138,7 +144,10 @@ __all__ = [
|
||||
"AutomationProfile",
|
||||
"BindingMode",
|
||||
"Change",
|
||||
"ChangeEntry",
|
||||
"ChangeOperation",
|
||||
"ChangeSet",
|
||||
"ChangeSetStore",
|
||||
"CheckpointScope",
|
||||
"CloudBillingFields",
|
||||
"Context",
|
||||
@@ -151,8 +160,10 @@ __all__ = [
|
||||
"CreditsTransaction",
|
||||
"CreditsTransactionType",
|
||||
"DebugAttempt",
|
||||
"InMemoryChangeSetStore",
|
||||
"InvariantSource",
|
||||
"Invite",
|
||||
"LegacyChangeSet",
|
||||
"LifecyclePlan",
|
||||
"LinkedResource",
|
||||
"MaxContextCount",
|
||||
@@ -206,6 +217,7 @@ __all__ = [
|
||||
"SkillMcpSource",
|
||||
"SkillResolver",
|
||||
"SkillToolRef",
|
||||
"SpecChangeSet",
|
||||
"SummaryForUpdateContextParams",
|
||||
"TemporalScope",
|
||||
"Tool",
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Change domain model for CleverAgents.
|
||||
|
||||
Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
|
||||
Based on Phase 0 discovery, ADR-004 (Pydantic Validation), and the
|
||||
D0.alpha changeset specification. Contains both the legacy change
|
||||
models (used by existing plan workflows) and the spec-aligned
|
||||
ChangeSet domain model with ULID identifiers and content hashes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from ulid import ULID
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy models (kept for backward compatibility with C0.files)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class OperationType(StrEnum):
|
||||
@@ -62,17 +71,19 @@ class Change(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChangeSet(BaseModel):
|
||||
"""A set of changes for a plan."""
|
||||
class LegacyChangeSet(BaseModel):
|
||||
"""Legacy set of changes for a plan (C0.files era)."""
|
||||
|
||||
plan_id: int = Field(..., gt=0)
|
||||
changes: list[Change] = Field(default_factory=lambda: [])
|
||||
changes: list[Change] = Field(
|
||||
default_factory=lambda: [],
|
||||
)
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
|
||||
@property
|
||||
def stats(self) -> dict[str, int]:
|
||||
"""Get statistics about the changeset."""
|
||||
stats = {
|
||||
stats: dict[str, int] = {
|
||||
"total": len(self.changes),
|
||||
"creates": 0,
|
||||
"modifies": 0,
|
||||
@@ -100,3 +111,237 @@ class ChangeSet(BaseModel):
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
# Backward-compatible alias — existing code imports ``ChangeSet``
|
||||
# from this module with ``plan_id: int``.
|
||||
ChangeSet = LegacyChangeSet
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Spec-aligned ChangeSet domain model (D0.alpha)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_ulid() -> str:
|
||||
"""Generate a new ULID string."""
|
||||
return str(ULID())
|
||||
|
||||
|
||||
class ChangeOperation(StrEnum):
|
||||
"""Type of change operation recorded in a ChangeEntry."""
|
||||
|
||||
CREATE = "create"
|
||||
MODIFY = "modify"
|
||||
DELETE = "delete"
|
||||
RENAME = "rename"
|
||||
|
||||
|
||||
class ChangeEntry(BaseModel):
|
||||
"""A single recorded change from a tool execution.
|
||||
|
||||
Each entry captures exactly one mutation made by a tool during
|
||||
the Execute phase, including content hashes for deterministic
|
||||
diff and rollback.
|
||||
"""
|
||||
|
||||
entry_id: str = Field(
|
||||
default_factory=_new_ulid,
|
||||
description="ULID uniquely identifying this entry",
|
||||
)
|
||||
plan_id: str = Field(
|
||||
...,
|
||||
description="ULID of the plan that owns this change",
|
||||
)
|
||||
resource_id: str = Field(
|
||||
...,
|
||||
description="ULID of the resource affected",
|
||||
)
|
||||
tool_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Namespaced tool name that caused the change",
|
||||
)
|
||||
operation: ChangeOperation = Field(
|
||||
...,
|
||||
description="Type of change operation",
|
||||
)
|
||||
path: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Repo-relative file path",
|
||||
)
|
||||
before_hash: str | None = Field(
|
||||
default=None,
|
||||
description="SHA-256 of file content before change",
|
||||
)
|
||||
after_hash: str | None = Field(
|
||||
default=None,
|
||||
description="SHA-256 of file content after change",
|
||||
)
|
||||
before_mode: int | None = Field(
|
||||
default=None,
|
||||
description="File mode before change",
|
||||
)
|
||||
after_mode: int | None = Field(
|
||||
default=None,
|
||||
description="File mode after change",
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description="UTC timestamp of the change",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
use_enum_values=True,
|
||||
)
|
||||
|
||||
|
||||
class SpecChangeSet(BaseModel):
|
||||
"""Accumulated set of ChangeEntry records for a plan.
|
||||
|
||||
This is the spec-aligned ChangeSet model introduced in D0.alpha.
|
||||
It uses ULID identifiers and provides computed summary properties.
|
||||
"""
|
||||
|
||||
changeset_id: str = Field(
|
||||
default_factory=_new_ulid,
|
||||
description="ULID uniquely identifying this changeset",
|
||||
)
|
||||
plan_id: str = Field(
|
||||
...,
|
||||
description="ULID of the plan",
|
||||
)
|
||||
entries: list[ChangeEntry] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered list of recorded change entries",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description="UTC timestamp when the changeset was created",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
# -- computed summary properties ---------------------------------
|
||||
|
||||
@property
|
||||
def creates(self) -> int:
|
||||
"""Count of CREATE entries."""
|
||||
return sum(1 for e in self.entries if e.operation == ChangeOperation.CREATE)
|
||||
|
||||
@property
|
||||
def modifies(self) -> int:
|
||||
"""Count of MODIFY entries."""
|
||||
return sum(1 for e in self.entries if e.operation == ChangeOperation.MODIFY)
|
||||
|
||||
@property
|
||||
def deletes(self) -> int:
|
||||
"""Count of DELETE entries."""
|
||||
return sum(1 for e in self.entries if e.operation == ChangeOperation.DELETE)
|
||||
|
||||
@property
|
||||
def renames(self) -> int:
|
||||
"""Count of RENAME entries."""
|
||||
return sum(1 for e in self.entries if e.operation == ChangeOperation.RENAME)
|
||||
|
||||
@property
|
||||
def paths_changed(self) -> set[str]:
|
||||
"""Unique set of file paths affected."""
|
||||
return {e.path for e in self.entries}
|
||||
|
||||
@property
|
||||
def resources_involved(self) -> set[str]:
|
||||
"""Unique set of resource IDs involved."""
|
||||
return {e.resource_id for e in self.entries}
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
"""Return summary counts as a dict."""
|
||||
return {
|
||||
"total": len(self.entries),
|
||||
"creates": self.creates,
|
||||
"modifies": self.modifies,
|
||||
"deletes": self.deletes,
|
||||
"renames": self.renames,
|
||||
"paths_changed": len(self.paths_changed),
|
||||
"resources_involved": len(self.resources_involved),
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ChangeSetStore protocol and in-memory implementation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChangeSetStore(Protocol):
|
||||
"""Interface for persisting and querying ChangeSet objects."""
|
||||
|
||||
def start(self, plan_id: str) -> str:
|
||||
"""Create a new empty ChangeSet for *plan_id*.
|
||||
|
||||
Returns the changeset_id (ULID).
|
||||
"""
|
||||
...
|
||||
|
||||
def record(self, changeset_id: str, entry: ChangeEntry) -> None:
|
||||
"""Append *entry* to the changeset identified by
|
||||
*changeset_id*.
|
||||
"""
|
||||
...
|
||||
|
||||
def get(self, changeset_id: str) -> SpecChangeSet | None:
|
||||
"""Retrieve a ChangeSet by its ID, or ``None``."""
|
||||
...
|
||||
|
||||
def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]:
|
||||
"""Return all ChangeSets associated with *plan_id*."""
|
||||
...
|
||||
|
||||
def summarize(self, changeset_id: str) -> dict[str, Any]:
|
||||
"""Return summary counts for a changeset."""
|
||||
...
|
||||
|
||||
|
||||
class InMemoryChangeSetStore:
|
||||
"""In-memory ChangeSetStore for M1 milestone.
|
||||
|
||||
Stores changeset data in a plain dict keyed by changeset_id.
|
||||
Not suitable for production — intended for tests and the M1
|
||||
single-process runtime.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._store: dict[str, SpecChangeSet] = {}
|
||||
|
||||
def start(self, plan_id: str) -> str:
|
||||
"""Create a new empty ChangeSet and return its ID."""
|
||||
cs = SpecChangeSet(plan_id=plan_id)
|
||||
self._store[cs.changeset_id] = cs
|
||||
return cs.changeset_id
|
||||
|
||||
def record(self, changeset_id: str, entry: ChangeEntry) -> None:
|
||||
"""Append an entry to the identified changeset."""
|
||||
cs = self._store.get(changeset_id)
|
||||
if cs is None:
|
||||
raise KeyError(f"ChangeSet '{changeset_id}' not found")
|
||||
cs.entries.append(entry)
|
||||
|
||||
def get(self, changeset_id: str) -> SpecChangeSet | None:
|
||||
"""Return a changeset by ID or ``None``."""
|
||||
return self._store.get(changeset_id)
|
||||
|
||||
def get_for_plan(self, plan_id: str) -> list[SpecChangeSet]:
|
||||
"""Return all changesets for a plan."""
|
||||
return [cs for cs in self._store.values() if cs.plan_id == plan_id]
|
||||
|
||||
def summarize(self, changeset_id: str) -> dict[str, Any]:
|
||||
"""Return summary counts for a changeset."""
|
||||
cs = self._store.get(changeset_id)
|
||||
if cs is None:
|
||||
return {}
|
||||
return cs.summary()
|
||||
|
||||
@@ -34,7 +34,11 @@ def register_file_tools_with_changeset(
|
||||
registry: ToolRegistry,
|
||||
capture: ChangeSetCapture,
|
||||
) -> None:
|
||||
"""Register wrapped versions of all file tools that record changes."""
|
||||
"""Register wrapped versions of file tools that record changes.
|
||||
|
||||
Accepts optional *resource_id* and *sandbox_root* via the
|
||||
``ChangeSetCapture`` constructor.
|
||||
"""
|
||||
for spec in ALL_FILE_TOOLS:
|
||||
wrapped = capture.wrap_tool(spec)
|
||||
registry.register(wrapped)
|
||||
|
||||
@@ -3,42 +3,70 @@
|
||||
Wraps tool execution to automatically record change entries for
|
||||
write/edit/delete operations. Provides ``ChangeSetCapture`` which
|
||||
can wrap any ``ToolSpec`` and maintain a running log of mutations.
|
||||
|
||||
The capture layer bridges the execution-layer ``ToolSpec`` handlers
|
||||
and the domain-layer ``SpecChangeSet`` / ``ChangeEntry`` models
|
||||
defined in ``cleveragents.domain.models.core.change``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from cleveragents.domain.models.core.change import (
|
||||
ChangeEntry,
|
||||
ChangeOperation,
|
||||
SpecChangeSet,
|
||||
)
|
||||
from cleveragents.tool.runtime import ToolSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# Legacy lightweight models (kept for backward compat)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChangeSetEntry(BaseModel):
|
||||
"""A single recorded change from a tool execution."""
|
||||
"""A single recorded change from a tool execution.
|
||||
|
||||
This is the lightweight execution-layer entry. For the full
|
||||
domain model see
|
||||
:class:`~cleveragents.domain.models.core.change.ChangeEntry`.
|
||||
"""
|
||||
|
||||
operation: str = Field(
|
||||
...,
|
||||
description="Type of change: create, modify, delete, or move",
|
||||
description=("Type of change: create, modify, delete, or move"),
|
||||
)
|
||||
path: str = Field(..., description="Path of the affected file")
|
||||
resource_id: str | None = Field(
|
||||
default=None, description="Optional resource identifier"
|
||||
default=None,
|
||||
description="Optional resource identifier",
|
||||
)
|
||||
tool_name: str | None = Field(
|
||||
default=None,
|
||||
description="Namespaced tool that caused the change",
|
||||
)
|
||||
before_hash: str | None = Field(
|
||||
default=None, description="SHA-256 hash of file before change"
|
||||
default=None,
|
||||
description="SHA-256 hash of file before change",
|
||||
)
|
||||
after_hash: str | None = Field(
|
||||
default=None, description="SHA-256 hash of file after change"
|
||||
default=None,
|
||||
description="SHA-256 hash of file after change",
|
||||
)
|
||||
timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
description="UTC timestamp of the change",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict, description="Additional metadata"
|
||||
default_factory=dict,
|
||||
description="Additional metadata",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
@@ -48,11 +76,16 @@ class ChangeSetEntry(BaseModel):
|
||||
|
||||
|
||||
class ChangeSet(BaseModel):
|
||||
"""Accumulated set of changes from tool executions."""
|
||||
"""Accumulated set of changes from tool executions.
|
||||
|
||||
Lightweight execution-layer container. The full domain model
|
||||
is :class:`~cleveragents.domain.models.core.change.SpecChangeSet`.
|
||||
"""
|
||||
|
||||
plan_id: str = Field(..., description="Plan identifier for this changeset")
|
||||
entries: list[ChangeSetEntry] = Field(
|
||||
default_factory=list, description="List of recorded changes"
|
||||
default_factory=list,
|
||||
description="List of recorded changes",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(UTC),
|
||||
@@ -77,15 +110,13 @@ class ChangeSet(BaseModel):
|
||||
return f"{len(self.entries)} changes: {', '.join(parts)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Capture
|
||||
# ---------------------------------------------------------------------------
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def _file_hash(path_str: str, sandbox_root: str | None = None) -> str | None:
|
||||
"""Compute SHA-256 hash of a file, or None if it doesn't exist."""
|
||||
from pathlib import Path
|
||||
|
||||
"""Compute SHA-256 hash of a file, or None if missing."""
|
||||
root = Path(sandbox_root) if sandbox_root else Path.cwd()
|
||||
p = (root / path_str).resolve()
|
||||
if not p.exists():
|
||||
@@ -93,6 +124,30 @@ def _file_hash(path_str: str, sandbox_root: str | None = None) -> str | None:
|
||||
return hashlib.sha256(p.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _normalize_path(path_str: str, sandbox_root: str | None = None) -> str:
|
||||
"""Normalize a file path to be repo-relative.
|
||||
|
||||
If *sandbox_root* is provided the path is made relative to it.
|
||||
Otherwise the original path is returned unchanged.
|
||||
"""
|
||||
if not path_str:
|
||||
return path_str
|
||||
if sandbox_root:
|
||||
try:
|
||||
return os.path.relpath(
|
||||
(Path(sandbox_root) / path_str).resolve(),
|
||||
Path(sandbox_root).resolve(),
|
||||
)
|
||||
except ValueError:
|
||||
return path_str
|
||||
return path_str
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capture wrapper
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
class ChangeSetCapture:
|
||||
"""Wraps tool execution to record change entries.
|
||||
|
||||
@@ -104,15 +159,22 @@ class ChangeSetCapture:
|
||||
changeset = capture.get_changeset()
|
||||
"""
|
||||
|
||||
def __init__(self, plan_id: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
plan_id: str,
|
||||
resource_id: str | None = None,
|
||||
sandbox_root: str | None = None,
|
||||
) -> None:
|
||||
self._plan_id = plan_id
|
||||
self._resource_id = resource_id or ""
|
||||
self._sandbox_root = sandbox_root
|
||||
self._entries: list[ChangeSetEntry] = []
|
||||
|
||||
def wrap_tool(self, tool_spec: ToolSpec) -> ToolSpec:
|
||||
"""Return a new ToolSpec whose handler records changes.
|
||||
|
||||
Only wraps tools that have ``writes=True`` in their capabilities.
|
||||
Read-only tools are returned unchanged.
|
||||
Only wraps tools that have ``writes=True`` in their
|
||||
capabilities. Read-only tools are returned unchanged.
|
||||
"""
|
||||
if not tool_spec.capabilities.writes:
|
||||
return tool_spec
|
||||
@@ -120,9 +182,11 @@ class ChangeSetCapture:
|
||||
original_handler = tool_spec.handler
|
||||
capture = self
|
||||
|
||||
def _wrapped_handler(inputs: dict[str, Any]) -> Any:
|
||||
def _wrapped_handler(
|
||||
inputs: dict[str, Any],
|
||||
) -> Any:
|
||||
path_str = inputs.get("path", "")
|
||||
sandbox = inputs.get("sandbox_root")
|
||||
sandbox = inputs.get("sandbox_root") or capture._sandbox_root
|
||||
before = _file_hash(path_str, sandbox) if path_str else None
|
||||
|
||||
result = original_handler(inputs)
|
||||
@@ -131,10 +195,14 @@ class ChangeSetCapture:
|
||||
output = result if isinstance(result, dict) else {}
|
||||
|
||||
operation = _detect_operation(tool_spec.name, before, after, output)
|
||||
resource_id = inputs.get("resource_id") or capture._resource_id
|
||||
normalized = _normalize_path(path_str, sandbox)
|
||||
|
||||
entry = ChangeSetEntry(
|
||||
operation=operation,
|
||||
path=path_str,
|
||||
path=normalized,
|
||||
resource_id=resource_id or None,
|
||||
tool_name=tool_spec.name,
|
||||
before_hash=before,
|
||||
after_hash=after,
|
||||
metadata={"tool": tool_spec.name},
|
||||
@@ -159,11 +227,49 @@ class ChangeSetCapture:
|
||||
entries=list(self._entries),
|
||||
)
|
||||
|
||||
def to_spec_changeset(self) -> SpecChangeSet:
|
||||
"""Convert accumulated changes to spec-aligned model.
|
||||
|
||||
Builds domain-layer ``ChangeEntry`` objects from the
|
||||
lightweight ``ChangeSetEntry`` records.
|
||||
"""
|
||||
domain_entries: list[ChangeEntry] = []
|
||||
for e in self._entries:
|
||||
op = _map_operation(e.operation)
|
||||
domain_entries.append(
|
||||
ChangeEntry(
|
||||
plan_id=self._plan_id,
|
||||
resource_id=e.resource_id or "",
|
||||
tool_name=e.tool_name or "",
|
||||
operation=op,
|
||||
path=e.path,
|
||||
before_hash=e.before_hash,
|
||||
after_hash=e.after_hash,
|
||||
timestamp=e.timestamp,
|
||||
)
|
||||
)
|
||||
return SpecChangeSet(
|
||||
plan_id=self._plan_id,
|
||||
entries=domain_entries,
|
||||
)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Reset the change tracking."""
|
||||
self._entries.clear()
|
||||
|
||||
|
||||
def _map_operation(op_str: str) -> ChangeOperation:
|
||||
"""Map a string operation to the domain enum."""
|
||||
mapping: dict[str, ChangeOperation] = {
|
||||
"create": ChangeOperation.CREATE,
|
||||
"modify": ChangeOperation.MODIFY,
|
||||
"delete": ChangeOperation.DELETE,
|
||||
"move": ChangeOperation.RENAME,
|
||||
"rename": ChangeOperation.RENAME,
|
||||
}
|
||||
return mapping.get(op_str, ChangeOperation.MODIFY)
|
||||
|
||||
|
||||
def _detect_operation(
|
||||
tool_name: str,
|
||||
before: str | None,
|
||||
|
||||
Reference in New Issue
Block a user