krons 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. kronos/__init__.py +0 -0
  2. kronos/core/__init__.py +145 -0
  3. kronos/core/broadcaster.py +116 -0
  4. kronos/core/element.py +225 -0
  5. kronos/core/event.py +316 -0
  6. kronos/core/eventbus.py +116 -0
  7. kronos/core/flow.py +356 -0
  8. kronos/core/graph.py +442 -0
  9. kronos/core/node.py +982 -0
  10. kronos/core/pile.py +575 -0
  11. kronos/core/processor.py +494 -0
  12. kronos/core/progression.py +296 -0
  13. kronos/enforcement/__init__.py +57 -0
  14. kronos/enforcement/common/__init__.py +34 -0
  15. kronos/enforcement/common/boolean.py +85 -0
  16. kronos/enforcement/common/choice.py +97 -0
  17. kronos/enforcement/common/mapping.py +118 -0
  18. kronos/enforcement/common/model.py +102 -0
  19. kronos/enforcement/common/number.py +98 -0
  20. kronos/enforcement/common/string.py +140 -0
  21. kronos/enforcement/context.py +129 -0
  22. kronos/enforcement/policy.py +80 -0
  23. kronos/enforcement/registry.py +153 -0
  24. kronos/enforcement/rule.py +312 -0
  25. kronos/enforcement/service.py +370 -0
  26. kronos/enforcement/validator.py +198 -0
  27. kronos/errors.py +146 -0
  28. kronos/operations/__init__.py +32 -0
  29. kronos/operations/builder.py +228 -0
  30. kronos/operations/flow.py +398 -0
  31. kronos/operations/node.py +101 -0
  32. kronos/operations/registry.py +92 -0
  33. kronos/protocols.py +414 -0
  34. kronos/py.typed +0 -0
  35. kronos/services/__init__.py +81 -0
  36. kronos/services/backend.py +286 -0
  37. kronos/services/endpoint.py +608 -0
  38. kronos/services/hook.py +471 -0
  39. kronos/services/imodel.py +465 -0
  40. kronos/services/registry.py +115 -0
  41. kronos/services/utilities/__init__.py +36 -0
  42. kronos/services/utilities/header_factory.py +87 -0
  43. kronos/services/utilities/rate_limited_executor.py +271 -0
  44. kronos/services/utilities/rate_limiter.py +180 -0
  45. kronos/services/utilities/resilience.py +414 -0
  46. kronos/session/__init__.py +41 -0
  47. kronos/session/exchange.py +258 -0
  48. kronos/session/message.py +60 -0
  49. kronos/session/session.py +411 -0
  50. kronos/specs/__init__.py +25 -0
  51. kronos/specs/adapters/__init__.py +0 -0
  52. kronos/specs/adapters/_utils.py +45 -0
  53. kronos/specs/adapters/dataclass_field.py +246 -0
  54. kronos/specs/adapters/factory.py +56 -0
  55. kronos/specs/adapters/pydantic_adapter.py +309 -0
  56. kronos/specs/adapters/sql_ddl.py +946 -0
  57. kronos/specs/catalog/__init__.py +36 -0
  58. kronos/specs/catalog/_audit.py +39 -0
  59. kronos/specs/catalog/_common.py +43 -0
  60. kronos/specs/catalog/_content.py +59 -0
  61. kronos/specs/catalog/_enforcement.py +70 -0
  62. kronos/specs/factory.py +120 -0
  63. kronos/specs/operable.py +314 -0
  64. kronos/specs/phrase.py +405 -0
  65. kronos/specs/protocol.py +140 -0
  66. kronos/specs/spec.py +506 -0
  67. kronos/types/__init__.py +60 -0
  68. kronos/types/_sentinel.py +311 -0
  69. kronos/types/base.py +369 -0
  70. kronos/types/db_types.py +260 -0
  71. kronos/types/identity.py +66 -0
  72. kronos/utils/__init__.py +40 -0
  73. kronos/utils/_hash.py +234 -0
  74. kronos/utils/_json_dump.py +392 -0
  75. kronos/utils/_lazy_init.py +63 -0
  76. kronos/utils/_to_list.py +165 -0
  77. kronos/utils/_to_num.py +85 -0
  78. kronos/utils/_utils.py +375 -0
  79. kronos/utils/concurrency/__init__.py +205 -0
  80. kronos/utils/concurrency/_async_call.py +333 -0
  81. kronos/utils/concurrency/_cancel.py +122 -0
  82. kronos/utils/concurrency/_errors.py +96 -0
  83. kronos/utils/concurrency/_patterns.py +363 -0
  84. kronos/utils/concurrency/_primitives.py +328 -0
  85. kronos/utils/concurrency/_priority_queue.py +135 -0
  86. kronos/utils/concurrency/_resource_tracker.py +110 -0
  87. kronos/utils/concurrency/_run_async.py +67 -0
  88. kronos/utils/concurrency/_task.py +95 -0
  89. kronos/utils/concurrency/_utils.py +79 -0
  90. kronos/utils/fuzzy/__init__.py +14 -0
  91. kronos/utils/fuzzy/_extract_json.py +90 -0
  92. kronos/utils/fuzzy/_fuzzy_json.py +288 -0
  93. kronos/utils/fuzzy/_fuzzy_match.py +149 -0
  94. kronos/utils/fuzzy/_string_similarity.py +187 -0
  95. kronos/utils/fuzzy/_to_dict.py +396 -0
  96. kronos/utils/sql/__init__.py +13 -0
  97. kronos/utils/sql/_sql_validation.py +142 -0
  98. krons-0.1.0.dist-info/METADATA +70 -0
  99. krons-0.1.0.dist-info/RECORD +101 -0
  100. krons-0.1.0.dist-info/WHEEL +4 -0
  101. krons-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,198 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections import deque
