krons 0.1.1__py3-none-any.whl → 0.2.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.
- krons/__init__.py +49 -0
- krons/agent/__init__.py +144 -0
- krons/agent/mcps/__init__.py +14 -0
- krons/agent/mcps/loader.py +287 -0
- krons/agent/mcps/wrapper.py +799 -0
- krons/agent/message/__init__.py +20 -0
- krons/agent/message/action.py +69 -0
- krons/agent/message/assistant.py +52 -0
- krons/agent/message/common.py +49 -0
- krons/agent/message/instruction.py +130 -0
- krons/agent/message/prepare_msg.py +187 -0
- krons/agent/message/role.py +53 -0
- krons/agent/message/system.py +53 -0
- krons/agent/operations/__init__.py +82 -0
- krons/agent/operations/act.py +100 -0
- krons/agent/operations/generate.py +145 -0
- krons/agent/operations/llm_reparse.py +89 -0
- krons/agent/operations/operate.py +247 -0
- krons/agent/operations/parse.py +243 -0
- krons/agent/operations/react.py +286 -0
- krons/agent/operations/specs.py +235 -0
- krons/agent/operations/structure.py +151 -0
- krons/agent/operations/utils.py +79 -0
- krons/agent/providers/__init__.py +17 -0
- krons/agent/providers/anthropic_messages.py +146 -0
- krons/agent/providers/claude_code.py +276 -0
- krons/agent/providers/gemini.py +268 -0
- krons/agent/providers/match.py +75 -0
- krons/agent/providers/oai_chat.py +174 -0
- krons/agent/third_party/__init__.py +2 -0
- krons/agent/third_party/anthropic_models.py +154 -0
- krons/agent/third_party/claude_code.py +682 -0
- krons/agent/third_party/gemini_models.py +508 -0
- krons/agent/third_party/openai_models.py +295 -0
- krons/agent/tool.py +291 -0
- krons/core/__init__.py +56 -74
- krons/core/base/__init__.py +121 -0
- krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
- krons/core/{element.py → base/element.py} +13 -5
- krons/core/{event.py → base/event.py} +39 -6
- krons/core/{eventbus.py → base/eventbus.py} +3 -1
- krons/core/{flow.py → base/flow.py} +11 -4
- krons/core/{graph.py → base/graph.py} +24 -8
- krons/core/{node.py → base/node.py} +44 -19
- krons/core/{pile.py → base/pile.py} +22 -8
- krons/core/{processor.py → base/processor.py} +21 -7
- krons/core/{progression.py → base/progression.py} +3 -1
- krons/{specs → core/specs}/__init__.py +0 -5
- krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
- krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
- krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
- krons/{specs → core/specs}/catalog/__init__.py +2 -2
- krons/{specs → core/specs}/catalog/_audit.py +2 -2
- krons/{specs → core/specs}/catalog/_common.py +2 -2
- krons/{specs → core/specs}/catalog/_content.py +4 -4
- krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
- krons/{specs → core/specs}/factory.py +5 -5
- krons/{specs → core/specs}/operable.py +8 -2
- krons/{specs → core/specs}/protocol.py +4 -2
- krons/{specs → core/specs}/spec.py +23 -11
- krons/{types → core/types}/base.py +4 -2
- krons/{types → core/types}/db_types.py +2 -2
- krons/errors.py +13 -13
- krons/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- krons/{services → resource}/backend.py +48 -22
- krons/{services → resource}/endpoint.py +28 -14
- krons/{services → resource}/hook.py +20 -7
- krons/{services → resource}/imodel.py +46 -28
- krons/{services → resource}/registry.py +26 -24
- krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
- krons/{services → resource}/utilities/rate_limiter.py +3 -1
- krons/{services → resource}/utilities/resilience.py +15 -5
- krons/resource/utilities/token_calculator.py +185 -0
- krons/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- krons/session/exchange.py +11 -3
- krons/session/message.py +3 -1
- krons/session/registry.py +35 -0
- krons/session/session.py +165 -174
- krons/utils/__init__.py +45 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- krons/utils/_to_list.py +9 -3
- krons/utils/_utils.py +6 -2
- krons/utils/concurrency/_async_call.py +4 -2
- krons/utils/concurrency/_errors.py +3 -1
- krons/utils/concurrency/_patterns.py +3 -1
- krons/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- krons/utils/fuzzy/__init__.py +6 -1
- krons/utils/fuzzy/_fuzzy_match.py +14 -8
- krons/utils/fuzzy/_string_similarity.py +3 -1
- krons/utils/fuzzy/_to_dict.py +3 -1
- krons/utils/schemas/__init__.py +26 -0
- krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
- krons/utils/schemas/_formatter.py +72 -0
- krons/utils/schemas/_minimal_yaml.py +151 -0
- krons/utils/schemas/_typescript.py +153 -0
- krons/utils/validators/__init__.py +3 -0
- krons/utils/validators/_validate_image_url.py +56 -0
- krons/work/__init__.py +126 -0
- krons/work/engine.py +333 -0
- krons/work/form.py +305 -0
- krons/{operations → work/operations}/__init__.py +7 -4
- krons/{operations → work/operations}/builder.py +1 -1
- krons/{enforcement → work/operations}/context.py +36 -5
- krons/{operations → work/operations}/flow.py +13 -5
- krons/{operations → work/operations}/node.py +45 -43
- krons/work/operations/registry.py +103 -0
- krons/{specs → work}/phrase.py +130 -13
- krons/{enforcement → work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- krons/{enforcement → work/rules}/common/boolean.py +3 -1
- krons/{enforcement → work/rules}/common/choice.py +9 -3
- krons/{enforcement → work/rules}/common/number.py +3 -1
- krons/{enforcement → work/rules}/common/string.py +9 -3
- krons/{enforcement → work/rules}/rule.py +1 -1
- krons/{enforcement → work/rules}/validator.py +20 -5
- krons/{enforcement → work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/METADATA +15 -1
- krons-0.2.0.dist-info/RECORD +154 -0
- krons/enforcement/__init__.py +0 -57
- krons/operations/registry.py +0 -92
- krons/services/__init__.py +0 -81
- krons-0.1.1.dist-info/RECORD +0 -101
- /krons/{specs → core/specs}/adapters/__init__.py +0 -0
- /krons/{specs → core/specs}/adapters/_utils.py +0 -0
- /krons/{specs → core/specs}/adapters/factory.py +0 -0
- /krons/{types → core/types}/__init__.py +0 -0
- /krons/{types → core/types}/_sentinel.py +0 -0
- /krons/{types → core/types}/identity.py +0 -0
- /krons/{services → resource}/utilities/__init__.py +0 -0
- /krons/{services → resource}/utilities/header_factory.py +0 -0
- /krons/{enforcement → work/rules}/common/__init__.py +0 -0
- /krons/{enforcement → work/rules}/common/mapping.py +0 -0
- /krons/{enforcement → work/rules}/common/model.py +0 -0
- /krons/{enforcement → work/rules}/registry.py +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.1.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
krons/work/form.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Form - Data binding and scheduling for work units.
|
|
5
|
+
|
|
6
|
+
A Form represents an instantiated work unit with:
|
|
7
|
+
- Data binding (input values)
|
|
8
|
+
- Execution state tracking (filled, workable)
|
|
9
|
+
- Optional Phrase reference for typed I/O
|
|
10
|
+
|
|
11
|
+
Forms are the stateful layer between Phrase (definition) and Operation (execution).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Any
|
|
18
|
+
|
|
19
|
+
from pydantic import Field
|
|
20
|
+
|
|
21
|
+
from krons.core import Element
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from .phrase import Phrase
|
|
25
|
+
|
|
26
|
+
__all__ = ("Form", "ParsedAssignment", "parse_assignment", "parse_full_assignment")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ParsedAssignment:
|
|
31
|
+
"""Parsed form assignment with all components.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
branch: Branch/worker name (e.g., "classifier1")
|
|
35
|
+
inputs: Input field names
|
|
36
|
+
outputs: Output field names
|
|
37
|
+
resource: Resource hint (e.g., "api:fast")
|
|
38
|
+
raw: Original assignment string
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
branch: str | None
|
|
42
|
+
inputs: list[str]
|
|
43
|
+
outputs: list[str]
|
|
44
|
+
resource: str | None
|
|
45
|
+
raw: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_assignment(assignment: str) -> tuple[list[str], list[str]]:
|
|
49
|
+
"""Parse 'inputs -> outputs' assignment DSL (simple form).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
assignment: DSL string like "a, b -> c, d"
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Tuple of (input_fields, output_fields)
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If assignment format is invalid
|
|
59
|
+
"""
|
|
60
|
+
parsed = parse_full_assignment(assignment)
|
|
61
|
+
return parsed.inputs, parsed.outputs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_full_assignment(assignment: str) -> ParsedAssignment:
|
|
65
|
+
"""Parse full assignment DSL with branch and resource hints.
|
|
66
|
+
|
|
67
|
+
Format: "branch: inputs -> outputs | resource"
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
"a, b -> c" # Simple
|
|
71
|
+
"classifier: job -> role | api:fast" # Full
|
|
72
|
+
"writer: context -> summary" # Branch, no resource
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
assignment: DSL string
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
ParsedAssignment with all components
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
ValueError: If format is invalid
|
|
82
|
+
"""
|
|
83
|
+
raw = assignment.strip()
|
|
84
|
+
branch = None
|
|
85
|
+
resource = None
|
|
86
|
+
|
|
87
|
+
# Extract resource hint (after |)
|
|
88
|
+
if "|" in raw:
|
|
89
|
+
main_part, resource_part = raw.rsplit("|", 1)
|
|
90
|
+
resource = resource_part.strip()
|
|
91
|
+
raw = main_part.strip()
|
|
92
|
+
|
|
93
|
+
# Extract branch name (before :)
|
|
94
|
+
if ":" in raw:
|
|
95
|
+
# Check it's not just inside the field list
|
|
96
|
+
colon_idx = raw.find(":")
|
|
97
|
+
arrow_idx = raw.find("->")
|
|
98
|
+
if arrow_idx == -1 or colon_idx < arrow_idx:
|
|
99
|
+
branch_part, raw = raw.split(":", 1)
|
|
100
|
+
branch = branch_part.strip()
|
|
101
|
+
raw = raw.strip()
|
|
102
|
+
|
|
103
|
+
# Parse inputs -> outputs
|
|
104
|
+
if "->" not in raw:
|
|
105
|
+
raise ValueError(f"Invalid assignment syntax (missing '->'): {assignment}")
|
|
106
|
+
|
|
107
|
+
parts = raw.split("->")
|
|
108
|
+
if len(parts) != 2:
|
|
109
|
+
raise ValueError(f"Invalid assignment syntax: {assignment}")
|
|
110
|
+
|
|
111
|
+
inputs = [f.strip() for f in parts[0].split(",") if f.strip()]
|
|
112
|
+
outputs = [f.strip() for f in parts[1].split(",") if f.strip()]
|
|
113
|
+
|
|
114
|
+
return ParsedAssignment(
|
|
115
|
+
branch=branch,
|
|
116
|
+
inputs=inputs,
|
|
117
|
+
outputs=outputs,
|
|
118
|
+
resource=resource,
|
|
119
|
+
raw=assignment,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Form(Element):
|
|
124
|
+
"""Data binding container for work units.
|
|
125
|
+
|
|
126
|
+
A Form binds input data and tracks execution state. It can be created:
|
|
127
|
+
1. From a Phrase (typed I/O)
|
|
128
|
+
2. From an assignment string (dynamic fields)
|
|
129
|
+
|
|
130
|
+
Assignment DSL supports full format:
|
|
131
|
+
"branch: inputs -> outputs | resource"
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
"a, b -> c" # Simple
|
|
135
|
+
"classifier: job -> role | api:fast" # Full with branch and resource
|
|
136
|
+
"writer: context -> summary" # Branch, no resource
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
assignment: DSL string 'branch: inputs -> outputs | resource'
|
|
140
|
+
branch: Worker/branch name for routing
|
|
141
|
+
resource: Resource hint for capability matching
|
|
142
|
+
input_fields: Fields required as inputs
|
|
143
|
+
output_fields: Fields produced as outputs
|
|
144
|
+
available_data: Current data values
|
|
145
|
+
output: Execution result
|
|
146
|
+
filled: Whether form has been executed
|
|
147
|
+
phrase: Optional Phrase reference for typed execution
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
assignment: str = Field(
|
|
151
|
+
default="",
|
|
152
|
+
description="Assignment DSL: 'branch: inputs -> outputs | resource'",
|
|
153
|
+
)
|
|
154
|
+
branch: str | None = Field(
|
|
155
|
+
default=None,
|
|
156
|
+
description="Worker/branch name for routing",
|
|
157
|
+
)
|
|
158
|
+
resource: str | None = Field(
|
|
159
|
+
default=None,
|
|
160
|
+
description="Resource hint (e.g., 'api:fast')",
|
|
161
|
+
)
|
|
162
|
+
input_fields: list[str] = Field(default_factory=list)
|
|
163
|
+
output_fields: list[str] = Field(default_factory=list)
|
|
164
|
+
available_data: dict[str, Any] = Field(default_factory=dict)
|
|
165
|
+
output: Any = Field(default=None)
|
|
166
|
+
filled: bool = Field(default=False)
|
|
167
|
+
|
|
168
|
+
# Optional phrase reference (set via from_phrase())
|
|
169
|
+
_phrase: "Phrase | None" = None
|
|
170
|
+
|
|
171
|
+
def model_post_init(self, _: Any) -> None:
|
|
172
|
+
"""Parse assignment to derive fields if not already set."""
|
|
173
|
+
if self.assignment and not self.input_fields and not self.output_fields:
|
|
174
|
+
parsed = parse_full_assignment(self.assignment)
|
|
175
|
+
self.input_fields = parsed.inputs
|
|
176
|
+
self.output_fields = parsed.outputs
|
|
177
|
+
if parsed.branch and self.branch is None:
|
|
178
|
+
self.branch = parsed.branch
|
|
179
|
+
if parsed.resource and self.resource is None:
|
|
180
|
+
self.resource = parsed.resource
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def from_phrase(
|
|
184
|
+
cls,
|
|
185
|
+
phrase: "Phrase",
|
|
186
|
+
**initial_data: Any,
|
|
187
|
+
) -> "Form":
|
|
188
|
+
"""Create Form from a Phrase with optional initial data.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
phrase: Phrase defining typed I/O
|
|
192
|
+
**initial_data: Initial input values
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Form bound to the phrase
|
|
196
|
+
"""
|
|
197
|
+
form = cls(
|
|
198
|
+
assignment=f"{', '.join(phrase.inputs)} -> {', '.join(phrase.outputs)}",
|
|
199
|
+
input_fields=list(phrase.inputs),
|
|
200
|
+
output_fields=list(phrase.outputs),
|
|
201
|
+
available_data=dict(initial_data),
|
|
202
|
+
)
|
|
203
|
+
form._phrase = phrase
|
|
204
|
+
return form
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def phrase(self) -> "Phrase | None":
|
|
208
|
+
"""Get bound phrase if any."""
|
|
209
|
+
return self._phrase
|
|
210
|
+
|
|
211
|
+
def is_workable(self) -> bool:
|
|
212
|
+
"""Check if form is ready for execution.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if all inputs available and not already filled
|
|
216
|
+
"""
|
|
217
|
+
if self.filled:
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
for field in self.input_fields:
|
|
221
|
+
if field not in self.available_data:
|
|
222
|
+
return False
|
|
223
|
+
if self.available_data[field] is None:
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
def get_inputs(self) -> dict[str, Any]:
|
|
229
|
+
"""Extract input data for execution.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Dict of input field values
|
|
233
|
+
"""
|
|
234
|
+
return {
|
|
235
|
+
f: self.available_data[f]
|
|
236
|
+
for f in self.input_fields
|
|
237
|
+
if f in self.available_data
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
def fill(self, **data: Any) -> None:
|
|
241
|
+
"""Add data to available_data.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
**data: Field values to add
|
|
245
|
+
"""
|
|
246
|
+
self.available_data.update(data)
|
|
247
|
+
|
|
248
|
+
def set_output(self, output: Any) -> None:
|
|
249
|
+
"""Mark form as filled with output.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
output: Execution result
|
|
253
|
+
"""
|
|
254
|
+
self.output = output
|
|
255
|
+
self.filled = True
|
|
256
|
+
|
|
257
|
+
# Extract output field values from result
|
|
258
|
+
if output is not None:
|
|
259
|
+
for field in self.output_fields:
|
|
260
|
+
if hasattr(output, field):
|
|
261
|
+
self.available_data[field] = getattr(output, field)
|
|
262
|
+
elif isinstance(output, dict) and field in output:
|
|
263
|
+
self.available_data[field] = output[field]
|
|
264
|
+
|
|
265
|
+
def get_output_data(self) -> dict[str, Any]:
|
|
266
|
+
"""Extract output field values.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Dict mapping output field names to values
|
|
270
|
+
"""
|
|
271
|
+
result = {}
|
|
272
|
+
for field in self.output_fields:
|
|
273
|
+
if field in self.available_data:
|
|
274
|
+
result[field] = self.available_data[field]
|
|
275
|
+
return result
|
|
276
|
+
|
|
277
|
+
async def execute(self, ctx: Any = None) -> Any:
|
|
278
|
+
"""Execute the form if it has a bound phrase.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
ctx: Execution context
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Execution result
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
RuntimeError: If no phrase bound or form not workable
|
|
288
|
+
"""
|
|
289
|
+
if self._phrase is None:
|
|
290
|
+
raise RuntimeError("Form has no bound phrase - cannot execute")
|
|
291
|
+
|
|
292
|
+
if not self.is_workable():
|
|
293
|
+
missing = [f for f in self.input_fields if f not in self.available_data]
|
|
294
|
+
raise RuntimeError(f"Form not workable - missing inputs: {missing}")
|
|
295
|
+
|
|
296
|
+
result = await self._phrase(self.get_inputs(), ctx)
|
|
297
|
+
self.set_output(result)
|
|
298
|
+
return result
|
|
299
|
+
|
|
300
|
+
def __repr__(self) -> str:
|
|
301
|
+
status = (
|
|
302
|
+
"filled" if self.filled else ("ready" if self.is_workable() else "pending")
|
|
303
|
+
)
|
|
304
|
+
phrase_info = f", phrase={self._phrase.name}" if self._phrase else ""
|
|
305
|
+
return f"Form('{self.assignment}', {status}{phrase_info})"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
Core types:
|
|
7
7
|
Operation: Node + Event hybrid for graph-based execution.
|
|
8
|
-
OperationRegistry: Per-session
|
|
8
|
+
OperationRegistry: Per-session handler mapping.
|
|
9
9
|
OperationGraphBuilder (Builder): Fluent DAG construction.
|
|
10
10
|
|
|
11
11
|
Execution:
|
|
@@ -16,17 +16,20 @@ Execution:
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
from .builder import Builder, OperationGraphBuilder
|
|
19
|
+
from .context import QueryFn, RequestContext
|
|
19
20
|
from .flow import DependencyAwareExecutor, flow, flow_stream
|
|
20
|
-
from .node import Operation
|
|
21
|
-
from .registry import OperationRegistry
|
|
21
|
+
from .node import Operation
|
|
22
|
+
from .registry import OperationHandler, OperationRegistry
|
|
22
23
|
|
|
23
24
|
__all__ = (
|
|
24
25
|
"Builder",
|
|
25
26
|
"DependencyAwareExecutor",
|
|
26
27
|
"Operation",
|
|
27
28
|
"OperationGraphBuilder",
|
|
29
|
+
"OperationHandler",
|
|
28
30
|
"OperationRegistry",
|
|
29
|
-
"
|
|
31
|
+
"QueryFn",
|
|
32
|
+
"RequestContext",
|
|
30
33
|
"flow",
|
|
31
34
|
"flow_stream",
|
|
32
35
|
)
|
|
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
13
13
|
from uuid import UUID
|
|
14
14
|
|
|
15
15
|
from krons.core import Edge, Graph
|
|
16
|
-
from krons.types import Undefined, UndefinedType, is_sentinel, not_sentinel
|
|
16
|
+
from krons.core.types import Undefined, UndefinedType, is_sentinel, not_sentinel
|
|
17
17
|
from krons.utils._utils import to_uuid
|
|
18
18
|
|
|
19
19
|
from .node import Operation
|
|
@@ -11,8 +11,8 @@ from datetime import datetime
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
12
12
|
from uuid import UUID, uuid4
|
|
13
13
|
|
|
14
|
-
from krons.types.base import DataClass
|
|
15
|
-
from krons.types.identity import ID
|
|
14
|
+
from krons.core.types.base import DataClass
|
|
15
|
+
from krons.core.types.identity import ID
|
|
16
16
|
|
|
17
17
|
if TYPE_CHECKING:
|
|
18
18
|
from krons.session import Branch, Session
|
|
@@ -85,7 +85,7 @@ class RequestContext(DataClass):
|
|
|
85
85
|
name: str
|
|
86
86
|
id: UUID = field(default_factory=uuid4)
|
|
87
87
|
session_id: ID[Session] | None = None
|
|
88
|
-
|
|
88
|
+
branch: ID[Branch] | str | None = None
|
|
89
89
|
metadata: dict[str, Any] = field(default_factory=dict)
|
|
90
90
|
conn: Any | None = None
|
|
91
91
|
query_fn: QueryFn | None = None
|
|
@@ -95,7 +95,7 @@ class RequestContext(DataClass):
|
|
|
95
95
|
self,
|
|
96
96
|
name: str,
|
|
97
97
|
session_id: ID[Session] | None = None,
|
|
98
|
-
|
|
98
|
+
branch: ID[Branch] | str | None = None,
|
|
99
99
|
id: UUID | None = None,
|
|
100
100
|
conn: Any | None = None,
|
|
101
101
|
query_fn: QueryFn | None = None,
|
|
@@ -105,7 +105,7 @@ class RequestContext(DataClass):
|
|
|
105
105
|
self.name = name
|
|
106
106
|
self.id = id or uuid4()
|
|
107
107
|
self.session_id = session_id
|
|
108
|
-
self.
|
|
108
|
+
self.branch = branch
|
|
109
109
|
self.conn = conn
|
|
110
110
|
self.query_fn = query_fn
|
|
111
111
|
self.now = now
|
|
@@ -127,3 +127,34 @@ class RequestContext(DataClass):
|
|
|
127
127
|
raise AttributeError(
|
|
128
128
|
f"'{type(self).__name__}' has no attribute '{name}'"
|
|
129
129
|
) from None
|
|
130
|
+
|
|
131
|
+
async def get_session(self) -> Session | None:
|
|
132
|
+
"""Get the Session object.
|
|
133
|
+
|
|
134
|
+
Checks bound reference first (set by Operation.invoke),
|
|
135
|
+
then falls back to global registry lookup via session_id.
|
|
136
|
+
|
|
137
|
+
Returns None if no session available.
|
|
138
|
+
"""
|
|
139
|
+
if "_bound_session" in self.metadata:
|
|
140
|
+
return self.metadata["_bound_session"]
|
|
141
|
+
if self.session_id is None:
|
|
142
|
+
return None
|
|
143
|
+
from krons.session.registry import get_session
|
|
144
|
+
|
|
145
|
+
return await get_session(self.session_id)
|
|
146
|
+
|
|
147
|
+
async def get_branch(self) -> Branch | None:
|
|
148
|
+
"""Get the Branch object.
|
|
149
|
+
|
|
150
|
+
Checks bound reference first (set by Operation.invoke),
|
|
151
|
+
then falls back to session lookup via branch identifier.
|
|
152
|
+
|
|
153
|
+
Returns None if no branch available.
|
|
154
|
+
"""
|
|
155
|
+
if "_bound_branch" in self.metadata:
|
|
156
|
+
return self.metadata["_bound_branch"]
|
|
157
|
+
session = await self.get_session()
|
|
158
|
+
if session is None or self.branch is None:
|
|
159
|
+
return None
|
|
160
|
+
return session.branches.get(self.branch)
|
|
@@ -16,7 +16,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
16
16
|
from uuid import UUID
|
|
17
17
|
|
|
18
18
|
from krons.core import EventStatus, Graph
|
|
19
|
-
from krons.types import Undefined, UndefinedType, is_sentinel
|
|
19
|
+
from krons.core.types import Undefined, UndefinedType, is_sentinel
|
|
20
20
|
from krons.utils import concurrency
|
|
21
21
|
from krons.utils.concurrency import CapacityLimiter, CompletionStream
|
|
22
22
|
|
|
@@ -94,8 +94,12 @@ class DependencyAwareExecutor:
|
|
|
94
94
|
"""
|
|
95
95
|
self.session = session
|
|
96
96
|
self.graph = graph
|
|
97
|
-
resolved_max_concurrent =
|
|
98
|
-
|
|
97
|
+
resolved_max_concurrent = (
|
|
98
|
+
None if is_sentinel(max_concurrent) else max_concurrent
|
|
99
|
+
)
|
|
100
|
+
resolved_default_branch = (
|
|
101
|
+
None if is_sentinel(default_branch) else default_branch
|
|
102
|
+
)
|
|
99
103
|
self.max_concurrent = resolved_max_concurrent
|
|
100
104
|
self.stop_on_error = stop_on_error
|
|
101
105
|
self.verbose = verbose
|
|
@@ -107,7 +111,9 @@ class DependencyAwareExecutor:
|
|
|
107
111
|
self.operation_branches: dict[UUID, Branch | None] = {}
|
|
108
112
|
|
|
109
113
|
self._limiter: CapacityLimiter | None = (
|
|
110
|
-
CapacityLimiter(resolved_max_concurrent)
|
|
114
|
+
CapacityLimiter(resolved_max_concurrent)
|
|
115
|
+
if resolved_max_concurrent
|
|
116
|
+
else None
|
|
111
117
|
)
|
|
112
118
|
|
|
113
119
|
for node in graph.nodes:
|
|
@@ -258,7 +264,9 @@ class DependencyAwareExecutor:
|
|
|
258
264
|
self.operation_branches[node.id] = default_branch
|
|
259
265
|
|
|
260
266
|
if self.verbose:
|
|
261
|
-
logger.debug(
|
|
267
|
+
logger.debug(
|
|
268
|
+
"Pre-allocated branches for %d operations", len(self.operation_branches)
|
|
269
|
+
)
|
|
262
270
|
|
|
263
271
|
async def _execute_operation(self, operation: Operation) -> Operation:
|
|
264
272
|
"""Execute single operation: wait deps -> acquire slot -> invoke -> signal."""
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
2
|
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Operation: executable graph node bridging session to handler.
|
|
5
|
+
|
|
6
|
+
Operation.invoke() creates a RequestContext from the bound session/branch
|
|
7
|
+
and calls the registered handler with (params, ctx). Handlers never need
|
|
8
|
+
to know about the factory pattern — they receive a clean RequestContext.
|
|
9
|
+
"""
|
|
10
|
+
|
|
3
11
|
from __future__ import annotations
|
|
4
12
|
|
|
5
13
|
from typing import TYPE_CHECKING, Any
|
|
@@ -7,23 +15,36 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
15
|
from pydantic import Field, PrivateAttr
|
|
8
16
|
|
|
9
17
|
from krons.core import Event, Node
|
|
10
|
-
|
|
18
|
+
|
|
19
|
+
from .context import RequestContext
|
|
11
20
|
|
|
12
21
|
if TYPE_CHECKING:
|
|
13
22
|
from krons.session import Branch, Session
|
|
14
23
|
|
|
15
|
-
__all__ = ("Operation",
|
|
24
|
+
__all__ = ("Operation",)
|
|
16
25
|
|
|
17
26
|
|
|
18
27
|
class Operation(Node, Event):
|
|
28
|
+
"""Executable operation node.
|
|
29
|
+
|
|
30
|
+
Bridges session.conduct() to handler(params, ctx) by:
|
|
31
|
+
1. Storing bound session/branch references
|
|
32
|
+
2. Creating RequestContext with those references
|
|
33
|
+
3. Looking up the handler from session.operations registry
|
|
34
|
+
4. Calling handler(params, ctx)
|
|
35
|
+
|
|
36
|
+
The result is stored in execution.response (via Event.invoke).
|
|
37
|
+
"""
|
|
38
|
+
|
|
19
39
|
operation_type: str
|
|
20
40
|
parameters: dict[str, Any] | Any = Field(
|
|
21
41
|
default_factory=dict,
|
|
22
|
-
description="Operation parameters (dict or
|
|
42
|
+
description="Operation parameters (Params dataclass, dict, or model)",
|
|
23
43
|
)
|
|
24
44
|
|
|
25
45
|
_session: Any = PrivateAttr(default=None)
|
|
26
46
|
_branch: Any = PrivateAttr(default=None)
|
|
47
|
+
_verbose: bool = PrivateAttr(default=False)
|
|
27
48
|
|
|
28
49
|
def bind(self, session: Session, branch: Branch) -> Operation:
|
|
29
50
|
"""Bind session and branch for execution.
|
|
@@ -31,11 +52,11 @@ class Operation(Node, Event):
|
|
|
31
52
|
Must be called before invoke() if not using Session.conduct().
|
|
32
53
|
|
|
33
54
|
Args:
|
|
34
|
-
session: Session with operations registry and services
|
|
35
|
-
branch: Branch for message context
|
|
55
|
+
session: Session with operations registry and services.
|
|
56
|
+
branch: Branch for message context.
|
|
36
57
|
|
|
37
58
|
Returns:
|
|
38
|
-
Self for chaining
|
|
59
|
+
Self for chaining.
|
|
39
60
|
"""
|
|
40
61
|
self._session = session
|
|
41
62
|
self._branch = branch
|
|
@@ -51,51 +72,32 @@ class Operation(Node, Event):
|
|
|
51
72
|
return self._session, self._branch
|
|
52
73
|
|
|
53
74
|
async def _invoke(self) -> Any:
|
|
54
|
-
"""Execute via session's operation registry.
|
|
75
|
+
"""Execute handler via session's operation registry.
|
|
76
|
+
|
|
77
|
+
Creates a RequestContext with bound session/branch references
|
|
78
|
+
and calls handler(params, ctx). Called by Event.invoke().
|
|
55
79
|
|
|
56
80
|
Returns:
|
|
57
|
-
|
|
81
|
+
Handler result (stored in execution.response).
|
|
58
82
|
|
|
59
83
|
Raises:
|
|
60
84
|
RuntimeError: If not bound.
|
|
61
85
|
KeyError: If operation_type not registered.
|
|
62
86
|
"""
|
|
63
87
|
session, branch = self._require_binding()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
handler = session.operations.get(self.operation_type)
|
|
89
|
+
|
|
90
|
+
ctx = RequestContext(
|
|
91
|
+
name=self.operation_type,
|
|
92
|
+
session_id=session.id,
|
|
93
|
+
branch=branch.name or str(branch.id),
|
|
94
|
+
_bound_session=session,
|
|
95
|
+
_bound_branch=branch,
|
|
96
|
+
_verbose=self._verbose,
|
|
71
97
|
)
|
|
72
98
|
|
|
99
|
+
return await handler(self.parameters, ctx)
|
|
73
100
|
|
|
74
|
-
def
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
**kwargs,
|
|
78
|
-
) -> Operation:
|
|
79
|
-
"""Factory for Operation nodes.
|
|
80
|
-
|
|
81
|
-
Args:
|
|
82
|
-
operation_type: Registry key (required).
|
|
83
|
-
parameters: Factory arguments dict (default: {}).
|
|
84
|
-
**kwargs: Additional fields (metadata, timeout, etc.).
|
|
85
|
-
|
|
86
|
-
Returns:
|
|
87
|
-
Unbound Operation ready for bind() and invoke().
|
|
88
|
-
|
|
89
|
-
Raises:
|
|
90
|
-
ValueError: If operation_type not provided.
|
|
91
|
-
"""
|
|
92
|
-
if is_sentinel(operation_type):
|
|
93
|
-
raise ValueError("operation_type is required")
|
|
94
|
-
|
|
95
|
-
resolved_params: dict[str, Any] = {} if is_sentinel(parameters) else parameters
|
|
96
|
-
|
|
97
|
-
return Operation(
|
|
98
|
-
operation_type=operation_type,
|
|
99
|
-
parameters=resolved_params,
|
|
100
|
-
**kwargs,
|
|
101
|
-
)
|
|
101
|
+
def __repr__(self) -> str:
|
|
102
|
+
bound = "bound" if self._session is not None else "unbound"
|
|
103
|
+
return f"Operation(type={self.operation_type}, status={self.execution.status.value}, {bound})"
|