krons 0.1.0__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 +127 -0
- krons/core/base/__init__.py +121 -0
- {kronos/core → krons/core/base}/broadcaster.py +7 -3
- {kronos/core → krons/core/base}/element.py +15 -7
- {kronos/core → krons/core/base}/event.py +41 -8
- {kronos/core → krons/core/base}/eventbus.py +4 -2
- {kronos/core → krons/core/base}/flow.py +14 -7
- {kronos/core → krons/core/base}/graph.py +27 -11
- {kronos/core → krons/core/base}/node.py +47 -22
- {kronos/core → krons/core/base}/pile.py +26 -12
- {kronos/core → krons/core/base}/processor.py +23 -9
- {kronos/core → krons/core/base}/progression.py +5 -3
- {kronos → krons/core}/specs/__init__.py +0 -5
- {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
- {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
- {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
- {kronos → krons/core}/specs/catalog/__init__.py +2 -2
- {kronos → krons/core}/specs/catalog/_audit.py +3 -3
- {kronos → krons/core}/specs/catalog/_common.py +2 -2
- {kronos → krons/core}/specs/catalog/_content.py +5 -5
- {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
- {kronos → krons/core}/specs/factory.py +7 -7
- {kronos → krons/core}/specs/operable.py +9 -3
- {kronos → krons/core}/specs/protocol.py +4 -2
- {kronos → krons/core}/specs/spec.py +25 -13
- {kronos → krons/core}/types/base.py +7 -5
- {kronos → krons/core}/types/db_types.py +2 -2
- {kronos → krons/core}/types/identity.py +1 -1
- {kronos → krons}/errors.py +13 -13
- {kronos → krons}/protocols.py +9 -4
- krons/resource/__init__.py +89 -0
- {kronos/services → krons/resource}/backend.py +50 -24
- {kronos/services → krons/resource}/endpoint.py +28 -14
- {kronos/services → krons/resource}/hook.py +22 -9
- {kronos/services → krons/resource}/imodel.py +50 -32
- {kronos/services → krons/resource}/registry.py +27 -25
- {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
- {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
- {kronos/services → krons/resource}/utilities/resilience.py +17 -7
- krons/resource/utilities/token_calculator.py +185 -0
- {kronos → krons}/session/__init__.py +12 -17
- krons/session/constraints.py +70 -0
- {kronos → krons}/session/exchange.py +14 -6
- {kronos → krons}/session/message.py +4 -2
- krons/session/registry.py +35 -0
- {kronos → krons}/session/session.py +165 -174
- krons/utils/__init__.py +85 -0
- krons/utils/_function_arg_parser.py +99 -0
- krons/utils/_pythonic_function_call.py +249 -0
- {kronos → krons}/utils/_to_list.py +9 -3
- {kronos → krons}/utils/_utils.py +9 -5
- {kronos → krons}/utils/concurrency/__init__.py +38 -38
- {kronos → krons}/utils/concurrency/_async_call.py +6 -4
- {kronos → krons}/utils/concurrency/_errors.py +3 -1
- {kronos → krons}/utils/concurrency/_patterns.py +3 -1
- {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
- krons/utils/display.py +257 -0
- {kronos → krons}/utils/fuzzy/__init__.py +6 -1
- {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
- {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
- {kronos → 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
- {kronos → krons}/utils/sql/_sql_validation.py +1 -1
- 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
- {kronos → krons/work}/operations/__init__.py +7 -4
- {kronos → krons/work}/operations/builder.py +4 -4
- {kronos/enforcement → krons/work/operations}/context.py +37 -6
- {kronos → krons/work}/operations/flow.py +17 -9
- krons/work/operations/node.py +103 -0
- krons/work/operations/registry.py +103 -0
- {kronos/specs → krons/work}/phrase.py +131 -14
- {kronos/enforcement → krons/work}/policy.py +3 -3
- krons/work/report.py +268 -0
- krons/work/rules/__init__.py +47 -0
- {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
- {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
- {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
- {kronos/enforcement → krons/work/rules}/rule.py +2 -2
- {kronos/enforcement → krons/work/rules}/validator.py +21 -6
- {kronos/enforcement → krons/work}/service.py +16 -7
- krons/work/worker.py +266 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
- krons-0.2.0.dist-info/RECORD +154 -0
- kronos/core/__init__.py +0 -145
- kronos/enforcement/__init__.py +0 -57
- kronos/operations/node.py +0 -101
- kronos/operations/registry.py +0 -92
- kronos/services/__init__.py +0 -81
- kronos/specs/adapters/__init__.py +0 -0
- kronos/utils/__init__.py +0 -40
- krons-0.1.0.dist-info/RECORD +0 -101
- {kronos → krons/core/specs/adapters}/__init__.py +0 -0
- {kronos → krons/core}/specs/adapters/_utils.py +0 -0
- {kronos → krons/core}/specs/adapters/factory.py +0 -0
- {kronos → krons/core}/types/__init__.py +0 -0
- {kronos → krons/core}/types/_sentinel.py +0 -0
- {kronos → krons}/py.typed +0 -0
- {kronos/services → krons/resource}/utilities/__init__.py +0 -0
- {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
- {kronos → krons}/utils/_hash.py +0 -0
- {kronos → krons}/utils/_json_dump.py +0 -0
- {kronos → krons}/utils/_lazy_init.py +0 -0
- {kronos → krons}/utils/_to_num.py +0 -0
- {kronos → krons}/utils/concurrency/_cancel.py +0 -0
- {kronos → krons}/utils/concurrency/_primitives.py +0 -0
- {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
- {kronos → krons}/utils/concurrency/_run_async.py +0 -0
- {kronos → krons}/utils/concurrency/_task.py +0 -0
- {kronos → krons}/utils/concurrency/_utils.py +0 -0
- {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
- {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
- {kronos → krons}/utils/sql/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
- {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
- {kronos/enforcement → krons/work/rules}/registry.py +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
- {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
krons/work/engine.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""WorkerEngine - Execution driver for Worker workflows.
|
|
5
|
+
|
|
6
|
+
The engine manages task execution, following worklinks to traverse the
|
|
7
|
+
workflow graph defined by @work and @worklink decorators.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
worker = FileCoder()
|
|
11
|
+
engine = WorkerEngine(worker=worker, refresh_time=0.3)
|
|
12
|
+
|
|
13
|
+
# Add a task starting at a specific function
|
|
14
|
+
task = await engine.add_task(
|
|
15
|
+
form=my_form,
|
|
16
|
+
task_function="start_task",
|
|
17
|
+
task_max_steps=20,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Run until all tasks complete
|
|
21
|
+
await engine.execute()
|
|
22
|
+
|
|
23
|
+
# Or run indefinitely
|
|
24
|
+
await engine.execute_lasting()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
from uuid import UUID, uuid4
|
|
33
|
+
|
|
34
|
+
from krons.utils import concurrency
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from .worker import Worker
|
|
38
|
+
|
|
39
|
+
__all__ = ("WorkerEngine", "WorkerTask")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class WorkerTask:
|
|
44
|
+
"""A task being executed by the engine.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
id: Unique task identifier
|
|
48
|
+
function: Current method name to execute
|
|
49
|
+
kwargs: Arguments for the method
|
|
50
|
+
status: PENDING, PROCESSING, COMPLETED, FAILED
|
|
51
|
+
result: Final result when completed
|
|
52
|
+
error: Exception if failed
|
|
53
|
+
max_steps: Max workflow steps before stopping
|
|
54
|
+
current_step: Current step count
|
|
55
|
+
history: List of (function, result) tuples for debugging
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
id: UUID = field(default_factory=uuid4)
|
|
59
|
+
function: str = ""
|
|
60
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
status: str = "PENDING"
|
|
62
|
+
result: Any = None
|
|
63
|
+
error: Exception | None = None
|
|
64
|
+
max_steps: int = 100
|
|
65
|
+
current_step: int = 0
|
|
66
|
+
history: list[tuple[str, Any]] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WorkerEngine:
|
|
70
|
+
"""Execution driver for Worker workflows.
|
|
71
|
+
|
|
72
|
+
Manages a queue of tasks, executing them through the workflow graph
|
|
73
|
+
defined by the worker's @work and @worklink decorators.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
worker: The Worker instance to execute
|
|
77
|
+
refresh_time: Seconds between processing cycles
|
|
78
|
+
tasks: Dict of active tasks by ID
|
|
79
|
+
_task_queue: Async queue for pending work
|
|
80
|
+
_stopped: Stop flag
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
engine = WorkerEngine(worker=my_worker)
|
|
84
|
+
task = await engine.add_task(
|
|
85
|
+
form=my_form,
|
|
86
|
+
task_function="entry_point",
|
|
87
|
+
)
|
|
88
|
+
await engine.execute()
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
worker: Worker,
|
|
94
|
+
refresh_time: float = 0.1,
|
|
95
|
+
max_concurrent: int = 10,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Initialize the engine.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
worker: Worker instance with @work/@worklink methods
|
|
101
|
+
refresh_time: Seconds between processing cycles
|
|
102
|
+
max_concurrent: Max concurrent task executions
|
|
103
|
+
"""
|
|
104
|
+
self.worker = worker
|
|
105
|
+
self.refresh_time = refresh_time
|
|
106
|
+
self.max_concurrent = max_concurrent
|
|
107
|
+
|
|
108
|
+
self.tasks: dict[UUID, WorkerTask] = {}
|
|
109
|
+
self._task_queue: asyncio.Queue[UUID] = asyncio.Queue()
|
|
110
|
+
self._stopped = False
|
|
111
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
|
112
|
+
|
|
113
|
+
async def add_task(
|
|
114
|
+
self,
|
|
115
|
+
task_function: str,
|
|
116
|
+
task_max_steps: int = 100,
|
|
117
|
+
**kwargs: Any,
|
|
118
|
+
) -> WorkerTask:
|
|
119
|
+
"""Add a new task to the execution queue.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
task_function: Entry method name to start execution
|
|
123
|
+
task_max_steps: Max workflow steps before stopping
|
|
124
|
+
**kwargs: Arguments for the entry method
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
WorkerTask instance (can be monitored for status)
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: If task_function not found in worker
|
|
131
|
+
"""
|
|
132
|
+
if task_function not in self.worker._work_methods:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"Method '{task_function}' not found. "
|
|
135
|
+
f"Available: {list(self.worker._work_methods.keys())}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
task = WorkerTask(
|
|
139
|
+
function=task_function,
|
|
140
|
+
kwargs=kwargs,
|
|
141
|
+
max_steps=task_max_steps,
|
|
142
|
+
)
|
|
143
|
+
self.tasks[task.id] = task
|
|
144
|
+
await self._task_queue.put(task.id)
|
|
145
|
+
|
|
146
|
+
return task
|
|
147
|
+
|
|
148
|
+
async def execute(self) -> None:
|
|
149
|
+
"""Execute all queued tasks until queue is empty.
|
|
150
|
+
|
|
151
|
+
Processes tasks through their workflow graphs, following worklinks.
|
|
152
|
+
Returns when all tasks are completed or failed.
|
|
153
|
+
"""
|
|
154
|
+
self._stopped = False
|
|
155
|
+
await self.worker.start()
|
|
156
|
+
|
|
157
|
+
while not self._stopped and not self._task_queue.empty():
|
|
158
|
+
await self._process_cycle()
|
|
159
|
+
await concurrency.sleep(self.refresh_time)
|
|
160
|
+
|
|
161
|
+
async def execute_lasting(self) -> None:
|
|
162
|
+
"""Execute indefinitely until stop() is called.
|
|
163
|
+
|
|
164
|
+
Useful for long-running worker services that continuously
|
|
165
|
+
process incoming tasks.
|
|
166
|
+
"""
|
|
167
|
+
self._stopped = False
|
|
168
|
+
await self.worker.start()
|
|
169
|
+
|
|
170
|
+
while not self._stopped:
|
|
171
|
+
await self._process_cycle()
|
|
172
|
+
await concurrency.sleep(self.refresh_time)
|
|
173
|
+
|
|
174
|
+
async def stop(self) -> None:
|
|
175
|
+
"""Stop the execution loop."""
|
|
176
|
+
self._stopped = True
|
|
177
|
+
await self.worker.stop()
|
|
178
|
+
|
|
179
|
+
async def _process_cycle(self) -> None:
|
|
180
|
+
"""Process one cycle of tasks."""
|
|
181
|
+
# Collect tasks to process this cycle
|
|
182
|
+
tasks_to_process: list[UUID] = []
|
|
183
|
+
|
|
184
|
+
while (
|
|
185
|
+
not self._task_queue.empty() and len(tasks_to_process) < self.max_concurrent
|
|
186
|
+
):
|
|
187
|
+
try:
|
|
188
|
+
task_id = self._task_queue.get_nowait()
|
|
189
|
+
tasks_to_process.append(task_id)
|
|
190
|
+
except asyncio.QueueEmpty:
|
|
191
|
+
break
|
|
192
|
+
|
|
193
|
+
if not tasks_to_process:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Process tasks concurrently
|
|
197
|
+
async with concurrency.create_task_group() as tg:
|
|
198
|
+
for task_id in tasks_to_process:
|
|
199
|
+
tg.start_soon(self._process_task, task_id)
|
|
200
|
+
|
|
201
|
+
async def _process_task(self, task_id: UUID) -> None:
|
|
202
|
+
"""Process a single task through one workflow step."""
|
|
203
|
+
async with self._semaphore:
|
|
204
|
+
task = self.tasks.get(task_id)
|
|
205
|
+
if task is None or task.status in ("COMPLETED", "FAILED"):
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Check step limit
|
|
209
|
+
if task.current_step >= task.max_steps:
|
|
210
|
+
task.status = "COMPLETED"
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
task.status = "PROCESSING"
|
|
214
|
+
task.current_step += 1
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
# Get the work method and config
|
|
218
|
+
method, config = self.worker._work_methods[task.function]
|
|
219
|
+
|
|
220
|
+
# Prepare kwargs with form binding
|
|
221
|
+
call_kwargs = dict(task.kwargs)
|
|
222
|
+
if config.form_param_key and config.assignment:
|
|
223
|
+
form_id = call_kwargs.get(config.form_param_key)
|
|
224
|
+
if form_id and form_id in self.worker.forms:
|
|
225
|
+
form = self.worker.forms[form_id]
|
|
226
|
+
# Bind input fields from form to kwargs
|
|
227
|
+
for input_field in form.input_fields:
|
|
228
|
+
if input_field in form.available_data:
|
|
229
|
+
call_kwargs[input_field] = form.available_data[
|
|
230
|
+
input_field
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
# Execute with optional timeout
|
|
234
|
+
if config.timeout:
|
|
235
|
+
result = await asyncio.wait_for(
|
|
236
|
+
method(**call_kwargs),
|
|
237
|
+
timeout=config.timeout,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
result = await method(**call_kwargs)
|
|
241
|
+
|
|
242
|
+
# Record history
|
|
243
|
+
task.history.append((task.function, result))
|
|
244
|
+
task.result = result
|
|
245
|
+
|
|
246
|
+
# Follow worklinks
|
|
247
|
+
next_tasks = await self._follow_links(task, result)
|
|
248
|
+
|
|
249
|
+
if next_tasks:
|
|
250
|
+
# Continue with next step(s)
|
|
251
|
+
for next_func, next_kwargs in next_tasks:
|
|
252
|
+
task.function = next_func
|
|
253
|
+
task.kwargs = next_kwargs
|
|
254
|
+
task.status = "PENDING"
|
|
255
|
+
await self._task_queue.put(task_id)
|
|
256
|
+
break # Only follow first matching link for now
|
|
257
|
+
else:
|
|
258
|
+
# No more links - task complete
|
|
259
|
+
task.status = "COMPLETED"
|
|
260
|
+
|
|
261
|
+
except Exception as e:
|
|
262
|
+
task.status = "FAILED"
|
|
263
|
+
task.error = e
|
|
264
|
+
|
|
265
|
+
async def _follow_links(
|
|
266
|
+
self, task: WorkerTask, result: Any
|
|
267
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
268
|
+
"""Follow worklinks from current method.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
task: Current task
|
|
272
|
+
result: Result from current method
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of (next_function, kwargs) tuples for matching links
|
|
276
|
+
"""
|
|
277
|
+
next_tasks: list[tuple[str, dict[str, Any]]] = []
|
|
278
|
+
|
|
279
|
+
for link in self.worker.get_links_from(task.function):
|
|
280
|
+
try:
|
|
281
|
+
# Get the handler method from worker and call it
|
|
282
|
+
handler = getattr(self.worker, link.handler_name)
|
|
283
|
+
next_kwargs = await handler(result)
|
|
284
|
+
|
|
285
|
+
# None means skip this edge
|
|
286
|
+
if next_kwargs is not None:
|
|
287
|
+
next_tasks.append((link.to_, next_kwargs))
|
|
288
|
+
|
|
289
|
+
except Exception:
|
|
290
|
+
# Link handler failed - skip this edge
|
|
291
|
+
continue
|
|
292
|
+
|
|
293
|
+
return next_tasks
|
|
294
|
+
|
|
295
|
+
def get_task(self, task_id: UUID) -> WorkerTask | None:
|
|
296
|
+
"""Get task by ID."""
|
|
297
|
+
return self.tasks.get(task_id)
|
|
298
|
+
|
|
299
|
+
def get_tasks_by_status(self, status: str) -> list[WorkerTask]:
|
|
300
|
+
"""Get all tasks with given status."""
|
|
301
|
+
return [t for t in self.tasks.values() if t.status == status]
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def pending_tasks(self) -> list[WorkerTask]:
|
|
305
|
+
"""Tasks waiting to be processed."""
|
|
306
|
+
return self.get_tasks_by_status("PENDING")
|
|
307
|
+
|
|
308
|
+
@property
|
|
309
|
+
def processing_tasks(self) -> list[WorkerTask]:
|
|
310
|
+
"""Tasks currently being processed."""
|
|
311
|
+
return self.get_tasks_by_status("PROCESSING")
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def completed_tasks(self) -> list[WorkerTask]:
|
|
315
|
+
"""Tasks that completed successfully."""
|
|
316
|
+
return self.get_tasks_by_status("COMPLETED")
|
|
317
|
+
|
|
318
|
+
@property
|
|
319
|
+
def failed_tasks(self) -> list[WorkerTask]:
|
|
320
|
+
"""Tasks that failed with errors."""
|
|
321
|
+
return self.get_tasks_by_status("FAILED")
|
|
322
|
+
|
|
323
|
+
def status_counts(self) -> dict[str, int]:
|
|
324
|
+
"""Count tasks by status."""
|
|
325
|
+
counts: dict[str, int] = {}
|
|
326
|
+
for task in self.tasks.values():
|
|
327
|
+
counts[task.status] = counts.get(task.status, 0) + 1
|
|
328
|
+
return counts
|
|
329
|
+
|
|
330
|
+
def __repr__(self) -> str:
|
|
331
|
+
counts = self.status_counts()
|
|
332
|
+
total = len(self.tasks)
|
|
333
|
+
return f"WorkerEngine(worker={self.worker.name}, tasks={total}, {counts})"
|
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
|
)
|
|
@@ -12,14 +12,14 @@ from __future__ import annotations
|
|
|
12
12
|
from typing import TYPE_CHECKING, Any
|
|
13
13
|
from uuid import UUID
|
|
14
14
|
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
from
|
|
15
|
+
from krons.core import Edge, Graph
|
|
16
|
+
from krons.core.types import Undefined, UndefinedType, is_sentinel, not_sentinel
|
|
17
|
+
from krons.utils._utils import to_uuid
|
|
18
18
|
|
|
19
19
|
from .node import Operation
|
|
20
20
|
|
|
21
21
|
if TYPE_CHECKING:
|
|
22
|
-
from
|
|
22
|
+
from krons.session import Branch
|
|
23
23
|
|
|
24
24
|
__all__ = ("Builder", "OperationGraphBuilder")
|
|
25
25
|
|