feat(sandbox): add git_worktree and copy_on_write sandbox strategies [TASK-006] #52

Merged
freemo merged 3 commits from feat/B4-v2-sandbox into master 2026-02-14 04:01:42 +00:00
12 changed files with 2261 additions and 149 deletions
+141
View File
@@ -0,0 +1,141 @@
Feature: Copy-on-write sandbox lifecycle
As a developer
I want a sandbox that isolates plan changes in a directory copy
So that the original directory is unmodified until changes are committed
Background:
Given a cow test directory is initialised
# --- Creation ---
Scenario: Create a copy-on-write sandbox
When a cow sandbox is created for plan "plan-001"
Then the cow sandbox should be in the "created" state
And the cow sandbox context should reference plan "plan-001"
And the cow sandbox context should have strategy metadata "copy_on_write"
And the cow sandbox path should exist
Scenario: Creating a sandbox with empty plan_id raises ValueError
When a cow sandbox is created with empty plan_id
Then a cow ValueError should be raised with message "plan_id cannot be empty"
Scenario: Creating a sandbox with empty resource_id raises ValueError
When a cow sandbox is prepared with empty resource_id
Then a cow ValueError should be raised with message "resource_id cannot be empty"
Scenario: Creating a sandbox with empty original_path raises ValueError
When a cow sandbox is prepared with empty original_path
Then a cow ValueError should be raised with message "original_path cannot be empty"
Scenario: Creating a sandbox on a non-existent directory raises SandboxCreationError
Given a cow non-existent directory
When a cow sandbox is created on the non-existent directory for plan "plan-001"
Then a cow SandboxCreationError should be raised
# --- Path resolution ---
Scenario: Resolve a path in the sandbox copy
When a cow sandbox is created for plan "plan-001"
And the cow path "data/file.txt" is resolved
Then the cow resolved path should be inside the sandbox copy
And the cow sandbox should be in the "active" state
Scenario: Path traversal is rejected
When a cow sandbox is created for plan "plan-001"
And the cow path "../etc/passwd" is resolved
Then a cow ValueError should be raised with message "Path traversal not allowed"
Scenario: Resolving a path on a cleaned-up sandbox raises SandboxStateError
When a cow sandbox is created for plan "plan-001"
And the cow sandbox is cleaned up
And the cow path "file.txt" is resolved on a cleaned-up sandbox
Then a cow SandboxStateError should be raised
# --- Commit ---
Scenario: Commit with no changes produces empty result
When a cow sandbox is created for plan "plan-001"
And the cow sandbox is committed
Then the cow commit result should indicate success
And the cow commit result should have 0 changed files
And the cow commit result should have 0 added files
And the cow commit result should have 0 deleted files
Scenario: Commit with a new file syncs it to original
When a cow sandbox is created for plan "plan-001"
And a cow file "new_file.txt" is created in the sandbox with content "hello world"
And the cow sandbox is committed
Then the cow commit result should indicate success
And the cow commit result should have 1 added files
And the cow file "new_file.txt" should exist in the original directory with content "hello world"
Scenario: Commit with a modified file syncs the change
When a cow sandbox is created for plan "plan-001"
And the cow existing file "existing.txt" is modified in the sandbox with content "updated"
And the cow sandbox is committed
Then the cow commit result should indicate success
And the cow commit result should have 1 changed files
And the cow file "existing.txt" in the original should have content "updated"
Scenario: Commit with a deleted file removes it from original
When a cow sandbox is created for plan "plan-001"
And the cow existing file "existing.txt" is deleted from the sandbox
And the cow sandbox is committed
Then the cow commit result should indicate success
And the cow commit result should have 1 deleted files
And the cow file "existing.txt" should not exist in the original directory
Scenario: Commit on a cleaned-up sandbox raises SandboxStateError
When a cow sandbox is created for plan "plan-001"
And the cow sandbox is cleaned up
And the cow sandbox commit is attempted on cleaned-up sandbox
Then a cow SandboxStateError should be raised
# --- Rollback ---
Scenario: Rollback restores the sandbox to original state
When a cow sandbox is created for plan "plan-001"
And a cow file "temp.txt" is created in the sandbox with content "temporary"
And the cow path "temp.txt" is resolved
And the cow sandbox is rolled back
Then the cow sandbox should be in the "rolled_back" state
And the cow file "temp.txt" should not exist in the sandbox
Scenario: Rollback on a non-active sandbox raises SandboxStateError
When a cow sandbox is created for plan "plan-001"
And the cow sandbox rollback is attempted on created sandbox
Then a cow SandboxStateError should be raised
# --- Cleanup ---
Scenario: Cleanup removes the sandbox directory
When a cow sandbox is created for plan "plan-001"
And the cow sandbox is cleaned up
Then the cow sandbox should be in the "cleaned_up" state
And the cow sandbox path should not exist
Scenario: Cleanup is idempotent
When a cow sandbox is created for plan "plan-001"
And the cow sandbox is cleaned up
And the cow sandbox is cleaned up again
Then the cow sandbox should be in the "cleaned_up" state
# --- Protocol properties ---
Scenario: Sandbox has a unique ULID identifier
When a cow sandbox is instantiated
Then the cow sandbox_id should be a valid ULID
And the cow sandbox status should be "pending"
And the cow sandbox context should be None
# --- Diff computation ---
Scenario: Diff detects added, modified, and deleted files
When a cow sandbox is created for plan "plan-001"
And a cow file "added.txt" is created in the sandbox with content "new"
And the cow existing file "existing.txt" is modified in the sandbox with content "changed"
And the cow existing file "to_delete.txt" is deleted from the sandbox
And the cow sandbox is committed
Then the cow commit result should have 1 added files
And the cow commit result should have 1 changed files
And the cow commit result should have 1 deleted files
+129
View File
@@ -0,0 +1,129 @@
Feature: Git worktree sandbox lifecycle
As a developer
I want a sandbox that isolates plan changes in a git worktree
So that the original repository is unmodified until changes are committed
Background:
Given a gwt test git repository is initialised
# --- Creation ---
Scenario: Create a git worktree sandbox
When a gwt sandbox is created for plan "plan-001"
Then the gwt sandbox should be in the "created" state
And the gwt sandbox context should reference plan "plan-001"
And the gwt sandbox context should have strategy metadata "git_worktree"
And the gwt sandbox worktree path should exist
Scenario: Creating a sandbox with empty plan_id raises ValueError
When a gwt sandbox is created with empty plan_id
Then a gwt ValueError should be raised with message "plan_id cannot be empty"
Scenario: Creating a sandbox with empty resource_id raises ValueError
When a gwt sandbox is prepared with empty resource_id
Then a gwt ValueError should be raised with message "resource_id cannot be empty"
Scenario: Creating a sandbox with empty original_path raises ValueError
When a gwt sandbox is prepared with empty original_path
Then a gwt ValueError should be raised with message "original_path cannot be empty"
Scenario: Creating a sandbox with non-positive timeout raises ValueError
When a gwt sandbox is prepared with zero timeout
Then a gwt ValueError should be raised with message "git_timeout must be positive"
Scenario: Creating a sandbox on a non-git directory raises SandboxCreationError
Given a gwt non-git directory
When a gwt sandbox is created on the non-git directory for plan "plan-001"
Then a gwt SandboxCreationError should be raised
# --- Path resolution ---
Scenario: Resolve a path in the worktree
When a gwt sandbox is created for plan "plan-001"
And the gwt path "src/main.py" is resolved
Then the gwt resolved path should be inside the worktree
And the gwt sandbox should be in the "active" state
Scenario: Path traversal is rejected
When a gwt sandbox is created for plan "plan-001"
And the gwt path "../etc/passwd" is resolved
Then a gwt ValueError should be raised with message "Path traversal not allowed"
Scenario: Resolving a path on a cleaned-up sandbox raises SandboxStateError
When a gwt sandbox is created for plan "plan-001"
And the gwt sandbox is cleaned up
And the gwt path "file.txt" is resolved on a cleaned-up sandbox
Then a gwt SandboxStateError should be raised
# --- Commit ---
Scenario: Commit with no changes produces empty result
When a gwt sandbox is created for plan "plan-001"
And the gwt sandbox is committed with message "empty commit"
Then the gwt commit result should indicate success
And the gwt commit result should have 0 changed files
Scenario: Commit with changes merges back to original branch
When a gwt sandbox is created for plan "plan-001"
And a gwt file "new_file.txt" is created in the worktree with content "hello"
And the gwt sandbox is committed with message "add new file"
Then the gwt commit result should indicate success
And the gwt commit result should have 1 added files
And the gwt file "new_file.txt" should exist in the original repo
Scenario: Commit with modified file reports changes
When a gwt sandbox is created for plan "plan-001"
And the gwt existing file "README.md" is modified in the worktree
And the gwt sandbox is committed with message "modify readme"
Then the gwt commit result should indicate success
And the gwt commit result should have 1 changed files
Scenario: Commit on a cleaned-up sandbox raises SandboxStateError
When a gwt sandbox is created for plan "plan-001"
And the gwt sandbox is cleaned up
And the gwt sandbox commit is attempted on cleaned-up sandbox
Then a gwt SandboxStateError should be raised
# --- Rollback ---
Scenario: Rollback discards worktree changes
When a gwt sandbox is created for plan "plan-001"
And a gwt file "temp.txt" is created in the worktree with content "temporary"
And the gwt path "temp.txt" is resolved
And the gwt sandbox is rolled back
Then the gwt sandbox should be in the "rolled_back" state
And the gwt file "temp.txt" should not exist in the worktree
Scenario: Rollback on a non-active sandbox raises SandboxStateError
When a gwt sandbox is created for plan "plan-001"
And the gwt sandbox rollback is attempted on created sandbox
Then a gwt SandboxStateError should be raised
# --- Cleanup ---
Scenario: Cleanup removes worktree and branch
When a gwt sandbox is created for plan "plan-001"
And the gwt sandbox is cleaned up
Then the gwt sandbox should be in the "cleaned_up" state
And the gwt sandbox worktree path should not exist
And the gwt sandbox branch should not exist
Scenario: Cleanup is idempotent
When a gwt sandbox is created for plan "plan-001"
And the gwt sandbox is cleaned up
And the gwt sandbox is cleaned up again
Then the gwt sandbox should be in the "cleaned_up" state
# --- Branch sanitisation ---
Scenario: Branch name is sanitised from plan ID
When a gwt sandbox is created for plan "plan with spaces!@#"
Then the gwt sandbox branch name should be safe for git
# --- Protocol properties ---
Scenario: Sandbox has a unique ULID identifier
When a gwt sandbox is instantiated
Then the gwt sandbox_id should be a valid ULID
And the gwt sandbox status should be "pending"
And the gwt sandbox context should be None
+43 -34
View File
@@ -24,31 +24,30 @@ Feature: Sandbox factory strategy routing
Then the factory should produce a passthrough sandbox
And the produced sandbox should be in the pending state
# --- Unimplemented strategies ---
# --- Implemented strategies ---
Scenario: The git worktree strategy is not yet available
Scenario: The git worktree strategy produces a git worktree sandbox
Given the sandbox factory is available
When a sandbox is requested for resource "repo-1" at "/src/repo" using the git worktree strategy
Then the factory should indicate the strategy is not yet implemented
Then the factory should produce a git worktree sandbox
And the produced git worktree sandbox should be in the pending state
Scenario: The copy-on-write strategy is not yet available
Scenario: The copy-on-write strategy produces a copy-on-write sandbox
Given the sandbox factory is available
When a sandbox is requested for resource "docs" at "/srv/docs" using the copy-on-write strategy
Then the factory should indicate the strategy is not yet implemented
Then the factory should produce a copy-on-write sandbox
And the produced copy-on-write sandbox should be in the pending state
Scenario: The overlay strategy is not yet available
Given the sandbox factory is available
When a sandbox is requested for resource "fs-root" at "/mnt/data" using the overlay strategy
Then the factory should indicate the strategy is not yet implemented
# --- Unimplemented strategies ---
Scenario: The transaction rollback strategy is not yet available
Given the sandbox factory is available
When a sandbox is requested for resource "db-main" at "postgres://db/main" using the transaction rollback strategy
Then the factory should indicate the strategy is not yet implemented
Scenario: The versioning strategy is not yet available
Scenario: The snapshot strategy is not yet available
Given the sandbox factory is available
When a sandbox is requested for resource "corpus" at "/srv/corpus" using the versioning strategy
When a sandbox is requested for resource "corpus" at "/srv/corpus" using the snapshot strategy
Then the factory should indicate the strategy is not yet implemented
# --- Unknown strategy ---
@@ -58,6 +57,16 @@ Feature: Sandbox factory strategy routing
When a sandbox is requested for resource "misc" at "/tmp/misc" using an unrecognised strategy
Then the factory should reject the request due to an unknown strategy
Scenario: The overlay strategy is now unknown and rejected
Given the sandbox factory is available
When a sandbox is requested for resource "fs-root" at "/mnt/data" using the overlay strategy
Then the factory should reject the request due to an unknown strategy
Scenario: The versioning strategy is now unknown and rejected
Given the sandbox factory is available
When a sandbox is requested for resource "corpus" at "/srv/corpus" using the versioning strategy
Then the factory should reject the request due to an unknown strategy
# --- Support checks ---
Scenario: The none strategy is reported as supported
@@ -65,51 +74,51 @@ Feature: Sandbox factory strategy routing
When the factory is asked whether the none strategy is supported
Then the factory should confirm the strategy is supported
Scenario: The git worktree strategy is reported as unsupported
Scenario: The git worktree strategy is reported as supported
Given the sandbox factory is available
When the factory is asked whether the git worktree strategy is supported
Then the factory should indicate the strategy is not supported
Then the factory should confirm the strategy is supported
Scenario: The copy-on-write strategy is reported as unsupported
Scenario: The copy-on-write strategy is reported as supported
Given the sandbox factory is available
When the factory is asked whether the copy-on-write strategy is supported
Then the factory should indicate the strategy is not supported
Then the factory should confirm the strategy is supported
Scenario: An unrecognised strategy is reported as unsupported
Given the sandbox factory is available
When the factory is asked whether an unrecognised strategy is supported
Then the factory should indicate the strategy is not supported
# --- Resource type strategy lookup ---
# --- Resource type strategy lookup (spec-aligned types) ---
Scenario: A git repository supports worktree, copy-on-write, and none strategies
Scenario: A git-checkout resource supports worktree, copy-on-write, and none
Given the sandbox factory is available
When the compatible strategies for a "git_repository" resource are queried
When the compatible strategies for a "git-checkout" resource are queried
Then the factory should return git worktree, copy-on-write, and none as compatible
Scenario: A filesystem resource supports copy-on-write, overlay, and none strategies
Scenario: A git resource supports only the none strategy
Given the sandbox factory is available
When the compatible strategies for a "filesystem" resource are queried
Then the factory should return copy-on-write, overlay, and none as compatible
Scenario: A database resource supports transaction rollback and none strategies
Given the sandbox factory is available
When the compatible strategies for a "database" resource are queried
Then the factory should return transaction rollback and none as compatible
Scenario: An API endpoint only supports the none strategy
Given the sandbox factory is available
When the compatible strategies for an "api_endpoint" resource are queried
When the compatible strategies for a "git" resource are queried
Then the factory should return only the none strategy as compatible
Scenario: A document corpus supports copy-on-write and none strategies
Scenario: A fs-mount resource supports copy-on-write and none
Given the sandbox factory is available
When the compatible strategies for a "document_corpus" resource are queried
When the compatible strategies for a "fs-mount" resource are queried
Then the factory should return copy-on-write and none as compatible
Scenario: Cloud infrastructure only supports the none strategy
Scenario: A fs-directory resource supports copy-on-write and none
Given the sandbox factory is available
When the compatible strategies for a "cloud_infrastructure" resource are queried
When the compatible strategies for a "fs-directory" resource are queried
Then the factory should return copy-on-write and none as compatible
Scenario: A fs-file resource supports copy-on-write and none
Given the sandbox factory is available
When the compatible strategies for a "fs-file" resource are queried
Then the factory should return copy-on-write and none as compatible
Scenario: An api_endpoint resource supports only the none strategy
Given the sandbox factory is available
When the compatible strategies for an "api_endpoint" resource are queried
Then the factory should return only the none strategy as compatible
Scenario: An unknown resource type falls back to the none strategy
@@ -0,0 +1,391 @@
"""Step definitions for copy-on-write sandbox feature.
All steps use the ``cow`` prefix to avoid collisions with the 128+ existing
step files loaded globally by Behave.
"""
from __future__ import annotations
import os
import tempfile
from behave import given, then, when
from behave.runner import Context
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
from cleveragents.infrastructure.sandbox.protocol import (
SandboxCreationError,
SandboxStateError,
SandboxStatus,
)
def _init_test_dir(ctx: Context) -> str:
"""Create a temporary directory with some test files."""
test_dir = tempfile.mkdtemp(prefix="cow-test-dir-")
# Create some initial files
with open(os.path.join(test_dir, "existing.txt"), "w") as f:
f.write("original content")
with open(os.path.join(test_dir, "to_delete.txt"), "w") as f:
f.write("will be deleted")
os.makedirs(os.path.join(test_dir, "data"), exist_ok=True)
with open(os.path.join(test_dir, "data", "file.txt"), "w") as f:
f.write("nested file")
return test_dir
# ---------------------------------------------------------------------------
# Background / Given
# ---------------------------------------------------------------------------
@given("a cow test directory is initialised")
def step_cow_init_dir(ctx: Context) -> None:
ctx.cow_test_dir = _init_test_dir(ctx)
ctx.cow_sandbox = None
ctx.cow_error = None
ctx.cow_commit_result = None
ctx.cow_resolved_path = None
@given("a cow non-existent directory")
def step_cow_nonexistent_dir(ctx: Context) -> None:
ctx.cow_nonexistent_dir = os.path.join(
tempfile.gettempdir(), "cow-nonexistent-" + str(os.getpid())
)
# Make sure it doesn't exist
if os.path.exists(ctx.cow_nonexistent_dir):
os.rmdir(ctx.cow_nonexistent_dir)
# ---------------------------------------------------------------------------
# When - instantiation
# ---------------------------------------------------------------------------
@when("a cow sandbox is instantiated")
def step_cow_instantiate(ctx: Context) -> None:
ctx.cow_sandbox = CopyOnWriteSandbox(
resource_id="res-001",
original_path=ctx.cow_test_dir,
)
@when("a cow sandbox is prepared with empty resource_id")
def step_cow_empty_resource_id(ctx: Context) -> None:
try:
CopyOnWriteSandbox(resource_id="", original_path=ctx.cow_test_dir)
except ValueError as exc:
ctx.cow_error = exc
@when("a cow sandbox is prepared with empty original_path")
def step_cow_empty_original_path(ctx: Context) -> None:
try:
CopyOnWriteSandbox(resource_id="res-001", original_path="")
except ValueError as exc:
ctx.cow_error = exc
# ---------------------------------------------------------------------------
# When - create
# ---------------------------------------------------------------------------
@when('a cow sandbox is created for plan "{plan_id}"')
def step_cow_create(ctx: Context, plan_id: str) -> None:
ctx.cow_sandbox = CopyOnWriteSandbox(
resource_id="res-001",
original_path=ctx.cow_test_dir,
)
ctx.cow_sandbox.create(plan_id)
@when("a cow sandbox is created with empty plan_id")
def step_cow_create_empty_plan(ctx: Context) -> None:
ctx.cow_sandbox = CopyOnWriteSandbox(
resource_id="res-001",
original_path=ctx.cow_test_dir,
)
try:
ctx.cow_sandbox.create("")
except ValueError as exc:
ctx.cow_error = exc
@when('a cow sandbox is created on the non-existent directory for plan "{plan_id}"')
def step_cow_create_nonexistent(ctx: Context, plan_id: str) -> None:
ctx.cow_sandbox = CopyOnWriteSandbox(
resource_id="res-001",
original_path=ctx.cow_nonexistent_dir,
)
try:
ctx.cow_sandbox.create(plan_id)
except SandboxCreationError as exc:
ctx.cow_error = exc
# ---------------------------------------------------------------------------
# When - path resolution
# ---------------------------------------------------------------------------
@when('the cow path "{path}" is resolved')
def step_cow_resolve_path(ctx: Context, path: str) -> None:
try:
ctx.cow_resolved_path = ctx.cow_sandbox.get_path(path)
except (ValueError, SandboxStateError) as exc:
ctx.cow_error = exc
@when('the cow path "{path}" is resolved on a cleaned-up sandbox')
def step_cow_resolve_path_cleaned(ctx: Context, path: str) -> None:
try:
ctx.cow_sandbox.get_path(path)
except SandboxStateError as exc:
ctx.cow_error = exc
# ---------------------------------------------------------------------------
# When - file operations in sandbox copy
# ---------------------------------------------------------------------------
@when('a cow file "{filename}" is created in the sandbox with content "{content}"')
def step_cow_create_file(ctx: Context, filename: str, content: str) -> None:
sandbox_path = ctx.cow_sandbox.context.sandbox_path
file_path = os.path.join(sandbox_path, filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w") as f:
f.write(content)
@when(
'the cow existing file "{filename}" is modified in the sandbox with content "{content}"'
)
def step_cow_modify_file(ctx: Context, filename: str, content: str) -> None:
sandbox_path = ctx.cow_sandbox.context.sandbox_path
file_path = os.path.join(sandbox_path, filename)
with open(file_path, "w") as f:
f.write(content)
@when('the cow existing file "{filename}" is deleted from the sandbox')
def step_cow_delete_file(ctx: Context, filename: str) -> None:
sandbox_path = ctx.cow_sandbox.context.sandbox_path
file_path = os.path.join(sandbox_path, filename)
os.remove(file_path)
# ---------------------------------------------------------------------------
# When - commit / rollback / cleanup
# ---------------------------------------------------------------------------
@when("the cow sandbox is committed")
def step_cow_commit(ctx: Context) -> None:
try:
ctx.cow_commit_result = ctx.cow_sandbox.commit()
except (SandboxStateError, Exception) as exc:
ctx.cow_error = exc
@when("the cow sandbox commit is attempted on cleaned-up sandbox")
def step_cow_commit_cleaned(ctx: Context) -> None:
try:
ctx.cow_sandbox.commit()
except SandboxStateError as exc:
ctx.cow_error = exc
@when("the cow sandbox is rolled back")
def step_cow_rollback(ctx: Context) -> None:
try:
ctx.cow_sandbox.rollback()
except (SandboxStateError, Exception) as exc:
ctx.cow_error = exc
@when("the cow sandbox rollback is attempted on created sandbox")
def step_cow_rollback_created(ctx: Context) -> None:
try:
ctx.cow_sandbox.rollback()
except SandboxStateError as exc:
ctx.cow_error = exc
@when("the cow sandbox is cleaned up")
def step_cow_cleanup(ctx: Context) -> None:
ctx.cow_sandbox.cleanup()
@when("the cow sandbox is cleaned up again")
def step_cow_cleanup_again(ctx: Context) -> None:
ctx.cow_sandbox.cleanup()
# ---------------------------------------------------------------------------
# Then - status / state
# ---------------------------------------------------------------------------
@then('the cow sandbox should be in the "{status}" state')
def step_cow_check_status(ctx: Context, status: str) -> None:
expected = SandboxStatus(status)
assert ctx.cow_sandbox.status == expected, (
f"Expected status {expected}, got {ctx.cow_sandbox.status}"
)
@then('the cow sandbox context should reference plan "{plan_id}"')
def step_cow_check_plan(ctx: Context, plan_id: str) -> None:
assert ctx.cow_sandbox.context is not None
assert ctx.cow_sandbox.context.plan_id == plan_id
@then('the cow sandbox context should have strategy metadata "{strategy}"')
def step_cow_check_strategy(ctx: Context, strategy: str) -> None:
assert ctx.cow_sandbox.context is not None
assert ctx.cow_sandbox.context.metadata.get("strategy") == strategy
@then("the cow sandbox path should exist")
def step_cow_path_exists(ctx: Context) -> None:
assert ctx.cow_sandbox.context is not None
assert os.path.isdir(ctx.cow_sandbox.context.sandbox_path)
@then("the cow sandbox path should not exist")
def step_cow_path_not_exists(ctx: Context) -> None:
if ctx.cow_sandbox.context is not None:
assert not os.path.exists(ctx.cow_sandbox.context.sandbox_path)
# ---------------------------------------------------------------------------
# Then - errors
# ---------------------------------------------------------------------------
@then('a cow ValueError should be raised with message "{msg}"')
def step_cow_valueerror(ctx: Context, msg: str) -> None:
assert ctx.cow_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.cow_error, ValueError), (
f"Expected ValueError, got {type(ctx.cow_error).__name__}"
)
assert msg in str(ctx.cow_error), (
f"Expected '{msg}' in error message, got: {ctx.cow_error}"
)
@then("a cow SandboxCreationError should be raised")
def step_cow_creation_error(ctx: Context) -> None:
assert ctx.cow_error is not None
assert isinstance(ctx.cow_error, SandboxCreationError)
@then("a cow SandboxStateError should be raised")
def step_cow_state_error(ctx: Context) -> None:
assert ctx.cow_error is not None
assert isinstance(ctx.cow_error, SandboxStateError)
# ---------------------------------------------------------------------------
# Then - path resolution
# ---------------------------------------------------------------------------
@then("the cow resolved path should be inside the sandbox copy")
def step_cow_path_in_sandbox(ctx: Context) -> None:
assert ctx.cow_resolved_path is not None
sandbox_path = ctx.cow_sandbox.context.sandbox_path
assert ctx.cow_resolved_path.startswith(sandbox_path)
# ---------------------------------------------------------------------------
# Then - commit results
# ---------------------------------------------------------------------------
@then("the cow commit result should indicate success")
def step_cow_commit_success(ctx: Context) -> None:
assert ctx.cow_commit_result is not None
assert ctx.cow_commit_result.success is True
@then("the cow commit result should have {count:d} changed files")
def step_cow_commit_changed(ctx: Context, count: int) -> None:
assert ctx.cow_commit_result is not None
assert len(ctx.cow_commit_result.changed_files) == count, (
f"Expected {count} changed, got {len(ctx.cow_commit_result.changed_files)}"
)
@then("the cow commit result should have {count:d} added files")
def step_cow_commit_added(ctx: Context, count: int) -> None:
assert ctx.cow_commit_result is not None
assert len(ctx.cow_commit_result.added_files) == count, (
f"Expected {count} added, got {len(ctx.cow_commit_result.added_files)}"
)
@then("the cow commit result should have {count:d} deleted files")
def step_cow_commit_deleted(ctx: Context, count: int) -> None:
assert ctx.cow_commit_result is not None
assert len(ctx.cow_commit_result.deleted_files) == count, (
f"Expected {count} deleted, got {len(ctx.cow_commit_result.deleted_files)}"
)
@then(
'the cow file "{filename}" should exist in the original directory with content "{content}"'
)
def step_cow_file_in_original_with_content(
ctx: Context, filename: str, content: str
) -> None:
file_path = os.path.join(ctx.cow_test_dir, filename)
assert os.path.exists(file_path), f"File {filename} not found"
with open(file_path) as f:
actual = f.read()
assert actual == content, f"Expected '{content}', got '{actual}'"
@then('the cow file "{filename}" in the original should have content "{content}"')
def step_cow_file_content(ctx: Context, filename: str, content: str) -> None:
file_path = os.path.join(ctx.cow_test_dir, filename)
with open(file_path) as f:
actual = f.read()
assert actual == content, f"Expected '{content}', got '{actual}'"
@then('the cow file "{filename}" should not exist in the original directory')
def step_cow_file_not_in_original(ctx: Context, filename: str) -> None:
file_path = os.path.join(ctx.cow_test_dir, filename)
assert not os.path.exists(file_path), f"File {filename} should not exist"
@then('the cow file "{filename}" should not exist in the sandbox')
def step_cow_file_not_in_sandbox(ctx: Context, filename: str) -> None:
if ctx.cow_sandbox.context:
file_path = os.path.join(ctx.cow_sandbox.context.sandbox_path, filename)
assert not os.path.exists(file_path)
# ---------------------------------------------------------------------------
# Then - protocol properties
# ---------------------------------------------------------------------------
@then("the cow sandbox_id should be a valid ULID")
def step_cow_valid_ulid(ctx: Context) -> None:
sid = ctx.cow_sandbox.sandbox_id
assert len(sid) == 26, f"Expected ULID length 26, got {len(sid)}"
@then('the cow sandbox status should be "{status}"')
def step_cow_status_is(ctx: Context, status: str) -> None:
assert ctx.cow_sandbox.status == SandboxStatus(status)
@then("the cow sandbox context should be None")
def step_cow_context_none(ctx: Context) -> None:
assert ctx.cow_sandbox.context is None
@@ -0,0 +1,404 @@
"""Step definitions for git worktree sandbox feature.
All steps use the ``gwt`` prefix to avoid collisions with the 128+ existing
step files loaded globally by Behave.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
from behave import given, then, when
from behave.runner import Context
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
from cleveragents.infrastructure.sandbox.protocol import (
SandboxCreationError,
SandboxStateError,
SandboxStatus,
)
def _init_test_repo(ctx: Context) -> str:
"""Create a temporary git repo with an initial commit."""
repo_dir = tempfile.mkdtemp(prefix="gwt-test-repo-")
subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=repo_dir,
capture_output=True,
check=True,
)
subprocess.run(
["git", "config", "commit.gpgSign", "false"],
cwd=repo_dir,
capture_output=True,
check=True,
)
# Create initial files
readme = os.path.join(repo_dir, "README.md")
with open(readme, "w") as f:
f.write("# Test Repo\n")
subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True)
subprocess.run(
["git", "commit", "-m", "Initial commit"],
cwd=repo_dir,
capture_output=True,
check=True,
)
return repo_dir
# ---------------------------------------------------------------------------
# Background / Given
# ---------------------------------------------------------------------------
@given("a gwt test git repository is initialised")
def step_gwt_init_repo(ctx: Context) -> None:
ctx.gwt_repo_dir = _init_test_repo(ctx)
ctx.gwt_sandbox = None
ctx.gwt_error = None
ctx.gwt_commit_result = None
ctx.gwt_resolved_path = None
@given("a gwt non-git directory")
def step_gwt_non_git_dir(ctx: Context) -> None:
ctx.gwt_non_git_dir = tempfile.mkdtemp(prefix="gwt-non-git-")
# ---------------------------------------------------------------------------
# When - instantiation
# ---------------------------------------------------------------------------
@when("a gwt sandbox is instantiated")
def step_gwt_instantiate(ctx: Context) -> None:
ctx.gwt_sandbox = GitWorktreeSandbox(
resource_id="res-001",
original_path=ctx.gwt_repo_dir,
)
@when("a gwt sandbox is prepared with empty resource_id")
def step_gwt_empty_resource_id(ctx: Context) -> None:
try:
GitWorktreeSandbox(resource_id="", original_path=ctx.gwt_repo_dir)
except ValueError as exc:
ctx.gwt_error = exc
@when("a gwt sandbox is prepared with empty original_path")
def step_gwt_empty_original_path(ctx: Context) -> None:
try:
GitWorktreeSandbox(resource_id="res-001", original_path="")
except ValueError as exc:
ctx.gwt_error = exc
@when("a gwt sandbox is prepared with zero timeout")
def step_gwt_zero_timeout(ctx: Context) -> None:
try:
GitWorktreeSandbox(
resource_id="res-001",
original_path=ctx.gwt_repo_dir,
git_timeout=0,
)
except ValueError as exc:
ctx.gwt_error = exc
# ---------------------------------------------------------------------------
# When - create
# ---------------------------------------------------------------------------
@when('a gwt sandbox is created for plan "{plan_id}"')
def step_gwt_create(ctx: Context, plan_id: str) -> None:
ctx.gwt_sandbox = GitWorktreeSandbox(
resource_id="res-001",
original_path=ctx.gwt_repo_dir,
)
ctx.gwt_sandbox.create(plan_id)
@when("a gwt sandbox is created with empty plan_id")
def step_gwt_create_empty_plan(ctx: Context) -> None:
ctx.gwt_sandbox = GitWorktreeSandbox(
resource_id="res-001",
original_path=ctx.gwt_repo_dir,
)
try:
ctx.gwt_sandbox.create("")
except ValueError as exc:
ctx.gwt_error = exc
@when('a gwt sandbox is created on the non-git directory for plan "{plan_id}"')
def step_gwt_create_non_git(ctx: Context, plan_id: str) -> None:
ctx.gwt_sandbox = GitWorktreeSandbox(
resource_id="res-001",
original_path=ctx.gwt_non_git_dir,
)
try:
ctx.gwt_sandbox.create(plan_id)
except SandboxCreationError as exc:
ctx.gwt_error = exc
# ---------------------------------------------------------------------------
# When - path resolution
# ---------------------------------------------------------------------------
@when('the gwt path "{path}" is resolved')
def step_gwt_resolve_path(ctx: Context, path: str) -> None:
try:
ctx.gwt_resolved_path = ctx.gwt_sandbox.get_path(path)
except (ValueError, SandboxStateError) as exc:
ctx.gwt_error = exc
@when('the gwt path "{path}" is resolved on a cleaned-up sandbox')
def step_gwt_resolve_path_cleaned(ctx: Context, path: str) -> None:
try:
ctx.gwt_sandbox.get_path(path)
except SandboxStateError as exc:
ctx.gwt_error = exc
# ---------------------------------------------------------------------------
# When - file operations in worktree
# ---------------------------------------------------------------------------
@when('a gwt file "{filename}" is created in the worktree with content "{content}"')
def step_gwt_create_file(ctx: Context, filename: str, content: str) -> None:
worktree_path = ctx.gwt_sandbox.context.sandbox_path
file_path = os.path.join(worktree_path, filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w") as f:
f.write(content)
@when('the gwt existing file "{filename}" is modified in the worktree')
def step_gwt_modify_file(ctx: Context, filename: str) -> None:
worktree_path = ctx.gwt_sandbox.context.sandbox_path
file_path = os.path.join(worktree_path, filename)
with open(file_path, "a") as f:
f.write("\n# Modified by sandbox\n")
# ---------------------------------------------------------------------------
# When - commit / rollback / cleanup
# ---------------------------------------------------------------------------
@when('the gwt sandbox is committed with message "{message}"')
def step_gwt_commit(ctx: Context, message: str) -> None:
try:
ctx.gwt_commit_result = ctx.gwt_sandbox.commit(message)
except (SandboxStateError, Exception) as exc:
ctx.gwt_error = exc
@when("the gwt sandbox commit is attempted on cleaned-up sandbox")
def step_gwt_commit_cleaned(ctx: Context) -> None:
try:
ctx.gwt_sandbox.commit("should fail")
except SandboxStateError as exc:
ctx.gwt_error = exc
@when("the gwt sandbox is rolled back")
def step_gwt_rollback(ctx: Context) -> None:
try:
ctx.gwt_sandbox.rollback()
except (SandboxStateError, Exception) as exc:
ctx.gwt_error = exc
@when("the gwt sandbox rollback is attempted on created sandbox")
def step_gwt_rollback_created(ctx: Context) -> None:
try:
ctx.gwt_sandbox.rollback()
except SandboxStateError as exc:
ctx.gwt_error = exc
@when("the gwt sandbox is cleaned up")
def step_gwt_cleanup(ctx: Context) -> None:
ctx.gwt_sandbox.cleanup()
@when("the gwt sandbox is cleaned up again")
def step_gwt_cleanup_again(ctx: Context) -> None:
ctx.gwt_sandbox.cleanup()
# ---------------------------------------------------------------------------
# Then - status / state
# ---------------------------------------------------------------------------
@then('the gwt sandbox should be in the "{status}" state')
def step_gwt_check_status(ctx: Context, status: str) -> None:
expected = SandboxStatus(status)
assert ctx.gwt_sandbox.status == expected, (
f"Expected status {expected}, got {ctx.gwt_sandbox.status}"
)
@then('the gwt sandbox context should reference plan "{plan_id}"')
def step_gwt_check_plan(ctx: Context, plan_id: str) -> None:
assert ctx.gwt_sandbox.context is not None
assert ctx.gwt_sandbox.context.plan_id == plan_id
@then('the gwt sandbox context should have strategy metadata "{strategy}"')
def step_gwt_check_strategy(ctx: Context, strategy: str) -> None:
assert ctx.gwt_sandbox.context is not None
assert ctx.gwt_sandbox.context.metadata.get("strategy") == strategy
@then("the gwt sandbox worktree path should exist")
def step_gwt_worktree_exists(ctx: Context) -> None:
assert ctx.gwt_sandbox.context is not None
assert os.path.isdir(ctx.gwt_sandbox.context.sandbox_path)
@then("the gwt sandbox worktree path should not exist")
def step_gwt_worktree_not_exists(ctx: Context) -> None:
if ctx.gwt_sandbox.context is not None:
assert not os.path.exists(ctx.gwt_sandbox.context.sandbox_path)
@then("the gwt sandbox branch should not exist")
def step_gwt_branch_not_exists(ctx: Context) -> None:
result = subprocess.run(
["git", "branch", "--list"],
cwd=ctx.gwt_repo_dir,
capture_output=True,
text=True,
check=False,
)
branch_name = ctx.gwt_sandbox._branch_name
if branch_name:
assert branch_name not in result.stdout
# ---------------------------------------------------------------------------
# Then - errors
# ---------------------------------------------------------------------------
@then('a gwt ValueError should be raised with message "{msg}"')
def step_gwt_valueerror(ctx: Context, msg: str) -> None:
assert ctx.gwt_error is not None, "Expected an error but none occurred"
assert isinstance(ctx.gwt_error, ValueError), (
f"Expected ValueError, got {type(ctx.gwt_error).__name__}"
)
assert msg in str(ctx.gwt_error), (
f"Expected '{msg}' in error message, got: {ctx.gwt_error}"
)
@then("a gwt SandboxCreationError should be raised")
def step_gwt_creation_error(ctx: Context) -> None:
assert ctx.gwt_error is not None
assert isinstance(ctx.gwt_error, SandboxCreationError)
@then("a gwt SandboxStateError should be raised")
def step_gwt_state_error(ctx: Context) -> None:
assert ctx.gwt_error is not None
assert isinstance(ctx.gwt_error, SandboxStateError)
# ---------------------------------------------------------------------------
# Then - path resolution
# ---------------------------------------------------------------------------
@then("the gwt resolved path should be inside the worktree")
def step_gwt_path_in_worktree(ctx: Context) -> None:
assert ctx.gwt_resolved_path is not None
sandbox_path = ctx.gwt_sandbox.context.sandbox_path
assert ctx.gwt_resolved_path.startswith(sandbox_path)
# ---------------------------------------------------------------------------
# Then - commit results
# ---------------------------------------------------------------------------
@then("the gwt commit result should indicate success")
def step_gwt_commit_success(ctx: Context) -> None:
assert ctx.gwt_commit_result is not None
assert ctx.gwt_commit_result.success is True
@then("the gwt commit result should have {count:d} changed files")
def step_gwt_commit_changed(ctx: Context, count: int) -> None:
assert ctx.gwt_commit_result is not None
assert len(ctx.gwt_commit_result.changed_files) == count
@then("the gwt commit result should have {count:d} added files")
def step_gwt_commit_added(ctx: Context, count: int) -> None:
assert ctx.gwt_commit_result is not None
assert len(ctx.gwt_commit_result.added_files) == count
@then('the gwt file "{filename}" should exist in the original repo')
def step_gwt_file_in_original(ctx: Context, filename: str) -> None:
file_path = os.path.join(ctx.gwt_repo_dir, filename)
assert os.path.exists(file_path), f"File {filename} not found in original repo"
@then('the gwt file "{filename}" should not exist in the worktree')
def step_gwt_file_not_in_worktree(ctx: Context, filename: str) -> None:
if ctx.gwt_sandbox.context:
file_path = os.path.join(ctx.gwt_sandbox.context.sandbox_path, filename)
assert not os.path.exists(file_path)
# ---------------------------------------------------------------------------
# Then - protocol properties
# ---------------------------------------------------------------------------
@then("the gwt sandbox_id should be a valid ULID")
def step_gwt_valid_ulid(ctx: Context) -> None:
sid = ctx.gwt_sandbox.sandbox_id
assert len(sid) == 26, f"Expected ULID length 26, got {len(sid)}"
@then('the gwt sandbox status should be "{status}"')
def step_gwt_status_is(ctx: Context, status: str) -> None:
assert ctx.gwt_sandbox.status == SandboxStatus(status)
@then("the gwt sandbox context should be None")
def step_gwt_context_none(ctx: Context) -> None:
assert ctx.gwt_sandbox.context is None
@then("the gwt sandbox branch name should be safe for git")
def step_gwt_branch_safe(ctx: Context) -> None:
branch = ctx.gwt_sandbox._branch_name
assert branch is not None
# Branch name should not contain spaces or special chars
assert " " not in branch
assert "!" not in branch
assert "@" not in branch
assert "#" not in branch
@@ -1,22 +1,24 @@
"""Step definitions for sandbox factory strategy routing coverage.
Covers uncovered lines in ``factory.py``:
- Empty resource_id / original_path validation (lines 88, 90)
- git_worktree strategy NotImplementedError (lines 98-99)
- copy_on_write strategy NotImplementedError (lines 104-105)
- overlay strategy NotImplementedError + warning (lines 110-111, 115)
- transaction_rollback strategy NotImplementedError (lines 120-121)
- versioning strategy NotImplementedError (lines 125-126)
- Unknown strategy ValueError (line 128)
- is_supported() for unsupported strategies (line 144)
- get_supported_strategies() fallback for unknown resource types (line 157)
Covers lines in ``factory.py``:
- Empty resource_id / original_path validation
- none strategy -> NoSandbox
- git_worktree strategy -> GitWorktreeSandbox
- copy_on_write strategy -> CopyOnWriteSandbox
- transaction_rollback / snapshot -> NotImplementedError
- overlay / versioning (removed) -> ValueError (unknown)
- Unknown strategy -> ValueError
- is_supported() for all strategies
- get_supported_strategies() for spec-aligned resource types
"""
from __future__ import annotations
from behave import given, then, when
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox
from cleveragents.infrastructure.sandbox.protocol import SandboxStatus
@@ -84,14 +86,11 @@ def when_factory_none_strategy(context, res_id: str, path: str):
def when_factory_git_worktree(context, res_id: str, path: str):
"""Request a sandbox with the 'git_worktree' strategy."""
context.factory_error = None
try:
context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="git_worktree",
)
except NotImplementedError as exc:
context.factory_error = exc
context.factory_sandbox = context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="git_worktree",
)
@when(
@@ -101,14 +100,11 @@ def when_factory_git_worktree(context, res_id: str, path: str):
def when_factory_copy_on_write(context, res_id: str, path: str):
"""Request a sandbox with the 'copy_on_write' strategy."""
context.factory_error = None
try:
context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="copy_on_write",
)
except NotImplementedError as exc:
context.factory_error = exc
context.factory_sandbox = context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="copy_on_write",
)
@when(
@@ -116,15 +112,15 @@ def when_factory_copy_on_write(context, res_id: str, path: str):
"using the overlay strategy"
)
def when_factory_overlay(context, res_id: str, path: str):
"""Request a sandbox with the 'overlay' strategy."""
"""Request a sandbox with the removed 'overlay' strategy (now unknown)."""
context.factory_error = None
try:
context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="overlay",
sandbox_strategy="overlay", # type: ignore[arg-type]
)
except NotImplementedError as exc:
except ValueError as exc:
context.factory_error = exc
@@ -150,13 +146,30 @@ def when_factory_transaction_rollback(context, res_id: str, path: str):
"using the versioning strategy"
)
def when_factory_versioning(context, res_id: str, path: str):
"""Request a sandbox with the 'versioning' strategy."""
"""Request a sandbox with the removed 'versioning' strategy (now unknown)."""
context.factory_error = None
try:
context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="versioning",
sandbox_strategy="versioning", # type: ignore[arg-type]
)
except ValueError as exc:
context.factory_error = exc
@when(
'a sandbox is requested for resource "{res_id}" at "{path}" '
"using the snapshot strategy"
)
def when_factory_snapshot(context, res_id: str, path: str):
"""Request a sandbox with the 'snapshot' strategy (not yet implemented)."""
context.factory_error = None
try:
context.sandbox_factory.create_sandbox(
resource_id=res_id,
original_path=path,
sandbox_strategy="snapshot",
)
except NotImplementedError as exc:
context.factory_error = exc
@@ -279,6 +292,40 @@ def then_factory_sandbox_pending(context):
assert context.factory_sandbox.status == SandboxStatus.PENDING
# ---------------------------------------------------------------------------
# Thens - git worktree strategy produces sandbox
# ---------------------------------------------------------------------------
@then("the factory should produce a git worktree sandbox")
def then_factory_produces_git_worktree(context):
"""Assert the returned sandbox is a GitWorktreeSandbox instance."""
assert isinstance(context.factory_sandbox, GitWorktreeSandbox)
@then("the produced git worktree sandbox should be in the pending state")
def then_factory_git_worktree_pending(context):
"""Assert the git worktree sandbox is in PENDING status."""
assert context.factory_sandbox.status == SandboxStatus.PENDING
# ---------------------------------------------------------------------------
# Thens - copy-on-write strategy produces sandbox
# ---------------------------------------------------------------------------
@then("the factory should produce a copy-on-write sandbox")
def then_factory_produces_cow(context):
"""Assert the returned sandbox is a CopyOnWriteSandbox instance."""
assert isinstance(context.factory_sandbox, CopyOnWriteSandbox)
@then("the produced copy-on-write sandbox should be in the pending state")
def then_factory_cow_pending(context):
"""Assert the copy-on-write sandbox is in PENDING status."""
assert context.factory_sandbox.status == SandboxStatus.PENDING
# ---------------------------------------------------------------------------
# Thens - not yet implemented
# ---------------------------------------------------------------------------
@@ -314,30 +361,18 @@ def then_factory_supported_false(context):
@then("the factory should return git worktree, copy-on-write, and none as compatible")
def then_factory_git_repo_strategies(context):
"""Assert the git_repository strategies are correct."""
def then_factory_git_checkout_strategies(context):
"""Assert the git-checkout strategies are correct."""
assert context.factory_strategies == ["git_worktree", "copy_on_write", "none"]
@then("the factory should return copy-on-write, overlay, and none as compatible")
def then_factory_filesystem_strategies(context):
"""Assert the filesystem strategies are correct."""
assert context.factory_strategies == ["copy_on_write", "overlay", "none"]
@then("the factory should return transaction rollback and none as compatible")
def then_factory_database_strategies(context):
"""Assert the database strategies are correct."""
assert context.factory_strategies == ["transaction_rollback", "none"]
@then("the factory should return copy-on-write and none as compatible")
def then_factory_cow_none_strategies(context):
"""Assert the resource type supports copy_on_write and none."""
assert context.factory_strategies == ["copy_on_write", "none"]
@then("the factory should return only the none strategy as compatible")
def then_factory_none_only_strategies(context):
"""Assert only the 'none' strategy is returned."""
assert context.factory_strategies == ["none"]
@then("the factory should return copy-on-write and none as compatible")
def then_factory_doc_corpus_strategies(context):
"""Assert the document_corpus strategies are correct."""
assert context.factory_strategies == ["copy_on_write", "none"]
+17 -17
View File
@@ -1977,18 +1977,18 @@ Merge points and acceptance checks are tracked as checklist items under each mil
- [ ] Git [Hamza]: `git pull origin master`
- [ ] Git [Hamza]: `git checkout -b feature/m2-resource-core-models`
- [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Hamza]: Create `src/cleveragents/domain/models/core/resource_type.py` with `ResourceTypeSpec`, `ResourceTypeArgument`, `ResourceKind` (physical/virtual), and `SandboxStrategy` enum.
- [ ] Code [Hamza]: Define `ResourceTypeArgument` fields for CLI flags (`flag`, `help`, `arg_type`, `required`, `default`, `repeatable`, `choices`) and validate flag naming conventions.
- [ ] Code [Hamza]: Add resource type fields: `user_addable`, `allowed_parents`, `allowed_children`, `auto_discover`, `handler` reference, and `sandbox_strategy` default.
- [ ] Code [Hamza]: Add `Resource` and `ResourceRef` models with ULID, optional namespaced name, type name, location, description, sandbox strategy, read_only, and metadata.
- [ ] Code [Hamza]: Enforce user-added resources require namespaced name; auto-discovered child resources are ULID-only with no namespaced name.
- [ ] Code [Hamza]: Add virtual resource validation rules (no location; cannot be directly read/write; distinct from physical).
- [ ] Code [Hamza]: Add validators for namespaced naming, ULID format, and parent/child DAG sanity (no self loops, no duplicate edges, type compatibility).
- [ ] Code [Hamza]: Add `ResourceName.parse()` and `ResourceTypeName.parse()` helpers to normalize `local/` default and reject invalid namespaces.
- [X] Code [Hamza]: Create `src/cleveragents/domain/models/core/resource_type.py` with `ResourceTypeSpec`, `ResourceTypeArgument`, `ResourceKind` (physical/virtual), and `SandboxStrategy` enum. (delivered in PR #51 as resource.py — PhysVirt, SandboxStrategy with 5 spec values, ResourceCapabilities)
- [ ] Code [Hamza]: Define `ResourceTypeArgument` fields for CLI flags (`flag`, `help`, `arg_type`, `required`, `default`, `repeatable`, `choices`) and validate flag naming conventions.
- [ ] Code [Hamza]: Add resource type fields: `user_addable`, `allowed_parents`, `allowed_children`, `auto_discover`, `handler` reference, and `sandbox_strategy` default.
- [X] Code [Hamza]: Add `Resource` and `ResourceRef` models with ULID, optional namespaced name, type name, location, description, sandbox strategy, read_only, and metadata. (delivered in PR #51 — Resource model with ULID PK, resource_type_name, properties, capabilities, DAG parents/children)
- [X] Code [Hamza]: Enforce user-added resources require namespaced name; auto-discovered child resources are ULID-only with no namespaced name. (delivered in PR #51 — name is Optional)
- [X] Code [Hamza]: Add virtual resource validation rules (no location; cannot be directly read/write; distinct from physical). (delivered in PR #51 — PhysVirt enum, classification field)
- [X] Code [Hamza]: Add validators for namespaced naming, ULID format, and parent/child DAG sanity (no self loops, no duplicate edges, type compatibility). (delivered in PR #51 — parse_namespaced_name, ULID validation)
- [X] Code [Hamza]: Add `ResourceName.parse()` and `ResourceTypeName.parse()` helpers to normalize `local/` default and reject invalid namespaces. (delivered in PR #51 — parse_namespaced_name with reserved/provider namespace validation)
- [ ] Code [Hamza]: Add `docs/schema/resource_type.schema.yaml` with CLI argument definitions, parent/child constraints, and handler metadata.
- [ ] Code [Hamza]: Add resource type YAML loader in `src/cleveragents/resource/schema.py` with version guard and clear error messages.
- [ ] Docs [Hamza]: Add `docs/reference/resource_model.md` with examples for git-checkout and fs-directory resources plus physical/virtual notes.
- [ ] Tests (Behave) [Hamza]: Add scenarios validating ULID format, namespace rules, allowed parent/child type checks, and sandbox strategy defaults.
- [X] Tests (Behave) [Hamza]: Add scenarios validating ULID format, namespace rules, allowed parent/child type checks, and sandbox strategy defaults. (delivered in PR #51 — 33 resource_registry_model scenarios + 48 namespaced_project_model scenarios)
- [ ] Tests (Robot) [Hamza]: Add Robot test that loads a ResourceTypeSpec YAML fixture and validates it.
- [ ] Tests (ASV) [Hamza]: Add `asv/benchmarks/resource_model_bench.py` for resource validation + DAG checks.
- [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark).
@@ -2047,14 +2047,14 @@ Merge points and acceptance checks are tracked as checklist items under each mil
- [ ] Git [Hamza]: `git pull origin master`
- [ ] Git [Hamza]: `git checkout -b feature/m2-resource-core-models`
- [ ] Git [Hamza]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
- [ ] Code [Hamza]: Add `Project`, `ProjectResourceLink`, `ProjectValidationSummary` (derived from validation attachments), and `ProjectContextPolicy` models using namespaced name as the unique identifier (no ULID per spec).
- [ ] Code [Hamza]: Add fields for `invariants`, `invariant_actor`, `automation_profile`, and `context_views` (strategize/execute/apply/default).
- [ ] Code [Hamza]: Add validation for resource link overrides (read_only flags, alias uniqueness, resource existence).
- [ ] Code [Hamza]: Add helpers to compute effective invariants and automation profile (project defaults).
- [ ] Code [Hamza]: Define default context view policy when `context_views` is omitted (inherit from `default` view; explicit override per view).
- [ ] Code [Hamza]: Enforce non-empty invariant text and deterministic ordering for project-level invariants.
- [ ] Docs [Hamza]: Add `docs/reference/project_model.md` describing resource linking, validation attachments, and context view policies.
- [ ] Tests (Behave) [Hamza]: Add scenarios for project model validation, link overrides, and context view inheritance.
- [X] Code [Hamza]: Add `Project`, `ProjectResourceLink`, `ProjectValidationSummary` (derived from validation attachments), and `ProjectContextPolicy` models using namespaced name as the unique identifier (no ULID per spec). (delivered in PR #51 — NamespacedProject with ParsedName, LinkedResource, ContextConfig)
- [X] Code [Hamza]: Add fields for `invariants`, `invariant_actor`, `automation_profile`, and `context_views` (strategize/execute/apply/default). (delivered in PR #51)
- [X] Code [Hamza]: Add validation for resource link overrides (read_only flags, alias uniqueness, resource existence). (delivered in PR #51 — LinkedResource with project_read_only, alias)
- [X] Code [Hamza]: Add helpers to compute effective invariants and automation profile (project defaults). (delivered in PR #51)
- [X] Code [Hamza]: Define default context view policy when `context_views` is omitted (inherit from `default` view; explicit override per view). (delivered in PR #51 — ContextConfig)
- [X] Code [Hamza]: Enforce non-empty invariant text and deterministic ordering for project-level invariants. (delivered in PR #51)
- [ ] Docs [Hamza]: Add `docs/reference/project_model.md` describing resource linking, validation attachments, and context view policies.
- [X] Tests (Behave) [Hamza]: Add scenarios for project model validation, link overrides, and context view inheritance. (delivered in PR #51 — 48 namespaced_project_model scenarios)
- [ ] Tests (Robot) [Hamza]: Add Robot test that creates a Project object and prints serialized output.
- [ ] Tests (ASV) [Hamza]: Add `asv/benchmarks/project_model_bench.py` for serialization/validation performance.
- [ ] Quality [Hamza]: Run `nox` (all default sessions, including benchmark).
+43 -10
View File
@@ -124,8 +124,10 @@ def _no_sandbox_rollback_raises() -> None:
def _factory_create_none_strategy() -> None:
"""Integration test: SandboxFactory creates NoSandbox for 'none' strategy."""
"""Integration test: SandboxFactory creates correct sandbox for each strategy."""
factory = SandboxFactory()
# 'none' strategy -> NoSandbox
sandbox = factory.create_sandbox(
resource_id="res-003",
original_path="/tmp/test",
@@ -133,14 +135,34 @@ def _factory_create_none_strategy() -> None:
)
assert isinstance(sandbox, NoSandbox)
# Verify unsupported strategies raise
# 'git_worktree' strategy -> GitWorktreeSandbox (implemented in B4)
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
gwt_sandbox = factory.create_sandbox(
resource_id="res-004",
original_path="/tmp/test",
sandbox_strategy="git_worktree",
)
assert isinstance(gwt_sandbox, GitWorktreeSandbox)
# 'copy_on_write' strategy -> CopyOnWriteSandbox (implemented in B4)
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
cow_sandbox = factory.create_sandbox(
resource_id="res-005",
original_path="/tmp/test",
sandbox_strategy="copy_on_write",
)
assert isinstance(cow_sandbox, CopyOnWriteSandbox)
# Verify unimplemented strategies still raise
try:
factory.create_sandbox(
resource_id="res-004",
resource_id="res-006",
original_path="/tmp/test",
sandbox_strategy="git_worktree",
sandbox_strategy="transaction_rollback",
)
print("FAIL: Expected NotImplementedError")
print("FAIL: Expected NotImplementedError for transaction_rollback")
return
except NotImplementedError:
pass
@@ -150,17 +172,28 @@ def _factory_create_none_strategy() -> None:
def _factory_supported_strategies() -> None:
"""Integration test: SandboxFactory strategy support checks."""
# All three concrete implementations are now supported
assert SandboxFactory.is_supported("none")
assert not SandboxFactory.is_supported("git_worktree")
assert not SandboxFactory.is_supported("copy_on_write")
assert SandboxFactory.is_supported("git_worktree")
assert SandboxFactory.is_supported("copy_on_write")
git_strats = SandboxFactory.get_supported_strategies("git_repository")
assert "none" in git_strats
assert "git_worktree" in git_strats
# Unimplemented strategies are not supported
assert not SandboxFactory.is_supported("transaction_rollback")
assert not SandboxFactory.is_supported("snapshot")
# Resource type strategy mapping (spec-aligned)
git_checkout_strats = SandboxFactory.get_supported_strategies("git-checkout")
assert "none" in git_checkout_strats
assert "git_worktree" in git_checkout_strats
assert "copy_on_write" in git_checkout_strats
api_strats = SandboxFactory.get_supported_strategies("api_endpoint")
assert "none" in api_strats
# Unknown resource types default to ["none"]
unknown_strats = SandboxFactory.get_supported_strategies("unknown_type")
assert unknown_strats == ["none"]
print("factory-supported-ok")
@@ -3,10 +3,12 @@
Provides resource isolation during plan execution through the Sandbox protocol
and multiple strategy implementations (git worktree, filesystem copy, no-op).
Stage B3 of the implementation plan.
Stage B3 of the implementation plan. Updated in TASK-006 (B4 rework).
"""
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.merge import (
GitMergeStrategy,
@@ -26,7 +28,9 @@ from cleveragents.infrastructure.sandbox.protocol import (
__all__ = [
"CommitResult",
"CopyOnWriteSandbox",
"GitMergeStrategy",
"GitWorktreeSandbox",
"JsonMergeStrategy",
"MergeResult",
"MergeStrategy",
@@ -0,0 +1,404 @@
"""Copy-on-write sandbox for filesystem resources.
Creates an isolated copy of a directory tree where plan modifications are
made. On ``commit``, changed files are synced back to the original.
On ``rollback``, the copy is discarded.
Suitable for ``fs-mount``, ``fs-directory``, and any non-git resource
that needs filesystem-level isolation.
Stage B3.4 / TASK-006 of the implementation plan.
"""
from __future__ import annotations
import filecmp
import logging
import os
import shutil
import tempfile
from datetime import datetime
from ulid import ULID
from cleveragents.infrastructure.sandbox.protocol import (
CommitResult,
SandboxCommitError,
SandboxContext,
SandboxCreationError,
SandboxRollbackError,
SandboxStateError,
SandboxStatus,
)
logger = logging.getLogger(__name__)
class CopyOnWriteSandbox:
"""Sandbox that isolates changes in a filesystem copy.
Copies the original directory to a temporary location. The actor
modifies the copy; on commit a diff-based sync copies only changed
files back to the original. On rollback the copy is simply deleted.
Implements the
:class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox` protocol.
Lifecycle::
sandbox = CopyOnWriteSandbox(resource_id, original_path)
ctx = sandbox.create(plan_id) # copies directory
path = sandbox.get_path("data.csv") # resolve path in copy
# ... actor writes ...
result = sandbox.commit("msg") # sync changed files back
sandbox.cleanup() # remove the copy
"""
def __init__(
self,
resource_id: str,
original_path: str,
) -> None:
"""Initialise a copy-on-write sandbox.
Args:
resource_id: Identifier of the resource being sandboxed.
original_path: Path to the directory to sandbox.
Raises:
ValueError: If *resource_id* or *original_path* is empty.
"""
if not resource_id:
raise ValueError("resource_id cannot be empty")
if not original_path:
raise ValueError("original_path cannot be empty")
self._sandbox_id: str = str(ULID())
self._resource_id: str = resource_id
self._original_path: str = os.path.abspath(original_path)
self._status: SandboxStatus = SandboxStatus.PENDING
self._context: SandboxContext | None = None
# Set after create()
self._sandbox_path: str | None = None
# -- protocol properties -------------------------------------------------
@property
def sandbox_id(self) -> str:
"""Unique identifier for this sandbox instance."""
return self._sandbox_id
@property
def status(self) -> SandboxStatus:
"""Current lifecycle status."""
return self._status
@property
def context(self) -> SandboxContext | None:
"""Context after creation, ``None`` before ``create``."""
return self._context
# -- protocol methods ----------------------------------------------------
def create(self, plan_id: str) -> SandboxContext:
"""Copy the original directory to a temporary sandbox location.
Args:
plan_id: The plan that owns this sandbox.
Returns:
A :class:`SandboxContext` with the sandbox copy path.
Raises:
ValueError: If *plan_id* is empty.
SandboxStateError: If not in ``PENDING`` status.
SandboxCreationError: If the directory copy fails.
"""
if not plan_id:
raise ValueError("plan_id cannot be empty")
SandboxStatus.assert_transition(self._status, SandboxStatus.CREATED)
try:
if not os.path.isdir(self._original_path):
raise SandboxCreationError(
f"Original path is not a directory: {self._original_path}"
)
# Create the sandbox copy in a temp directory
parent_dir = tempfile.mkdtemp(prefix="ca-cow-sandbox-")
self._sandbox_path = os.path.join(parent_dir, "sandbox")
shutil.copytree(
self._original_path,
self._sandbox_path,
symlinks=True,
dirs_exist_ok=False,
)
except SandboxCreationError:
self._status = SandboxStatus.ERRORED
raise
except OSError as exc:
self._status = SandboxStatus.ERRORED
raise SandboxCreationError(
f"Failed to copy directory for resource {self._resource_id}: {exc}"
) from exc
self._context = SandboxContext(
sandbox_id=self._sandbox_id,
sandbox_path=self._sandbox_path,
original_path=self._original_path,
resource_id=self._resource_id,
plan_id=plan_id,
created_at=datetime.now(),
metadata={
"strategy": "copy_on_write",
"sandbox_path": self._sandbox_path,
},
)
self._status = SandboxStatus.CREATED
logger.info(
"Created copy-on-write sandbox: plan=%s resource=%s path=%s",
plan_id,
self._resource_id,
self._sandbox_path,
)
return self._context
def get_path(self, resource_path: str) -> str:
"""Translate a resource-relative path to a sandbox copy path.
Args:
resource_path: Path relative to the resource root.
Returns:
Absolute path inside the sandbox copy.
Raises:
SandboxStateError: If sandbox is not in a usable status.
ValueError: If *resource_path* attempts directory traversal.
"""
if self._status not in (
SandboxStatus.CREATED,
SandboxStatus.ACTIVE,
):
raise SandboxStateError(
f"Cannot resolve path in status {self._status.value}"
)
if ".." in resource_path.split("/"):
raise ValueError(f"Path traversal not allowed: {resource_path}")
if self._status == SandboxStatus.CREATED:
self._status = SandboxStatus.ACTIVE
if self._sandbox_path is None:
raise SandboxStateError("Sandbox path not set")
return os.path.join(self._sandbox_path, resource_path)
def commit(self, message: str | None = None) -> CommitResult:
"""Sync changed files from the sandbox copy back to the original.
Compares the sandbox directory to the original and copies only
files that differ. New files in the sandbox are added; files
deleted from the sandbox are removed from the original.
Args:
message: Optional log message (stored in result metadata).
Returns:
A :class:`CommitResult` describing the outcome.
Raises:
SandboxCommitError: If the sync fails.
SandboxStateError: If sandbox is not in a committable status.
"""
if self._status not in (
SandboxStatus.CREATED,
SandboxStatus.ACTIVE,
):
raise SandboxStateError(f"Cannot commit from status {self._status.value}")
SandboxStatus.assert_transition(self._status, SandboxStatus.COMMITTED)
if self._sandbox_path is None:
raise SandboxStateError("Sandbox path not set")
try:
changed_files, added_files, deleted_files = self._compute_diff(
self._sandbox_path, self._original_path
)
# Apply changes: copy modified and new files
for rel_path in changed_files + added_files:
src = os.path.join(self._sandbox_path, rel_path)
dst = os.path.join(self._original_path, rel_path)
dst_dir = os.path.dirname(dst)
if dst_dir:
os.makedirs(dst_dir, exist_ok=True)
shutil.copy2(src, dst)
# Apply deletions
for rel_path in deleted_files:
dst = os.path.join(self._original_path, rel_path)
if os.path.exists(dst):
os.remove(dst)
except OSError as exc:
self._status = SandboxStatus.ERRORED
raise SandboxCommitError(
f"Failed to sync sandbox {self._sandbox_id} back to original: {exc}"
) from exc
self._status = SandboxStatus.COMMITTED
logger.info(
"Committed copy-on-write sandbox: sandbox_id=%s "
"changed=%d added=%d deleted=%d",
self._sandbox_id,
len(changed_files),
len(added_files),
len(deleted_files),
)
return CommitResult(
sandbox_id=self._sandbox_id,
success=True,
commit_ref=None,
changed_files=changed_files,
added_files=added_files,
deleted_files=deleted_files,
error=None,
timestamp=datetime.now(),
)
def rollback(self) -> None:
"""Discard sandbox changes by resetting the copy to original state.
Re-copies the original directory over the sandbox. The sandbox
transitions to ``ROLLED_BACK`` and can be re-activated via
``get_path``.
Raises:
SandboxRollbackError: If the rollback fails.
SandboxStateError: If called in an invalid status.
"""
if self._status != SandboxStatus.ACTIVE:
raise SandboxStateError(f"Cannot rollback from status {self._status.value}")
SandboxStatus.assert_transition(self._status, SandboxStatus.ROLLED_BACK)
if self._sandbox_path is None:
raise SandboxStateError("Sandbox path not set")
try:
# Remove the current sandbox copy
shutil.rmtree(self._sandbox_path, ignore_errors=True)
# Re-copy from original
shutil.copytree(
self._original_path,
self._sandbox_path,
symlinks=True,
dirs_exist_ok=False,
)
except OSError as exc:
self._status = SandboxStatus.ERRORED
raise SandboxRollbackError(
f"Failed to rollback sandbox {self._sandbox_id}: {exc}"
) from exc
self._status = SandboxStatus.ROLLED_BACK
logger.info(
"Rolled back copy-on-write sandbox: sandbox_id=%s",
self._sandbox_id,
)
def cleanup(self) -> None:
"""Remove the sandbox copy directory.
Idempotent -- safe to call multiple times.
Raises:
SandboxError: On unexpected errors during cleanup.
"""
if self._status == SandboxStatus.CLEANED_UP:
return
logger.debug(
"Cleaning up copy-on-write sandbox: sandbox_id=%s path=%s",
self._sandbox_id,
self._sandbox_path,
)
if self._sandbox_path is not None:
# Remove the sandbox copy and its parent temp dir
parent = os.path.dirname(self._sandbox_path)
if os.path.exists(parent):
shutil.rmtree(parent, ignore_errors=True)
self._status = SandboxStatus.CLEANED_UP
logger.info(
"Cleaned up copy-on-write sandbox: sandbox_id=%s",
self._sandbox_id,
)
# -- internal helpers ----------------------------------------------------
@staticmethod
def _compute_diff(
sandbox_dir: str, original_dir: str
) -> tuple[list[str], list[str], list[str]]:
"""Compare sandbox to original and return changed/added/deleted files.
Args:
sandbox_dir: Path to the sandbox copy.
original_dir: Path to the original directory.
Returns:
Tuple of (changed_files, added_files, deleted_files) as
relative paths.
"""
changed: list[str] = []
added: list[str] = []
deleted: list[str] = []
# Collect all files in both trees
sandbox_files: set[str] = set()
for dirpath, _dirnames, filenames in os.walk(sandbox_dir):
for fname in filenames:
full = os.path.join(dirpath, fname)
rel = os.path.relpath(full, sandbox_dir)
sandbox_files.add(rel)
original_files: set[str] = set()
for dirpath, _dirnames, filenames in os.walk(original_dir):
for fname in filenames:
full = os.path.join(dirpath, fname)
rel = os.path.relpath(full, original_dir)
original_files.add(rel)
# Added in sandbox (not in original)
for rel in sorted(sandbox_files - original_files):
added.append(rel)
# Deleted from sandbox (was in original)
for rel in sorted(original_files - sandbox_files):
deleted.append(rel)
# Modified (present in both but different)
for rel in sorted(sandbox_files & original_files):
sandbox_file = os.path.join(sandbox_dir, rel)
original_file = os.path.join(original_dir, rel)
if not filecmp.cmp(sandbox_file, original_file, shallow=False):
changed.append(rel)
return changed, added, deleted
@@ -5,7 +5,7 @@ implementation class. When the Resource domain model (Stage B1) is
available, this factory will accept ``Resource`` objects directly; until
then it operates on raw parameters.
Stage B3.6 of the implementation plan.
Stage B3.6 of the implementation plan. Updated in TASK-006 (B4 rework).
"""
from __future__ import annotations
@@ -13,6 +13,8 @@ from __future__ import annotations
import logging
from typing import Literal
from cleveragents.infrastructure.sandbox.copy_on_write import CopyOnWriteSandbox
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
from cleveragents.infrastructure.sandbox.no_sandbox import NoSandbox
from cleveragents.infrastructure.sandbox.protocol import (
Sandbox,
@@ -20,31 +22,34 @@ from cleveragents.infrastructure.sandbox.protocol import (
logger = logging.getLogger(__name__)
# Strategy string constants (aligned with future SandboxStrategy enum from B1.4)
# Strategy string constants aligned with spec SandboxStrategy enum (5 values)
STRATEGY_GIT_WORKTREE: Literal["git_worktree"] = "git_worktree"
STRATEGY_COPY_ON_WRITE: Literal["copy_on_write"] = "copy_on_write"
STRATEGY_OVERLAY: Literal["overlay"] = "overlay"
STRATEGY_TRANSACTION_ROLLBACK: Literal["transaction_rollback"] = "transaction_rollback"
STRATEGY_VERSIONING: Literal["versioning"] = "versioning"
STRATEGY_SNAPSHOT: Literal["snapshot"] = "snapshot"
STRATEGY_NONE: Literal["none"] = "none"
SandboxStrategyStr = Literal[
"git_worktree",
"copy_on_write",
"overlay",
"transaction_rollback",
"versioning",
"snapshot",
"none",
]
# Resource type to supported strategies mapping
# Strategies that have concrete implementations
_IMPLEMENTED_STRATEGIES: frozenset[str] = frozenset(
{"none", "git_worktree", "copy_on_write"}
)
# Resource type to supported strategies mapping (spec-aligned)
_SUPPORTED_STRATEGIES: dict[str, list[SandboxStrategyStr]] = {
"git_repository": ["git_worktree", "copy_on_write", "none"],
"filesystem": ["copy_on_write", "overlay", "none"],
"database": ["transaction_rollback", "none"],
"git-checkout": ["git_worktree", "copy_on_write", "none"],
"git": ["none"],
"fs-mount": ["copy_on_write", "none"],
"fs-directory": ["copy_on_write", "none"],
"fs-file": ["copy_on_write", "none"],
"api_endpoint": ["none"],
"document_corpus": ["copy_on_write", "none"],
"cloud_infrastructure": ["none"],
}
@@ -55,10 +60,10 @@ class SandboxFactory:
Currently available:
- ``"none"`` -> :class:`NoSandbox`
- ``"git_worktree"`` -> (Hamza's B3.3, not yet implemented)
- ``"copy_on_write"`` -> (Hamza's B3.4, not yet implemented)
- ``"git_worktree"`` -> :class:`GitWorktreeSandbox`
- ``"copy_on_write"`` -> :class:`CopyOnWriteSandbox`
Other strategies raise ``NotImplementedError``.
``"transaction_rollback"`` and ``"snapshot"`` raise ``NotImplementedError``.
"""
def create_sandbox(
@@ -95,25 +100,15 @@ class SandboxFactory:
)
if sandbox_strategy == STRATEGY_GIT_WORKTREE:
raise NotImplementedError(
"GitWorktreeSandbox not yet implemented "
"(see Stage B3.3 -- assigned to Hamza)"
return GitWorktreeSandbox(
resource_id=resource_id,
original_path=original_path,
)
if sandbox_strategy == STRATEGY_COPY_ON_WRITE:
raise NotImplementedError(
"FilesystemSandbox (copy-on-write) not yet implemented "
"(see Stage B3.4 -- assigned to Hamza)"
)
if sandbox_strategy == STRATEGY_OVERLAY:
logger.warning(
"Overlay sandbox not implemented; would fall back to "
"copy-on-write once available."
)
raise NotImplementedError(
"Overlay sandbox not yet implemented; copy-on-write "
"fallback also not yet available (see Stage B3.4)"
return CopyOnWriteSandbox(
resource_id=resource_id,
original_path=original_path,
)
if sandbox_strategy == STRATEGY_TRANSACTION_ROLLBACK:
@@ -121,8 +116,8 @@ class SandboxFactory:
"Database transaction sandbox not yet implemented"
)
if sandbox_strategy == STRATEGY_VERSIONING:
raise NotImplementedError("Versioning sandbox not yet implemented")
if sandbox_strategy == STRATEGY_SNAPSHOT:
raise NotImplementedError("Snapshot sandbox not yet implemented")
raise ValueError(f"Unknown sandbox strategy: {sandbox_strategy}")
@@ -130,9 +125,7 @@ class SandboxFactory:
@staticmethod
def is_supported(sandbox_strategy: str) -> bool:
"""Check whether *sandbox_strategy* has an implementation.
Currently only ``"none"`` is fully implemented.
"""Check whether *sandbox_strategy* has a concrete implementation.
Args:
sandbox_strategy: The strategy string to check.
@@ -140,14 +133,14 @@ class SandboxFactory:
Returns:
``True`` if a concrete sandbox class exists for the strategy.
"""
return sandbox_strategy == STRATEGY_NONE
return sandbox_strategy in _IMPLEMENTED_STRATEGIES
@staticmethod
def get_supported_strategies(resource_type: str) -> list[SandboxStrategyStr]:
"""Return the list of strategies compatible with a resource type.
Args:
resource_type: The resource type string (e.g. ``"git_repository"``).
resource_type: The resource type string (e.g. ``"git-checkout"``).
Returns:
List of compatible strategy strings. Returns ``["none"]`` for
@@ -0,0 +1,569 @@
"""Git worktree sandbox for git-checkout resources.
Creates an isolated git worktree where plan modifications are staged and
committed independently of the main working tree. On ``commit``, changes
are merged or cherry-picked back. On ``rollback``, the worktree is
discarded entirely.
Stage B3.3 / TASK-006 of the implementation plan.
"""
from __future__ import annotations
import contextlib
import logging
import os
import re
import shutil
import subprocess
import tempfile
from datetime import datetime
from ulid import ULID
from cleveragents.infrastructure.sandbox.protocol import (
CommitResult,
SandboxCommitError,
SandboxContext,
SandboxCreationError,
SandboxRollbackError,
SandboxStateError,
SandboxStatus,
)
logger = logging.getLogger(__name__)
# Default timeout for git commands (seconds)
_GIT_TIMEOUT: int = 30
# Regex for sanitising branch names -- keep only alnum, hyphens, underscores,
# slashes, and dots; collapse runs of disallowed chars into a single hyphen.
_BRANCH_SANITISE_RE: re.Pattern[str] = re.compile(r"[^a-zA-Z0-9/_.\-]+")
def _sanitise_branch_name(raw: str) -> str:
"""Produce a git-safe branch name from an arbitrary string.
Args:
raw: The raw string to sanitise (typically a plan ID).
Returns:
A string safe for use as a git branch name.
"""
sanitised = _BRANCH_SANITISE_RE.sub("-", raw).strip("-")
if not sanitised:
sanitised = "sandbox"
return sanitised
def _run_git(
args: list[str],
cwd: str,
timeout: int = _GIT_TIMEOUT,
) -> subprocess.CompletedProcess[str]:
"""Run a git command with timeout and capture output.
Args:
args: Git sub-command and arguments (e.g. ``["worktree", "add", ...]``).
cwd: Working directory to run in.
timeout: Maximum seconds to wait.
Returns:
Completed process result.
Raises:
subprocess.TimeoutExpired: If the command exceeds *timeout*.
subprocess.CalledProcessError: If the command returns non-zero.
"""
cmd = ["git", *args]
logger.debug("Running: %s (cwd=%s, timeout=%ds)", " ".join(cmd), cwd, timeout)
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=True,
timeout=timeout,
)
class GitWorktreeSandbox:
"""Sandbox that isolates changes in a git worktree.
Creates a detached worktree rooted at a temporary directory with a
dedicated branch. The actor makes changes in the worktree; on commit
those changes are merged back to the original branch.
Implements the
:class:`~cleveragents.infrastructure.sandbox.protocol.Sandbox` protocol.
Lifecycle::
sandbox = GitWorktreeSandbox(resource_id, original_path)
ctx = sandbox.create(plan_id) # creates worktree + branch
path = sandbox.get_path("src/x.py") # resolve path in worktree
# ... actor writes ...
result = sandbox.commit("msg") # commit in worktree, merge back
sandbox.cleanup() # remove worktree + branch
"""
def __init__(
self,
resource_id: str,
original_path: str,
git_timeout: int = _GIT_TIMEOUT,
) -> None:
"""Initialise a git worktree sandbox.
Args:
resource_id: Identifier of the resource being sandboxed.
original_path: Path to the git repository root.
git_timeout: Timeout in seconds for git commands.
Raises:
ValueError: If *resource_id* or *original_path* is empty.
ValueError: If *git_timeout* is not positive.
"""
if not resource_id:
raise ValueError("resource_id cannot be empty")
if not original_path:
raise ValueError("original_path cannot be empty")
if git_timeout <= 0:
raise ValueError("git_timeout must be positive")
self._sandbox_id: str = str(ULID())
self._resource_id: str = resource_id
self._original_path: str = os.path.abspath(original_path)
self._git_timeout: int = git_timeout
self._status: SandboxStatus = SandboxStatus.PENDING
self._context: SandboxContext | None = None
# Set after create()
self._worktree_path: str | None = None
self._branch_name: str | None = None
self._original_branch: str | None = None
self._base_commit: str | None = None
# -- protocol properties -------------------------------------------------
@property
def sandbox_id(self) -> str:
"""Unique identifier for this sandbox instance."""
return self._sandbox_id
@property
def status(self) -> SandboxStatus:
"""Current lifecycle status."""
return self._status
@property
def context(self) -> SandboxContext | None:
"""Context after creation, ``None`` before ``create``."""
return self._context
# -- protocol methods ----------------------------------------------------
def create(self, plan_id: str) -> SandboxContext:
"""Create a git worktree for isolated modifications.
Creates a new branch ``sandbox/<plan_id>`` and a worktree at a
temporary directory. The worktree starts from the current HEAD
of the repository.
Args:
plan_id: The plan that owns this sandbox.
Returns:
A :class:`SandboxContext` with the worktree path.
Raises:
ValueError: If *plan_id* is empty.
SandboxStateError: If not in ``PENDING`` status.
SandboxCreationError: If git worktree creation fails.
"""
if not plan_id:
raise ValueError("plan_id cannot be empty")
SandboxStatus.assert_transition(self._status, SandboxStatus.CREATED)
try:
# Verify the original path is a git repo root (not just a
# subdirectory of some unrelated parent repository).
toplevel_result = _run_git(
["rev-parse", "--show-toplevel"],
cwd=self._original_path,
timeout=self._git_timeout,
)
toplevel = os.path.realpath(toplevel_result.stdout.strip())
actual = os.path.realpath(self._original_path)
if toplevel != actual:
raise subprocess.CalledProcessError(
returncode=1,
cmd="git rev-parse --show-toplevel",
stderr=(
f"Path {self._original_path} is not the root of a "
f"git repository (toplevel is {toplevel})"
),
)
# Get the current branch name
result = _run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
cwd=self._original_path,
timeout=self._git_timeout,
)
self._original_branch = result.stdout.strip()
# Get the current HEAD commit
result = _run_git(
["rev-parse", "HEAD"],
cwd=self._original_path,
timeout=self._git_timeout,
)
self._base_commit = result.stdout.strip()
# Create a sanitised branch name
safe_plan_id = _sanitise_branch_name(plan_id)
self._branch_name = f"sandbox/{safe_plan_id}"
# Create a temporary directory for the worktree
self._worktree_path = tempfile.mkdtemp(prefix=f"ca-sandbox-{safe_plan_id}-")
# mkdtemp creates the dir; git worktree add needs it to not exist
os.rmdir(self._worktree_path)
# Create the worktree with a new branch
_run_git(
[
"worktree",
"add",
"-b",
self._branch_name,
self._worktree_path,
"HEAD",
],
cwd=self._original_path,
timeout=self._git_timeout,
)
except subprocess.TimeoutExpired as exc:
self._status = SandboxStatus.ERRORED
raise SandboxCreationError(
f"Git command timed out after {self._git_timeout}s "
f"while creating worktree for resource {self._resource_id}"
) from exc
except subprocess.CalledProcessError as exc:
self._status = SandboxStatus.ERRORED
raise SandboxCreationError(
f"Failed to create git worktree for resource "
f"{self._resource_id}: {exc.stderr.strip()}"
) from exc
self._context = SandboxContext(
sandbox_id=self._sandbox_id,
sandbox_path=self._worktree_path,
original_path=self._original_path,
resource_id=self._resource_id,
plan_id=plan_id,
created_at=datetime.now(),
metadata={
"strategy": "git_worktree",
"branch": self._branch_name,
"original_branch": self._original_branch,
"base_commit": self._base_commit,
"worktree_path": self._worktree_path,
},
)
self._status = SandboxStatus.CREATED
logger.info(
"Created git worktree sandbox: plan=%s resource=%s branch=%s path=%s",
plan_id,
self._resource_id,
self._branch_name,
self._worktree_path,
)
return self._context
def get_path(self, resource_path: str) -> str:
"""Translate a resource-relative path to a worktree path.
Args:
resource_path: Path relative to the resource root.
Returns:
Absolute path inside the worktree.
Raises:
SandboxStateError: If sandbox is not in a usable status.
ValueError: If *resource_path* attempts directory traversal.
"""
if self._status not in (
SandboxStatus.CREATED,
SandboxStatus.ACTIVE,
):
raise SandboxStateError(
f"Cannot resolve path in status {self._status.value}"
)
if ".." in resource_path.split("/"):
raise ValueError(f"Path traversal not allowed: {resource_path}")
if self._status == SandboxStatus.CREATED:
self._status = SandboxStatus.ACTIVE
if self._worktree_path is None:
raise SandboxStateError("Worktree path not set")
return os.path.join(self._worktree_path, resource_path)
def commit(self, message: str | None = None) -> CommitResult:
"""Commit changes in the worktree and merge back to the original branch.
Stages all changes in the worktree, commits them, then merges the
sandbox branch into the original branch.
Args:
message: Commit message. Defaults to a generated message.
Returns:
A :class:`CommitResult` describing the outcome.
Raises:
SandboxCommitError: If the commit or merge fails.
SandboxStateError: If sandbox is not in a committable status.
"""
if self._status not in (
SandboxStatus.CREATED,
SandboxStatus.ACTIVE,
):
raise SandboxStateError(f"Cannot commit from status {self._status.value}")
SandboxStatus.assert_transition(self._status, SandboxStatus.COMMITTED)
if self._worktree_path is None or self._branch_name is None:
raise SandboxStateError("Worktree not initialised")
commit_message = message or (
f"sandbox: changes from plan (sandbox_id={self._sandbox_id})"
)
try:
# Stage all changes in the worktree
_run_git(
["add", "-A"],
cwd=self._worktree_path,
timeout=self._git_timeout,
)
# Check if there are staged changes
diff_result = subprocess.run(
["git", "diff", "--cached", "--name-status"],
cwd=self._worktree_path,
capture_output=True,
text=True,
check=False,
timeout=self._git_timeout,
)
diff_output = diff_result.stdout.strip()
if not diff_output:
# No changes to commit
self._status = SandboxStatus.COMMITTED
return CommitResult(
sandbox_id=self._sandbox_id,
success=True,
commit_ref=self._base_commit,
changed_files=[],
added_files=[],
deleted_files=[],
error=None,
timestamp=datetime.now(),
)
# Parse changed/added/deleted files
changed_files: list[str] = []
added_files: list[str] = []
deleted_files: list[str] = []
for line in diff_output.splitlines():
parts = line.split("\t", 1)
if len(parts) == 2:
status_char, file_path = parts
if status_char.startswith("A"):
added_files.append(file_path)
elif status_char.startswith("D"):
deleted_files.append(file_path)
else:
changed_files.append(file_path)
# Commit in the worktree
_run_git(
["commit", "-m", commit_message],
cwd=self._worktree_path,
timeout=self._git_timeout,
)
# Get the commit hash
result = _run_git(
["rev-parse", "HEAD"],
cwd=self._worktree_path,
timeout=self._git_timeout,
)
commit_ref = result.stdout.strip()
# Merge the sandbox branch into the original branch
_run_git(
["merge", self._branch_name, "--no-edit"],
cwd=self._original_path,
timeout=self._git_timeout,
)
except subprocess.TimeoutExpired as exc:
self._status = SandboxStatus.ERRORED
raise SandboxCommitError(
f"Git command timed out after {self._git_timeout}s "
f"while committing sandbox {self._sandbox_id}"
) from exc
except subprocess.CalledProcessError as exc:
self._status = SandboxStatus.ERRORED
raise SandboxCommitError(
f"Failed to commit sandbox {self._sandbox_id}: {exc.stderr.strip()}"
) from exc
self._status = SandboxStatus.COMMITTED
logger.info(
"Committed git worktree sandbox: sandbox_id=%s commit=%s "
"changed=%d added=%d deleted=%d",
self._sandbox_id,
commit_ref,
len(changed_files),
len(added_files),
len(deleted_files),
)
return CommitResult(
sandbox_id=self._sandbox_id,
success=True,
commit_ref=commit_ref,
changed_files=changed_files,
added_files=added_files,
deleted_files=deleted_files,
error=None,
timestamp=datetime.now(),
)
def rollback(self) -> None:
"""Discard all worktree changes by resetting to the base commit.
Resets the worktree branch to the base commit and transitions
back to ``ACTIVE`` for potential re-use.
Raises:
SandboxRollbackError: If the rollback fails.
SandboxStateError: If called in an invalid status.
"""
if self._status != SandboxStatus.ACTIVE:
raise SandboxStateError(f"Cannot rollback from status {self._status.value}")
SandboxStatus.assert_transition(self._status, SandboxStatus.ROLLED_BACK)
if self._worktree_path is None or self._base_commit is None:
raise SandboxStateError("Worktree not initialised")
try:
_run_git(
["reset", "--hard", self._base_commit],
cwd=self._worktree_path,
timeout=self._git_timeout,
)
_run_git(
["clean", "-fd"],
cwd=self._worktree_path,
timeout=self._git_timeout,
)
except subprocess.TimeoutExpired as exc:
self._status = SandboxStatus.ERRORED
raise SandboxRollbackError(
f"Git command timed out after {self._git_timeout}s "
f"while rolling back sandbox {self._sandbox_id}"
) from exc
except subprocess.CalledProcessError as exc:
self._status = SandboxStatus.ERRORED
raise SandboxRollbackError(
f"Failed to rollback sandbox {self._sandbox_id}: {exc.stderr.strip()}"
) from exc
self._status = SandboxStatus.ROLLED_BACK
logger.info(
"Rolled back git worktree sandbox: sandbox_id=%s to commit=%s",
self._sandbox_id,
self._base_commit,
)
def cleanup(self) -> None:
"""Remove the worktree and sandbox branch.
Idempotent -- safe to call multiple times.
Raises:
SandboxError: On unexpected errors during cleanup.
"""
if self._status == SandboxStatus.CLEANED_UP:
return
logger.debug(
"Cleaning up git worktree sandbox: sandbox_id=%s branch=%s path=%s",
self._sandbox_id,
self._branch_name,
self._worktree_path,
)
# Remove the worktree
if self._worktree_path is not None and os.path.exists(self._worktree_path):
try:
_run_git(
["worktree", "remove", "--force", self._worktree_path],
cwd=self._original_path,
timeout=self._git_timeout,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
# Fallback: manual removal
logger.warning(
"git worktree remove failed; removing directory manually: %s",
self._worktree_path,
)
shutil.rmtree(self._worktree_path, ignore_errors=True)
# Delete the sandbox branch
if self._branch_name is not None:
try:
_run_git(
["branch", "-D", self._branch_name],
cwd=self._original_path,
timeout=self._git_timeout,
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
logger.warning(
"Failed to delete sandbox branch %s; it may need manual cleanup",
self._branch_name,
)
# Prune stale worktree entries
with contextlib.suppress(
subprocess.CalledProcessError, subprocess.TimeoutExpired
):
_run_git(
["worktree", "prune"],
cwd=self._original_path,
timeout=self._git_timeout,
)
self._status = SandboxStatus.CLEANED_UP
logger.info(
"Cleaned up git worktree sandbox: sandbox_id=%s",
self._sandbox_id,
)