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.
- abstractruntime/__init__.py +104 -2
- abstractruntime/core/__init__.py +26 -0
- abstractruntime/core/config.py +101 -0
- abstractruntime/core/models.py +282 -0
- abstractruntime/core/policy.py +166 -0
- abstractruntime/core/runtime.py +736 -0
- abstractruntime/core/spec.py +53 -0
- abstractruntime/core/vars.py +94 -0
- abstractruntime/identity/__init__.py +7 -0
- abstractruntime/identity/fingerprint.py +57 -0
- abstractruntime/integrations/__init__.py +11 -0
- abstractruntime/integrations/abstractcore/__init__.py +47 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +119 -0
- abstractruntime/integrations/abstractcore/factory.py +187 -0
- abstractruntime/integrations/abstractcore/llm_client.py +397 -0
- abstractruntime/integrations/abstractcore/logging.py +27 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +168 -0
- abstractruntime/scheduler/__init__.py +13 -0
- abstractruntime/scheduler/convenience.py +324 -0
- abstractruntime/scheduler/registry.py +101 -0
- abstractruntime/scheduler/scheduler.py +431 -0
- abstractruntime/storage/__init__.py +25 -0
- abstractruntime/storage/artifacts.py +519 -0
- abstractruntime/storage/base.py +107 -0
- abstractruntime/storage/in_memory.py +119 -0
- abstractruntime/storage/json_files.py +208 -0
- abstractruntime/storage/ledger_chain.py +153 -0
- abstractruntime/storage/snapshots.py +217 -0
- abstractruntime-0.2.0.dist-info/METADATA +163 -0
- abstractruntime-0.2.0.dist-info/RECORD +32 -0
- {abstractruntime-0.0.0.dist-info → abstractruntime-0.2.0.dist-info}/licenses/LICENSE +3 -1
- abstractruntime-0.0.0.dist-info/METADATA +0 -89
- abstractruntime-0.0.0.dist-info/RECORD +0 -5
- {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
|
+
|