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.
Files changed (162) 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 +127 -0
  37. krons/core/base/__init__.py +121 -0
  38. {kronos/core → krons/core/base}/broadcaster.py +7 -3
  39. {kronos/core → krons/core/base}/element.py +15 -7
  40. {kronos/core → krons/core/base}/event.py +41 -8
  41. {kronos/core → krons/core/base}/eventbus.py +4 -2
  42. {kronos/core → krons/core/base}/flow.py +14 -7
  43. {kronos/core → krons/core/base}/graph.py +27 -11
  44. {kronos/core → krons/core/base}/node.py +47 -22
  45. {kronos/core → krons/core/base}/pile.py +26 -12
  46. {kronos/core → krons/core/base}/processor.py +23 -9
  47. {kronos/core → krons/core/base}/progression.py +5 -3
  48. {kronos → krons/core}/specs/__init__.py +0 -5
  49. {kronos → krons/core}/specs/adapters/dataclass_field.py +16 -8
  50. {kronos → krons/core}/specs/adapters/pydantic_adapter.py +11 -5
  51. {kronos → krons/core}/specs/adapters/sql_ddl.py +16 -10
  52. {kronos → krons/core}/specs/catalog/__init__.py +2 -2
  53. {kronos → krons/core}/specs/catalog/_audit.py +3 -3
  54. {kronos → krons/core}/specs/catalog/_common.py +2 -2
  55. {kronos → krons/core}/specs/catalog/_content.py +5 -5
  56. {kronos → krons/core}/specs/catalog/_enforcement.py +4 -4
  57. {kronos → krons/core}/specs/factory.py +7 -7
  58. {kronos → krons/core}/specs/operable.py +9 -3
  59. {kronos → krons/core}/specs/protocol.py +4 -2
  60. {kronos → krons/core}/specs/spec.py +25 -13
  61. {kronos → krons/core}/types/base.py +7 -5
  62. {kronos → krons/core}/types/db_types.py +2 -2
  63. {kronos → krons/core}/types/identity.py +1 -1
  64. {kronos → krons}/errors.py +13 -13
  65. {kronos → krons}/protocols.py +9 -4
  66. krons/resource/__init__.py +89 -0
  67. {kronos/services → krons/resource}/backend.py +50 -24
  68. {kronos/services → krons/resource}/endpoint.py +28 -14
  69. {kronos/services → krons/resource}/hook.py +22 -9
  70. {kronos/services → krons/resource}/imodel.py +50 -32
  71. {kronos/services → krons/resource}/registry.py +27 -25
  72. {kronos/services → krons/resource}/utilities/rate_limited_executor.py +10 -6
  73. {kronos/services → krons/resource}/utilities/rate_limiter.py +4 -2
  74. {kronos/services → krons/resource}/utilities/resilience.py +17 -7
  75. krons/resource/utilities/token_calculator.py +185 -0
  76. {kronos → krons}/session/__init__.py +12 -17
  77. krons/session/constraints.py +70 -0
  78. {kronos → krons}/session/exchange.py +14 -6
  79. {kronos → krons}/session/message.py +4 -2
  80. krons/session/registry.py +35 -0
  81. {kronos → krons}/session/session.py +165 -174
  82. krons/utils/__init__.py +85 -0
  83. krons/utils/_function_arg_parser.py +99 -0
  84. krons/utils/_pythonic_function_call.py +249 -0
  85. {kronos → krons}/utils/_to_list.py +9 -3
  86. {kronos → krons}/utils/_utils.py +9 -5
  87. {kronos → krons}/utils/concurrency/__init__.py +38 -38
  88. {kronos → krons}/utils/concurrency/_async_call.py +6 -4
  89. {kronos → krons}/utils/concurrency/_errors.py +3 -1
  90. {kronos → krons}/utils/concurrency/_patterns.py +3 -1
  91. {kronos → krons}/utils/concurrency/_resource_tracker.py +6 -2
  92. krons/utils/display.py +257 -0
  93. {kronos → krons}/utils/fuzzy/__init__.py +6 -1
  94. {kronos → krons}/utils/fuzzy/_fuzzy_match.py +14 -8
  95. {kronos → krons}/utils/fuzzy/_string_similarity.py +3 -1
  96. {kronos → krons}/utils/fuzzy/_to_dict.py +3 -1
  97. krons/utils/schemas/__init__.py +26 -0
  98. krons/utils/schemas/_breakdown_pydantic_annotation.py +131 -0
  99. krons/utils/schemas/_formatter.py +72 -0
  100. krons/utils/schemas/_minimal_yaml.py +151 -0
  101. krons/utils/schemas/_typescript.py +153 -0
  102. {kronos → krons}/utils/sql/_sql_validation.py +1 -1
  103. krons/utils/validators/__init__.py +3 -0
  104. krons/utils/validators/_validate_image_url.py +56 -0
  105. krons/work/__init__.py +126 -0
  106. krons/work/engine.py +333 -0
  107. krons/work/form.py +305 -0
  108. {kronos → krons/work}/operations/__init__.py +7 -4
  109. {kronos → krons/work}/operations/builder.py +4 -4
  110. {kronos/enforcement → krons/work/operations}/context.py +37 -6
  111. {kronos → krons/work}/operations/flow.py +17 -9
  112. krons/work/operations/node.py +103 -0
  113. krons/work/operations/registry.py +103 -0
  114. {kronos/specs → krons/work}/phrase.py +131 -14
  115. {kronos/enforcement → krons/work}/policy.py +3 -3
  116. krons/work/report.py +268 -0
  117. krons/work/rules/__init__.py +47 -0
  118. {kronos/enforcement → krons/work/rules}/common/boolean.py +3 -1
  119. {kronos/enforcement → krons/work/rules}/common/choice.py +9 -3
  120. {kronos/enforcement → krons/work/rules}/common/number.py +3 -1
  121. {kronos/enforcement → krons/work/rules}/common/string.py +9 -3
  122. {kronos/enforcement → krons/work/rules}/rule.py +2 -2
  123. {kronos/enforcement → krons/work/rules}/validator.py +21 -6
  124. {kronos/enforcement → krons/work}/service.py +16 -7
  125. krons/work/worker.py +266 -0
  126. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/METADATA +19 -5
  127. krons-0.2.0.dist-info/RECORD +154 -0
  128. kronos/core/__init__.py +0 -145
  129. kronos/enforcement/__init__.py +0 -57
  130. kronos/operations/node.py +0 -101
  131. kronos/operations/registry.py +0 -92
  132. kronos/services/__init__.py +0 -81
  133. kronos/specs/adapters/__init__.py +0 -0
  134. kronos/utils/__init__.py +0 -40
  135. krons-0.1.0.dist-info/RECORD +0 -101
  136. {kronos → krons/core/specs/adapters}/__init__.py +0 -0
  137. {kronos → krons/core}/specs/adapters/_utils.py +0 -0
  138. {kronos → krons/core}/specs/adapters/factory.py +0 -0
  139. {kronos → krons/core}/types/__init__.py +0 -0
  140. {kronos → krons/core}/types/_sentinel.py +0 -0
  141. {kronos → krons}/py.typed +0 -0
  142. {kronos/services → krons/resource}/utilities/__init__.py +0 -0
  143. {kronos/services → krons/resource}/utilities/header_factory.py +0 -0
  144. {kronos → krons}/utils/_hash.py +0 -0
  145. {kronos → krons}/utils/_json_dump.py +0 -0
  146. {kronos → krons}/utils/_lazy_init.py +0 -0
  147. {kronos → krons}/utils/_to_num.py +0 -0
  148. {kronos → krons}/utils/concurrency/_cancel.py +0 -0
  149. {kronos → krons}/utils/concurrency/_primitives.py +0 -0
  150. {kronos → krons}/utils/concurrency/_priority_queue.py +0 -0
  151. {kronos → krons}/utils/concurrency/_run_async.py +0 -0
  152. {kronos → krons}/utils/concurrency/_task.py +0 -0
  153. {kronos → krons}/utils/concurrency/_utils.py +0 -0
  154. {kronos → krons}/utils/fuzzy/_extract_json.py +0 -0
  155. {kronos → krons}/utils/fuzzy/_fuzzy_json.py +0 -0
  156. {kronos → krons}/utils/sql/__init__.py +0 -0
  157. {kronos/enforcement → krons/work/rules}/common/__init__.py +0 -0
  158. {kronos/enforcement → krons/work/rules}/common/mapping.py +0 -0
  159. {kronos/enforcement → krons/work/rules}/common/model.py +0 -0
  160. {kronos/enforcement → krons/work/rules}/registry.py +0 -0
  161. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/WHEEL +0 -0
  162. {krons-0.1.0.dist-info → krons-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -11,11 +11,11 @@ from datetime import datetime
11
11
  from typing import TYPE_CHECKING, Any, Protocol
12
12
  from uuid import UUID, uuid4
13
13
 
14
- from kronos.types.base import DataClass
15
- from kronos.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
- from kronos.session import Branch, Session
18
+ from krons.session import Branch, Session
19
19
 
20
20
  __all__ = ("QueryFn", "RequestContext")
21
21
 
@@ -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)
@@ -15,15 +15,15 @@ from dataclasses import dataclass
15
15
  from typing import TYPE_CHECKING, Any
