feat(execute): add changeset model and change capture

This commit is contained in:
2026-02-17 09:01:17 +00:00
parent 05921903f0
commit 71af9ccb83
12 changed files with 1437 additions and 49 deletions
+126
View File
@@ -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()
-1
View File
@@ -7,7 +7,6 @@ negligible overhead to instantiation paths.
from __future__ import annotations
import warnings
from typing import Any
from unittest.mock import MagicMock
+1 -1
View File
@@ -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 (
+163
View File
@@ -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))
```
+124
View File
@@ -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
+509
View File
@@ -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
View File
@@ -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`
+100
View File
@@ -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",
+251 -6
View File
@@ -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()
+5 -1
View File
@@ -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)
+129 -23
View File
@@ -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,