krons 0.1.1__py3-none-any.whl → 0.2.1__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 (142) hide show
  1. krons/__init__.py +49 -0
  2. krons/agent/__init__.py +144 -0
  3. krons/agent/mcps/__init__.py +14 -0
  4. krons/agent/mcps/loader.py +287 -0
  5. krons/agent/mcps/wrapper.py +799 -0
  6. krons/agent/message/__init__.py +20 -0
  7. krons/agent/message/action.py +69 -0
  8. krons/agent/message/assistant.py +52 -0
  9. krons/agent/message/common.py +49 -0
  10. krons/agent/message/instruction.py +130 -0
  11. krons/agent/message/prepare_msg.py +187 -0
  12. krons/agent/message/role.py +53 -0
  13. krons/agent/message/system.py +53 -0
  14. krons/agent/operations/__init__.py +82 -0
  15. krons/agent/operations/act.py +100 -0
  16. krons/agent/operations/generate.py +145 -0
  17. krons/agent/operations/llm_reparse.py +89 -0
  18. krons/agent/operations/operate.py +247 -0
  19. krons/agent/operations/parse.py +243 -0
  20. krons/agent/operations/react.py +286 -0
  21. krons/agent/operations/specs.py +235 -0
  22. krons/agent/operations/structure.py +151 -0
  23. krons/agent/operations/utils.py +79 -0
  24. krons/agent/providers/__init__.py +17 -0
  25. krons/agent/providers/anthropic_messages.py +146 -0
  26. krons/agent/providers/claude_code.py +276 -0
  27. krons/agent/providers/gemini.py +268 -0
  28. krons/agent/providers/match.py +75 -0
  29. krons/agent/providers/oai_chat.py +174 -0
  30. krons/agent/third_party/__init__.py +2 -0
  31. krons/agent/third_party/anthropic_models.py +154 -0
  32. krons/agent/third_party/claude_code.py +682 -0
  33. krons/agent/third_party/gemini_models.py +508 -0
  34. krons/agent/third_party/openai_models.py +295 -0
  35. krons/agent/tool.py +291 -0
  36. krons/core/__init__.py +56 -74
  37. krons/core/base/__init__.py +121 -0
  38. krons/core/{broadcaster.py → base/broadcaster.py} +7 -3
  39. krons/core/{element.py → base/element.py} +13 -5
  40. krons/core/{event.py → base/event.py} +39 -6
  41. krons/core/{eventbus.py → base/eventbus.py} +3 -1
  42. krons/core/{flow.py → base/flow.py} +11 -4
  43. krons/core/{graph.py → base/graph.py} +24 -8
  44. krons/core/{node.py → base/node.py} +44 -19
  45. krons/core/{pile.py → base/pile.py} +22 -8
  46. krons/core/{processor.py → base/processor.py} +21 -7
  47. krons/core/{progression.py → base/progression.py} +3 -1
  48. krons/{specs → core/specs}/__init__.py +0 -5
  49. krons/{specs → core/specs}/adapters/dataclass_field.py +16 -8
  50. krons/{specs → core/specs}/adapters/pydantic_adapter.py +11 -5
  51. krons/{specs → core/specs}/adapters/sql_ddl.py +14 -8
  52. krons/{specs → core/specs}/catalog/__init__.py +2 -2
  53. krons/{specs → core/specs}/catalog/_audit.py +2 -2
  54. krons/{specs → core/specs}/catalog/_common.py +2 -2
  55. krons/{specs → core/specs}/catalog/_content.py +4 -4
  56. krons/{specs → core/specs}/catalog/_enforcement.py +3 -3
  57. krons/{specs → core/specs}/factory.py +5 -5
  58. krons/{specs → core/specs}/operable.py +8 -2
  59. krons/{specs → core/specs}/protocol.py +4 -2
  60. krons/{specs → core/specs}/spec.py +23 -11
  61. krons/{types → core/types}/base.py +4 -2
  62. krons/{types → core/types}/db_types.py +2 -2
  63. krons/errors.py +13 -13
  64. krons/protocols.py +9 -4
  65. krons/resource/__init__.py +89 -0
  66. krons/{services → resource}/backend.py +48 -22
  67. krons/{services → resource}/endpoint.py +28 -14
  68. krons/{services → resource}/hook.py +20 -7
  69. krons/{services → resource}/imodel.py +46 -28
  70. krons/{services → resource}/registry.py +26 -24
  71. krons/{services → resource}/utilities/rate_limited_executor.py +7 -3
  72. krons/{services → resource}/utilities/rate_limiter.py +3 -1
  73. krons/{services → resource}/utilities/resilience.py +15 -5
  74. krons/resource/utilities/token_calculator.py +185 -0
  75. krons/session/__init__.py +12 -17
  76. krons/session/constraints.py +70 -0
  77. krons/session/exchange.py +11 -3
  78. krons/session/message.py +3 -1
  79. krons/session/registry.py +35 -0
  80. krons/session/session.py +165 -174
  81. krons/utils/__init__.py +45 -0
  82. krons/utils/_function_arg_parser.py +99 -0
  83. krons/utils/_pythonic_function_call.py +249 -0
  84. krons/utils/_to_list.py +9 -3
  85. krons/utils/_utils.py +6 -2
  86. krons/utils/concurrency/_async_call.py +4 -2
  87. krons/utils/concurrency/_errors.py +3 -1
  88. krons/utils/concurrency/_patterns.py +3 -1
  89. krons/utils/concurrency/_resource_tracker.py +6 -2
  90. krons/utils/display.py +257 -0
  91. krons/utils/fuzzy/__init__.py +6 -1
  92. krons/utils/fuzzy/_fuzzy_match.py +14 -8
  93. krons/utils/fuzzy/_string_similarity.py +3 -1
  94. krons/utils/fuzzy/_to_dict.py +3 -1
  95. krons/utils/schemas/__init__.py +26 -0
  96. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  97. krons/utils/schemas/_formatter.py +72 -0
  98. krons/utils/schemas/_minimal_yaml.py +151 -0
  99. krons/utils/schemas/_typescript.py +153 -0
  100. krons/utils/validators/__init__.py +3 -0
  101. krons/utils/validators/_validate_image_url.py +56 -0
  102. krons/work/__init__.py +115 -0
  103. krons/work/engine.py +333 -0
  104. krons/work/form.py +242 -0
  105. krons/{operations → work/operations}/__init__.py +7 -4
  106. krons/{operations → work/operations}/builder.py +1 -1
  107. krons/{enforcement → work/operations}/context.py +36 -5
  108. krons/{operations → work/operations}/flow.py +13 -5
  109. krons/{operations → work/operations}/node.py +45 -43
  110. krons/work/operations/registry.py +103 -0
  111. krons/work/report.py +268 -0
  112. krons/work/rules/__init__.py +47 -0
  113. krons/{enforcement → work/rules}/common/boolean.py +3 -1
  114. krons/{enforcement → work/rules}/common/choice.py +9 -3
  115. krons/{enforcement → work/rules}/common/number.py +3 -1
  116. krons/{enforcement → work/rules}/common/string.py +9 -3
  117. krons/{enforcement → work/rules}/rule.py +1 -1
  118. krons/{enforcement → work/rules}/validator.py +20 -5
  119. krons/work/worker.py +266 -0
  120. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/METADATA +15 -1
  121. krons-0.2.1.dist-info/RECORD +151 -0
  122. krons/enforcement/__init__.py +0 -57
  123. krons/enforcement/policy.py +0 -80
  124. krons/enforcement/service.py +0 -370
  125. krons/operations/registry.py +0 -92
  126. krons/services/__init__.py +0 -81
  127. krons/specs/phrase.py +0 -405
  128. krons-0.1.1.dist-info/RECORD +0 -101
  129. /krons/{specs → core/specs}/adapters/__init__.py +0 -0
  130. /krons/{specs → core/specs}/adapters/_utils.py +0 -0
  131. /krons/{specs → core/specs}/adapters/factory.py +0 -0
  132. /krons/{types → core/types}/__init__.py +0 -0
  133. /krons/{types → core/types}/_sentinel.py +0 -0
  134. /krons/{types → core/types}/identity.py +0 -0
  135. /krons/{services → resource}/utilities/__init__.py +0 -0
  136. /krons/{services → resource}/utilities/header_factory.py +0 -0
  137. /krons/{enforcement → work/rules}/common/__init__.py +0 -0
  138. /krons/{enforcement → work/rules}/common/mapping.py +0 -0
  139. /krons/{enforcement → work/rules}/common/model.py +0 -0
  140. /krons/{enforcement → work/rules}/registry.py +0 -0
  141. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/WHEEL +0 -0
  142. {krons-0.1.1.dist-info → krons-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,7 @@
5
5
 
6
6
  Core types:
7
7
  Operation: Node + Event hybrid for graph-based execution.
8
- OperationRegistry: Per-session factory mapping.
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, create_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
- "create_operation",
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
- branch_id: ID[Branch] | None = None
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
- branch_id: ID[Branch] | None = None,
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.branch_id = branch_id
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 = None if is_sentinel(max_concurrent) else max_concurrent
98
- resolved_default_branch = None if is_sentinel(default_branch) else default_branch
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) if resolved_max_concurrent else None
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("Pre-allocated branches for %d operations", len(self.operation_branches))
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
- from krons.types import Undefined, UndefinedType, is_sentinel
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", "create_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 Pydantic model)",
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. Called by Event.invoke().
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
- Factory result (stored in execution.response).
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
- factory = session.operations.get(self.operation_type)
65
- return await factory(session, branch, self.parameters)
66
-
67
- def __repr__(self) -> str:
68
- bound = "bound" if self._session is not None else "unbound"
69
- return (
70
- f"Operation(type={self.operation_type}, status={self.execution.status.value}, {bound})"
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 create_operation(
75
- operation_type: str | UndefinedType = Undefined,
76
- parameters: dict[str, Any] | UndefinedType = Undefined,
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})"
@@ -0,0 +1,103 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Per-session operation handler registry.
5
+
6
+ Maps operation names to async handlers. Instantiated per-Session
7
+ for isolation, testability, and per-session customization.
8
+
9
+ Handler signature: async handler(params, ctx: RequestContext) -> result
10
+ Operation._invoke() creates the RequestContext from bound session/branch.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Awaitable, Callable
16
+ from typing import Any
17
+
18
+ __all__ = ("OperationHandler", "OperationRegistry")
19
+
20
+ OperationHandler = Callable[..., Awaitable[Any]]
21
+ """Handler signature: async (params, ctx: RequestContext) -> result"""
22
+
23
+
24
+ class OperationRegistry:
25
+ """Map operation names to async handler functions.
26
+
27
+ Per-session registry (not global) for isolation and testability.
28
+
29
+ Example:
30
+ from krons.agent.operations import generate, structure, operate
31
+
32
+ registry = OperationRegistry()
33
+ registry.register("generate", generate)
34
+ registry.register("structure", structure)
35
+ registry.register("operate", operate)
36
+
37
+ # Called by Operation._invoke() — users call session.conduct()
38
+ handler = registry.get("generate")
39
+ result = await handler(params, ctx)
40
+ """
41
+
42
+ def __init__(self):
43
+ """Initialize empty registry."""
44
+ self._handlers: dict[str, OperationHandler] = {}
45
+
46
+ def register(
47
+ self,
48
+ operation_name: str,
49
+ handler: OperationHandler,
50
+ *,
51
+ override: bool = False,
52
+ ) -> None:
53
+ """Register handler for operation name.
54
+
55
+ Args:
56
+ operation_name: Lookup key (e.g. "generate", "operate").
57
+ handler: Async (params, ctx) -> result.
58
+ override: Allow replacing existing. Default False.
59
+
60
+ Raises:
61
+ ValueError: If name exists and override=False.
62
+ """
63
+ if operation_name in self._handlers and not override:
64
+ raise ValueError(
65
+ f"Operation '{operation_name}' already registered. "
66
+ "Use override=True to replace."
67
+ )
68
+ self._handlers[operation_name] = handler
69
+
70
+ def get(self, operation_name: str) -> OperationHandler:
71
+ """Get handler by name. Raises KeyError with available names if not found."""
72
+ if operation_name not in self._handlers:
73
+ raise KeyError(
74
+ f"Operation '{operation_name}' not registered. "
75
+ f"Available: {self.list_names()}"
76
+ )
77
+ return self._handlers[operation_name]
78
+
79
+ def has(self, operation_name: str) -> bool:
80
+ """Check if name is registered."""
81
+ return operation_name in self._handlers
82
+
83
+ def unregister(self, operation_name: str) -> bool:
84
+ """Remove registration. Returns True if existed."""
85
+ if operation_name in self._handlers:
86
+ del self._handlers[operation_name]
87
+ return True
88
+ return False
89
+
90
+ def list_names(self) -> list[str]:
91
+ """Return all registered operation names."""
92
+ return list(self._handlers.keys())
93
+
94
+ def __contains__(self, operation_name: str) -> bool:
95
+ """Support 'name in registry' syntax."""
96
+ return operation_name in self._handlers
97
+
98
+ def __len__(self) -> int:
99
+ """Count of registered operations."""
100
+ return len(self._handlers)
101
+
102
+ def __repr__(self) -> str:
103
+ return f"OperationRegistry(operations={self.list_names()})"
krons/work/report.py ADDED
@@ -0,0 +1,268 @@
1
+ # Copyright (c) 2025 - 2026, HaiyangLi <quantocean.li at gmail dot com>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Report - Multi-step workflow orchestration.
5
+
6
+ A Report orchestrates multiple Forms based on data availability:
7
+ - Schedules forms when their inputs become available
8
+ - Groups forms by branch for sequential execution within branch
9
+ - Propagates outputs between forms
10
+ - Tracks overall workflow completion
11
+
12
+ This is the scheduling layer that enables data-driven DAG execution.
13
+
14
+ The Report pattern supports declarative workflow definition:
15
+ - Fields as class attributes (typed outputs)
16
+ - form_assignments DSL with branch/resource hints
17
+ - Implicit dependencies from data flow
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections import defaultdict
23
+ from typing import Any
24
+
25
+ from pydantic import Field
26
+
27
+ from krons.core import Element, Pile
28
+
29
+ from .form import Form, parse_assignment
30
+
31
+ __all__ = ("Report",)
32
+
33
+
34
+ class Report(Element):
35
+ """Workflow orchestrator - schedules forms based on field availability.
36
+
37
+ A Report manages a collection of Forms, executing them as their
38
+ inputs become available. Forms are grouped by branch - forms on the
39
+ same branch execute sequentially, different branches can run in parallel.
40
+
41
+ Example (simple):
42
+ report = Report(
43
+ assignment="context -> final_score",
44
+ form_assignments=[
45
+ "context -> analysis",
46
+ "analysis -> score",
47
+ "score -> final_score",
48
+ ],
49
+ )
50
+ report.initialize(context="some input")
51
+
52
+ while not report.is_complete():
53
+ for form in report.next_forms():
54
+ await form.execute(ctx)
55
+ report.complete_form(form)
56
+
57
+ Example (with branches and resources):
58
+ class HiringBriefReport(Report):
59
+ role_classification: RoleClassification | None = None
60
+ strategic_context: StrategicContext | None = None
61
+
62
+ assignment: str = "job_input -> executive_summary"
63
+
64
+ form_assignments: list[str] = [
65
+ "classifier: job_input -> role_classification | api:fast",
66
+ "strategist: job_input, role_classification -> strategic_context | api:synthesis",
67
+ "writer: strategic_context -> executive_summary | api:reasoning",
68
+ ]
69
+
70
+ Attributes:
71
+ assignment: Overall workflow 'inputs -> final_outputs'
72
+ form_assignments: List of form assignments with optional branch/resource
73
+ input_fields: Workflow input fields
74
+ output_fields: Workflow output fields
75
+ forms: All forms in workflow
76
+ completed_forms: Forms that have finished
77
+ available_data: Current state of all field values
78
+ """
79
+
80
+ assignment: str = Field(
81
+ default="",
82
+ description="Overall workflow: 'inputs -> final_outputs'",
83
+ )
84
+ form_assignments: list[str] = Field(
85
+ default_factory=list,
86
+ description="List of form assignments: ['branch: a -> b | resource', ...]",
87
+ )
88
+
89
+ input_fields: list[str] = Field(default_factory=list)
90
+ output_fields: list[str] = Field(default_factory=list)
91
+
92
+ forms: Pile[Form] = Field(
93
+ default_factory=lambda: Pile(item_type=Form),
94
+ description="All forms in the workflow",
95
+ )
96
+ completed_forms: Pile[Form] = Field(
97
+ default_factory=lambda: Pile(item_type=Form),
98
+ description="Completed forms",
99
+ )
100
+ available_data: dict[str, Any] = Field(
101
+ default_factory=dict,
102
+ description="Current state of all field values",
103
+ )
104
+
105
+ # Branch tracking: branch_name -> list of form IDs in order
106
+ _branch_forms: dict[str, list[Form]] = {}
107
+ # Track last completed form per branch for sequential execution
108
+ _branch_progress: dict[str, int] = {}
109
+
110
+ def model_post_init(self, _: Any) -> None:
111
+ """Parse assignment and create forms."""
112
+ self._branch_forms = defaultdict(list)
113
+ self._branch_progress = defaultdict(int)
114
+
115
+ if not self.assignment:
116
+ return
117
+
118
+ # Parse overall assignment
119
+ self.input_fields, self.output_fields = parse_assignment(self.assignment)
120
+
121
+ # Create forms from form_assignments
122
+ for fa in self.form_assignments:
123
+ form = Form(assignment=fa)
124
+ self.forms.include(form)
125
+
126
+ # Track by branch
127
+ branch = form.branch or "_default"
128
+ self._branch_forms[branch].append(form)
129
+
130
+ def initialize(self, **inputs: Any) -> None:
131
+ """Provide initial input data.
132
+
133
+ Args:
134
+ **inputs: Initial field values
135
+
136
+ Raises:
137
+ ValueError: If required input is missing
138
+ """
139
+ for field in self.input_fields:
140
+ if field not in inputs:
141
+ raise ValueError(f"Missing required input: '{field}'")
142
+ self.available_data[field] = inputs[field]
143
+
144
+ def next_forms(self) -> list[Form]:
145
+ """Get forms that are ready to execute.
146
+
147
+ Forms with explicit branches execute sequentially within their branch.
148
+ Forms without branches (None) execute in parallel based on data availability.
149
+
150
+ Returns:
151
+ List of forms with all inputs available and not yet filled
152
+ """
153
+ ready = []
154
+
155
+ for branch, forms in self._branch_forms.items():
156
+ if branch == "_default":
157
+ # No explicit branch - parallel execution based on data
158
+ for form in forms:
159
+ if form.filled:
160
+ continue
161
+ form.available_data = self.available_data.copy()
162
+ if form.is_workable():
163
+ ready.append(form)
164
+ else:
165
+ # Explicit branch - sequential execution
166
+ progress = self._branch_progress[branch]
167
+
168
+ # Only consider the next form in this branch
169
+ if progress < len(forms):
170
+ form = forms[progress]
171
+ if form.filled:
172
+ # Already done, advance progress
173
+ self._branch_progress[branch] += 1
174
+ continue
175
+
176
+ form.available_data = self.available_data.copy()
177
+ if form.is_workable():
178
+ ready.append(form)
179
+
180
+ return ready
181
+
182
+ def complete_form(self, form: Form) -> None:
183
+ """Mark a form as completed and update available data.
184
+
185
+ Args:
186
+ form: The completed form
187
+
188
+ Raises:
189
+ ValueError: If form is not filled
190
+ """
191
+ if not form.filled:
192
+ raise ValueError("Form is not filled")
193
+
194
+ self.completed_forms.include(form)
195
+
196
+ # Advance branch progress
197
+ branch = form.branch or "_default"
198
+ if branch in self._branch_forms:
199
+ forms = self._branch_forms[branch]
200
+ progress = self._branch_progress[branch]
201
+ if progress < len(forms) and forms[progress].id == form.id:
202
+ self._branch_progress[branch] += 1
203
+
204
+ # Update available data with form outputs
205
+ output_data = form.get_output_data()
206
+ self.available_data.update(output_data)
207
+
208
+ def is_complete(self) -> bool:
209
+ """Check if all output fields are available.
210
+
211
+ Returns:
212
+ True if workflow is complete
213
+ """
214
+ return all(field in self.available_data for field in self.output_fields)
215
+
216
+ def get_deliverable(self) -> dict[str, Any]:
217
+ """Get final output values.
218
+
219
+ Returns:
220
+ Dict of output field values
221
+ """
222
+ return {f: self.available_data.get(f) for f in self.output_fields}
223
+
224
+ @property
225
+ def progress(self) -> tuple[int, int]:
226
+ """Get progress as (completed, total).
227
+
228
+ Returns:
229
+ Tuple of (completed forms, total forms)
230
+ """
231
+ return len(self.completed_forms), len(self.forms)
232
+
233
+ def get_forms_by_branch(self, branch: str) -> list[Form]:
234
+ """Get all forms for a specific branch.
235
+
236
+ Args:
237
+ branch: Branch name
238
+
239
+ Returns:
240
+ List of forms on that branch (in order)
241
+ """
242
+ return list(self._branch_forms.get(branch, []))
243
+
244
+ def get_forms_by_resource(self, resource: str) -> list[Form]:
245
+ """Get all forms requiring a specific resource.
246
+
247
+ Args:
248
+ resource: Resource hint (e.g., 'api:fast')
249
+
250
+ Returns:
251
+ List of forms with that resource hint
252
+ """
253
+ return [f for f in self.forms if f.resource == resource]
254
+
255
+ @property
256
+ def branches(self) -> list[str]:
257
+ """Get all branch names in this report."""
258
+ return list(self._branch_forms.keys())
259
+
260
+ @property
261
+ def resources(self) -> set[str]:
262
+ """Get all resource hints used in this report."""
263
+ return {f.resource for f in self.forms if f.resource}
264
+
265
+ def __repr__(self) -> str:
266
+ completed, total = self.progress
267
+ branches = len(self._branch_forms)
268
+ return f"Report('{self.assignment}', {completed}/{total} forms, {branches} branches)"