tactus 0.31.2__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 (160) hide show
  1. tactus/__init__.py +49 -0
  2. tactus/adapters/__init__.py +9 -0
  3. tactus/adapters/broker_log.py +76 -0
  4. tactus/adapters/cli_hitl.py +189 -0
  5. tactus/adapters/cli_log.py +223 -0
  6. tactus/adapters/cost_collector_log.py +56 -0
  7. tactus/adapters/file_storage.py +367 -0
  8. tactus/adapters/http_callback_log.py +109 -0
  9. tactus/adapters/ide_log.py +71 -0
  10. tactus/adapters/lua_tools.py +336 -0
  11. tactus/adapters/mcp.py +289 -0
  12. tactus/adapters/mcp_manager.py +196 -0
  13. tactus/adapters/memory.py +53 -0
  14. tactus/adapters/plugins.py +419 -0
  15. tactus/backends/http_backend.py +58 -0
  16. tactus/backends/model_backend.py +35 -0
  17. tactus/backends/pytorch_backend.py +110 -0
  18. tactus/broker/__init__.py +12 -0
  19. tactus/broker/client.py +247 -0
  20. tactus/broker/protocol.py +183 -0
  21. tactus/broker/server.py +1123 -0
  22. tactus/broker/stdio.py +12 -0
  23. tactus/cli/__init__.py +7 -0
  24. tactus/cli/app.py +2245 -0
  25. tactus/cli/commands/__init__.py +0 -0
  26. tactus/core/__init__.py +32 -0
  27. tactus/core/config_manager.py +790 -0
  28. tactus/core/dependencies/__init__.py +14 -0
  29. tactus/core/dependencies/registry.py +180 -0
  30. tactus/core/dsl_stubs.py +2117 -0
  31. tactus/core/exceptions.py +66 -0
  32. tactus/core/execution_context.py +480 -0
  33. tactus/core/lua_sandbox.py +508 -0
  34. tactus/core/message_history_manager.py +236 -0
  35. tactus/core/mocking.py +286 -0
  36. tactus/core/output_validator.py +291 -0
  37. tactus/core/registry.py +499 -0
  38. tactus/core/runtime.py +2907 -0
  39. tactus/core/template_resolver.py +142 -0
  40. tactus/core/yaml_parser.py +301 -0
  41. tactus/docker/Dockerfile +61 -0
  42. tactus/docker/entrypoint.sh +69 -0
  43. tactus/dspy/__init__.py +39 -0
  44. tactus/dspy/agent.py +1144 -0
  45. tactus/dspy/broker_lm.py +181 -0
  46. tactus/dspy/config.py +212 -0
  47. tactus/dspy/history.py +196 -0
  48. tactus/dspy/module.py +405 -0
  49. tactus/dspy/prediction.py +318 -0
  50. tactus/dspy/signature.py +185 -0
  51. tactus/formatting/__init__.py +7 -0
  52. tactus/formatting/formatter.py +437 -0
  53. tactus/ide/__init__.py +9 -0
  54. tactus/ide/coding_assistant.py +343 -0
  55. tactus/ide/server.py +2223 -0
  56. tactus/primitives/__init__.py +49 -0
  57. tactus/primitives/control.py +168 -0
  58. tactus/primitives/file.py +229 -0
  59. tactus/primitives/handles.py +378 -0
  60. tactus/primitives/host.py +94 -0
  61. tactus/primitives/human.py +342 -0
  62. tactus/primitives/json.py +189 -0
  63. tactus/primitives/log.py +187 -0
  64. tactus/primitives/message_history.py +157 -0
  65. tactus/primitives/model.py +163 -0
  66. tactus/primitives/procedure.py +564 -0
  67. tactus/primitives/procedure_callable.py +318 -0
  68. tactus/primitives/retry.py +155 -0
  69. tactus/primitives/session.py +152 -0
  70. tactus/primitives/state.py +182 -0
  71. tactus/primitives/step.py +209 -0
  72. tactus/primitives/system.py +93 -0
  73. tactus/primitives/tool.py +375 -0
  74. tactus/primitives/tool_handle.py +279 -0
  75. tactus/primitives/toolset.py +229 -0
  76. tactus/protocols/__init__.py +38 -0
  77. tactus/protocols/chat_recorder.py +81 -0
  78. tactus/protocols/config.py +97 -0
  79. tactus/protocols/cost.py +31 -0
  80. tactus/protocols/hitl.py +71 -0
  81. tactus/protocols/log_handler.py +27 -0
  82. tactus/protocols/models.py +355 -0
  83. tactus/protocols/result.py +33 -0
  84. tactus/protocols/storage.py +90 -0
  85. tactus/providers/__init__.py +13 -0
  86. tactus/providers/base.py +92 -0
  87. tactus/providers/bedrock.py +117 -0
  88. tactus/providers/google.py +105 -0
  89. tactus/providers/openai.py +98 -0
  90. tactus/sandbox/__init__.py +63 -0
  91. tactus/sandbox/config.py +171 -0
  92. tactus/sandbox/container_runner.py +1099 -0
  93. tactus/sandbox/docker_manager.py +433 -0
  94. tactus/sandbox/entrypoint.py +227 -0
  95. tactus/sandbox/protocol.py +213 -0
  96. tactus/stdlib/__init__.py +10 -0
  97. tactus/stdlib/io/__init__.py +13 -0
  98. tactus/stdlib/io/csv.py +88 -0
  99. tactus/stdlib/io/excel.py +136 -0
  100. tactus/stdlib/io/file.py +90 -0
  101. tactus/stdlib/io/fs.py +154 -0
  102. tactus/stdlib/io/hdf5.py +121 -0
  103. tactus/stdlib/io/json.py +109 -0
  104. tactus/stdlib/io/parquet.py +83 -0
  105. tactus/stdlib/io/tsv.py +88 -0
  106. tactus/stdlib/loader.py +274 -0
  107. tactus/stdlib/tac/tactus/tools/done.tac +33 -0
  108. tactus/stdlib/tac/tactus/tools/log.tac +50 -0
  109. tactus/testing/README.md +273 -0
  110. tactus/testing/__init__.py +61 -0
  111. tactus/testing/behave_integration.py +380 -0
  112. tactus/testing/context.py +486 -0
  113. tactus/testing/eval_models.py +114 -0
  114. tactus/testing/evaluation_runner.py +222 -0
  115. tactus/testing/evaluators.py +634 -0
  116. tactus/testing/events.py +94 -0
  117. tactus/testing/gherkin_parser.py +134 -0
  118. tactus/testing/mock_agent.py +315 -0
  119. tactus/testing/mock_dependencies.py +234 -0
  120. tactus/testing/mock_hitl.py +171 -0
  121. tactus/testing/mock_registry.py +168 -0
  122. tactus/testing/mock_tools.py +133 -0
  123. tactus/testing/models.py +115 -0
  124. tactus/testing/pydantic_eval_runner.py +508 -0
  125. tactus/testing/steps/__init__.py +13 -0
  126. tactus/testing/steps/builtin.py +902 -0
  127. tactus/testing/steps/custom.py +69 -0
  128. tactus/testing/steps/registry.py +68 -0
  129. tactus/testing/test_runner.py +489 -0
  130. tactus/tracing/__init__.py +5 -0
  131. tactus/tracing/trace_manager.py +417 -0
  132. tactus/utils/__init__.py +1 -0
  133. tactus/utils/cost_calculator.py +72 -0
  134. tactus/utils/model_pricing.py +132 -0
  135. tactus/utils/safe_file_library.py +502 -0
  136. tactus/utils/safe_libraries.py +234 -0
  137. tactus/validation/LuaLexerBase.py +66 -0
  138. tactus/validation/LuaParserBase.py +23 -0
  139. tactus/validation/README.md +224 -0
  140. tactus/validation/__init__.py +7 -0
  141. tactus/validation/error_listener.py +21 -0
  142. tactus/validation/generated/LuaLexer.interp +231 -0
  143. tactus/validation/generated/LuaLexer.py +5548 -0
  144. tactus/validation/generated/LuaLexer.tokens +124 -0
  145. tactus/validation/generated/LuaLexerBase.py +66 -0
  146. tactus/validation/generated/LuaParser.interp +173 -0
  147. tactus/validation/generated/LuaParser.py +6439 -0
  148. tactus/validation/generated/LuaParser.tokens +124 -0
  149. tactus/validation/generated/LuaParserBase.py +23 -0
  150. tactus/validation/generated/LuaParserVisitor.py +118 -0
  151. tactus/validation/generated/__init__.py +7 -0
  152. tactus/validation/grammar/LuaLexer.g4 +123 -0
  153. tactus/validation/grammar/LuaParser.g4 +178 -0
  154. tactus/validation/semantic_visitor.py +817 -0
  155. tactus/validation/validator.py +157 -0
  156. tactus-0.31.2.dist-info/METADATA +1809 -0
  157. tactus-0.31.2.dist-info/RECORD +160 -0
  158. tactus-0.31.2.dist-info/WHEEL +4 -0
  159. tactus-0.31.2.dist-info/entry_points.txt +2 -0
  160. tactus-0.31.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,66 @@