16
16
  from uuid import UUID
17
17
 
18
- from kronos.core import EventStatus, Graph
19
- from kronos.types import Undefined, UndefinedType, is_sentinel
20
- from kronos.utils import concurrency
21
- from kronos.utils.concurrency import CapacityLimiter, CompletionStream
18
+ from krons.core import EventStatus, Graph
19
+ from krons.core.types import Undefined, UndefinedType, is_sentinel
20
+ from krons.utils import concurrency
21
+ from krons.utils.concurrency import CapacityLimiter, CompletionStream
22
22
 
23
23
  from .node import Operation
24
24
 
25
25
  if TYPE_CHECKING:
26
- from kronos.session import Branch, Session
26
+ from krons.session import Branch, Session
27
27
 
28
28
  logger = logging.getLogger(__name__)
29
29
 
@@ -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."""
@@ -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
+ """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
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from pydantic import Field, PrivateAttr
16
+
17
+ from krons.core import Event, Node
18
+
19
+ from .context import RequestContext
20
+
21
+ if TYPE_CHECKING:
22
+ from krons.session import Branch, Session
23
+
24
+ __all__ = ("Operation",)
25
+
26
+
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
+
39
+ operation_type: str
40
+ parameters: dict[str, Any] | Any = Field(
41
+ default_factory=dict,
42
+ description="Operation parameters (Params dataclass, dict, or model)",
43
+ )
44
+
45
+ _session: Any = PrivateAttr(default=None)
46
+ _branch: Any = PrivateAttr(default=None)
47
+ _verbose: bool = PrivateAttr(default=False)
48
+
49
+ def bind(self, session: Session, branch: Branch) -> Operation:
50
+ """Bind session and branch for execution.
51
+
52
+ Must be called before invoke() if not using Session.conduct().
53
+
54
+ Args:
55
+ session: Session with operations registry and services.
56
+ branch: Branch for message context.
57
+
58
+ Returns:
59
+ Self for chaining.
60
+ """
61
+ self._session = session
62
+ self._branch = branch
63
+ return self
64
+
65
+ def _require_binding(self) -> tuple[Session, Branch]:
66
+ """Return bound (session, branch) tuple or raise RuntimeError if unbound."""
67
+ if self._session is None or self._branch is None:
68
+ raise RuntimeError(
69
+ "Operation not bound to session/branch. "
70
+ "Use operation.bind(session, branch) or session.conduct(...)"
71
+ )
72
+ return self._session, self._branch
73
+
74
+ async def _invoke(self) -> Any:
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().
79
+
80
+ Returns:
81
+ Handler result (stored in execution.response).
82
+
83
+ Raises:
84
+ RuntimeError: If not bound.
85
+ KeyError: If operation_type not registered.
86
+ """
87
+ session, branch = self._require_binding()
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,
97
+ )
98
+
99
+ return await handler(self.parameters, ctx)
100
+
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()})"
@@ -6,7 +6,7 @@ A Phrase wraps an async handler with:
6
6
  - Validation via Operable
7
7
 
8
8
  Usage with decorator (custom handler):
9
- from kronos.specs import Operable, phrase
9
+ from krons.core.specs import Operable, phrase
10
10
 
11
11
  consent_operable = Operable([
12
12
  Spec("subject_id", UUID),
@@ -25,7 +25,7 @@ Usage with decorator (custom handler):
25
25
  result = await verify_consent({"subject_id": id, "scope": "background"}, ctx)
26
26
 
27
27
  Usage with CrudPattern (declarative):
28
- from kronos.specs import Operable, phrase, CrudPattern
28
+ from krons.core.specs import Operable, phrase, CrudPattern
29
29
 
30
30
  def check_has_consent(row):
31
31
  return {"has_consent": row["status"] in {"active"} if row else False}
@@ -48,12 +48,14 @@ from collections.abc import Awaitable, Callable, Mapping
48
48
  from dataclasses import dataclass
49
49
  from enum import Enum
50
50
  from types import MappingProxyType
51
- from typing import Any
51
+ from typing import TYPE_CHECKING, Any
52
52
 
53
- from kronos.types import Unset, is_unset
54
- from kronos.utils.sql import validate_identifier
53
+ from krons.core.specs.operable import Operable
54
+ from krons.core.types import Unset, is_unset
55
+ from krons.utils.sql import validate_identifier
55
56
 
56
- from .operable import Operable
57
+ if TYPE_CHECKING:
58
+ from krons.work.operations.node import Operation
57
59
 
58
60
  __all__ = ("CrudPattern", "CrudOperation", "Phrase", "phrase")
59
61
 
@@ -111,18 +113,31 @@ class CrudPattern:
111
113
  object.__setattr__(self, "lookup", frozenset(self.lookup))
112
114
  # Normalize None mappings to immutable empty maps; freeze mutable dicts
113
115
  object.__setattr__(
114
- self, "filters",
115
- _EMPTY_MAP if self.filters is None else MappingProxyType(dict(self.filters)),
116
+ self,
117
+ "filters",
118
+ (
119
+ _EMPTY_MAP
120
+ if self.filters is None
121
+ else MappingProxyType(dict(self.filters))
122
+ ),
116
123
  )
117
124
  object.__setattr__(
118
- self, "set_fields",
119
- _EMPTY_MAP if self.set_fields is None
120
- else MappingProxyType(dict(self.set_fields)),
125
+ self,
126
+ "set_fields",
127
+ (
128
+ _EMPTY_MAP
129
+ if self.set_fields is None
130
+ else MappingProxyType(dict(self.set_fields))
131
+ ),
121
132
  )
122
133
  object.__setattr__(
123
- self, "defaults",
124
- _EMPTY_MAP if self.defaults is None
125
- else MappingProxyType(dict(self.defaults)),
134
+ self,
135
+ "defaults",
136
+ (
137
+ _EMPTY_MAP
138
+ if self.defaults is None
139
+ else MappingProxyType(dict(self.defaults))
140
+ ),
126
141
  )
127
142
 
128
143
 
@@ -307,6 +322,108 @@ class Phrase:
307
322
  )
308
323
  return self._result_type
309
324
 
325
+ # --- Form-like interface ---
326
+
327
+ @property
328
+ def input_fields(self) -> list[str]:
329
+ """Input field names (Form-compatible interface)."""
330
+ return list(self.inputs)
331
+
332
+ @property
333
+ def output_fields(self) -> list[str]:
334
+ """Output field names (Form-compatible interface)."""
335
+ return list(self.outputs)
336
+
337
+ def is_workable(self, available_data: dict[str, Any]) -> bool:
338
+ """Check if all inputs are available in data dict.
339
+
340
+ Enables Form/Report-style scheduling based on data availability.
341
+ """
342
+ return all(field in available_data for field in self.inputs)
343
+
344
+ def extract_inputs(self, available_data: dict[str, Any]) -> dict[str, Any]:
345
+ """Extract input values from available data.
346
+
347
+ Returns:
348
+ Dict with only the fields declared as inputs.
349
+
350
+ Raises:
351
+ KeyError: If any required input is missing.
352
+ """
353
+ return {field: available_data[field] for field in self.inputs}
354
+
355
+ # --- Operation bridge ---
356
+
357
+ def as_operation(
358
+ self,
359
+ options: dict[str, Any] | None = None,
360
+ *,
361
+ available_data: dict[str, Any] | None = None,
362
+ ctx: Any = None,
363
+ **metadata,
364
+ ) -> "Operation":
365
+ """Create an Operation that invokes this phrase.
366
+
367
+ The Operation can participate in DAG flow while preserving
368
+ the phrase's typed I/O semantics.
369
+
370
+ Args:
371
+ options: Direct input values (takes precedence)
372
+ available_data: Data dict to extract inputs from (if options not given)
373
+ ctx: Execution context passed to phrase handler
374
+ **metadata: Additional metadata for Operation
375
+
376
+ Returns:
377
+ PhraseOperation instance ready for DAG execution.
378
+
379
+ Example:
380
+ # Direct options
381
+ op = phrase.as_operation({"subject_id": id, "scope": "read"})
382
+
383
+ # From available data (Form/Report pattern)
384
+ op = phrase.as_operation(available_data=report.available_data)
385
+
386
+ # Build DAG
387
+ graph = Graph()
388
+ graph.add_node(phrase1.as_operation({"input": "x"}))
389
+ await flow(session, graph)
390
+ """
391
+ from krons.work.operations.node import Operation
392
+
393
+ # Resolve options
394
+ if options is not None:
395
+ resolved_options = dict(options)
396
+ elif available_data is not None:
397
+ if not self.is_workable(available_data):
398
+ missing = [f for f in self.inputs if f not in available_data]
399
+ raise ValueError(f"Missing required inputs: {missing}")
400
+ resolved_options = self.extract_inputs(available_data)
401
+ else:
402
+ raise ValueError("Either options or available_data must be provided")
403
+
404
+ # Create closure-based operation that bypasses registry
405
+ phrase = self
406
+ bound_ctx = ctx
407
+
408
+ class PhraseOperation(Operation):
409
+ """Operation wrapping a Phrase for direct invocation."""
410
+
411
+ async def _invoke(self) -> Any:
412
+ # Direct phrase invocation - no registry lookup
413
+ return await phrase(self.parameters, bound_ctx)
414
+
415
+ return PhraseOperation(
416
+ operation_type=self.name,
417
+ parameters=resolved_options,
418
+ metadata={
419
+ "name": self.name,
420
+ "phrase": True,
421
+ "input_fields": self.input_fields,
422
+ "output_fields": self.output_fields,
423
+ **metadata,
424
+ },
425
+ )
426
+
310
427
 
311
428
  def _to_pascal(snake_name: str) -> str:
312
429
  """Convert snake_case name to PascalCase.
@@ -13,9 +13,9 @@ from collections.abc import Sequence
13
13
  from dataclasses import dataclass, field
14
14
  from typing import Any, Protocol, runtime_checkable
15
15
 
16
- from kronos.enforcement.context import RequestContext
17
- from kronos.specs.catalog._enforcement import EnforcementLevel
18
- from kronos.types.base import DataClass
16
+ from krons.core.specs.catalog._enforcement import EnforcementLevel
17
+ from krons.core.types.base import DataClass
18
+ from krons.work.operations.context import RequestContext
19
19
 
20
20
  __all__ = (
21
21
  "EnforcementLevel",