AbstractRuntime 0.0.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 (34) hide show
  1. abstractruntime/__init__.py +104 -2
  2. abstractruntime/core/__init__.py +26 -0
  3. abstractruntime/core/config.py +101 -0
  4. abstractruntime/core/models.py +282 -0
  5. abstractruntime/core/policy.py +166 -0
  6. abstractruntime/core/runtime.py +736 -0
  7. abstractruntime/core/spec.py +53 -0
  8. abstractruntime/core/vars.py +94 -0
  9. abstractruntime/identity/__init__.py +7 -0
  10. abstractruntime/identity/fingerprint.py +57 -0
  11. abstractruntime/integrations/__init__.py +11 -0
  12. abstractruntime/integrations/abstractcore/__init__.py +47 -0
  13. abstractruntime/integrations/abstractcore/effect_handlers.py +119 -0
  14. abstractruntime/integrations/abstractcore/factory.py +187 -0
  15. abstractruntime/integrations/abstractcore/llm_client.py +397 -0
  16. abstractruntime/integrations/abstractcore/logging.py +27 -0
  17. abstractruntime/integrations/abstractcore/tool_executor.py +168 -0
  18. abstractruntime/scheduler/__init__.py +13 -0
  19. abstractruntime/scheduler/convenience.py +324 -0
  20. abstractruntime/scheduler/registry.py +101 -0
  21. abstractruntime/scheduler/scheduler.py +431 -0
  22. abstractruntime/storage/__init__.py +25 -0
  23. abstractruntime/storage/artifacts.py +519 -0
  24. abstractruntime/storage/base.py +107 -0
  25. abstractruntime/storage/in_memory.py +119 -0
  26. abstractruntime/storage/json_files.py +208 -0
  27. abstractruntime/storage/ledger_chain.py +153 -0
  28. abstractruntime/storage/snapshots.py +217 -0
  29. abstractruntime-0.2.0.dist-info/METADATA +163 -0
  30. abstractruntime-0.2.0.dist-info/RECORD +32 -0
  31. {abstractruntime-0.0.0.dist-info → abstractruntime-0.2.0.dist-info}/licenses/LICENSE +3 -1
  32. abstractruntime-0.0.0.dist-info/METADATA +0 -89
  33. abstractruntime-0.0.0.dist-info/RECORD +0 -5
  34. {abstractruntime-0.0.0.dist-info → abstractruntime-0.2.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,431 @@
1
+ """abstractruntime.scheduler.scheduler
2
+
3
+ Built-in scheduler for automatic run resumption.
4
+
5
+ The scheduler:
6
+ - Polls for due wait_until runs and resumes them automatically
7
+ - Provides an API to resume wait_event/ask_user runs
8
+ - Runs in a background thread for zero-config operation
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import threading
15
+ import time
16
+ from dataclasses import dataclass, field
17
+ from datetime import datetime, timezone
18
+ from typing import Any, Callable, Dict, List, Optional
19
+
20
+ from ..core.models import RunState, RunStatus, WaitReason, WaitState
21
+ from ..core.runtime import Runtime
22
+ from ..storage.base import QueryableRunStore
23
+ from .registry import WorkflowRegistry
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def utc_now_iso() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ @dataclass
34
+ class SchedulerStats:
35
+ """Statistics about scheduler operation."""
36
+
37
+ runs_resumed: int = 0
38
+ runs_failed: int = 0
39
+ poll_cycles: int = 0
40
+ last_poll_at: Optional[str] = None
41
+ errors: List[str] = field(default_factory=list)
42
+
43
+
44
+ class Scheduler:
45
+ """Built-in scheduler for automatic run resumption.
46
+
47
+ The scheduler runs a background polling loop that:
48
+ 1. Finds runs waiting for wait_until whose time has passed
49
+ 2. Calls runtime.tick() to resume them
50
+
51
+ It also provides an API to resume wait_event/ask_user runs.
52
+
53
+ Example:
54
+ # Create runtime with queryable store
55
+ run_store = InMemoryRunStore()
56
+ ledger_store = InMemoryLedgerStore()
57
+ runtime = Runtime(run_store=run_store, ledger_store=ledger_store)
58
+
59
+ # Create registry and register workflows
60
+ registry = WorkflowRegistry()
61
+ registry.register(my_workflow)
62
+
63
+ # Create and start scheduler
64
+ scheduler = Scheduler(runtime=runtime, registry=registry)
65
+ scheduler.start()
66
+
67
+ # ... runs with wait_until will be resumed automatically ...
68
+
69
+ # Resume a wait_event run manually
70
+ state = scheduler.resume_event(
71
+ run_id="...",
72
+ wait_key="my_event",
73
+ payload={"data": "value"},
74
+ )
75
+
76
+ # Stop scheduler gracefully
77
+ scheduler.stop()
78
+ """
79
+
80
+ def __init__(
81
+ self,
82
+ *,
83
+ runtime: Runtime,
84
+ registry: WorkflowRegistry,
85
+ poll_interval_s: float = 1.0,
86
+ max_errors_kept: int = 100,
87
+ on_run_resumed: Optional[Callable[[RunState], None]] = None,
88
+ on_run_failed: Optional[Callable[[str, Exception], None]] = None,
89
+ ) -> None:
90
+ """Initialize the scheduler.
91
+
92
+ Args:
93
+ runtime: The Runtime instance to use for tick/resume.
94
+ registry: WorkflowRegistry mapping workflow_id -> WorkflowSpec.
95
+ poll_interval_s: Seconds between poll cycles (default: 1.0).
96
+ max_errors_kept: Maximum number of errors to keep in stats (default: 100).
97
+ on_run_resumed: Optional callback when a run is resumed successfully.
98
+ on_run_failed: Optional callback when a run fails to resume.
99
+
100
+ Raises:
101
+ TypeError: If the runtime's run_store doesn't implement QueryableRunStore.
102
+ """
103
+ self._runtime = runtime
104
+ self._registry = registry
105
+ self._poll_interval = poll_interval_s
106
+ self._max_errors = max_errors_kept
107
+ self._on_run_resumed = on_run_resumed
108
+ self._on_run_failed = on_run_failed
109
+
110
+ # Verify run_store is queryable
111
+ run_store = runtime.run_store
112
+ if not isinstance(run_store, QueryableRunStore):
113
+ raise TypeError(
114
+ f"Scheduler requires a QueryableRunStore, but got {type(run_store).__name__}. "
115
+ "Use InMemoryRunStore or JsonFileRunStore which implement QueryableRunStore."
116
+ )
117
+ self._run_store: QueryableRunStore = run_store
118
+
119
+ # Threading state
120
+ self._running = False
121
+ self._stop_event = threading.Event()
122
+ self._thread: Optional[threading.Thread] = None
123
+ self._lock = threading.Lock()
124
+
125
+ # Stats
126
+ self._stats = SchedulerStats()
127
+
128
+ @property
129
+ def is_running(self) -> bool:
130
+ """Check if the scheduler is currently running."""
131
+ return self._running
132
+
133
+ @property
134
+ def stats(self) -> SchedulerStats:
135
+ """Get scheduler statistics."""
136
+ return self._stats
137
+
138
+ def start(self) -> None:
139
+ """Start the scheduler background thread.
140
+
141
+ Raises:
142
+ RuntimeError: If the scheduler is already running.
143
+ """
144
+ with self._lock:
145
+ if self._running:
146
+ raise RuntimeError("Scheduler is already running")
147
+
148
+ self._running = True
149
+ self._stop_event.clear()
150
+ self._thread = threading.Thread(
151
+ target=self._poll_loop,
152
+ name="abstractruntime-scheduler",
153
+ daemon=True,
154
+ )
155
+ self._thread.start()
156
+ logger.info("Scheduler started (poll_interval=%.1fs)", self._poll_interval)
157
+
158
+ def stop(self, timeout: float = 5.0) -> None:
159
+ """Stop the scheduler gracefully.
160
+
161
+ Args:
162
+ timeout: Maximum seconds to wait for the thread to stop.
163
+ """
164
+ with self._lock:
165
+ if not self._running:
166
+ return
167
+
168
+ self._running = False
169
+ self._stop_event.set()
170
+
171
+ if self._thread is not None:
172
+ self._thread.join(timeout=timeout)
173
+ if self._thread.is_alive():
174
+ logger.warning("Scheduler thread did not stop within timeout")
175
+ self._thread = None
176
+
177
+ logger.info("Scheduler stopped")
178
+
179
+ def resume_event(
180
+ self,
181
+ *,
182
+ run_id: str,
183
+ wait_key: str,
184
+ payload: Dict[str, Any],
185
+ ) -> RunState:
186
+ """Resume a run waiting for an event.
187
+
188
+ This is used to resume runs waiting for:
189
+ - wait_event (external events)
190
+ - ask_user (user input)
191
+
192
+ Args:
193
+ run_id: The run ID to resume.
194
+ wait_key: The wait key to match.
195
+ payload: The payload to inject.
196
+
197
+ Returns:
198
+ The updated RunState after resumption.
199
+
200
+ Raises:
201
+ KeyError: If the run or workflow is not found.
202
+ ValueError: If the run is not waiting or wait_key doesn't match.
203
+ """
204
+ run = self._runtime.get_state(run_id)
205
+
206
+ if run.status != RunStatus.WAITING:
207
+ raise ValueError(f"Run '{run_id}' is not waiting (status={run.status.value})")
208
+
209
+ if run.waiting is None:
210
+ raise ValueError(f"Run '{run_id}' has no waiting state")
211
+
212
+ workflow = self._registry.get_or_raise(run.workflow_id)
213
+
214
+ return self._runtime.resume(
215
+ workflow=workflow,
216
+ run_id=run_id,
217
+ wait_key=wait_key,
218
+ payload=payload,
219
+ )
220
+
221
+ def find_waiting_runs(
222
+ self,
223
+ *,
224
+ wait_reason: Optional[WaitReason] = None,
225
+ workflow_id: Optional[str] = None,
226
+ limit: int = 100,
227
+ ) -> List[RunState]:
228
+ """Find runs that are currently waiting.
229
+
230
+ Useful for building UIs that show pending user prompts.
231
+
232
+ Args:
233
+ wait_reason: Filter by wait reason (USER, EVENT, UNTIL).
234
+ workflow_id: Filter by workflow ID.
235
+ limit: Maximum number of runs to return.
236
+
237
+ Returns:
238
+ List of waiting RunState objects.
239
+ """
240
+ return self._run_store.list_runs(
241
+ status=RunStatus.WAITING,
242
+ wait_reason=wait_reason,
243
+ workflow_id=workflow_id,
244
+ limit=limit,
245
+ )
246
+
247
+ def poll_once(self) -> int:
248
+ """Run a single poll cycle manually.
249
+
250
+ This is useful for testing or for manual control.
251
+
252
+ Returns:
253
+ Number of runs resumed in this cycle.
254
+ """
255
+ return self._do_poll_cycle()
256
+
257
+ # -------------------------------------------------------------------------
258
+ # Internal
259
+ # -------------------------------------------------------------------------
260
+
261
+ def _poll_loop(self) -> None:
262
+ """Background polling loop."""
263
+ logger.debug("Scheduler poll loop started")
264
+
265
+ while not self._stop_event.is_set():
266
+ try:
267
+ self._do_poll_cycle()
268
+ except Exception as e:
269
+ logger.exception("Error in scheduler poll cycle: %s", e)
270
+ self._record_error(f"Poll cycle error: {e}")
271
+
272
+ # Wait for next cycle or stop signal
273
+ self._stop_event.wait(timeout=self._poll_interval)
274
+
275
+ logger.debug("Scheduler poll loop exited")
276
+
277
+ def _do_poll_cycle(self) -> int:
278
+ """Execute one poll cycle.
279
+
280
+ Returns:
281
+ Number of runs resumed.
282
+ """
283
+ self._stats.poll_cycles += 1
284
+ self._stats.last_poll_at = utc_now_iso()
285
+
286
+ # Find due wait_until runs
287
+ now = utc_now_iso()
288
+ due_runs = self._run_store.list_due_wait_until(now_iso=now)
289
+
290
+ resumed_count = 0
291
+ for run in due_runs:
292
+ try:
293
+ self._resume_wait_until(run)
294
+ resumed_count += 1
295
+ self._stats.runs_resumed += 1
296
+ except Exception as e:
297
+ logger.error("Failed to resume run %s: %s", run.run_id, e)
298
+ self._stats.runs_failed += 1
299
+ self._record_error(f"Run {run.run_id}: {e}")
300
+ if self._on_run_failed:
301
+ try:
302
+ self._on_run_failed(run.run_id, e)
303
+ except Exception:
304
+ pass
305
+
306
+ return resumed_count
307
+
308
+ def _resume_wait_until(self, run: RunState) -> RunState:
309
+ """Resume a wait_until run.
310
+
311
+ Args:
312
+ run: The run to resume.
313
+
314
+ Returns:
315
+ The updated RunState.
316
+
317
+ Raises:
318
+ KeyError: If the workflow is not registered.
319
+ """
320
+ workflow = self._registry.get_or_raise(run.workflow_id)
321
+
322
+ # For wait_until, we just call tick() - it will auto-unblock
323
+ new_state = self._runtime.tick(workflow=workflow, run_id=run.run_id)
324
+
325
+ logger.debug(
326
+ "Resumed wait_until run %s (workflow=%s, new_status=%s)",
327
+ run.run_id,
328
+ run.workflow_id,
329
+ new_state.status.value,
330
+ )
331
+
332
+ if self._on_run_resumed:
333
+ try:
334
+ self._on_run_resumed(new_state)
335
+ except Exception:
336
+ pass
337
+
338
+ return new_state
339
+
340
+ def _record_error(self, error: str) -> None:
341
+ """Record an error in stats, keeping only the most recent."""
342
+ self._stats.errors.append(f"{utc_now_iso()}: {error}")
343
+ if len(self._stats.errors) > self._max_errors:
344
+ self._stats.errors = self._stats.errors[-self._max_errors :]
345
+
346
+ def resume_subworkflow_parent(
347
+ self,
348
+ *,
349
+ child_run_id: str,
350
+ child_output: Dict[str, Any],
351
+ ) -> Optional[RunState]:
352
+ """Resume a parent workflow when its child subworkflow completes.
353
+
354
+ This finds the parent waiting on the given child and resumes it
355
+ with the child's output.
356
+
357
+ Args:
358
+ child_run_id: The completed child run ID.
359
+ child_output: The child's output to pass to parent.
360
+
361
+ Returns:
362
+ The updated parent RunState, or None if no parent was waiting.
363
+ """
364
+ # Find runs waiting for this subworkflow
365
+ waiting_runs = self._run_store.list_runs(
366
+ status=RunStatus.WAITING,
367
+ limit=1000,
368
+ )
369
+
370
+ for run in waiting_runs:
371
+ if run.waiting is None:
372
+ continue
373
+ if run.waiting.reason != WaitReason.SUBWORKFLOW:
374
+ continue
375
+ if run.waiting.details is None:
376
+ continue
377
+ if run.waiting.details.get("sub_run_id") != child_run_id:
378
+ continue
379
+
380
+ # Found the parent - resume it
381
+ wait_key = run.waiting.wait_key
382
+ workflow = self._registry.get_or_raise(run.workflow_id)
383
+
384
+ return self._runtime.resume(
385
+ workflow=workflow,
386
+ run_id=run.run_id,
387
+ wait_key=wait_key,
388
+ payload={"sub_run_id": child_run_id, "output": child_output},
389
+ )
390
+
391
+ return None
392
+
393
+ def cancel_with_children(
394
+ self,
395
+ run_id: str,
396
+ *,
397
+ reason: Optional[str] = None,
398
+ ) -> List[RunState]:
399
+ """Cancel a run and all its descendant subworkflows.
400
+
401
+ Traverses the parent-child tree and cancels all runs that are
402
+ still active (RUNNING or WAITING).
403
+
404
+ Args:
405
+ run_id: The root run to cancel.
406
+ reason: Optional cancellation reason.
407
+
408
+ Returns:
409
+ List of all cancelled RunState objects (including the root).
410
+ """
411
+ cancelled: List[RunState] = []
412
+ to_cancel = [run_id]
413
+
414
+ while to_cancel:
415
+ current_id = to_cancel.pop(0)
416
+
417
+ try:
418
+ state = self._runtime.cancel_run(current_id, reason=reason)
419
+ if state.status == RunStatus.CANCELLED:
420
+ cancelled.append(state)
421
+ except KeyError:
422
+ continue
423
+
424
+ # Find children using list_children if available
425
+ if hasattr(self._run_store, "list_children"):
426
+ children = self._run_store.list_children(parent_run_id=current_id)
427
+ for child in children:
428
+ if child.status in (RunStatus.RUNNING, RunStatus.WAITING):
429
+ to_cancel.append(child.run_id)
430
+
431
+ return cancelled
@@ -0,0 +1,25 @@
1
+ """Storage backends for durability."""
2
+
3
+ from .base import RunStore, LedgerStore, QueryableRunStore
4
+ from .in_memory import InMemoryRunStore, InMemoryLedgerStore
5
+ from .json_files import JsonFileRunStore, JsonlLedgerStore
6
+ from .ledger_chain import HashChainedLedgerStore, verify_ledger_chain
7
+ from .snapshots import Snapshot, SnapshotStore, InMemorySnapshotStore, JsonSnapshotStore
8
+
9
+ __all__ = [
10
+ "RunStore",
11
+ "LedgerStore",
12
+ "QueryableRunStore",
13
+ "InMemoryRunStore",
14
+ "InMemoryLedgerStore",
15
+ "JsonFileRunStore",
16
+ "JsonlLedgerStore",
17
+ "HashChainedLedgerStore",
18
+ "verify_ledger_chain",
19
+ "Snapshot",
20
+ "SnapshotStore",
21
+ "InMemorySnapshotStore",
22
+ "JsonSnapshotStore",
23
+ ]
24
+
25
+