1
+ """
2
+ Tactus exception classes.
3
+
4
+ All custom exceptions raised by the Tactus runtime.
5
+ """
6
+
7
+
8
+ class TactusRuntimeError(Exception):
9
+ """Base exception for all Tactus runtime errors."""
10
+
11
+ pass
12
+
13
+
14
+ class ProcedureWaitingForHuman(Exception):
15
+ """
16
+ Raised to exit workflow when waiting for human response.
17
+
18
+ In execution contexts that support exit-and-resume, this signals:
19
+ 1. Update Procedure status to 'WAITING_FOR_HUMAN'
20
+ 2. Save the pending message ID
21
+ 3. Exit cleanly
22
+ 4. Wait for resume trigger
23
+ """
24
+
25
+ def __init__(self, procedure_id: str, pending_message_id: str):
26
+ self.procedure_id = procedure_id
27
+ self.pending_message_id = pending_message_id
28
+ super().__init__(
29
+ f"Procedure {procedure_id} waiting for human response to message {pending_message_id}"
30
+ )
31
+
32
+
33
+ class ProcedureConfigError(Exception):
34
+ """Raised when procedure configuration is invalid."""
35
+
36
+ pass
37
+
38
+
39
+ class LuaSandboxError(Exception):
40
+ """Raised when Lua sandbox setup or execution fails."""
41
+
42
+ pass
43
+
44
+
45
+ class OutputValidationError(Exception):
46
+ """Raised when workflow output doesn't match schema."""
47
+
48
+ pass
49
+
50
+
51
+ class StorageError(TactusRuntimeError):
52
+ """Raised when storage backend operations fail."""
53
+
54
+ pass
55
+
56
+
57
+ class HITLError(TactusRuntimeError):
58
+ """Raised when HITL handler operations fail."""
59
+
60
+ pass
61
+
62
+
63
+ class ChatRecorderError(TactusRuntimeError):
64
+ """Raised when chat recorder operations fail."""
65
+
66
+ pass
@@ -0,0 +1,480 @@
1
+ """
2
+ Execution context abstraction for Tactus runtime.
3
+
4
+ Provides execution backend support with position-based checkpointing and HITL capabilities.
5
+ Uses pluggable storage and HITL handlers via protocols.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Any, Optional, Callable, List, Dict
10
+ from datetime import datetime, timezone
11
+ import logging
12
+ import time
13
+ import uuid
14
+
15
+ from tactus.protocols.storage import StorageBackend
16
+ from tactus.protocols.hitl import HITLHandler
17
+ from tactus.protocols.models import (
18
+ HITLRequest,
19
+ HITLResponse,
20
+ CheckpointEntry,
21
+ SourceLocation,
22
+ ExecutionRun,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class ExecutionContext(ABC):
29
+ """
30
+ Abstract execution context for procedure workflows.
31
+
32
+ Provides position-based checkpointing and HITL capabilities. Implementations
33
+ determine how to persist state and handle human interactions.
34
+ """
35
+
36
+ @abstractmethod
37
+ def checkpoint(
38
+ self,
39
+ fn: Callable[[], Any],
40
+ checkpoint_type: str,
41
+ source_info: Optional[Dict[str, Any]] = None,
42
+ ) -> Any:
43
+ """
44
+ Execute fn with position-based checkpointing. On replay, return stored result.
45
+
46
+ Args:
47
+ fn: Function to execute (should be deterministic)
48
+ checkpoint_type: Type of checkpoint (agent_turn, model_predict, procedure_call, etc.)
49
+ source_info: Optional dict with {file, line, function} for debugging
50
+
51
+ Returns:
52
+ Result of fn() on first execution, cached result from execution log on replay
53
+ """
54
+ pass
55
+
56
+ @abstractmethod
57
+ def wait_for_human(
58
+ self,
59
+ request_type: str,
60
+ message: str,
61
+ timeout_seconds: Optional[int],
62
+ default_value: Any,
63
+ options: Optional[List[dict]],
64
+ metadata: dict,
65
+ ) -> HITLResponse:
66
+ """
67
+ Suspend until human responds.
68
+
69
+ Args:
70
+ request_type: 'approval', 'input', 'review', or 'escalation'
71
+ message: Message to display to human
72
+ timeout_seconds: Timeout in seconds, None = wait forever
73
+ default_value: Value to return on timeout
74
+ options: For review requests: [{label, type}, ...]
75
+ metadata: Additional context data
76
+
77
+ Returns:
78
+ HITLResponse with value and timestamp
79
+
80
+ Raises:
81
+ ProcedureWaitingForHuman: May exit to wait for resume
82
+ """
83
+ pass
84
+
85
+ @abstractmethod
86
+ def sleep(self, seconds: int) -> None:
87
+ """
88
+ Sleep without consuming resources.
89
+
90
+ Different contexts may implement this differently.
91
+ """
92
+ pass
93
+
94
+ @abstractmethod
95
+ def checkpoint_clear_all(self) -> None:
96
+ """Clear all checkpoints (execution log). Used for testing."""
97
+ pass
98
+
99
+ @abstractmethod
100
+ def checkpoint_clear_after(self, position: int) -> None:
101
+ """Clear checkpoint at position and all subsequent ones. Used for testing."""
102
+ pass
103
+
104
+ @abstractmethod
105
+ def next_position(self) -> int:
106
+ """Get the next checkpoint position."""
107
+ pass
108
+
109
+
110
+ class BaseExecutionContext(ExecutionContext):
111
+ """
112
+ Base execution context using pluggable storage and HITL handlers.
113
+
114
+ Uses position-based checkpointing with execution log for replay.
115
+ This implementation works with any StorageBackend and HITLHandler,
116
+ making it suitable for various deployment scenarios (CLI, web, API, etc.).
117
+ """
118
+
119
+ def __init__(
120
+ self,
121
+ procedure_id: str,
122
+ storage_backend: StorageBackend,
123
+ hitl_handler: Optional[HITLHandler] = None,
124
+ strict_determinism: bool = False,
125
+ log_handler=None,
126
+ ):
127
+ """
128
+ Initialize base execution context.
129
+
130
+ Args:
131
+ procedure_id: ID of the running procedure
132
+ storage_backend: Storage backend for execution log and state
133
+ hitl_handler: Optional HITL handler for human interactions
134
+ strict_determinism: If True, raise errors for non-deterministic operations outside checkpoints
135
+ log_handler: Optional log handler for emitting events
136
+ """
137
+ self.procedure_id = procedure_id
138
+ self.storage = storage_backend
139
+ self.hitl = hitl_handler
140
+ self.strict_determinism = strict_determinism
141
+ self.log_handler = log_handler
142
+
143
+ # Checkpoint scope tracking for determinism safety
144
+ self._inside_checkpoint = False
145
+
146
+ # Run ID tracking for distinguishing between different executions
147
+ self.current_run_id: Optional[str] = None
148
+
149
+ # .tac file tracking for accurate source locations
150
+ self.current_tac_file: Optional[str] = None
151
+ self.current_tac_content: Optional[str] = None
152
+
153
+ # Lua sandbox reference for debug.getinfo access
154
+ self.lua_sandbox: Optional[Any] = None
155
+
156
+ # Load procedure metadata (contains execution_log and replay_index)
157
+ self.metadata = self.storage.load_procedure_metadata(procedure_id)
158
+
159
+ def set_run_id(self, run_id: str) -> None:
160
+ """Set the run_id for subsequent checkpoints in this execution."""
161
+ self.current_run_id = run_id
162
+
163
+ def set_tac_file(self, file_path: str, content: Optional[str] = None) -> None:
164
+ """
165
+ Store the currently executing .tac file for accurate source location capture.
166
+
167
+ Args:
168
+ file_path: Path to the .tac file being executed
169
+ content: Optional content of the .tac file (for code context)
170
+ """
171
+ self.current_tac_file = file_path
172
+ self.current_tac_content = content
173
+
174
+ def set_lua_sandbox(self, lua_sandbox: Any) -> None:
175
+ """Store reference to Lua sandbox for debug.getinfo access."""
176
+ self.lua_sandbox = lua_sandbox
177
+
178
+ def checkpoint(
179
+ self,
180
+ fn: Callable[[], Any],
181
+ checkpoint_type: str,
182
+ source_info: Optional[Dict[str, Any]] = None,
183
+ ) -> Any:
184
+ """
185
+ Execute fn with position-based checkpointing and source tracking.
186
+
187
+ On replay, returns cached result from execution log.
188
+ On first execution, runs fn(), records in log, and returns result.
189
+ """
190
+ logger.debug(
191
+ f"[CHECKPOINT] checkpoint() called, type={checkpoint_type}, has_log_handler={self.log_handler is not None}"
192
+ )
193
+ current_position = self.metadata.replay_index
194
+
195
+ # Check if we're in replay mode (checkpoint exists at this position)
196
+ if current_position < len(self.metadata.execution_log):
197
+ # Replay mode: return cached result
198
+ entry = self.metadata.execution_log[current_position]
199
+ self.metadata.replay_index += 1
200
+ return entry.result
201
+
202
+ # Execute mode: run function with checkpoint scope tracking
203
+ old_checkpoint_flag = self._inside_checkpoint
204
+ self._inside_checkpoint = True
205
+
206
+ # Capture source location if provided
207
+ source_location = None
208
+ if source_info:
209
+ source_location = SourceLocation(
210
+ file=source_info["file"],
211
+ line=source_info["line"],
212
+ function=source_info.get("function"),
213
+ code_context=self._get_code_context(source_info["file"], source_info["line"]),
214
+ )
215
+ elif self.current_tac_file:
216
+ # Use .tac file context if no source_info provided
217
+ source_location = SourceLocation(
218
+ file=self.current_tac_file,
219
+ line=0, # Will be improved with Lua line tracking
220
+ function="unknown",
221
+ code_context=None, # Can be added later if needed
222
+ )
223
+
224
+ try:
225
+ start_time = time.time()
226
+ result = fn()
227
+ duration_ms = (time.time() - start_time) * 1000
228
+
229
+ # Create checkpoint entry with source location and run_id (if available)
230
+ entry = CheckpointEntry(
231
+ position=current_position,
232
+ type=checkpoint_type,
233
+ result=result,
234
+ timestamp=datetime.now(timezone.utc),
235
+ duration_ms=duration_ms,
236
+ run_id=self.current_run_id, # Can be None for backward compatibility
237
+ source_location=source_location,
238
+ captured_vars=(
239
+ self.metadata.state.copy() if hasattr(self.metadata, "state") else None
240
+ ),
241
+ )
242
+ finally:
243
+ # Always restore checkpoint flag, even if fn() raises
244
+ self._inside_checkpoint = old_checkpoint_flag
245
+
246
+ # Add to execution log
247
+ self.metadata.execution_log.append(entry)
248
+ self.metadata.replay_index += 1
249
+
250
+ # Emit checkpoint created event if we have a log handler
251
+ if self.log_handler:
252
+ try:
253
+ from tactus.protocols.models import CheckpointCreatedEvent
254
+
255
+ event = CheckpointCreatedEvent(
256
+ checkpoint_position=current_position,
257
+ checkpoint_type=checkpoint_type,
258
+ duration_ms=duration_ms,
259
+ source_location=source_location,
260
+ procedure_id=self.procedure_id,
261
+ )
262
+ logger.debug(
263
+ f"[CHECKPOINT] Emitting CheckpointCreatedEvent: position={current_position}, type={checkpoint_type}, duration_ms={duration_ms}"
264
+ )
265
+ self.log_handler.log(event)
266
+ except Exception as e:
267
+ logger.warning(f"Failed to emit checkpoint event: {e}")
268
+ else:
269
+ logger.warning("[CHECKPOINT] No log_handler available to emit checkpoint event")
270
+
271
+ # Persist metadata
272
+ self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
273
+
274
+ return result
275
+
276
+ def _get_code_context(self, file_path: str, line: int, context_lines: int = 3) -> Optional[str]:
277
+ """Read source file and extract surrounding lines for debugging."""
278
+ try:
279
+ with open(file_path, "r") as f:
280
+ lines = f.readlines()
281
+ start = max(0, line - context_lines - 1)
282
+ end = min(len(lines), line + context_lines)
283
+ return "".join(lines[start:end])
284
+ except Exception:
285
+ return None
286
+
287
+ def wait_for_human(
288
+ self,
289
+ request_type: str,
290
+ message: str,
291
+ timeout_seconds: Optional[int],
292
+ default_value: Any,
293
+ options: Optional[List[dict]],
294
+ metadata: dict,
295
+ ) -> HITLResponse:
296
+ """
297
+ Wait for human response using the configured HITL handler.
298
+
299
+ Delegates to the HITLHandler protocol implementation.
300
+ """
301
+ if not self.hitl:
302
+ # No HITL handler - return default immediately
303
+ return HITLResponse(
304
+ value=default_value, responded_at=datetime.now(timezone.utc), timed_out=True
305
+ )
306
+
307
+ # Create HITL request
308
+ request = HITLRequest(
309
+ request_type=request_type,
310
+ message=message,
311
+ timeout_seconds=timeout_seconds,
312
+ default_value=default_value,
313
+ options=options,
314
+ metadata=metadata,
315
+ )
316
+
317
+ # Delegate to HITL handler (may raise ProcedureWaitingForHuman)
318
+ return self.hitl.request_interaction(self.procedure_id, request)
319
+
320
+ def sleep(self, seconds: int) -> None:
321
+ """
322
+ Sleep with checkpointing.
323
+
324
+ On replay, skips the sleep. On first execution, sleeps and checkpoints.
325
+ """
326
+
327
+ def sleep_fn():
328
+ time.sleep(seconds)
329
+ return None
330
+
331
+ self.checkpoint(sleep_fn, "sleep")
332
+
333
+ def checkpoint_clear_all(self) -> None:
334
+ """Clear all checkpoints (execution log)."""
335
+ self.metadata.execution_log.clear()
336
+ self.metadata.replay_index = 0
337
+ self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
338
+
339
+ def checkpoint_clear_after(self, position: int) -> None:
340
+ """Clear checkpoint at position and all subsequent ones."""
341
+ # Keep only checkpoints before the given position
342
+ self.metadata.execution_log = self.metadata.execution_log[:position]
343
+ self.metadata.replay_index = min(self.metadata.replay_index, position)
344
+ self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
345
+
346
+ def next_position(self) -> int:
347
+ """Get the next checkpoint position."""
348
+ return self.metadata.replay_index
349
+
350
+ def store_procedure_handle(self, handle: Any) -> None:
351
+ """
352
+ Store async procedure handle.
353
+
354
+ Args:
355
+ handle: ProcedureHandle instance
356
+ """
357
+ # Store in metadata under "async_procedures" key
358
+ if "async_procedures" not in self.metadata:
359
+ self.metadata["async_procedures"] = {}
360
+
361
+ self.metadata["async_procedures"][handle.procedure_id] = handle.to_dict()
362
+ self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
363
+
364
+ def get_procedure_handle(self, procedure_id: str) -> Optional[Dict[str, Any]]:
365
+ """
366
+ Retrieve procedure handle.
367
+
368
+ Args:
369
+ procedure_id: ID of the procedure
370
+
371
+ Returns:
372
+ Handle dict or None
373
+ """
374
+ async_procedures = self.metadata.get("async_procedures", {})
375
+ return async_procedures.get(procedure_id)
376
+
377
+ def list_pending_procedures(self) -> List[Dict[str, Any]]:
378
+ """
379
+ List all pending async procedures.
380
+
381
+ Returns:
382
+ List of handle dicts for procedures with status "running" or "waiting"
383
+ """
384
+ async_procedures = self.metadata.get("async_procedures", {})
385
+ return [
386
+ handle
387
+ for handle in async_procedures.values()
388
+ if handle.get("status") in ("running", "waiting")
389
+ ]
390
+
391
+ def update_procedure_status(
392
+ self, procedure_id: str, status: str, result: Any = None, error: str = None
393
+ ) -> None:
394
+ """
395
+ Update procedure status.
396
+
397
+ Args:
398
+ procedure_id: ID of the procedure
399
+ status: New status
400
+ result: Optional result value
401
+ error: Optional error message
402
+ """
403
+ if "async_procedures" not in self.metadata:
404
+ return
405
+
406
+ if procedure_id in self.metadata["async_procedures"]:
407
+ handle = self.metadata["async_procedures"][procedure_id]
408
+ handle["status"] = status
409
+ if result is not None:
410
+ handle["result"] = result
411
+ if error is not None:
412
+ handle["error"] = error
413
+ if status in ("completed", "failed", "cancelled"):
414
+ handle["completed_at"] = datetime.now(timezone.utc).isoformat()
415
+
416
+ self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
417
+
418
+ def save_execution_run(
419
+ self, procedure_name: str, file_path: str, status: str = "COMPLETED"
420
+ ) -> str:
421
+ """
422
+ Convert current execution to ExecutionRun and save for tracing.
423
+
424
+ Args:
425
+ procedure_name: Name of the procedure
426
+ file_path: Path to the .tac file
427
+ status: Run status (COMPLETED, FAILED, etc.)
428
+
429
+ Returns:
430
+ The run_id of the saved run
431
+ """
432
+ # Generate run ID
433
+ run_id = str(uuid.uuid4())
434
+
435
+ # Determine start time from first checkpoint or now
436
+ start_time = (
437
+ self.metadata.execution_log[0].timestamp
438
+ if self.metadata.execution_log
439
+ else datetime.now(timezone.utc)
440
+ )
441
+
442
+ # Create ExecutionRun
443
+ run = ExecutionRun(
444
+ run_id=run_id,
445
+ procedure_name=procedure_name,
446
+ file_path=file_path,
447
+ start_time=start_time,
448
+ end_time=datetime.now(timezone.utc),
449
+ status=status,
450
+ execution_log=self.metadata.execution_log.copy(),
451
+ final_state=self.metadata.state.copy() if hasattr(self.metadata, "state") else {},
452
+ breakpoints=[],
453
+ )
454
+
455
+ # Save to storage
456
+ self.storage.save_run(run)
457
+
458
+ return run_id
459
+
460
+
461
+ class InMemoryExecutionContext(BaseExecutionContext):
462
+ """
463
+ Simple in-memory execution context.
464
+
465
+ Uses in-memory storage with no persistence. Useful for testing
466
+ and simple CLI workflows that don't need to survive restarts.
467
+ """
468
+
469
+ def __init__(self, procedure_id: str, hitl_handler: Optional[HITLHandler] = None):
470
+ """
471
+ Initialize with in-memory storage.
472
+
473
+ Args:
474
+ procedure_id: ID of the running procedure
475
+ hitl_handler: Optional HITL handler
476
+ """
477
+ from tactus.adapters.memory import MemoryStorage
478
+
479
+ storage = MemoryStorage()
480
+ super().__init__(procedure_id, storage, hitl_handler)