forked from HAL9000/cleveragents-core
d5f7f15215
Temporal metadata fields (validFrom, validUntil, isCurrent, isRevisionOf) on UKO InformationUnit nodes enable revision chain tracking: when code changes, old nodes are marked historical and new revision nodes are created with back-links. Three storage tiers (hot/warm/cold) filter nodes by temporal scope (current/recent/all) with configurable retention (warm_retention_hours default 24h, cold_retention_days default 90d). Includes TemporalMetadata, TemporalNode, RevisionChain, TierQueryResult, TierRetentionConfig frozen domain models, TemporalBackend protocol, InMemoryTemporalBackend stub, TemporalService with structlog and DI, BackendSet.temporal typing upgrade from object|None to TemporalBackend|None. 67 Behave scenarios, 8 Robot Framework tests, ASV benchmarks, and reference documentation. ISSUES CLOSED: #577
500 lines
15 KiB
Python
500 lines
15 KiB
Python
"""Step definitions for Temporal Data Model — backend steps."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.domain.models.acms.temporal import (
|
|
TemporalMetadata,
|
|
TemporalNode,
|
|
TierRetentionConfig,
|
|
)
|
|
from cleveragents.domain.models.acms.temporal_stubs import InMemoryTemporalBackend
|
|
from cleveragents.domain.models.acms.tiers import ContextTier
|
|
from cleveragents.domain.models.core.project import TemporalScope
|
|
|
|
__all__: list[str] = []
|
|
|
|
|
|
# ---- InMemoryTemporalBackend steps ----
|
|
|
|
|
|
@given("an InMemoryTemporalBackend instance")
|
|
def step_given_inmemory_temporal_backend(context: Any) -> None:
|
|
context.temporal_backend = InMemoryTemporalBackend()
|
|
|
|
|
|
@given('a stored temporal node "{uri}" for resource "{resource}" at "{path}"')
|
|
def step_given_stored_temporal_node(
|
|
context: Any,
|
|
uri: str,
|
|
resource: str,
|
|
path: str,
|
|
) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
node = TemporalNode(
|
|
node_uri=uri,
|
|
source_resource=resource,
|
|
source_path=path,
|
|
temporal=TemporalMetadata(
|
|
valid_from=now - timedelta(days=5),
|
|
is_current=True,
|
|
),
|
|
)
|
|
context.temporal_backend.store_node(node)
|
|
context.last_stored_node = node
|
|
|
|
|
|
@when('I get the current node for base "{base}"')
|
|
def step_when_get_current(context: Any, base: str) -> None:
|
|
context.current_node = context.temporal_backend.get_current(base)
|
|
|
|
|
|
@then('the current node uri should be "{expected}"')
|
|
def step_then_current_node_uri(context: Any, expected: str) -> None:
|
|
assert context.current_node is not None
|
|
assert context.current_node.node_uri == expected
|
|
|
|
|
|
@then("the current node should be None")
|
|
def step_then_current_node_none(context: Any) -> None:
|
|
assert context.current_node is None
|
|
|
|
|
|
@given('I create a revision from "{old}" to "{new}"')
|
|
@when('I create a revision from "{old}" to "{new}"')
|
|
def step_when_create_revision(context: Any, old: str, new: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri=new,
|
|
source_resource="RES001",
|
|
source_path="src/auth.py",
|
|
temporal=TemporalMetadata(
|
|
valid_from=now,
|
|
is_current=True,
|
|
is_revision_of=old,
|
|
),
|
|
)
|
|
context.new_revision = context.temporal_backend.create_revision(
|
|
old,
|
|
new_node,
|
|
now,
|
|
)
|
|
|
|
|
|
@then('the old node "{uri}" should not be current')
|
|
def step_then_old_node_not_current(context: Any, uri: str) -> None:
|
|
node = context.temporal_backend._nodes[uri]
|
|
assert node.temporal.is_current is False
|
|
|
|
|
|
@then('the old node "{uri}" should have valid_until set')
|
|
def step_then_old_node_valid_until(context: Any, uri: str) -> None:
|
|
node = context.temporal_backend._nodes[uri]
|
|
assert node.temporal.valid_until is not None
|
|
|
|
|
|
@then('the new node "{uri}" should be current')
|
|
def step_then_new_node_current(context: Any, uri: str) -> None:
|
|
node = context.temporal_backend._nodes[uri]
|
|
assert node.temporal.is_current is True
|
|
|
|
|
|
@then('the new node "{uri}" should have is_revision_of "{expected}"')
|
|
def step_then_new_node_revision_of(
|
|
context: Any,
|
|
uri: str,
|
|
expected: str,
|
|
) -> None:
|
|
node = context.temporal_backend._nodes[uri]
|
|
assert node.temporal.is_revision_of == expected
|
|
|
|
|
|
@then('creating a revision from nonexistent "{uri}" should raise KeyError')
|
|
def step_then_revision_nonexistent(context: Any, uri: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri="uko:new",
|
|
source_resource="RES",
|
|
source_path="src/a.py",
|
|
temporal=TemporalMetadata(valid_from=now, is_current=True),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision(uri, new_node, now)
|
|
msg = "Expected KeyError"
|
|
raise AssertionError(msg)
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
@then("creating a revision from blank URI should raise ValueError")
|
|
def step_then_revision_blank_uri(context: Any) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri="uko:new",
|
|
source_resource="RES",
|
|
source_path="src/a.py",
|
|
temporal=TemporalMetadata(valid_from=now, is_current=True),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision("", new_node, now)
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then('creating a revision from "{uri}" to itself should raise ValueError')
|
|
def step_then_revision_same_uri(context: Any, uri: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
same_uri_node = TemporalNode(
|
|
node_uri=uri,
|
|
source_resource="RES",
|
|
source_path="src/a.py",
|
|
temporal=TemporalMetadata(valid_from=now, is_current=True),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision(uri, same_uri_node, now)
|
|
msg = "Expected ValueError for same-URI revision"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then('storing a duplicate node "{uri}" should raise ValueError')
|
|
def step_then_store_duplicate_raises(context: Any, uri: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
node = TemporalNode(
|
|
node_uri=uri,
|
|
source_resource="RES_DUP",
|
|
source_path="src/dup.py",
|
|
temporal=TemporalMetadata(valid_from=now, is_current=True),
|
|
)
|
|
try:
|
|
context.temporal_backend.store_node(node)
|
|
msg = "Expected ValueError for duplicate store_node"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ---- get_history steps ----
|
|
|
|
|
|
@when('I get the history for base "{base}" with scope ALL')
|
|
def step_when_get_history_all(context: Any, base: str) -> None:
|
|
context.history = context.temporal_backend.get_history(
|
|
base,
|
|
TemporalScope.ALL,
|
|
)
|
|
|
|
|
|
@when('I get the history for base "{base}" with scope CURRENT')
|
|
def step_when_get_history_current(context: Any, base: str) -> None:
|
|
context.history = context.temporal_backend.get_history(
|
|
base,
|
|
TemporalScope.CURRENT,
|
|
)
|
|
|
|
|
|
@then("the history should have {count:d} nodes")
|
|
def step_then_history_count(context: Any, count: int) -> None:
|
|
assert len(context.history) == count
|
|
|
|
|
|
@then("the history should be ordered newest first")
|
|
def step_then_history_ordered(context: Any) -> None:
|
|
for i in range(len(context.history) - 1):
|
|
assert (
|
|
context.history[i].temporal.valid_from
|
|
>= context.history[i + 1].temporal.valid_from
|
|
)
|
|
|
|
|
|
@then("getting history for blank base URI should raise ValueError")
|
|
def step_then_history_blank_uri(context: Any) -> None:
|
|
try:
|
|
context.temporal_backend.get_history("", TemporalScope.ALL)
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
# ---- revision chain steps ----
|
|
|
|
|
|
@when('I get the revision chain for "{uri}"')
|
|
def step_when_get_chain(context: Any, uri: str) -> None:
|
|
context.chain = context.temporal_backend.get_revision_chain(uri)
|
|
|
|
|
|
@then("the revision chain should have depth {expected:d}")
|
|
def step_then_chain_depth_backend(context: Any, expected: int) -> None:
|
|
assert context.chain.depth == expected
|
|
|
|
|
|
@then('the revision chain current should be "{expected}"')
|
|
def step_then_chain_current(context: Any, expected: str) -> None:
|
|
assert context.chain.current_uri == expected
|
|
|
|
|
|
@then('the revision chain predecessors should include "{uri}"')
|
|
def step_then_chain_preds_include(context: Any, uri: str) -> None:
|
|
assert uri in context.chain.predecessors
|
|
|
|
|
|
@then("getting revision chain for nonexistent URI should raise KeyError")
|
|
def step_then_chain_nonexistent(context: Any) -> None:
|
|
try:
|
|
context.temporal_backend.get_revision_chain("uko:nonexistent")
|
|
msg = "Expected KeyError"
|
|
raise AssertionError(msg)
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
# ---- query by tier steps ----
|
|
|
|
|
|
@when("I query the HOT tier")
|
|
def step_when_query_hot(context: Any) -> None:
|
|
retention = TierRetentionConfig()
|
|
context.tier_query = context.temporal_backend.query_by_tier(
|
|
ContextTier.HOT,
|
|
TemporalScope.CURRENT,
|
|
retention,
|
|
)
|
|
|
|
|
|
@when("I query the COLD tier")
|
|
def step_when_query_cold(context: Any) -> None:
|
|
retention = TierRetentionConfig()
|
|
context.tier_query = context.temporal_backend.query_by_tier(
|
|
ContextTier.COLD,
|
|
TemporalScope.ALL,
|
|
retention,
|
|
)
|
|
|
|
|
|
@then("the tier query should return {count:d} node")
|
|
def step_then_tier_query_count_singular(context: Any, count: int) -> None:
|
|
assert len(context.tier_query.nodes) == count
|
|
|
|
|
|
@then("the tier query should return {count:d} nodes")
|
|
def step_then_tier_query_count(context: Any, count: int) -> None:
|
|
assert len(context.tier_query.nodes) == count
|
|
|
|
|
|
@then("the tier query node should be current")
|
|
def step_then_tier_query_node_current(context: Any) -> None:
|
|
for node in context.tier_query.nodes:
|
|
assert node.temporal.is_current is True
|
|
|
|
|
|
# ---- mark historical steps ----
|
|
|
|
|
|
@given('I mark "{uri}" as historical')
|
|
@when('I mark "{uri}" as historical')
|
|
def step_when_mark_historical(context: Any, uri: str) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
context.marked_node = context.temporal_backend.mark_historical(
|
|
uri,
|
|
now,
|
|
)
|
|
|
|
|
|
@then("the marked node should not be current")
|
|
def step_then_marked_not_current(context: Any) -> None:
|
|
assert context.marked_node.temporal.is_current is False
|
|
|
|
|
|
@then("the marked node should have valid_until set")
|
|
def step_then_marked_valid_until(context: Any) -> None:
|
|
assert context.marked_node.temporal.valid_until is not None
|
|
|
|
|
|
@then("marking blank URI as historical should raise ValueError")
|
|
def step_then_mark_blank_uri(context: Any) -> None:
|
|
try:
|
|
context.temporal_backend.mark_historical(
|
|
"",
|
|
datetime.now(tz=UTC),
|
|
)
|
|
msg = "Expected ValueError"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then("marking nonexistent URI as historical should raise KeyError")
|
|
def step_then_mark_nonexistent(context: Any) -> None:
|
|
try:
|
|
context.temporal_backend.mark_historical(
|
|
"uko:nonexistent",
|
|
datetime.now(tz=UTC),
|
|
)
|
|
msg = "Expected KeyError"
|
|
raise AssertionError(msg)
|
|
except KeyError:
|
|
pass
|
|
|
|
|
|
# ---- Edge-case guards (Round 3 bug-hunt fixes) ----
|
|
|
|
|
|
@then(
|
|
'marking already-historical "{uri}" should raise ValueError',
|
|
)
|
|
def step_then_mark_already_historical(context: Any, uri: str) -> None:
|
|
try:
|
|
context.temporal_backend.mark_historical(uri, datetime.now(tz=UTC))
|
|
msg = "Expected ValueError for already-historical node"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then(
|
|
'creating a revision from historical "{current}" '
|
|
'to "{new_uri}" should raise ValueError',
|
|
)
|
|
def step_then_revise_historical_raises(
|
|
context: Any,
|
|
current: str,
|
|
new_uri: str,
|
|
) -> None:
|
|
now = datetime.now(tz=UTC)
|
|
new_node = TemporalNode(
|
|
node_uri=new_uri,
|
|
source_resource="RES001",
|
|
source_path="src/auth.py",
|
|
temporal=TemporalMetadata(
|
|
valid_from=now,
|
|
is_current=True,
|
|
is_revision_of=current,
|
|
),
|
|
)
|
|
try:
|
|
context.temporal_backend.create_revision(current, new_node, now)
|
|
msg = "Expected ValueError for revising historical node"
|
|
raise AssertionError(msg)
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
@then('the revision chain current_uri should be "{expected}"')
|
|
def step_then_chain_current_uri(context: Any, expected: str) -> None:
|
|
assert context.chain.current_uri == expected
|
|
|
|
|
|
@then('the revision chain predecessors should contain "{expected}"')
|
|
def step_then_chain_predecessors_contain(context: Any, expected: str) -> None:
|
|
assert expected in context.chain.predecessors
|
|
|
|
|
|
@given("a stored temporal node with branching revision chain")
|
|
def step_given_branching_chain(context: Any) -> None:
|
|
"""Create A → B and A → C (sibling branches) via _nodes."""
|
|
now = datetime.now(tz=UTC)
|
|
base = "uko-py:class/Branch"
|
|
nodes = context.temporal_backend._nodes
|
|
for suffix, meta_kw in [
|
|
(
|
|
"_A",
|
|
{
|
|
"valid_from": now - timedelta(hours=2),
|
|
"valid_until": now - timedelta(hours=1),
|
|
"is_current": False,
|
|
},
|
|
),
|
|
(
|
|
"_B",
|
|
{
|
|
"valid_from": now - timedelta(hours=1),
|
|
"is_current": True,
|
|
"is_revision_of": f"{base}_A",
|
|
},
|
|
),
|
|
("_C", {"valid_from": now, "is_current": True, "is_revision_of": f"{base}_A"}),
|
|
]:
|
|
uri = f"{base}{suffix}"
|
|
nodes[uri] = TemporalNode(
|
|
node_uri=uri,
|
|
source_resource="RES001",
|
|
source_path="src/a.py",
|
|
temporal=TemporalMetadata(**meta_kw),
|
|
)
|
|
|
|
|
|
@then('the revision chain should not contain "{uri}"')
|
|
def step_then_chain_not_contain(context: Any, uri: str) -> None:
|
|
assert uri not in context.chain.all_uris, (
|
|
f"{uri} should not be in chain {context.chain.all_uris}"
|
|
)
|
|
|
|
|
|
# ---- Diff-coverage steps (RECENT scope, cycle guard, WARM tier) ----
|
|
|
|
|
|
@when('I get the history for base "{base}" with scope RECENT')
|
|
def step_when_get_history_recent(context: Any, base: str) -> None:
|
|
context.history = context.temporal_backend.get_history(
|
|
base,
|
|
TemporalScope.RECENT,
|
|
)
|
|
|
|
|
|
@given("a stored temporal node with cyclic revision chain")
|
|
def step_given_cyclic_revision_chain(context: Any) -> None:
|
|
"""Create two nodes whose is_revision_of pointers form a cycle."""
|
|
now = datetime.now(tz=UTC)
|
|
for uri, meta_kw in [
|
|
(
|
|
"uko-py:class/Cycle_A",
|
|
{
|
|
"valid_from": now - timedelta(days=2),
|
|
"valid_until": now - timedelta(days=1),
|
|
"is_current": False,
|
|
"is_revision_of": "uko-py:class/Cycle_B",
|
|
},
|
|
),
|
|
(
|
|
"uko-py:class/Cycle_B",
|
|
{
|
|
"valid_from": now,
|
|
"is_current": True,
|
|
"is_revision_of": "uko-py:class/Cycle_A",
|
|
},
|
|
),
|
|
]:
|
|
context.temporal_backend.store_node(
|
|
TemporalNode(
|
|
node_uri=uri,
|
|
source_resource="RES001",
|
|
source_path="src/cycle.py",
|
|
temporal=TemporalMetadata(**meta_kw),
|
|
)
|
|
)
|
|
|
|
|
|
@given("a stored temporal node with dangling predecessor")
|
|
def step_given_dangling_predecessor(context: Any) -> None:
|
|
context.temporal_backend.store_node(
|
|
TemporalNode(
|
|
node_uri="uko-py:class/Dangling_B",
|
|
source_resource="RES001",
|
|
source_path="src/dangling.py",
|
|
temporal=TemporalMetadata(
|
|
valid_from=datetime.now(tz=UTC),
|
|
is_current=True,
|
|
is_revision_of="uko-py:class/Dangling_A",
|
|
),
|
|
)
|
|
)
|