feat(domain): add spec-aligned Resource and Project models #51
@@ -18,7 +18,7 @@ from cleveragents.domain.models.core import (
|
||||
PlanStatus,
|
||||
Project,
|
||||
)
|
||||
from cleveragents.domain.models.core.project import ProjectSettings
|
||||
from cleveragents.domain.models.core.project_legacy import ProjectSettings
|
||||
|
||||
|
||||
def _sample_project() -> Project:
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
Feature: Namespaced Project Domain Model
|
||||
As a developer
|
||||
I want spec-aligned project domain models
|
||||
So that projects use namespaced names with linked resources
|
||||
|
||||
# parse_namespaced_name
|
||||
|
||||
Scenario: Parse bare name defaults to local namespace
|
||||
When I parse the project namespaced name "my-project"
|
||||
Then the nsproject parsed namespace should be "local"
|
||||
And the nsproject parsed name should be "my-project"
|
||||
And the nsproject parsed server should be empty
|
||||
|
||||
Scenario: Parse name with explicit namespace
|
||||
When I parse the project namespaced name "freemo/code-coverage"
|
||||
Then the nsproject parsed namespace should be "freemo"
|
||||
And the nsproject parsed name should be "code-coverage"
|
||||
And the nsproject parsed server should be empty
|
||||
|
||||
Scenario: Parse name with server qualifier
|
||||
When I parse the project namespaced name "dev:freemo/code-coverage"
|
||||
Then the nsproject parsed server should be "dev"
|
||||
And the nsproject parsed namespace should be "freemo"
|
||||
And the nsproject parsed name should be "code-coverage"
|
||||
|
||||
Scenario: Parse local namespace
|
||||
When I parse the project namespaced name "local/my-project"
|
||||
Then the nsproject parsed namespace should be "local"
|
||||
And the nsproject parsed name should be "my-project"
|
||||
|
||||
Scenario: ParsedName qualified_name with server
|
||||
When I parse the project namespaced name "prod:myorg/api"
|
||||
Then the nsproject qualified name should be "prod:myorg/api"
|
||||
|
||||
Scenario: ParsedName qualified_name without server
|
||||
When I parse the project namespaced name "myorg/api"
|
||||
Then the nsproject qualified name should be "myorg/api"
|
||||
|
||||
Scenario: ParsedName namespaced_name
|
||||
When I parse the project namespaced name "dev:myorg/api"
|
||||
Then the nsproject namespaced name should be "myorg/api"
|
||||
|
||||
Scenario: ParsedName is_local for bare name
|
||||
When I parse the project namespaced name "my-project"
|
||||
Then the nsproject parsed name should be local
|
||||
|
||||
Scenario: ParsedName is_remote with server
|
||||
When I parse the project namespaced name "dev:myorg/api"
|
||||
Then the nsproject parsed name should be remote
|
||||
|
||||
Scenario: ParsedName is_remote with non-local namespace
|
||||
When I parse the project namespaced name "freemo/api"
|
||||
Then the nsproject parsed name should be remote
|
||||
|
||||
Scenario: Reject empty name
|
||||
When I try to parse an empty project namespaced name
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Reject reserved namespace system
|
||||
When I try to parse the project namespaced name "system/my-project"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Reject reserved namespace admin
|
||||
When I try to parse the project namespaced name "admin/my-project"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Reject provider namespace openai
|
||||
When I try to parse the project namespaced name "openai/my-project"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Reject provider namespace anthropic
|
||||
When I try to parse the project namespaced name "anthropic/my-model"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Reject invalid characters in name
|
||||
When I try to parse the project namespaced name "my@project"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
# TemporalScope enum
|
||||
|
||||
Scenario: TemporalScope has three values
|
||||
Then the nsproject TemporalScope enum should have values "current, recent, all"
|
||||
|
||||
# ContextConfig model
|
||||
|
||||
Scenario: ContextConfig defaults
|
||||
Given a default ContextConfig
|
||||
Then the nsproject ctx max_file_size should be 1000000
|
||||
And the nsproject ctx max_total_size should be 52428800
|
||||
And the nsproject ctx indexing_strategy should be "full_text"
|
||||
And the nsproject ctx chunking_policy should be "smart"
|
||||
And the nsproject ctx chunk_size should be 1000
|
||||
And the nsproject ctx summarize should be true
|
||||
And the nsproject ctx auto_refresh should be true
|
||||
And the nsproject ctx temporal_scope should be "current"
|
||||
And the nsproject ctx retention_policy should be empty
|
||||
|
||||
Scenario: ContextConfig merges default ignore patterns
|
||||
Given a ContextConfig with custom ignore patterns
|
||||
Then the nsproject ctx ignore patterns should include defaults
|
||||
And the nsproject ctx ignore patterns should include the custom pattern
|
||||
|
||||
Scenario: ContextConfig with memory tier settings
|
||||
Given a ContextConfig with hot_max_tokens 4096 and warm_max_decisions 50
|
||||
Then the nsproject ctx hot_max_tokens should be 4096
|
||||
And the nsproject ctx warm_max_decisions should be 50
|
||||
|
||||
Scenario: ContextConfig is frozen
|
||||
Given a default ContextConfig
|
||||
When I try to mutate a ContextConfig field
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: ContextConfig rejects zero max_file_size
|
||||
When I try to create a ContextConfig with max_file_size 0
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: ContextConfig rejects negative chunk_size
|
||||
When I try to create a ContextConfig with chunk_size -1
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
# LinkedResource model
|
||||
|
||||
Scenario: Create a LinkedResource with defaults
|
||||
Given a LinkedResource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
Then the nsproject linked rid should be "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
And the nsproject linked read_only should be false
|
||||
And the nsproject linked alias should be empty
|
||||
And the nsproject linked_at should be set
|
||||
|
||||
Scenario: Create a LinkedResource with alias
|
||||
Given a LinkedResource with id "01HZQX7K8M3N4P5R6S7T8V9W0X" and alias "main-repo"
|
||||
Then the nsproject linked alias should be "main-repo"
|
||||
|
||||
Scenario: LinkedResource rejects invalid ULID
|
||||
When I try to create a LinkedResource with resource_id "invalid"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: LinkedResource rejects empty alias
|
||||
When I try to create a LinkedResource with empty alias
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: LinkedResource is frozen
|
||||
Given a LinkedResource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
When I try to mutate a LinkedResource field
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
# NamespacedProject model
|
||||
|
||||
Scenario: Create a minimal NamespacedProject
|
||||
Given a NamespacedProject with name "my-project"
|
||||
Then the nsproject name should be "my-project"
|
||||
And the nsproject namespace should be "local"
|
||||
And the nsproject server should be empty
|
||||
And the nsproject namespaced name should be "local/my-project"
|
||||
And the nsproject qualified name should be "local/my-project"
|
||||
And the nsproject linked resources should be empty
|
||||
And the nsproject created_at should be set
|
||||
And the nsproject updated_at should be set
|
||||
|
||||
Scenario: Create a NamespacedProject with namespace
|
||||
Given a project "api" under namespace "freemo"
|
||||
Then the nsproject namespace should be "freemo"
|
||||
And the nsproject namespaced name should be "freemo/api"
|
||||
|
||||
Scenario: Create a NamespacedProject with server
|
||||
Given a project "api" under namespace "freemo" on server "dev"
|
||||
Then the nsproject server should be "dev"
|
||||
And the nsproject qualified name should be "dev:freemo/api"
|
||||
|
||||
Scenario: NamespacedProject is_local
|
||||
Given a NamespacedProject with name "my-project"
|
||||
Then the nsproject should be local
|
||||
|
||||
Scenario: NamespacedProject is_remote with namespace
|
||||
Given a project "api" under namespace "freemo"
|
||||
Then the nsproject should be remote
|
||||
|
||||
Scenario: NamespacedProject is_remote with server
|
||||
Given a project "api" under namespace "freemo" on server "dev"
|
||||
Then the nsproject should be remote
|
||||
|
||||
# NamespacedProject - validation
|
||||
|
||||
Scenario: NamespacedProject rejects invalid name
|
||||
When I try to create a NamespacedProject with name "123invalid"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: NamespacedProject rejects reserved namespace
|
||||
When I try to create a NamespacedProject with namespace "system"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: NamespacedProject rejects provider namespace
|
||||
When I try to create a NamespacedProject with namespace "openai"
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: NamespacedProject rejects duplicate linked resources
|
||||
When I try to create a NamespacedProject with duplicate linked resources
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
# NamespacedProject - domain methods
|
||||
|
||||
Scenario: Link a resource to a project
|
||||
Given a NamespacedProject with name "my-project"
|
||||
When I link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" to the project
|
||||
Then the nsproject should have 1 linked resource
|
||||
And the nsproject first linked resource_id should be "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
|
||||
Scenario: Link a resource with read_only and alias
|
||||
Given a NamespacedProject with name "my-project"
|
||||
When I link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" with read_only true and alias "docs"
|
||||
Then the nsproject should have 1 linked resource
|
||||
And the nsproject first linked resource should be read_only
|
||||
And the nsproject first linked resource alias should be "docs"
|
||||
|
||||
Scenario: Unlink a resource from a project
|
||||
Given a NamespacedProject with name "my-project"
|
||||
And I link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" to the project
|
||||
When I unlink resource "01HZQX7K8M3N4P5R6S7T8V9W0X" from the project
|
||||
Then the nsproject linked resources should be empty
|
||||
|
||||
Scenario: Link duplicate resource fails
|
||||
Given a NamespacedProject with name "my-project"
|
||||
And I link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" to the project
|
||||
When I try to link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" again
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Unlink non-existent resource fails
|
||||
Given a NamespacedProject with name "my-project"
|
||||
When I try to unlink resource "01HZQX7K8M3N4P5R6S7T8V9W0X" from the project
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Get linked resource by resource_id
|
||||
Given a NamespacedProject with name "my-project"
|
||||
And I link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" to the project
|
||||
When I get linked resource "01HZQX7K8M3N4P5R6S7T8V9W0X" from the project
|
||||
Then the nsproject retrieved link should not be empty
|
||||
|
||||
Scenario: Get linked resource by alias
|
||||
Given a NamespacedProject with name "my-project"
|
||||
And I link resource "01HZQX7K8M3N4P5R6S7T8V9W0X" with read_only false and alias "main"
|
||||
When I get linked resource by alias "main" from the project
|
||||
Then the nsproject retrieved link should not be empty
|
||||
And the nsproject retrieved link alias should be "main"
|
||||
|
||||
Scenario: Get non-existent linked resource returns None
|
||||
Given a NamespacedProject with name "my-project"
|
||||
When I get linked resource "01HZQX7K8M3N4P5R6S7T8V9W0X" from the project
|
||||
Then the nsproject retrieved link should be empty
|
||||
|
||||
Scenario: Link resource with empty resource_id fails
|
||||
Given a NamespacedProject with name "my-project"
|
||||
When I try to link an empty resource_id to the project
|
||||
Then a nsproject validation error should be raised
|
||||
|
||||
Scenario: Unlink resource with empty resource_id fails
|
||||
Given a NamespacedProject with name "my-project"
|
||||
When I try to unlink an empty resource_id from the project
|
||||
Then a nsproject validation error should be raised
|
||||
@@ -0,0 +1,171 @@
|
||||
Feature: Resource Registry Domain Model
|
||||
As a developer
|
||||
I want spec-aligned Resource Registry domain models
|
||||
So that resources are tracked with proper types, capabilities, and relationships
|
||||
|
||||
# PhysVirt enum
|
||||
|
||||
Scenario: PhysVirt has exactly two values
|
||||
Then the PhysVirt enum should have values "physical, virtual"
|
||||
|
||||
Scenario: PhysVirt physical value
|
||||
Given a PhysVirt value of "physical"
|
||||
Then the PhysVirt string value should be "physical"
|
||||
|
||||
Scenario: PhysVirt virtual value
|
||||
Given a PhysVirt value of "virtual"
|
||||
Then the PhysVirt string value should be "virtual"
|
||||
|
||||
Scenario: PhysVirt rejects invalid value
|
||||
When I try to create a PhysVirt with value "hybrid"
|
||||
Then a resource validation error should be raised
|
||||
|
||||
# SandboxStrategy enum
|
||||
|
||||
Scenario: SandboxStrategy has exactly five values
|
||||
Then the SandboxStrategy enum should have values "git_worktree, copy_on_write, transaction_rollback, snapshot, none"
|
||||
|
||||
Scenario Outline: SandboxStrategy accepts valid values
|
||||
Given a SandboxStrategy value of "<strategy>"
|
||||
Then the SandboxStrategy string value should be "<strategy>"
|
||||
|
||||
Examples:
|
||||
| strategy |
|
||||
| git_worktree |
|
||||
| copy_on_write |
|
||||
| transaction_rollback |
|
||||
| snapshot |
|
||||
| none |
|
||||
|
||||
Scenario: SandboxStrategy rejects overlay
|
||||
When I try to create a SandboxStrategy with value "overlay"
|
||||
Then a resource validation error should be raised
|
||||
|
||||
Scenario: SandboxStrategy rejects versioning
|
||||
When I try to create a SandboxStrategy with value "versioning"
|
||||
Then a resource validation error should be raised
|
||||
|
||||
# ResourceCapabilities model
|
||||
|
||||
Scenario: ResourceCapabilities defaults
|
||||
Given default ResourceCapabilities
|
||||
Then the capability readable should be true
|
||||
And the capability writable should be true
|
||||
And the capability sandboxable should be true
|
||||
And the capability checkpointable should be false
|
||||
|
||||
Scenario: ResourceCapabilities with custom values
|
||||
Given ResourceCapabilities with readable false and checkpointable true
|
||||
Then the capability readable should be false
|
||||
And the capability writable should be true
|
||||
And the capability sandboxable should be true
|
||||
And the capability checkpointable should be true
|
||||
|
||||
Scenario: ResourceCapabilities is frozen
|
||||
Given default ResourceCapabilities
|
||||
When I try to mutate a ResourceCapabilities field
|
||||
Then a resource validation error should be raised
|
||||
|
||||
# Resource model - creation
|
||||
|
||||
Scenario: Create a minimal physical resource
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git-checkout" and classification "physical"
|
||||
Then the resource resource_id should be "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
And the resource type name should be "git-checkout"
|
||||
And the resource classification should be "physical"
|
||||
And the resource name should be empty
|
||||
And the resource description should be empty
|
||||
And the resource properties should be empty
|
||||
And the resource parents should be empty
|
||||
And the resource children should be empty
|
||||
And the resource linked_projects should be empty
|
||||
And the resource created_at should be set
|
||||
And the resource updated_at should be set
|
||||
|
||||
Scenario: Create a full resource with all fields
|
||||
Given a full Resource with id "01HZQX7K8M3N4P5R6S7T8V9W0X" name "local/my-repo" type "git-checkout" classification "physical" description "Main repo"
|
||||
Then the resource name should be "local/my-repo"
|
||||
And the resource description should be "Main repo"
|
||||
|
||||
Scenario: Resource with sandbox strategy override
|
||||
Given a Resource with sandbox strategy "git_worktree"
|
||||
Then the resource sandbox strategy should be "git_worktree"
|
||||
|
||||
Scenario: Resource with capabilities
|
||||
Given a Resource with sandboxable false and checkpointable true
|
||||
Then the resource can_sandbox should be false
|
||||
And the resource can_checkpoint should be true
|
||||
|
||||
# Resource model - validation
|
||||
|
||||
Scenario: Resource rejects invalid ULID
|
||||
When I try to create a Resource with resource_id "invalid-ulid"
|
||||
Then a resource validation error should be raised
|
||||
|
||||
Scenario: Resource rejects empty resource_type_name
|
||||
When I try to create a Resource with empty resource_type_name
|
||||
Then a resource validation error should be raised
|
||||
|
||||
Scenario: Resource rejects empty string name
|
||||
When I try to create a Resource with empty string name
|
||||
Then a resource validation error should be raised
|
||||
|
||||
Scenario: Resource accepts None name
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git-checkout" and classification "physical"
|
||||
Then the resource name should be empty
|
||||
|
||||
# Resource model - domain properties
|
||||
|
||||
Scenario: Physical resource is_physical
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git-checkout" and classification "physical"
|
||||
Then the resource is_physical should be true
|
||||
And the resource is_virtual should be false
|
||||
|
||||
Scenario: Virtual resource is_virtual
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git" and classification "virtual"
|
||||
Then the resource is_physical should be false
|
||||
And the resource is_virtual should be true
|
||||
|
||||
Scenario: Read-only resource
|
||||
Given a Resource with writable false
|
||||
Then the resource is_read_only should be true
|
||||
|
||||
Scenario: Writable resource
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git-checkout" and classification "physical"
|
||||
Then the resource is_read_only should be false
|
||||
|
||||
# Resource model - frozen
|
||||
|
||||
Scenario: Resource is frozen
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git-checkout" and classification "physical"
|
||||
When I try to mutate a Resource field
|
||||
Then a resource validation error should be raised
|
||||
|
||||
# Resource model - with_updated_at
|
||||
|
||||
Scenario: with_updated_at returns new instance
|
||||
Given a Resource with resource_id "01HZQX7K8M3N4P5R6S7T8V9W0X" type "git-checkout" and classification "physical"
|
||||
When I call with_updated_at on the resource
|
||||
Then the new resource should have a different updated_at
|
||||
And the new resource should have the same resource_id
|
||||
|
||||
# Resource model - content_hash and location
|
||||
|
||||
Scenario: Resource with content_hash
|
||||
Given a Resource with content_hash "sha256:abc123"
|
||||
Then the resource content_hash should be "sha256:abc123"
|
||||
|
||||
Scenario: Resource with location
|
||||
Given a Resource with location "/home/user/repos/main"
|
||||
Then the resource location should be "/home/user/repos/main"
|
||||
|
||||
Scenario: Resource rejects empty location string
|
||||
When I try to create a Resource with empty location
|
||||
Then a resource validation error should be raised
|
||||
|
||||
# Resource model - parents and children
|
||||
|
||||
Scenario: Resource with parents and children
|
||||
Given a Resource with parents and children
|
||||
Then the resource should have 1 parent
|
||||
And the resource should have 2 children
|
||||
@@ -18,7 +18,7 @@ from cleveragents.cli.commands.auto_debug import (
|
||||
app as auto_debug_app,
|
||||
)
|
||||
from cleveragents.core.exceptions import CleverAgentsError, PlanError
|
||||
from cleveragents.domain.models.core.project import Project
|
||||
from cleveragents.domain.models.core.project_legacy import Project
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
"""Step definitions for Namespaced Project domain model tests.
|
||||
|
||||
All assertion steps use 'nsproject' prefix to avoid collisions
|
||||
with the 128 other step files loaded by Behave globally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, use_step_matcher, when # type: ignore[attr-defined]
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.domain.models.core.project import (
|
||||
ContextConfig,
|
||||
LinkedResource,
|
||||
NamespacedProject,
|
||||
TemporalScope,
|
||||
parse_namespaced_name,
|
||||
)
|
||||
|
||||
VALID_ULID = "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
|
||||
|
||||
# -- parse_namespaced_name ---------------------------------------------------
|
||||
|
||||
|
||||
@when('I parse the project namespaced name "{value}"')
|
||||
def step_parse_project_name(context: Context, value: str) -> None:
|
||||
"""Parse a namespaced name string."""
|
||||
context.parsed_name = parse_namespaced_name(value)
|
||||
|
||||
|
||||
@then('the nsproject parsed namespace should be "{expected}"')
|
||||
def step_check_parsed_namespace(context: Context, expected: str) -> None:
|
||||
"""Check parsed namespace."""
|
||||
assert context.parsed_name.namespace == expected, (
|
||||
f"Expected '{expected}', got '{context.parsed_name.namespace}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the nsproject parsed name should be "{expected}"')
|
||||
def step_check_parsed_name(context: Context, expected: str) -> None:
|
||||
"""Check parsed name."""
|
||||
assert context.parsed_name.name == expected, (
|
||||
f"Expected '{expected}', got '{context.parsed_name.name}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the nsproject parsed server should be empty")
|
||||
def step_check_parsed_server_empty(context: Context) -> None:
|
||||
"""Check parsed server is None."""
|
||||
assert context.parsed_name.server is None, (
|
||||
f"Expected None, got '{context.parsed_name.server}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the nsproject parsed server should be "{expected}"')
|
||||
def step_check_parsed_server(context: Context, expected: str) -> None:
|
||||
"""Check parsed server."""
|
||||
assert context.parsed_name.server == expected, (
|
||||
f"Expected '{expected}', got '{context.parsed_name.server}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the nsproject qualified name should be "{expected}"')
|
||||
def step_check_qualified_name(context: Context, expected: str) -> None:
|
||||
"""Check qualified name from parsed or project context."""
|
||||
obj = getattr(context, "parsed_name", None) or context.project
|
||||
assert obj.qualified_name == expected, (
|
||||
f"Expected '{expected}', got '{obj.qualified_name}'"
|
||||
)
|
||||
|
||||
|
||||
@then('the nsproject namespaced name should be "{expected}"')
|
||||
def step_check_namespaced_name(context: Context, expected: str) -> None:
|
||||
"""Check namespaced_name from parsed or project context."""
|
||||
obj = getattr(context, "parsed_name", None) or context.project
|
||||
assert obj.namespaced_name == expected, (
|
||||
f"Expected '{expected}', got '{obj.namespaced_name}'"
|
||||
)
|
||||
|
||||
|
||||
@then("the nsproject parsed name should be local")
|
||||
def step_check_parsed_is_local(context: Context) -> None:
|
||||
"""Check parsed name is local."""
|
||||
assert context.parsed_name.is_local, "Expected is_local=True"
|
||||
|
||||
|
||||
@then("the nsproject parsed name should be remote")
|
||||
def step_check_parsed_is_remote(context: Context) -> None:
|
||||
"""Check parsed name is remote."""
|
||||
assert context.parsed_name.is_remote, "Expected is_remote=True"
|
||||
|
||||
|
||||
@when('I try to parse the project namespaced name "{value}"')
|
||||
def step_try_parse_project_name(context: Context, value: str) -> None:
|
||||
"""Try to parse a namespaced name, expecting failure."""
|
||||
try:
|
||||
parse_namespaced_name(value)
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to parse an empty project namespaced name")
|
||||
def step_try_parse_empty_name(context: Context) -> None:
|
||||
"""Try to parse an empty string."""
|
||||
try:
|
||||
parse_namespaced_name("")
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@then("a nsproject validation error should be raised")
|
||||
def step_check_project_error(context: Context) -> None:
|
||||
"""Check that a validation error was raised."""
|
||||
assert context.project_error is not None, (
|
||||
"Expected a validation error but none was raised"
|
||||
)
|
||||
|
||||
|
||||
# -- TemporalScope enum -----------------------------------------------------
|
||||
|
||||
|
||||
@then('the nsproject TemporalScope enum should have values "{values}"')
|
||||
def step_temporal_scope_values(context: Context, values: str) -> None:
|
||||
"""Check TemporalScope enum values."""
|
||||
expected = {v.strip() for v in values.split(",")}
|
||||
actual = {member.value for member in TemporalScope}
|
||||
assert actual == expected, f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
# -- ContextConfig model -----------------------------------------------------
|
||||
|
||||
|
||||
@given("a default ContextConfig")
|
||||
def step_default_context_config(context: Context) -> None:
|
||||
"""Create a default ContextConfig."""
|
||||
context.context_config = ContextConfig()
|
||||
|
||||
|
||||
@given("a ContextConfig with custom ignore patterns")
|
||||
def step_context_config_custom_ignores(context: Context) -> None:
|
||||
"""Create a ContextConfig with extra ignore patterns."""
|
||||
context.context_config = ContextConfig(ignore_patterns=["*.log"])
|
||||
|
||||
|
||||
@given("a ContextConfig with hot_max_tokens {hot:d} and warm_max_decisions {warm:d}")
|
||||
def step_context_config_memory_tiers(context: Context, hot: int, warm: int) -> None:
|
||||
"""Create a ContextConfig with memory tier settings."""
|
||||
context.context_config = ContextConfig(hot_max_tokens=hot, warm_max_decisions=warm)
|
||||
|
||||
|
||||
@then("the nsproject ctx max_file_size should be {expected:d}")
|
||||
def step_check_max_file_size(context: Context, expected: int) -> None:
|
||||
"""Check max_file_size."""
|
||||
assert context.context_config.max_file_size == expected
|
||||
|
||||
|
||||
@then("the nsproject ctx max_total_size should be {expected:d}")
|
||||
def step_check_max_total_size(context: Context, expected: int) -> None:
|
||||
"""Check max_total_size."""
|
||||
assert context.context_config.max_total_size == expected
|
||||
|
||||
|
||||
@then('the nsproject ctx indexing_strategy should be "{expected}"')
|
||||
def step_check_indexing_strategy(context: Context, expected: str) -> None:
|
||||
"""Check indexing_strategy."""
|
||||
assert context.context_config.indexing_strategy == expected
|
||||
|
||||
|
||||
@then('the nsproject ctx chunking_policy should be "{expected}"')
|
||||
def step_check_chunking_policy(context: Context, expected: str) -> None:
|
||||
"""Check chunking_policy."""
|
||||
assert context.context_config.chunking_policy == expected
|
||||
|
||||
|
||||
@then("the nsproject ctx chunk_size should be {expected:d}")
|
||||
def step_check_chunk_size(context: Context, expected: int) -> None:
|
||||
"""Check chunk_size."""
|
||||
assert context.context_config.chunk_size == expected
|
||||
|
||||
|
||||
@then("the nsproject ctx summarize should be {expected}")
|
||||
def step_check_summarize(context: Context, expected: str) -> None:
|
||||
"""Check summarize."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.context_config.summarize == expected_bool
|
||||
|
||||
|
||||
@then("the nsproject ctx auto_refresh should be {expected}")
|
||||
def step_check_auto_refresh(context: Context, expected: str) -> None:
|
||||
"""Check auto_refresh."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.context_config.auto_refresh == expected_bool
|
||||
|
||||
|
||||
@then('the nsproject ctx temporal_scope should be "{expected}"')
|
||||
def step_check_temporal_scope(context: Context, expected: str) -> None:
|
||||
"""Check temporal_scope."""
|
||||
assert context.context_config.temporal_scope == expected
|
||||
|
||||
|
||||
@then("the nsproject ctx retention_policy should be empty")
|
||||
def step_check_retention_policy_empty(context: Context) -> None:
|
||||
"""Check retention_policy is None."""
|
||||
assert context.context_config.retention_policy is None
|
||||
|
||||
|
||||
@then("the nsproject ctx ignore patterns should include defaults")
|
||||
def step_check_ignore_patterns_defaults(context: Context) -> None:
|
||||
"""Check that default ignore patterns are present."""
|
||||
from cleveragents.domain.models.core.project import (
|
||||
DEFAULT_IGNORE_PATTERNS,
|
||||
)
|
||||
|
||||
for pattern in DEFAULT_IGNORE_PATTERNS:
|
||||
assert pattern in context.context_config.ignore_patterns, (
|
||||
f"Default pattern '{pattern}' missing"
|
||||
)
|
||||
|
||||
|
||||
@then("the nsproject ctx ignore patterns should include the custom pattern")
|
||||
def step_check_ignore_patterns_custom(context: Context) -> None:
|
||||
"""Check that custom ignore pattern is present."""
|
||||
assert "*.log" in context.context_config.ignore_patterns
|
||||
|
||||
|
||||
@then("the nsproject ctx hot_max_tokens should be {expected:d}")
|
||||
def step_check_hot_max_tokens(context: Context, expected: int) -> None:
|
||||
"""Check hot_max_tokens."""
|
||||
assert context.context_config.hot_max_tokens == expected
|
||||
|
||||
|
||||
@then("the nsproject ctx warm_max_decisions should be {expected:d}")
|
||||
def step_check_warm_max_decisions(context: Context, expected: int) -> None:
|
||||
"""Check warm_max_decisions."""
|
||||
assert context.context_config.warm_max_decisions == expected
|
||||
|
||||
|
||||
@when("I try to mutate a ContextConfig field")
|
||||
def step_try_mutate_context_config(context: Context) -> None:
|
||||
"""Try to mutate a frozen ContextConfig."""
|
||||
try:
|
||||
context.context_config.chunk_size = 500 # type: ignore[misc]
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to create a ContextConfig with max_file_size {size:d}")
|
||||
def step_try_create_context_config_bad_size(context: Context, size: int) -> None:
|
||||
"""Try to create ContextConfig with invalid max_file_size."""
|
||||
try:
|
||||
ContextConfig(max_file_size=size)
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to create a ContextConfig with chunk_size {size:d}")
|
||||
def step_try_create_context_config_bad_chunk(context: Context, size: int) -> None:
|
||||
"""Try to create ContextConfig with invalid chunk_size."""
|
||||
try:
|
||||
ContextConfig(chunk_size=size)
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
# -- LinkedResource model ----------------------------------------------------
|
||||
|
||||
|
||||
@given('a LinkedResource with resource_id "{rid}"')
|
||||
def step_create_linked_resource(context: Context, rid: str) -> None:
|
||||
"""Create a LinkedResource with defaults."""
|
||||
context.linked_resource = LinkedResource(resource_id=rid)
|
||||
|
||||
|
||||
@given('a LinkedResource with id "{rid}" and alias "{alias}"')
|
||||
def step_create_linked_resource_with_alias(
|
||||
context: Context, rid: str, alias: str
|
||||
) -> None:
|
||||
"""Create a LinkedResource with alias."""
|
||||
context.linked_resource = LinkedResource(resource_id=rid, alias=alias)
|
||||
|
||||
|
||||
@then('the nsproject linked rid should be "{expected}"')
|
||||
def step_check_linked_rid(context: Context, expected: str) -> None:
|
||||
"""Check linked resource_id."""
|
||||
assert context.linked_resource.resource_id == expected
|
||||
|
||||
|
||||
@then("the nsproject linked read_only should be {expected}")
|
||||
def step_check_linked_readonly(context: Context, expected: str) -> None:
|
||||
"""Check project_read_only."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.linked_resource.project_read_only == expected_bool
|
||||
|
||||
|
||||
@then("the nsproject linked alias should be empty")
|
||||
def step_check_linked_alias_empty(context: Context) -> None:
|
||||
"""Check alias is None."""
|
||||
assert context.linked_resource.alias is None
|
||||
|
||||
|
||||
@then('the nsproject linked alias should be "{expected}"')
|
||||
def step_check_linked_alias(context: Context, expected: str) -> None:
|
||||
"""Check alias."""
|
||||
assert context.linked_resource.alias == expected
|
||||
|
||||
|
||||
@then("the nsproject linked_at should be set")
|
||||
def step_check_linked_at(context: Context) -> None:
|
||||
"""Check linked_at is set."""
|
||||
assert context.linked_resource.linked_at is not None
|
||||
|
||||
|
||||
@when('I try to create a LinkedResource with resource_id "{rid}"')
|
||||
def step_try_create_linked_resource_bad(context: Context, rid: str) -> None:
|
||||
"""Try to create a LinkedResource with invalid resource_id."""
|
||||
try:
|
||||
LinkedResource(resource_id=rid)
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to create a LinkedResource with empty alias")
|
||||
def step_try_create_linked_resource_empty_alias(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""Try to create a LinkedResource with empty alias."""
|
||||
try:
|
||||
LinkedResource(resource_id=VALID_ULID, alias="")
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to mutate a LinkedResource field")
|
||||
def step_try_mutate_linked_resource(context: Context) -> None:
|
||||
"""Try to mutate a frozen LinkedResource."""
|
||||
try:
|
||||
context.linked_resource.alias = "x" # type: ignore[misc]
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
# -- NamespacedProject model -------------------------------------------------
|
||||
|
||||
|
||||
@given('a NamespacedProject with name "{name}"')
|
||||
def step_create_namespaced_project(context: Context, name: str) -> None:
|
||||
"""Create a minimal NamespacedProject."""
|
||||
context.project = NamespacedProject(name=name)
|
||||
|
||||
|
||||
use_step_matcher("re") # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@given( # type: ignore[no-redef]
|
||||
r'a project "(?P<name>[^"]+)" under namespace'
|
||||
r' "(?P<namespace>[^"]+)"'
|
||||
)
|
||||
def step_create_namespaced_project_with_ns(
|
||||
context: Context, name: str, namespace: str
|
||||
) -> None:
|
||||
"""Create a NamespacedProject with namespace."""
|
||||
context.project = NamespacedProject(name=name, namespace=namespace)
|
||||
|
||||
|
||||
@given( # type: ignore[no-redef]
|
||||
r'a project "(?P<name>[^"]+)" under namespace'
|
||||
r' "(?P<namespace>[^"]+)" on server "(?P<server>[^"]+)"'
|
||||
)
|
||||
def step_create_namespaced_project_full(
|
||||
context: Context, name: str, namespace: str, server: str
|
||||
) -> None:
|
||||
"""Create a NamespacedProject with all identity fields."""
|
||||
context.project = NamespacedProject(name=name, namespace=namespace, server=server)
|
||||
|
||||
|
||||
use_step_matcher("parse") # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@then('the nsproject name should be "{expected}"')
|
||||
def step_check_project_name(context: Context, expected: str) -> None:
|
||||
"""Check project name."""
|
||||
assert context.project.name == expected
|
||||
|
||||
|
||||
@then('the nsproject namespace should be "{expected}"')
|
||||
def step_check_project_namespace(context: Context, expected: str) -> None:
|
||||
"""Check project namespace."""
|
||||
assert context.project.namespace == expected
|
||||
|
||||
|
||||
@then("the nsproject server should be empty")
|
||||
def step_check_project_server_empty(context: Context) -> None:
|
||||
"""Check project server is None."""
|
||||
assert context.project.server is None
|
||||
|
||||
|
||||
@then('the nsproject server should be "{expected}"')
|
||||
def step_check_project_server(context: Context, expected: str) -> None:
|
||||
"""Check project server."""
|
||||
assert context.project.server == expected
|
||||
|
||||
|
||||
@then("the nsproject linked resources should be empty")
|
||||
def step_check_project_no_linked_resources(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""Check linked_resources is empty."""
|
||||
assert context.project.linked_resources == []
|
||||
|
||||
|
||||
@then("the nsproject created_at should be set")
|
||||
def step_check_project_created_at(context: Context) -> None:
|
||||
"""Check created_at is set."""
|
||||
assert context.project.created_at is not None
|
||||
|
||||
|
||||
@then("the nsproject updated_at should be set")
|
||||
def step_check_project_updated_at(context: Context) -> None:
|
||||
"""Check updated_at is set."""
|
||||
assert context.project.updated_at is not None
|
||||
|
||||
|
||||
@then("the nsproject should be local")
|
||||
def step_check_project_is_local(context: Context) -> None:
|
||||
"""Check project is local."""
|
||||
assert context.project.is_local, "Expected is_local=True"
|
||||
|
||||
|
||||
@then("the nsproject should be remote")
|
||||
def step_check_project_is_remote(context: Context) -> None:
|
||||
"""Check project is remote."""
|
||||
assert context.project.is_remote, "Expected is_remote=True"
|
||||
|
||||
|
||||
# -- NamespacedProject validation -------------------------------------------
|
||||
|
||||
|
||||
@when('I try to create a NamespacedProject with name "{name}"')
|
||||
def step_try_create_project_bad_name(context: Context, name: str) -> None:
|
||||
"""Try to create a NamespacedProject with invalid name."""
|
||||
try:
|
||||
NamespacedProject(name=name)
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when('I try to create a NamespacedProject with namespace "{namespace}"')
|
||||
def step_try_create_project_bad_namespace(context: Context, namespace: str) -> None:
|
||||
"""Try to create a NamespacedProject with reserved namespace."""
|
||||
try:
|
||||
NamespacedProject(name="test", namespace=namespace)
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to create a NamespacedProject with duplicate linked resources")
|
||||
def step_try_create_project_dup_resources(
|
||||
context: Context,
|
||||
) -> None:
|
||||
"""Try to create a NamespacedProject with duplicate links."""
|
||||
rid = VALID_ULID
|
||||
lr1 = LinkedResource(resource_id=rid)
|
||||
lr2 = LinkedResource(resource_id=rid)
|
||||
try:
|
||||
NamespacedProject(name="test", linked_resources=[lr1, lr2])
|
||||
context.project_error = None
|
||||
except ValidationError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
# -- NamespacedProject domain methods ----------------------------------------
|
||||
|
||||
|
||||
@when('I link resource "{rid}" to the project')
|
||||
@given('I link resource "{rid}" to the project')
|
||||
def step_link_resource(context: Context, rid: str) -> None:
|
||||
"""Link a resource to the project."""
|
||||
context.project = context.project.link_resource(rid)
|
||||
|
||||
|
||||
@when('I link resource "{rid}" with read_only {ro} and alias "{alias}"')
|
||||
@given('I link resource "{rid}" with read_only {ro} and alias "{alias}"')
|
||||
def step_link_resource_with_options(
|
||||
context: Context, rid: str, ro: str, alias: str
|
||||
) -> None:
|
||||
"""Link a resource with options."""
|
||||
read_only = ro.lower() == "true"
|
||||
context.project = context.project.link_resource(
|
||||
rid, read_only=read_only, alias=alias
|
||||
)
|
||||
|
||||
|
||||
@when('I unlink resource "{rid}" from the project')
|
||||
def step_unlink_resource(context: Context, rid: str) -> None:
|
||||
"""Unlink a resource from the project."""
|
||||
context.project = context.project.unlink_resource(rid)
|
||||
|
||||
|
||||
@when('I try to link resource "{rid}" again')
|
||||
def step_try_link_duplicate(context: Context, rid: str) -> None:
|
||||
"""Try to link a resource that's already linked."""
|
||||
try:
|
||||
context.project.link_resource(rid)
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when('I try to unlink resource "{rid}" from the project')
|
||||
def step_try_unlink_nonexistent(context: Context, rid: str) -> None:
|
||||
"""Try to unlink a resource that's not linked."""
|
||||
try:
|
||||
context.project.unlink_resource(rid)
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when('I try to link resource "{rid}" to the project')
|
||||
def step_try_link_resource(context: Context, rid: str) -> None:
|
||||
"""Try to link a resource, might fail."""
|
||||
try:
|
||||
context.project = context.project.link_resource(rid)
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to link an empty resource_id to the project")
|
||||
def step_try_link_empty_resource(context: Context) -> None:
|
||||
"""Try to link with empty resource_id."""
|
||||
try:
|
||||
context.project = context.project.link_resource("")
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when("I try to unlink an empty resource_id from the project")
|
||||
def step_try_unlink_empty_resource(context: Context) -> None:
|
||||
"""Try to unlink with empty resource_id."""
|
||||
try:
|
||||
context.project = context.project.unlink_resource("")
|
||||
context.project_error = None
|
||||
except ValueError as e:
|
||||
context.project_error = e
|
||||
|
||||
|
||||
@when('I get linked resource "{rid}" from the project')
|
||||
def step_get_linked_resource(context: Context, rid: str) -> None:
|
||||
"""Get a linked resource by resource_id."""
|
||||
context.retrieved_link = context.project.get_linked_resource(rid)
|
||||
|
||||
|
||||
@when('I get linked resource by alias "{alias}" from the project')
|
||||
def step_get_linked_resource_by_alias(context: Context, alias: str) -> None:
|
||||
"""Get a linked resource by alias."""
|
||||
context.retrieved_link = context.project.get_linked_resource_by_alias(alias)
|
||||
|
||||
|
||||
@then("the nsproject should have {count:d} linked resource")
|
||||
def step_check_linked_count(context: Context, count: int) -> None:
|
||||
"""Check linked resource count."""
|
||||
actual = len(context.project.linked_resources)
|
||||
assert actual == count, f"Expected {count}, got {actual}"
|
||||
|
||||
|
||||
@then('the nsproject first linked resource_id should be "{expected}"')
|
||||
def step_check_linked_resource_id(context: Context, expected: str) -> None:
|
||||
"""Check the first linked resource's resource_id."""
|
||||
assert context.project.linked_resources[0].resource_id == expected
|
||||
|
||||
|
||||
@then("the nsproject first linked resource should be read_only")
|
||||
def step_check_first_linked_readonly(context: Context) -> None:
|
||||
"""Check first linked resource is read_only."""
|
||||
assert context.project.linked_resources[0].project_read_only
|
||||
|
||||
|
||||
@then('the nsproject first linked resource alias should be "{expected}"')
|
||||
def step_check_first_linked_alias(context: Context, expected: str) -> None:
|
||||
"""Check first linked resource alias."""
|
||||
assert context.project.linked_resources[0].alias == expected
|
||||
|
||||
|
||||
@then("the nsproject retrieved link should not be empty")
|
||||
def step_check_retrieved_link_not_none(context: Context) -> None:
|
||||
"""Check retrieved link is not None."""
|
||||
assert context.retrieved_link is not None
|
||||
|
||||
|
||||
@then("the nsproject retrieved link should be empty")
|
||||
def step_check_retrieved_link_none(context: Context) -> None:
|
||||
"""Check retrieved link is None."""
|
||||
assert context.retrieved_link is None
|
||||
|
||||
|
||||
@then('the nsproject retrieved link alias should be "{expected}"')
|
||||
def step_check_retrieved_link_alias(context: Context, expected: str) -> None:
|
||||
"""Check retrieved link alias."""
|
||||
assert context.retrieved_link.alias == expected
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Step definitions for Resource Registry domain model tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
VALID_ULID = "01HZQX7K8M3N4P5R6S7T8V9W0X"
|
||||
|
||||
|
||||
# -- PhysVirt enum -----------------------------------------------------------
|
||||
|
||||
|
||||
@then('the PhysVirt enum should have values "{values}"')
|
||||
def step_physvirt_values(context: Context, values: str) -> None:
|
||||
"""Check PhysVirt enum has exactly the expected values."""
|
||||
expected = {v.strip() for v in values.split(",")}
|
||||
actual = {member.value for member in PhysVirt}
|
||||
assert actual == expected, f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
@given('a PhysVirt value of "{value}"')
|
||||
def step_create_physvirt(context: Context, value: str) -> None:
|
||||
"""Create a PhysVirt enum value."""
|
||||
context.physvirt = PhysVirt(value)
|
||||
|
||||
|
||||
@then('the PhysVirt string value should be "{expected}"')
|
||||
def step_check_physvirt_value(context: Context, expected: str) -> None:
|
||||
"""Check PhysVirt string value."""
|
||||
assert str(context.physvirt) == expected, (
|
||||
f"Expected '{expected}', got '{context.physvirt}'"
|
||||
)
|
||||
|
||||
|
||||
@when('I try to create a PhysVirt with value "{value}"')
|
||||
def step_try_create_physvirt(context: Context, value: str) -> None:
|
||||
"""Try to create a PhysVirt enum value, expecting failure."""
|
||||
try:
|
||||
PhysVirt(value)
|
||||
context.resource_error = None
|
||||
except ValueError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
@then("a resource validation error should be raised")
|
||||
def step_check_resource_error(context: Context) -> None:
|
||||
"""Check that a validation error was raised."""
|
||||
assert context.resource_error is not None, (
|
||||
"Expected a validation error but none was raised"
|
||||
)
|
||||
|
||||
|
||||
# -- SandboxStrategy enum ---------------------------------------------------
|
||||
|
||||
|
||||
@then('the SandboxStrategy enum should have values "{values}"')
|
||||
def step_sandbox_strategy_values(context: Context, values: str) -> None:
|
||||
"""Check SandboxStrategy enum has exactly the expected values."""
|
||||
expected = {v.strip() for v in values.split(",")}
|
||||
actual = {member.value for member in SandboxStrategy}
|
||||
assert actual == expected, f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
@given('a SandboxStrategy value of "{value}"')
|
||||
def step_create_sandbox_strategy(context: Context, value: str) -> None:
|
||||
"""Create a SandboxStrategy enum value."""
|
||||
context.sandbox_strategy = SandboxStrategy(value)
|
||||
|
||||
|
||||
@then('the SandboxStrategy string value should be "{expected}"')
|
||||
def step_check_sandbox_strategy_value(context: Context, expected: str) -> None:
|
||||
"""Check SandboxStrategy string value."""
|
||||
assert str(context.sandbox_strategy) == expected, (
|
||||
f"Expected '{expected}', got '{context.sandbox_strategy}'"
|
||||
)
|
||||
|
||||
|
||||
@when('I try to create a SandboxStrategy with value "{value}"')
|
||||
def step_try_create_sandbox_strategy(context: Context, value: str) -> None:
|
||||
"""Try to create a SandboxStrategy, expecting failure."""
|
||||
try:
|
||||
SandboxStrategy(value)
|
||||
context.resource_error = None
|
||||
except ValueError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
# -- ResourceCapabilities ---------------------------------------------------
|
||||
|
||||
|
||||
@given("default ResourceCapabilities")
|
||||
def step_default_capabilities(context: Context) -> None:
|
||||
"""Create default ResourceCapabilities."""
|
||||
context.capabilities = ResourceCapabilities()
|
||||
|
||||
|
||||
@given("ResourceCapabilities with readable false and checkpointable true")
|
||||
def step_custom_capabilities(context: Context) -> None:
|
||||
"""Create ResourceCapabilities with custom values."""
|
||||
context.capabilities = ResourceCapabilities(readable=False, checkpointable=True)
|
||||
|
||||
|
||||
@then("the capability {field} should be {expected}")
|
||||
def step_check_capability(context: Context, field: str, expected: str) -> None:
|
||||
"""Check a capability field value."""
|
||||
actual = getattr(context.capabilities, field)
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert actual == expected_bool, f"Expected {field}={expected_bool}, got {actual}"
|
||||
|
||||
|
||||
@when("I try to mutate a ResourceCapabilities field")
|
||||
def step_try_mutate_capabilities(context: Context) -> None:
|
||||
"""Try to mutate a frozen ResourceCapabilities."""
|
||||
try:
|
||||
context.capabilities.readable = False # type: ignore[misc]
|
||||
context.resource_error = None
|
||||
except ValidationError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
# -- Resource model - creation -----------------------------------------------
|
||||
|
||||
|
||||
@given(
|
||||
'a Resource with resource_id "{rid}" type "{rtype}"'
|
||||
' and classification "{classification}"'
|
||||
)
|
||||
def step_create_minimal_resource(
|
||||
context: Context, rid: str, rtype: str, classification: str
|
||||
) -> None:
|
||||
"""Create a minimal Resource."""
|
||||
context.resource = Resource(
|
||||
resource_id=rid,
|
||||
resource_type_name=rtype,
|
||||
classification=PhysVirt(classification),
|
||||
)
|
||||
|
||||
|
||||
@given(
|
||||
'a full Resource with id "{rid}" name "{name}" type "{rtype}"'
|
||||
' classification "{classification}" description "{desc}"'
|
||||
)
|
||||
def step_create_full_resource(
|
||||
context: Context,
|
||||
rid: str,
|
||||
name: str,
|
||||
rtype: str,
|
||||
classification: str,
|
||||
desc: str,
|
||||
) -> None:
|
||||
"""Create a full Resource with all fields."""
|
||||
context.resource = Resource(
|
||||
resource_id=rid,
|
||||
name=name,
|
||||
resource_type_name=rtype,
|
||||
classification=PhysVirt(classification),
|
||||
description=desc,
|
||||
)
|
||||
|
||||
|
||||
@given('a Resource with sandbox strategy "{strategy}"')
|
||||
def step_create_resource_with_sandbox(context: Context, strategy: str) -> None:
|
||||
"""Create a Resource with a sandbox strategy override."""
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
sandbox_strategy=SandboxStrategy(strategy),
|
||||
)
|
||||
|
||||
|
||||
@given("a Resource with sandboxable false and checkpointable true")
|
||||
def step_create_resource_with_capabilities(context: Context) -> None:
|
||||
"""Create a Resource with custom capabilities."""
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
capabilities=ResourceCapabilities(sandboxable=False, checkpointable=True),
|
||||
)
|
||||
|
||||
|
||||
@given("a Resource with writable false")
|
||||
def step_create_readonly_resource(context: Context) -> None:
|
||||
"""Create a Resource with writable=false."""
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
capabilities=ResourceCapabilities(writable=False),
|
||||
)
|
||||
|
||||
|
||||
@given('a Resource with content_hash "{hash_val}"')
|
||||
def step_create_resource_with_hash(context: Context, hash_val: str) -> None:
|
||||
"""Create a Resource with content_hash."""
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
content_hash=hash_val,
|
||||
)
|
||||
|
||||
|
||||
@given('a Resource with location "{location}"')
|
||||
def step_create_resource_with_location(context: Context, location: str) -> None:
|
||||
"""Create a Resource with location."""
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
location=location,
|
||||
)
|
||||
|
||||
|
||||
@given("a Resource with parents and children")
|
||||
def step_create_resource_with_dag(context: Context) -> None:
|
||||
"""Create a Resource with parent/child references."""
|
||||
parent_id = "01HZQX7K8M3N4P5R6S7T8V9W01"
|
||||
child1_id = "01HZQX7K8M3N4P5R6S7T8V9W02"
|
||||
child2_id = "01HZQX7K8M3N4P5R6S7T8V9W03"
|
||||
context.resource = Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
parents=[parent_id],
|
||||
children=[child1_id, child2_id],
|
||||
)
|
||||
|
||||
|
||||
# -- Resource model - assertion steps ----------------------------------------
|
||||
|
||||
|
||||
@then('the resource resource_id should be "{expected}"')
|
||||
def step_check_resource_id(context: Context, expected: str) -> None:
|
||||
"""Check resource_id."""
|
||||
assert context.resource.resource_id == expected
|
||||
|
||||
|
||||
@then('the resource type name should be "{expected}"')
|
||||
def step_check_resource_type_name(context: Context, expected: str) -> None:
|
||||
"""Check resource_type_name."""
|
||||
assert context.resource.resource_type_name == expected
|
||||
|
||||
|
||||
@then('the resource classification should be "{expected}"')
|
||||
def step_check_classification(context: Context, expected: str) -> None:
|
||||
"""Check classification."""
|
||||
assert context.resource.classification == expected
|
||||
|
||||
|
||||
@then("the resource name should be empty")
|
||||
def step_check_name_empty(context: Context) -> None:
|
||||
"""Check name is None."""
|
||||
assert context.resource.name is None
|
||||
|
||||
|
||||
@then('the resource name should be "{expected}"')
|
||||
def step_check_resource_name(context: Context, expected: str) -> None:
|
||||
"""Check resource name."""
|
||||
assert context.resource.name == expected
|
||||
|
||||
|
||||
@then("the resource description should be empty")
|
||||
def step_check_description_empty(context: Context) -> None:
|
||||
"""Check description is None."""
|
||||
assert context.resource.description is None
|
||||
|
||||
|
||||
@then('the resource description should be "{expected}"')
|
||||
def step_check_resource_description(context: Context, expected: str) -> None:
|
||||
"""Check description."""
|
||||
assert context.resource.description == expected
|
||||
|
||||
|
||||
@then("the resource properties should be empty")
|
||||
def step_check_properties_empty(context: Context) -> None:
|
||||
"""Check properties dict is empty."""
|
||||
assert context.resource.properties == {}
|
||||
|
||||
|
||||
@then("the resource parents should be empty")
|
||||
def step_check_parents_empty(context: Context) -> None:
|
||||
"""Check parents list is empty."""
|
||||
assert context.resource.parents == []
|
||||
|
||||
|
||||
@then("the resource children should be empty")
|
||||
def step_check_children_empty(context: Context) -> None:
|
||||
"""Check children list is empty."""
|
||||
assert context.resource.children == []
|
||||
|
||||
|
||||
@then("the resource linked_projects should be empty")
|
||||
def step_check_linked_projects_empty(context: Context) -> None:
|
||||
"""Check linked_projects list is empty."""
|
||||
assert context.resource.linked_projects == []
|
||||
|
||||
|
||||
@then("the resource created_at should be set")
|
||||
def step_check_created_at(context: Context) -> None:
|
||||
"""Check created_at is not None."""
|
||||
assert context.resource.created_at is not None
|
||||
|
||||
|
||||
@then("the resource updated_at should be set")
|
||||
def step_check_updated_at(context: Context) -> None:
|
||||
"""Check updated_at is not None."""
|
||||
assert context.resource.updated_at is not None
|
||||
|
||||
|
||||
@then('the resource sandbox strategy should be "{expected}"')
|
||||
def step_check_sandbox_strategy(context: Context, expected: str) -> None:
|
||||
"""Check sandbox_strategy."""
|
||||
assert context.resource.sandbox_strategy == expected
|
||||
|
||||
|
||||
@then("the resource can_sandbox should be {expected}")
|
||||
def step_check_can_sandbox(context: Context, expected: str) -> None:
|
||||
"""Check can_sandbox property."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.resource.can_sandbox == expected_bool
|
||||
|
||||
|
||||
@then("the resource can_checkpoint should be {expected}")
|
||||
def step_check_can_checkpoint(context: Context, expected: str) -> None:
|
||||
"""Check can_checkpoint property."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.resource.can_checkpoint == expected_bool
|
||||
|
||||
|
||||
@then("the resource is_physical should be {expected}")
|
||||
def step_check_is_physical(context: Context, expected: str) -> None:
|
||||
"""Check is_physical property."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.resource.is_physical == expected_bool
|
||||
|
||||
|
||||
@then("the resource is_virtual should be {expected}")
|
||||
def step_check_is_virtual(context: Context, expected: str) -> None:
|
||||
"""Check is_virtual property."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.resource.is_virtual == expected_bool
|
||||
|
||||
|
||||
@then("the resource is_read_only should be {expected}")
|
||||
def step_check_is_read_only(context: Context, expected: str) -> None:
|
||||
"""Check is_read_only property."""
|
||||
expected_bool = expected.lower() == "true"
|
||||
assert context.resource.is_read_only == expected_bool
|
||||
|
||||
|
||||
@then('the resource content_hash should be "{expected}"')
|
||||
def step_check_content_hash(context: Context, expected: str) -> None:
|
||||
"""Check content_hash."""
|
||||
assert context.resource.content_hash == expected
|
||||
|
||||
|
||||
@then('the resource location should be "{expected}"')
|
||||
def step_check_location(context: Context, expected: str) -> None:
|
||||
"""Check location."""
|
||||
assert context.resource.location == expected
|
||||
|
||||
|
||||
@then("the resource should have {count:d} parent")
|
||||
def step_check_parent_count(context: Context, count: int) -> None:
|
||||
"""Check parent count."""
|
||||
assert len(context.resource.parents) == count
|
||||
|
||||
|
||||
@then("the resource should have {count:d} children")
|
||||
def step_check_child_count(context: Context, count: int) -> None:
|
||||
"""Check children count."""
|
||||
assert len(context.resource.children) == count
|
||||
|
||||
|
||||
# -- Resource model - validation failures ------------------------------------
|
||||
|
||||
|
||||
@when('I try to create a Resource with resource_id "{rid}"')
|
||||
def step_try_create_resource_bad_ulid(context: Context, rid: str) -> None:
|
||||
"""Try to create a Resource with invalid ULID."""
|
||||
try:
|
||||
Resource(
|
||||
resource_id=rid,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
)
|
||||
context.resource_error = None
|
||||
except ValidationError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
@when("I try to create a Resource with empty resource_type_name")
|
||||
def step_try_create_resource_empty_type(context: Context) -> None:
|
||||
"""Try to create a Resource with empty type name."""
|
||||
try:
|
||||
Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
)
|
||||
context.resource_error = None
|
||||
except ValidationError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
@when("I try to create a Resource with empty string name")
|
||||
def step_try_create_resource_empty_name(context: Context) -> None:
|
||||
"""Try to create a Resource with empty string name."""
|
||||
try:
|
||||
Resource(
|
||||
resource_id=VALID_ULID,
|
||||
name="",
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
)
|
||||
context.resource_error = None
|
||||
except ValidationError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
@when("I try to create a Resource with empty location")
|
||||
def step_try_create_resource_empty_location(context: Context) -> None:
|
||||
"""Try to create a Resource with empty location."""
|
||||
try:
|
||||
Resource(
|
||||
resource_id=VALID_ULID,
|
||||
resource_type_name="git-checkout",
|
||||
classification=PhysVirt.PHYSICAL,
|
||||
location="",
|
||||
)
|
||||
context.resource_error = None
|
||||
except ValidationError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
# -- Resource model - frozen -------------------------------------------------
|
||||
|
||||
|
||||
@when("I try to mutate a Resource field")
|
||||
def step_try_mutate_resource(context: Context) -> None:
|
||||
"""Try to mutate a frozen Resource."""
|
||||
try:
|
||||
context.resource.name = "new-name" # type: ignore[misc]
|
||||
context.resource_error = None
|
||||
except ValidationError as e:
|
||||
context.resource_error = e
|
||||
|
||||
|
||||
# -- Resource model - with_updated_at ---------------------------------------
|
||||
|
||||
|
||||
@when("I call with_updated_at on the resource")
|
||||
def step_call_with_updated_at(context: Context) -> None:
|
||||
"""Call with_updated_at and store old/new resources."""
|
||||
context.old_resource = context.resource
|
||||
time.sleep(0.01) # Ensure timestamp differs
|
||||
context.new_resource = context.resource.with_updated_at()
|
||||
|
||||
|
||||
@then("the new resource should have a different updated_at")
|
||||
def step_check_different_updated_at(context: Context) -> None:
|
||||
"""Check updated_at changed."""
|
||||
assert context.new_resource.updated_at != context.old_resource.updated_at
|
||||
|
||||
|
||||
@then("the new resource should have the same resource_id")
|
||||
def step_check_same_resource_id(context: Context) -> None:
|
||||
"""Check resource_id unchanged."""
|
||||
assert context.new_resource.resource_id == context.old_resource.resource_id
|
||||
@@ -50,10 +50,24 @@ from cleveragents.domain.models.core.plan_legacy import (
|
||||
PlanStatus,
|
||||
)
|
||||
from cleveragents.domain.models.core.project import (
|
||||
ContextConfig,
|
||||
LinkedResource,
|
||||
NamespacedProject,
|
||||
ParsedName,
|
||||
TemporalScope,
|
||||
parse_namespaced_name,
|
||||
)
|
||||
from cleveragents.domain.models.core.project_legacy import (
|
||||
Project,
|
||||
ProjectSettings,
|
||||
ProjectStats,
|
||||
)
|
||||
from cleveragents.domain.models.core.resource import (
|
||||
PhysVirt,
|
||||
Resource,
|
||||
ResourceCapabilities,
|
||||
SandboxStrategy,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActionState",
|
||||
@@ -62,6 +76,7 @@ __all__ = [
|
||||
"ChangeSet",
|
||||
"CloudBillingFields",
|
||||
"Context",
|
||||
"ContextConfig",
|
||||
"ContextFile",
|
||||
"ContextType",
|
||||
"ContextUpdateResult",
|
||||
@@ -72,13 +87,17 @@ __all__ = [
|
||||
"InvariantSource",
|
||||
"Invite",
|
||||
"LifecyclePlan",
|
||||
"LinkedResource",
|
||||
"MaxContextCount",
|
||||
"NamespacedName",
|
||||
"NamespacedProject",
|
||||
"Operation",
|
||||
"OperationType",
|
||||
"Org",
|
||||
"OrgRole",
|
||||
"OrgUser",
|
||||
"ParsedName",
|
||||
"PhysVirt",
|
||||
"Plan",
|
||||
"PlanBuild",
|
||||
"PlanIdentity",
|
||||
@@ -92,7 +111,12 @@ __all__ = [
|
||||
"ProjectLink",
|
||||
"ProjectSettings",
|
||||
"ProjectStats",
|
||||
"Resource",
|
||||
"ResourceCapabilities",
|
||||
"SandboxStrategy",
|
||||
"SummaryForUpdateContextParams",
|
||||
"TemporalScope",
|
||||
"User",
|
||||
"can_transition",
|
||||
"parse_namespaced_name",
|
||||
]
|
||||
|
||||
@@ -1,85 +1,534 @@
|
||||
"""Project domain model for CleverAgents.
|
||||
|
||||
Based on Phase 0 discovery and ADR-004 (Pydantic Validation).
|
||||
Contains both the legacy Project model (for backward compatibility with
|
||||
existing services) and the spec-aligned models:
|
||||
- LinkedResource (project-resource link record)
|
||||
- ContextConfig (project-level context configuration)
|
||||
- NamespacedProject (spec-aligned project identified solely by namespaced name)
|
||||
|
||||
Spec references:
|
||||
- Project Data Model (lines 6477-6511)
|
||||
- Namespaces (lines 6524-6584)
|
||||
- project_resources DDL (lines 26726-26733)
|
||||
- Context configuration CLI flags (lines 94-114)
|
||||
- ADR-004: Pydantic v2 Validation
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Namespace parsing utilities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Full namespaced name pattern: [[server:]namespace/]name
|
||||
# Examples: "local/my-project", "dev:freemo/api-service",
|
||||
# "my-project" (defaults to local/)
|
||||
_SERVER_NS_NAME_RE = re.compile(
|
||||
r"^(?:(?P<server>[a-zA-Z][a-zA-Z0-9_-]*):)?"
|
||||
r"(?P<namespace>[a-zA-Z][a-zA-Z0-9_-]*)/"
|
||||
r"(?P<name>[a-zA-Z][a-zA-Z0-9_-]*)$"
|
||||
)
|
||||
|
||||
# Name-only pattern (no namespace, no server)
|
||||
_BARE_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")
|
||||
|
||||
RESERVED_NAMESPACES = frozenset(
|
||||
{
|
||||
"system",
|
||||
"internal",
|
||||
"admin",
|
||||
"root",
|
||||
}
|
||||
)
|
||||
|
||||
# Built-in provider namespaces (reserved for LLM actors, cannot be used for projects)
|
||||
PROVIDER_NAMESPACES = frozenset(
|
||||
{
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"gemini",
|
||||
"deepseek",
|
||||
"mistral",
|
||||
"perplexity",
|
||||
"qwen",
|
||||
"amazon",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_NAMESPACE = "local"
|
||||
|
||||
|
||||
class ProjectSettings(BaseModel):
|
||||
"""Project-specific settings and configuration."""
|
||||
class ParsedName(BaseModel):
|
||||
"""Result of parsing a [[server:]namespace/]name string.
|
||||
|
||||
auto_build: bool = Field(False, description="Automatically build plans")
|
||||
auto_apply: bool = Field(False, description="Automatically apply changes")
|
||||
confirm_apply: bool = Field(
|
||||
True, description="Require confirmation before applying"
|
||||
)
|
||||
max_context_size: int = Field(
|
||||
52428800, description="Maximum context size in bytes (50MB)"
|
||||
)
|
||||
default_model: str = Field("mock-gpt", description="Default AI model")
|
||||
include_paths: list[str] = Field(
|
||||
default_factory=list, description="Relative include globs"
|
||||
)
|
||||
exclude_paths: list[str] = Field(
|
||||
default_factory=list, description="Relative exclude globs"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectStats(BaseModel):
|
||||
"""Statistics for a project."""
|
||||
|
||||
plans: int = Field(0, ge=0)
|
||||
context_files: int = Field(0, ge=0)
|
||||
changes: int = Field(0, ge=0)
|
||||
current_plan: str | None = Field(None)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Domain model for a CleverAgents project.
|
||||
|
||||
A project represents a workspace with plans, contexts, and changes.
|
||||
Spec reference: Namespace format (line 47 glossary, lines 6524-6584).
|
||||
"""
|
||||
|
||||
id: int | None = Field(None, description="Project ID")
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
path: Path = Field(..., description="Project root path")
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
settings: ProjectSettings = Field(default_factory=lambda: ProjectSettings()) # type: ignore
|
||||
current_plan_id: int | None = Field(None)
|
||||
server: str | None = Field(
|
||||
default=None, description="Server prefix (e.g., 'dev', 'prod')"
|
||||
)
|
||||
namespace: str = Field(
|
||||
default=DEFAULT_NAMESPACE,
|
||||
description="Namespace (e.g., 'local', 'freemo', 'cleverthis')",
|
||||
)
|
||||
name: str = Field(..., min_length=1, description="Entity name")
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Return the fully qualified name: [[server:]namespace/]name."""
|
||||
parts: list[str] = []
|
||||
if self.server is not None:
|
||||
parts.append(f"{self.server}:")
|
||||
parts.append(f"{self.namespace}/{self.name}")
|
||||
return "".join(parts)
|
||||
|
||||
@property
|
||||
def namespaced_name(self) -> str:
|
||||
"""Return namespace/name (without server prefix)."""
|
||||
return f"{self.namespace}/{self.name}"
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
"""Check if this is a local-only entity."""
|
||||
return self.namespace == DEFAULT_NAMESPACE and self.server is None
|
||||
|
||||
@property
|
||||
def is_remote(self) -> bool:
|
||||
"""Check if this entity lives on a server.
|
||||
|
||||
True when namespace is non-local or server-qualified.
|
||||
"""
|
||||
return self.server is not None or self.namespace != DEFAULT_NAMESPACE
|
||||
|
||||
|
||||
def parse_namespaced_name(value: str) -> ParsedName:
|
||||
"""Parse a [[server:]namespace/]name string.
|
||||
|
||||
Spec reference: Namespace format (line 47, lines 6524-6584).
|
||||
|
||||
Args:
|
||||
value: The name string to parse.
|
||||
|
||||
Returns:
|
||||
ParsedName with server, namespace, and name components.
|
||||
|
||||
Raises:
|
||||
ValueError: If the string is empty, has invalid characters,
|
||||
or uses a reserved/provider namespace.
|
||||
"""
|
||||
if not value or not value.strip():
|
||||
raise ValueError("Name cannot be empty")
|
||||
|
||||
value = value.strip()
|
||||
|
||||
# Try full pattern: [[server:]namespace/]name
|
||||
match = _SERVER_NS_NAME_RE.match(value)
|
||||
if match:
|
||||
server = match.group("server")
|
||||
namespace = match.group("namespace")
|
||||
name = match.group("name")
|
||||
|
||||
if namespace in RESERVED_NAMESPACES:
|
||||
raise ValueError(
|
||||
f"Namespace '{namespace}' is reserved. "
|
||||
f"Reserved: {', '.join(sorted(RESERVED_NAMESPACES))}"
|
||||
)
|
||||
if namespace in PROVIDER_NAMESPACES:
|
||||
raise ValueError(
|
||||
f"Namespace '{namespace}' is reserved for built-in LLM actors "
|
||||
f"and cannot be used for projects"
|
||||
)
|
||||
|
||||
return ParsedName(server=server, namespace=namespace, name=name)
|
||||
|
||||
# Try bare name (no namespace): defaults to local/
|
||||
if _BARE_NAME_RE.match(value):
|
||||
return ParsedName(server=None, namespace=DEFAULT_NAMESPACE, name=value)
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid name format: '{value}'. "
|
||||
f"Expected [[server:]namespace/]name where each part starts with "
|
||||
f"a letter and contains only letters, digits, hyphens, and underscores."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Spec-aligned models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_IGNORE_PATTERNS: list[str] = [
|
||||
".git/",
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".venv/",
|
||||
"*.pyc",
|
||||
".DS_Store",
|
||||
]
|
||||
|
||||
|
||||
class TemporalScope(StrEnum):
|
||||
"""Temporal scope for context retrieval."""
|
||||
|
||||
CURRENT = "current"
|
||||
RECENT = "recent"
|
||||
ALL = "all"
|
||||
|
||||
|
||||
class ContextConfig(BaseModel):
|
||||
"""Project-level context configuration.
|
||||
|
||||
Spec reference: Project context configuration (lines 6503-6511)
|
||||
and ``agents project context set`` CLI flags (lines 94-114).
|
||||
"""
|
||||
|
||||
# Path filtering
|
||||
ignore_patterns: list[str] = Field(
|
||||
default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS),
|
||||
description="File patterns to ignore during indexing (.gitignore semantics)",
|
||||
)
|
||||
include_patterns: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="File patterns to include (empty means include all)",
|
||||
)
|
||||
|
||||
# Size limits
|
||||
max_file_size: int = Field(
|
||||
default=1_000_000,
|
||||
gt=0,
|
||||
description="Maximum file size in bytes",
|
||||
)
|
||||
max_total_size: int = Field(
|
||||
default=52_428_800,
|
||||
gt=0,
|
||||
description="Maximum total context size in bytes (50MB default)",
|
||||
)
|
||||
|
||||
# Indexing and chunking
|
||||
indexing_strategy: str = Field(
|
||||
default="full_text",
|
||||
description="Indexing strategy (e.g., 'full_text', 'semantic')",
|
||||
)
|
||||
chunking_policy: str = Field(
|
||||
default="smart",
|
||||
description="Chunking policy (e.g., 'smart', 'fixed')",
|
||||
)
|
||||
chunk_size: int = Field(
|
||||
default=1000,
|
||||
gt=0,
|
||||
description="Chunk size in tokens",
|
||||
)
|
||||
|
||||
# Memory tier configuration
|
||||
hot_max_tokens: int | None = Field(
|
||||
default=None, description="Max tokens for hot tier context"
|
||||
)
|
||||
warm_max_decisions: int | None = Field(
|
||||
default=None, description="Max decisions for warm tier"
|
||||
)
|
||||
cold_max_decisions: int | None = Field(
|
||||
default=None, description="Max decisions for cold tier"
|
||||
)
|
||||
|
||||
# Summarization
|
||||
summarize: bool = Field(default=True, description="Whether to enable summarization")
|
||||
summary_max_tokens: int | None = Field(
|
||||
default=None, description="Max tokens for summaries"
|
||||
)
|
||||
|
||||
# Temporal and refresh
|
||||
temporal_scope: TemporalScope = Field(
|
||||
default=TemporalScope.CURRENT,
|
||||
description="Temporal scope for context retrieval",
|
||||
)
|
||||
auto_refresh: bool = Field(
|
||||
default=True, description="Auto-refresh context on changes"
|
||||
)
|
||||
|
||||
# Retention policy (spec: "context retention policy")
|
||||
retention_policy: str | None = Field(
|
||||
default=None,
|
||||
description="Context retention policy",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
use_enum_values=True,
|
||||
)
|
||||
|
||||
@field_validator("ignore_patterns")
|
||||
@classmethod
|
||||
def merge_default_ignore_patterns(
|
||||
cls: type[ContextConfig], v: list[str]
|
||||
) -> list[str]:
|
||||
"""Ensure default ignore patterns are always present."""
|
||||
merged = list(DEFAULT_IGNORE_PATTERNS)
|
||||
for pattern in v:
|
||||
if pattern not in merged:
|
||||
merged.append(pattern)
|
||||
return merged
|
||||
|
||||
|
||||
class LinkedResource(BaseModel):
|
||||
"""A project-resource link record.
|
||||
|
||||
Spec reference: Project Data Model, section 2 "Linked Resources"
|
||||
(lines 6488-6502) and project_resources DDL (lines 26726-26733).
|
||||
|
||||
Projects link to resources from the Resource Registry rather than
|
||||
embedding them. A resource can be linked to multiple projects.
|
||||
"""
|
||||
|
||||
resource_id: str = Field(
|
||||
...,
|
||||
description="ULID reference to a Resource Registry entry",
|
||||
pattern=r"^[0-9A-HJKMNP-TV-Z]{26}$",
|
||||
)
|
||||
project_read_only: bool = Field(
|
||||
default=False,
|
||||
description="Project-level read-only override",
|
||||
)
|
||||
alias: str | None = Field(
|
||||
default=None,
|
||||
description="Optional short name for referencing within the project",
|
||||
)
|
||||
linked_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=UTC),
|
||||
description="When this resource was linked to the project",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
@field_validator("alias")
|
||||
@classmethod
|
||||
def validate_alias(cls: type[LinkedResource], v: str | None) -> str | None:
|
||||
"""Validate alias is non-empty if provided."""
|
||||
if v is not None and not v.strip():
|
||||
raise ValueError("Alias cannot be empty (use None instead)")
|
||||
return v
|
||||
|
||||
|
||||
class NamespacedProject(BaseModel):
|
||||
"""Spec-aligned project model.
|
||||
|
||||
Spec reference: Project Data Model (lines 6477-6511).
|
||||
|
||||
A project is identified SOLELY by its namespaced name -- no ULID
|
||||
or integer ID is generated. The namespacing scheme
|
||||
([[server:]namespace/]name) ensures global uniqueness.
|
||||
"""
|
||||
|
||||
# Identity -- namespaced name IS the identifier (no ULID)
|
||||
name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
description="Project name (without namespace prefix)",
|
||||
)
|
||||
namespace: str = Field(
|
||||
default=DEFAULT_NAMESPACE,
|
||||
description="Project namespace (e.g., 'local', 'freemo', 'cleverthis')",
|
||||
)
|
||||
server: str | None = Field(
|
||||
default=None,
|
||||
description="Server prefix for multi-server disambiguation",
|
||||
)
|
||||
|
||||
# Description
|
||||
description: str | None = Field(
|
||||
default=None, description="Human-readable project description"
|
||||
)
|
||||
|
||||
# Linked resources (spec section 2)
|
||||
linked_resources: list[LinkedResource] = Field(
|
||||
default_factory=list,
|
||||
description="Resources linked to this project from the Resource Registry",
|
||||
)
|
||||
|
||||
# Context configuration (spec section 3)
|
||||
context_config: ContextConfig = Field(
|
||||
default_factory=ContextConfig,
|
||||
description="Project-level context configuration",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=UTC),
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=UTC),
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
# -- Validators ----------------------------------------------------------
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls: type["Project"], v: str) -> str:
|
||||
"""Validate project name."""
|
||||
if not v.replace("-", "").replace("_", "").replace(" ", "").isalnum():
|
||||
def validate_name(cls: type[NamespacedProject], v: str) -> str:
|
||||
"""Validate project name format.
|
||||
|
||||
Must start with a letter, alphanumeric + hyphens + underscores.
|
||||
"""
|
||||
if not _BARE_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
"Name must be alphanumeric with hyphens, underscores, or spaces"
|
||||
f"Project name '{v}' is invalid. Must start with a letter "
|
||||
f"and contain only letters, digits, hyphens, and underscores."
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("path")
|
||||
@field_validator("namespace")
|
||||
@classmethod
|
||||
def validate_path(cls: type["Project"], v: Path) -> Path:
|
||||
"""Ensure path is absolute."""
|
||||
return v.resolve()
|
||||
def validate_namespace(cls: type[NamespacedProject], v: str) -> str:
|
||||
"""Validate namespace: must be a valid namespace, not reserved."""
|
||||
if not _BARE_NAME_RE.match(v):
|
||||
raise ValueError(
|
||||
f"Namespace '{v}' is invalid. Must start with a letter "
|
||||
f"and contain only letters, digits, hyphens, and underscores."
|
||||
)
|
||||
if v in RESERVED_NAMESPACES:
|
||||
raise ValueError(
|
||||
f"Namespace '{v}' is reserved. "
|
||||
f"Reserved: {', '.join(sorted(RESERVED_NAMESPACES))}"
|
||||
)
|
||||
if v in PROVIDER_NAMESPACES:
|
||||
raise ValueError(f"Namespace '{v}' is reserved for built-in LLM actors.")
|
||||
return v
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
arbitrary_types_allowed=True, # Allow Path
|
||||
)
|
||||
@model_validator(mode="after")
|
||||
def validate_linked_resources_unique(self) -> NamespacedProject:
|
||||
"""Ensure no duplicate resource_ids in linked_resources."""
|
||||
ids = [lr.resource_id for lr in self.linked_resources]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError("Duplicate resource_id in linked_resources")
|
||||
return self
|
||||
|
||||
# -- Properties ----------------------------------------------------------
|
||||
|
||||
@property
|
||||
def namespaced_name(self) -> str:
|
||||
"""Return namespace/name."""
|
||||
return f"{self.namespace}/{self.name}"
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Return [[server:]namespace/]name."""
|
||||
if self.server is not None:
|
||||
return f"{self.server}:{self.namespace}/{self.name}"
|
||||
return self.namespaced_name
|
||||
|
||||
@property
|
||||
def is_local(self) -> bool:
|
||||
"""Check if this is a local-only project."""
|
||||
return self.namespace == DEFAULT_NAMESPACE and self.server is None
|
||||
|
||||
@property
|
||||
def is_remote(self) -> bool:
|
||||
"""Check if this project lives on a server."""
|
||||
return self.server is not None or self.namespace != DEFAULT_NAMESPACE
|
||||
|
||||
# -- Domain Methods ------------------------------------------------------
|
||||
|
||||
def link_resource(
|
||||
self,
|
||||
resource_id: str,
|
||||
read_only: bool = False,
|
||||
alias: str | None = None,
|
||||
) -> NamespacedProject:
|
||||
"""Return a new project with the resource linked.
|
||||
|
||||
Args:
|
||||
resource_id: ULID of the resource to link.
|
||||
read_only: Whether this resource is read-only in this project context.
|
||||
alias: Optional short name for the resource within the project.
|
||||
|
||||
Raises:
|
||||
ValueError: If resource_id is empty or already linked.
|
||||
"""
|
||||
if not resource_id or not resource_id.strip():
|
||||
raise ValueError("resource_id cannot be empty")
|
||||
if any(lr.resource_id == resource_id for lr in self.linked_resources):
|
||||
raise ValueError(
|
||||
f"Resource '{resource_id}' is already linked to this project"
|
||||
)
|
||||
|
||||
link = LinkedResource(
|
||||
resource_id=resource_id,
|
||||
project_read_only=read_only,
|
||||
alias=alias,
|
||||
)
|
||||
new_resources = [*self.linked_resources, link]
|
||||
return self.model_copy(
|
||||
update={
|
||||
"linked_resources": new_resources,
|
||||
"updated_at": datetime.now(tz=UTC),
|
||||
}
|
||||
)
|
||||
|
||||
def unlink_resource(self, resource_id: str) -> NamespacedProject:
|
||||
"""Return a new project with the resource unlinked.
|
||||
|
||||
Args:
|
||||
resource_id: ULID of the resource to unlink.
|
||||
|
||||
Raises:
|
||||
ValueError: If resource_id is empty or not linked.
|
||||
"""
|
||||
if not resource_id or not resource_id.strip():
|
||||
raise ValueError("resource_id cannot be empty")
|
||||
if not any(lr.resource_id == resource_id for lr in self.linked_resources):
|
||||
raise ValueError(f"Resource '{resource_id}' is not linked to this project")
|
||||
|
||||
new_resources = [
|
||||
lr for lr in self.linked_resources if lr.resource_id != resource_id
|
||||
]
|
||||
return self.model_copy(
|
||||
update={
|
||||
"linked_resources": new_resources,
|
||||
"updated_at": datetime.now(tz=UTC),
|
||||
}
|
||||
)
|
||||
|
||||
def get_linked_resource(self, resource_id: str) -> LinkedResource | None:
|
||||
"""Get a linked resource by resource_id.
|
||||
|
||||
Args:
|
||||
resource_id: ULID to look up.
|
||||
|
||||
Raises:
|
||||
ValueError: If resource_id is empty.
|
||||
"""
|
||||
if not resource_id or not resource_id.strip():
|
||||
raise ValueError("resource_id cannot be empty")
|
||||
for lr in self.linked_resources:
|
||||
if lr.resource_id == resource_id:
|
||||
return lr
|
||||
return None
|
||||
|
||||
def get_linked_resource_by_alias(self, alias: str) -> LinkedResource | None:
|
||||
"""Get a linked resource by alias.
|
||||
|
||||
Args:
|
||||
alias: Alias to look up.
|
||||
|
||||
Raises:
|
||||
ValueError: If alias is empty.
|
||||
"""
|
||||
if not alias or not alias.strip():
|
||||
raise ValueError("alias cannot be empty")
|
||||
for lr in self.linked_resources:
|
||||
if lr.alias == alias:
|
||||
return lr
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Legacy project models (preserved for backward compatibility).
|
||||
|
||||
New code should use the spec-aligned models in project.py:
|
||||
- NamespacedProject, LinkedResource, ContextConfig, ParsedName
|
||||
|
||||
These legacy models exist to avoid breaking existing services,
|
||||
repositories, and CLI commands that depend on the old API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class ProjectSettings(BaseModel):
|
||||
"""Project-specific settings and configuration (legacy)."""
|
||||
|
||||
auto_build: bool = Field(default=False, description="Automatically build plans")
|
||||
auto_apply: bool = Field(
|
||||
default=False,
|
||||
description="Automatically apply changes",
|
||||
)
|
||||
confirm_apply: bool = Field(
|
||||
default=True,
|
||||
description="Require confirmation before applying",
|
||||
)
|
||||
max_context_size: int = Field(
|
||||
default=52428800,
|
||||
description="Maximum context size in bytes (50MB)",
|
||||
)
|
||||
default_model: str = Field(default="mock-gpt", description="Default AI model")
|
||||
include_paths: list[str] = Field(
|
||||
default_factory=list, description="Relative include globs"
|
||||
)
|
||||
exclude_paths: list[str] = Field(
|
||||
default_factory=list, description="Relative exclude globs"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class ProjectStats(BaseModel):
|
||||
"""Statistics for a project (legacy)."""
|
||||
|
||||
plans: int = Field(default=0, ge=0)
|
||||
context_files: int = Field(default=0, ge=0)
|
||||
changes: int = Field(default=0, ge=0)
|
||||
current_plan: str | None = Field(default=None)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Legacy project model (preserved for backward compatibility).
|
||||
|
||||
New code should use NamespacedProject instead. This model exists
|
||||
to avoid breaking existing services, repositories, and CLI commands.
|
||||
"""
|
||||
|
||||
id: int | None = Field(default=None, description="Project ID")
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
path: Path = Field(..., description="Project root path")
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
settings: ProjectSettings = Field(
|
||||
default_factory=lambda: ProjectSettings() # type: ignore[arg-type]
|
||||
)
|
||||
current_plan_id: int | None = Field(default=None)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls: type[Project], v: str) -> str:
|
||||
"""Validate project name."""
|
||||
if not v.replace("-", "").replace("_", "").replace(" ", "").isalnum():
|
||||
raise ValueError(
|
||||
"Name must be alphanumeric with hyphens, underscores, or spaces"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("path")
|
||||
@classmethod
|
||||
def validate_path(cls: type[Project], v: Path) -> Path:
|
||||
"""Ensure path is absolute."""
|
||||
return v.resolve()
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Resource domain models for CleverAgents.
|
||||
|
||||
Implements the Resource Registry data model as defined in docs/specification.md:
|
||||
- PhysVirt enum (physical | virtual classification)
|
||||
- SandboxStrategy enum (git_worktree | copy_on_write |
|
||||
transaction_rollback | snapshot | none)
|
||||
- ResourceCapabilities (readable, writable, sandboxable, checkpointable)
|
||||
- ResourceRecord (the core Resource domain model)
|
||||
|
||||
Based on:
|
||||
- Specification: ResourceRecord UML (lines 9932-9944)
|
||||
- Specification: PhysVirt enum (lines 9961-9964)
|
||||
- Specification: SandboxStrategy JSON schema (lines 17135-17138)
|
||||
- Specification: Resource Capabilities (lines 9892-9903)
|
||||
- Specification: resources DDL (lines 26696-26713)
|
||||
- ADR-004: Pydantic v2 Validation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# ULID: 26 characters, Crockford's base32
|
||||
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"
|
||||
|
||||
# Namespaced name: [[server:]namespace/]name
|
||||
# Built-in resource types are unnamespaced (e.g., "git-checkout").
|
||||
# Custom types follow: namespace/name (e.g., "local/my-type").
|
||||
NAMESPACED_NAME_PATTERN = re.compile(
|
||||
r"^(?:(?P<server>[a-zA-Z0-9_-]+):)?(?P<namespace>[a-zA-Z0-9_-]+)/(?P<name>[a-zA-Z0-9_-]+)$"
|
||||
)
|
||||
|
||||
|
||||
class PhysVirt(StrEnum):
|
||||
"""Classification of a resource as physical or virtual.
|
||||
|
||||
Spec reference: PhysVirt enum (lines 9961-9964).
|
||||
- physical: Represents a real, tangible resource (file, directory, git repo).
|
||||
- virtual: Represents a derived/computed resource (git branch, tree entry).
|
||||
"""
|
||||
|
||||
PHYSICAL = "physical"
|
||||
VIRTUAL = "virtual"
|
||||
|
||||
|
||||
class SandboxStrategy(StrEnum):
|
||||
"""Sandbox isolation strategy for a resource.
|
||||
|
||||
Spec reference: JSON schema enum (lines 17135-17138).
|
||||
Exactly 5 values as defined in the specification.
|
||||
"""
|
||||
|
||||
GIT_WORKTREE = "git_worktree"
|
||||
COPY_ON_WRITE = "copy_on_write"
|
||||
TRANSACTION_ROLLBACK = "transaction_rollback"
|
||||
SNAPSHOT = "snapshot"
|
||||
NONE = "none"
|
||||
|
||||
|
||||
class ResourceCapabilities(BaseModel):
|
||||
"""Capabilities declared by a resource, derived from its resource type.
|
||||
|
||||
Spec reference: Resource Capabilities (lines 9892-9903).
|
||||
These are used by the tool execution flow to validate that a tool's
|
||||
resource binding is compatible with the resource.
|
||||
"""
|
||||
|
||||
readable: bool = Field(default=True, description="Whether the resource can be read")
|
||||
writable: bool = Field(
|
||||
default=True, description="Whether the resource can be modified"
|
||||
)
|
||||
sandboxable: bool = Field(
|
||||
default=True,
|
||||
description="Whether the resource supports sandbox isolation",
|
||||
)
|
||||
checkpointable: bool = Field(
|
||||
default=False,
|
||||
description="Whether the resource supports checkpoint/rollback",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""Domain model for a Resource Registry entry (ResourceRecord).
|
||||
|
||||
Spec reference: ResourceRecord UML (lines 9932-9944) and
|
||||
resources DDL (lines 26696-26713).
|
||||
|
||||
Resources are identified by ULID (resource_id). They may optionally
|
||||
have a namespaced name (NULL for auto-discovered children).
|
||||
"""
|
||||
|
||||
# Identity -- resource_id is the primary key (ULID)
|
||||
resource_id: str = Field(
|
||||
...,
|
||||
description="Unique ULID identifier (primary key)",
|
||||
pattern=ULID_PATTERN,
|
||||
)
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Namespaced name (NULL for auto-discovered children)",
|
||||
)
|
||||
|
||||
# Type classification
|
||||
resource_type_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Namespaced type name (e.g., 'git-checkout', 'local/my-type')",
|
||||
)
|
||||
classification: PhysVirt = Field(
|
||||
...,
|
||||
description="Physical or virtual classification",
|
||||
)
|
||||
|
||||
# Descriptive
|
||||
description: str | None = Field(
|
||||
default=None,
|
||||
description="Human-readable description",
|
||||
)
|
||||
|
||||
# Type-specific properties (JSON in DB)
|
||||
properties: dict[str, str | int | float | bool | None] = Field(
|
||||
default_factory=dict,
|
||||
description="Type-specific properties (stored as JSON)",
|
||||
)
|
||||
|
||||
# Location and hashing
|
||||
location: str | None = Field(
|
||||
default=None,
|
||||
description="Physical location (physical resources only)",
|
||||
)
|
||||
content_hash: str | None = Field(
|
||||
default=None,
|
||||
description="Content hash for equivalence tracking",
|
||||
)
|
||||
|
||||
# Sandbox strategy override (per-resource, may differ from type default)
|
||||
sandbox_strategy: SandboxStrategy | None = Field(
|
||||
default=None,
|
||||
description="Sandbox strategy override per resource",
|
||||
)
|
||||
|
||||
# Capabilities (derived from resource type)
|
||||
capabilities: ResourceCapabilities = Field(
|
||||
default_factory=ResourceCapabilities,
|
||||
description=(
|
||||
"Resource capabilities (readable, writable, sandboxable, checkpointable)"
|
||||
),
|
||||
)
|
||||
|
||||
# DAG relationships (loaded lazily in practice,
|
||||
# represented here for domain completeness)
|
||||
parents: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Parent resource_id list (DAG upward references)",
|
||||
)
|
||||
children: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Child resource_id list (DAG downward references)",
|
||||
)
|
||||
|
||||
# Cross-references
|
||||
linked_projects: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Project names this resource is linked to",
|
||||
)
|
||||
|
||||
# Timestamps
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=UTC),
|
||||
description="Creation timestamp (UTC)",
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(tz=UTC),
|
||||
description="Last update timestamp (UTC)",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
frozen=True,
|
||||
str_strip_whitespace=True,
|
||||
use_enum_values=True,
|
||||
)
|
||||
|
||||
# -- Validators ----------------------------------------------------------
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls: type[Resource], v: str | None) -> str | None:
|
||||
"""Validate namespaced name format if provided.
|
||||
|
||||
Names follow the pattern: [[server:]namespace/]name
|
||||
Built-in resource types may be unnamespaced (e.g., 'git-checkout').
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
if not v.strip():
|
||||
raise ValueError("Resource name cannot be empty (use None instead)")
|
||||
return v
|
||||
|
||||
@field_validator("location")
|
||||
@classmethod
|
||||
def validate_location(cls: type[Resource], v: str | None) -> str | None:
|
||||
"""Validate location is non-empty if provided."""
|
||||
if v is not None and not v.strip():
|
||||
raise ValueError("Location cannot be empty (use None instead)")
|
||||
return v
|
||||
|
||||
@field_validator("resource_type_name")
|
||||
@classmethod
|
||||
def validate_resource_type_name(cls: type[Resource], v: str) -> str:
|
||||
"""Validate resource type name is non-empty."""
|
||||
if not v.strip():
|
||||
raise ValueError("resource_type_name cannot be empty")
|
||||
return v
|
||||
|
||||
# -- Domain Methods ------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_physical(self) -> bool:
|
||||
"""Check if this is a physical resource."""
|
||||
return self.classification == PhysVirt.PHYSICAL
|
||||
|
||||
@property
|
||||
def is_virtual(self) -> bool:
|
||||
"""Check if this is a virtual resource."""
|
||||
return self.classification == PhysVirt.VIRTUAL
|
||||
|
||||
@property
|
||||
def can_sandbox(self) -> bool:
|
||||
"""Check if this resource supports sandbox isolation."""
|
||||
return self.capabilities.sandboxable
|
||||
|
||||
@property
|
||||
def can_checkpoint(self) -> bool:
|
||||
"""Check if this resource supports checkpoint/rollback."""
|
||||
return self.capabilities.checkpointable
|
||||
|
||||
@property
|
||||
def is_read_only(self) -> bool:
|
||||
"""Check if this resource is read-only."""
|
||||
return not self.capabilities.writable
|
||||
|
||||
def with_updated_at(self) -> Resource:
|
||||
"""Return a copy with updated_at set to now (UTC)."""
|
||||
return self.model_copy(update={"updated_at": datetime.now(tz=UTC)})
|
||||
Reference in New Issue
Block a user