7
+ from datetime import datetime
8
+ from typing import TYPE_CHECKING, Any, ClassVar
9
+
10
+ from kronos.types import is_sentinel
11
+ from kronos.utils.concurrency import is_coro_func
12
+
13
+ from .registry import RuleRegistry, get_default_registry
14
+ from .rule import Rule, ValidationError
15
+
16
+ if TYPE_CHECKING:
17
+ from kronos.specs import Operable, Spec
18
+
19
+ __all__ = ("Validator",)
20
+
21
+
22
+ class Validator:
23
+ DEFAULT_MAX_LOG_ENTRIES: ClassVar[int] = 1000
24
+
25
+ def __init__(
26
+ self,
27
+ registry: RuleRegistry | None = None,
28
+ max_log_entries: int | None = None,
29
+ ):
30
+ self.registry = registry or get_default_registry()
31
+ max_entries = (
32
+ max_log_entries if max_log_entries is not None else self.DEFAULT_MAX_LOG_ENTRIES
33
+ )
34
+ self.validation_log: deque[dict[str, Any]] = deque(
35
+ maxlen=max_entries if max_entries > 0 else None
36
+ )
37
+
38
+ def log_validation_error(self, field: str, value: Any, error: str) -> None:
39
+ """Log a validation error with timestamp.
40
+
41
+ Args:
42
+ field: Field name that failed validation
43
+ value: Value that failed validation
44
+ error: Error message
45
+ """
46
+ log_entry = {
47
+ "field": field,
48
+ "value": value,
49
+ "error": error,
50
+ "timestamp": datetime.now().isoformat(),
51
+ }
52
+ self.validation_log.append(log_entry)
53
+
54
+ def get_validation_summary(self) -> dict[str, Any]:
55
+ """Get summary of validation log.
56
+
57
+ Returns:
58
+ Dict with total_errors, fields_with_errors, and error_entries
59
+ """
60
+ fields_with_errors = set()
61
+ for entry in self.validation_log:
62
+ if "field" in entry:
63
+ fields_with_errors.add(entry["field"])
64
+
65
+ return {
66
+ "total_errors": len(self.validation_log),
67
+ "fields_with_errors": sorted(list(fields_with_errors)),
68
+ "error_entries": list(self.validation_log),
69
+ }
70
+
71
+ def clear_log(self) -> None:
72
+ """Clear the validation log."""
73
+ self.validation_log.clear()
74
+
75
+ def get_rule_for_spec(self, spec: Spec) -> Rule | None:
76
+ override = spec.get("rule")
77
+ if override is not None and isinstance(override, Rule):
78
+ return override
79
+
80
+ return self.registry.get_rule(
81
+ base_type=spec.base_type,
82
+ field_name=spec.name if spec.name else None,
83
+ )
84
+
85
+ async def validate_spec(
86
+ self,
87
+ spec: Spec,
88
+ value: Any,
89
+ auto_fix: bool = True,
90
+ strict: bool = True,
91
+ ) -> Any:
92
+ field_name = spec.name or "<unnamed>"
93
+
94
+ if value is None:
95
+ if spec.is_nullable:
96
+ return None
97
+ try:
98
+ value = await spec.acreate_default_value()
99
+ except ValueError:
100
+ if strict:
101
+ error_msg = f"Field '{field_name}' is None but not nullable and has no default"
102
+ self.log_validation_error(field_name, value, error_msg)
103
+ raise ValidationError(error_msg)
104
+ return value
105
+
106
+ rule = self.get_rule_for_spec(spec)
107
+
108
+ if spec.is_listable:
109
+ if not isinstance(value, list):
110
+ if auto_fix:
111
+ value = [value]
112
+ else:
113
+ error_msg = f"Field '{field_name}' expected list, got {type(value).__name__}"
114
+ self.log_validation_error(field_name, value, error_msg)
115
+ raise ValidationError(error_msg)
116
+
117
+ validated_items = []
118
+ for i, item in enumerate(value):
119
+ item_name = f"{field_name}[{i}]"
120
+ if rule is not None:
121
+ try:
122
+ validated_item = await rule.invoke(
123
+ item_name, item, spec.base_type, auto_fix=auto_fix
124
+ )
125
+ except Exception as e:
126
+ self.log_validation_error(item_name, item, str(e))
127
+ raise
128
+ else:
129
+ validated_item = item
130
+ validated_items.append(validated_item)
131
+
132
+ value = validated_items
133
+ else:
134
+ if rule is None:
135
+ if strict:
136
+ error_msg = (
137
+ f"No rule found for field '{field_name}' with type {spec.base_type}. "
138
+ f"Register a rule or set strict=False."
139
+ )
140
+ self.log_validation_error(field_name, value, error_msg)
141
+ raise ValidationError(error_msg)
142
+ else:
143
+ try:
144
+ value = await rule.invoke(field_name, value, spec.base_type, auto_fix=auto_fix)
145
+ except Exception as e:
146
+ self.log_validation_error(field_name, value, str(e))
147
+ raise
148
+
149
+ custom_validators = spec.get("validator")
150
+ if is_sentinel(custom_validators) or custom_validators is None:
151
+ validators = []
152
+ elif callable(custom_validators):
153
+ validators = [custom_validators]
154
+ elif isinstance(custom_validators, list):
155
+ validators = custom_validators
156
+ else:
157
+ validators = []
158
+
159
+ for validator_fn in validators:
160
+ if not callable(validator_fn):
161
+ continue
162
+ try:
163
+ if is_coro_func(validator_fn):
164
+ value = await validator_fn(value)
165
+ else:
166
+ value = validator_fn(value)
167
+ except Exception as e:
168
+ error_msg = f"Custom validator failed for '{field_name}': {e}"
169
+ self.log_validation_error(field_name, value, error_msg)
170
+ raise ValidationError(error_msg) from e
171
+
172
+ return value
173
+
174
+ async def validate_operable(
175
+ self,
176
+ data: dict[str, Any],
177
+ operable: Operable,
178
+ capabilities: set[str] | None = None,
179
+ auto_fix: bool = True,
180
+ strict: bool = True,
181
+ ) -> dict[str, Any]:
182
+ capabilities = capabilities or operable.allowed()
183
+ validated: dict[str, Any] = {}
184
+
185
+ for spec in operable.get_specs():
186
+ field_name = spec.name
187
+ if is_sentinel(field_name) or not isinstance(field_name, str):
188
+ continue
189
+
190
+ if field_name not in capabilities:
191
+ continue
192
+
193
+ value = data.get(field_name)
194
+ validated[field_name] = await self.validate_spec(
195
+ spec, value, auto_fix=auto_fix, strict=strict
196
+ )
197
+
198
+ return validated
kronos/errors.py ADDED
@@ -0,0 +1,146 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Kron exception hierarchy with structured details and retryability.
5
+
6
+ All exceptions inherit from KronError and include:
7
+ - message: Human-readable description
8
+ - details: Structured context dict
9
+ - retryable: Whether retry might succeed
10
+
11
+ Naming: KronConnectionError/KronTimeoutError avoid shadowing builtins.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from .protocols import Serializable, implements
19
+
20
+ __all__ = (
21
+ "AccessError",
22
+ "ConfigurationError",
23
+ "ExecutionError",
24
+ "ExistsError",
25
+ "KronConnectionError",
26
+ "KronError",
27
+ "KronTimeoutError",
28
+ "NotFoundError",
29
+ "OperationError",
30
+ "QueueFullError",
31
+ "ValidationError",
32
+ )
33
+
34
+
35
+ @implements(Serializable)
36
+ class KronError(Exception):
37
+ """Base exception for kron. Serializable with structured details.
38
+
39
+ Subclasses set default_message and default_retryable.
40
+ Use cause= to chain exceptions with preserved traceback.
41
+
42
+ Attributes:
43
+ message: Human-readable description.
44
+ details: Structured context for debugging/logging.
45
+ retryable: Whether retry might succeed.
46
+ """
47
+
48
+ default_message: str = "kron error"
49
+ default_retryable: bool = True
50
+
51
+ def __init__(
52
+ self,
53
+ message: str | None = None,
54
+ *,
55
+ details: dict[str, Any] | None = None,
56
+ retryable: bool | None = None,
57
+ cause: Exception | None = None,
58
+ ):
59
+ """Initialize with optional message, details, retryability, and cause."""
60
+ self.message = message or self.default_message
61
+ self.details = details or {}
62
+ self.retryable = retryable if retryable is not None else self.default_retryable
63
+
64
+ if cause:
65
+ self.__cause__ = cause # Preserve traceback
66
+
67
+ super().__init__(self.message)
68
+
69
+ def to_dict(self, **kwargs: Any) -> dict[str, Any]:
70
+ """Serialize to dict: {error, message, retryable, details?}."""
71
+ return {
72
+ "error": self.__class__.__name__,
73
+ "message": self.message,
74
+ "retryable": self.retryable,
75
+ **({"details": self.details} if self.details else {}),
76
+ }
77
+
78
+
79
+ class ValidationError(KronError):
80
+ """Data validation failure. Raise when input fails schema/constraint checks."""
81
+
82
+ default_message = "Validation failed"
83
+ default_retryable = False
84
+
85
+
86
+ class AccessError(KronError):
87
+ """Permission denied. Raise when capability/resource access is blocked."""
88
+
89
+ default_message = "Access denied"
90
+ default_retryable = False
91
+
92
+
93
+ class ConfigurationError(KronError):
94
+ """Invalid configuration. Raise when setup/config is incorrect."""
95
+
96
+ default_message = "Configuration error"
97
+ default_retryable = False
98
+
99
+
100
+ class ExecutionError(KronError):
101
+ """Execution failure. Raise when Event/Calling invoke fails (often transient)."""
102
+
103
+ default_message = "Execution failed"
104
+ default_retryable = True
105
+
106
+
107
+ class KronConnectionError(KronError):
108
+ """Network/connection failure. Named to avoid shadowing builtins."""
109
+
110
+ default_message = "Connection error"
111
+ default_retryable = True
112
+
113
+
114
+ class KronTimeoutError(KronError):
115
+ """Operation timeout. Named to avoid shadowing builtins."""
116
+
117
+ default_message = "Operation timed out"
118
+ default_retryable = True
119
+
120
+
121
+ class NotFoundError(KronError):
122
+ """Resource/item not found. Raise when lookup fails."""
123
+
124
+ default_message = "Item not found"
125
+ default_retryable = False
126
+
127
+
128
+ class ExistsError(KronError):
129
+ """Duplicate item. Raise when creating item that already exists."""
130
+
131
+ default_message = "Item already exists"
132
+ default_retryable = False
133
+
134
+
135
+ class QueueFullError(KronError):
136
+ """Capacity exceeded. Raise when queue/buffer is full."""
137
+
138
+ default_message = "Queue is full"
139
+ default_retryable = True
140
+
141
+
142
+ class OperationError(KronError):
143
+ """Generic operation failure. Use for unclassified operation errors."""
144
+
145
+ default_message = "Operation failed"
146
+ default_retryable = False
@@ -0,0 +1,32 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Operations: executable graph nodes with dependency-aware execution.
5
+
6
+ Core types:
7
+ Operation: Node + Event hybrid for graph-based execution.
8
+ OperationRegistry: Per-session factory mapping.
9
+ OperationGraphBuilder (Builder): Fluent DAG construction.
10
+
11
+ Execution:
12
+ flow(): Execute DAG, return results dict.
13
+ flow_stream(): Execute DAG, yield results incrementally.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from .builder import Builder, OperationGraphBuilder
19
+ from .flow import DependencyAwareExecutor, flow, flow_stream
20
+ from .node import Operation, create_operation
21
+ from .registry import OperationRegistry
22
+
23
+ __all__ = (
24
+ "Builder",
25
+ "DependencyAwareExecutor",
26
+ "Operation",
27
+ "OperationGraphBuilder",
28
+ "OperationRegistry",
29
+ "create_operation",
30
+ "flow",
31
+ "flow_stream",
32
+ )
@@ -0,0 +1,228 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Fluent builder for operation DAGs.
5
+
6
+ Build graphs with chained .add() calls, automatic sequential linking,
7
+ and explicit dependency management. Alias: Builder = OperationGraphBuilder.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+ from uuid import UUID
14
+
15
+ from kronos.core import Edge, Graph
16
+ from kronos.types import Undefined, UndefinedType, is_sentinel, not_sentinel
17
+ from kronos.utils._utils import to_uuid
18
+
19
+ from .node import Operation
20
+
21
+ if TYPE_CHECKING:
22
+ from kronos.session import Branch
23
+
24
+ __all__ = ("Builder", "OperationGraphBuilder")
25
+
26
+
27
+ def _resolve_branch_ref(branch: Any) -> UUID | str:
28
+ """Convert branch reference to UUID or trimmed name string.
29
+
30
+ Raises:
31
+ ValueError: If branch is not a valid UUID, Branch, or non-empty string.
32
+ """
33
+ try:
34
+ return to_uuid(branch)
35
+ except (ValueError, TypeError):
36
+ pass
37
+
38
+ if isinstance(branch, str) and branch.strip():
39
+ return branch.strip()
40
+
41
+ raise ValueError(f"Invalid branch reference: {branch}")
42
+
43
+
44
+ class OperationGraphBuilder:
45
+ """Fluent builder for operation DAGs with automatic linking.
46
+
47
+ Operations added sequentially auto-link unless depends_on is specified.
48
+ Use depends_on=[] for independent operations, depends_on=["op1"] for explicit.
49
+
50
+ Example:
51
+ graph = (Builder()
52
+ .add("fetch", "http_get", {"url": "..."})
53
+ .add("parse", "json_parse") # auto-links to fetch
54
+ .add("validate", "schema_check", depends_on=["parse"])
55
+ .build())
56
+
57
+ Attributes:
58
+ graph: Underlying Graph being built.
59
+ """
60
+
61
+ def __init__(self, graph: Graph | UndefinedType = Undefined):
62
+ """Initialize builder, optionally with existing graph.
63
+
64
+ Args:
65
+ graph: Pre-existing Graph to extend, or Undefined for new.
66
+ """
67
+ self.graph = Graph() if is_sentinel(graph) else graph
68
+ self._nodes: dict[str, Operation] = {}
69
+ self._executed: set[UUID] = set()
70
+ self._current_heads: list[str] = []
71
+
72
+ def add(
73
+ self,
74
+ name: str,
75
+ operation: str,
76
+ parameters: dict[str, Any] | Any | UndefinedType = Undefined,
77
+ depends_on: list[str] | UndefinedType = Undefined,
78
+ branch: str | UUID | Branch | UndefinedType = Undefined,
79
+ inherit_context: bool = False,
80
+ metadata: dict[str, Any] | UndefinedType = Undefined,
81
+ ) -> OperationGraphBuilder:
82
+ """Add operation to graph.
83
+
84
+ Args:
85
+ name: Unique operation name for reference.
86
+ operation: Operation type (registry key).
87
+ parameters: Factory arguments (dict or model).
88
+ depends_on: Explicit dependencies. Undefined=auto-link, []=independent.
89
+ branch: Target branch (Branch|UUID|str). Undefined=default.
90
+ inherit_context: Store primary_dependency for context inheritance.
91
+ metadata: Additional metadata dict.
92
+
93
+ Returns:
94
+ Self for chaining.
95
+
96
+ Raises:
97
+ ValueError: If name exists or dependency not found.
98
+ """
99
+ if name in self._nodes:
100
+ raise ValueError(f"Operation with name '{name}' already exists")
101
+
102
+ resolved_params = {} if is_sentinel(parameters) else parameters
103
+ resolved_metadata = {} if is_sentinel(metadata) else metadata
104
+ op = Operation(
105
+ operation_type=operation,
106
+ parameters=resolved_params,
107
+ metadata=resolved_metadata,
108
+ streaming=False,
109
+ )
110
+ op.metadata["name"] = name
111
+
112
+ if not_sentinel(branch):
113
+ op.metadata["branch"] = _resolve_branch_ref(branch)
114
+
115
+ if inherit_context and not_sentinel(depends_on) and depends_on:
116
+ op.metadata["inherit_context"] = True
117
+ op.metadata["primary_dependency"] = self._nodes[depends_on[0]].id
118
+
119
+ self.graph.add_node(op)
120
+ self._nodes[name] = op
121
+
122
+ if not_sentinel(depends_on):
123
+ for dep_name in depends_on:
124
+ if dep_name not in self._nodes:
125
+ raise ValueError(f"Dependency '{dep_name}' not found")
126
+ dep_node = self._nodes[dep_name]
127
+ edge = Edge(head=dep_node.id, tail=op.id, label=["depends_on"])
128
+ self.graph.add_edge(edge)
129
+ elif self._current_heads:
130
+ for head_name in self._current_heads:
131
+ if head_name in self._nodes:
132
+ head_node = self._nodes[head_name]
133
+ edge = Edge(head=head_node.id, tail=op.id, label=["sequential"])
134
+ self.graph.add_edge(edge)
135
+
136
+ self._current_heads = [name]
137
+ return self
138
+
139
+ def depends_on(
140
+ self,
141
+ target: str,
142
+ *dependencies: str,
143
+ label: list[str] | UndefinedType = Undefined,
144
+ ) -> OperationGraphBuilder:
145
+ """Add explicit dependency edges: dependencies -> target.
146
+
147
+ Args:
148
+ target: Operation that depends on others.
149
+ *dependencies: Operations that target depends on.
150
+ label: Optional edge labels.
151
+
152
+ Returns:
153
+ Self for chaining.
154
+
155
+ Raises:
156
+ ValueError: If target or any dependency not found.
157
+ """
158
+ if target not in self._nodes:
159
+ raise ValueError(f"Target operation '{target}' not found")
160
+
161
+ target_node = self._nodes[target]
162
+ resolved_label = [] if is_sentinel(label, {"none", "empty"}) else label
163
+
164
+ for dep_name in dependencies:
165
+ if dep_name not in self._nodes:
166
+ raise ValueError(f"Dependency operation '{dep_name}' not found")
167
+
168
+ dep_node = self._nodes[dep_name]
169
+
170
+ # Create edge: dependency -> target
171
+ edge = Edge(
172
+ head=dep_node.id,
173
+ tail=target_node.id,
174
+ label=resolved_label,
175
+ )
176
+ self.graph.add_edge(edge)
177
+
178
+ return self
179
+
180
+ def get(self, name: str) -> Operation:
181
+ """Get operation by name. Raises ValueError if not found."""
182
+ if name not in self._nodes:
183
+ raise ValueError(f"Operation '{name}' not found")
184
+ return self._nodes[name]
185
+
186
+ def get_by_id(self, operation_id: UUID) -> Operation | None:
187
+ """Get operation by UUID, or None if not in graph."""
188
+ node = self.graph.nodes.get(operation_id, None)
189
+ if isinstance(node, Operation):
190
+ return node
191
+ return None
192
+
193
+ def mark_executed(self, *names: str) -> OperationGraphBuilder:
194
+ """Mark operations as executed for incremental workflows. Returns self."""
195
+ for name in names:
196
+ if name in self._nodes:
197
+ self._executed.add(self._nodes[name].id)
198
+ return self
199
+
200
+ def get_unexecuted_nodes(self) -> list[Operation]:
201
+ """Return operations not marked as executed."""
202
+ return [op for op in self._nodes.values() if op.id not in self._executed]
203
+
204
+ def build(self) -> Graph:
205
+ """Validate and return the graph. Raises ValueError if cyclic."""
206
+ if not self.graph.is_acyclic():
207
+ raise ValueError("Operation graph has cycles - must be a DAG")
208
+ return self.graph
209
+
210
+ def clear(self) -> OperationGraphBuilder:
211
+ """Reset builder to empty state. Returns self."""
212
+ self.graph = Graph()
213
+ self._nodes = {}
214
+ self._executed = set()
215
+ self._current_heads = []
216
+ return self
217
+
218
+ def __repr__(self) -> str:
219
+ return (
220
+ f"OperationGraphBuilder("
221
+ f"operations={len(self._nodes)}, "
222
+ f"edges={len(self.graph.edges)}, "
223
+ f"executed={len(self._executed)})"
224
+ )
225
+
226
+
227
+ # Alias for convenience
228
+ Builder = OperationGraphBuilder