agentdeck-sdk 3.1.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.
- agentdeck/README.md +50 -0
- agentdeck/__init__.py +51 -0
- agentdeck/adapters/__init__.py +5 -0
- agentdeck/adapters/control/__init__.py +1 -0
- agentdeck/adapters/control/memory/__init__.py +5 -0
- agentdeck/adapters/control/memory/port.py +25 -0
- agentdeck/adapters/control/sqlite/__init__.py +5 -0
- agentdeck/adapters/control/sqlite/port.py +111 -0
- agentdeck/adapters/engines/__init__.py +1 -0
- agentdeck/adapters/engines/langgraph/__init__.py +8 -0
- agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
- agentdeck/adapters/engines/langgraph/engine.py +410 -0
- agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
- agentdeck/adapters/engines/openai_agents/engine.py +321 -0
- agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
- agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
- agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
- agentdeck/adapters/engines/openai_agents/translate.py +124 -0
- agentdeck/adapters/engines/stub/__init__.py +5 -0
- agentdeck/adapters/engines/stub/engine.py +100 -0
- agentdeck/adapters/stores/__init__.py +1 -0
- agentdeck/adapters/stores/memory/__init__.py +5 -0
- agentdeck/adapters/stores/memory/store.py +148 -0
- agentdeck/adapters/stores/postgres/__init__.py +5 -0
- agentdeck/adapters/stores/postgres/store.py +374 -0
- agentdeck/adapters/stores/redis/__init__.py +5 -0
- agentdeck/adapters/stores/redis/store.py +359 -0
- agentdeck/adapters/stores/sqlite/__init__.py +5 -0
- agentdeck/adapters/stores/sqlite/store.py +338 -0
- agentdeck/adapters/telemetry/__init__.py +1 -0
- agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
- agentdeck/adapters/telemetry/langfuse/client.py +180 -0
- agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
- agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
- agentdeck/adapters/tools/__init__.py +1 -0
- agentdeck/adapters/tools/mcp/__init__.py +18 -0
- agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
- agentdeck/adapters/tools/mcp/source.py +47 -0
- agentdeck/adapters/tools/mcp/transport.py +234 -0
- agentdeck/adapters/tools/mcp/wiring.py +63 -0
- agentdeck/authoring/__init__.py +22 -0
- agentdeck/authoring/agent.py +169 -0
- agentdeck/authoring/compile.py +250 -0
- agentdeck/authoring/graphs.py +145 -0
- agentdeck/authoring/hooks.py +117 -0
- agentdeck/authoring/injection.py +233 -0
- agentdeck/authoring/instructions.py +80 -0
- agentdeck/authoring/interrupts.py +37 -0
- agentdeck/authoring/nodes.py +140 -0
- agentdeck/authoring/runners/__init__.py +6 -0
- agentdeck/authoring/runners/agent.py +171 -0
- agentdeck/authoring/runners/workflow.py +99 -0
- agentdeck/authoring/skills.py +56 -0
- agentdeck/authoring/state.py +44 -0
- agentdeck/authoring/timers.py +44 -0
- agentdeck/authoring/tools.py +147 -0
- agentdeck/authoring/web_search.py +43 -0
- agentdeck/authoring/workflow.py +266 -0
- agentdeck/cli.py +56 -0
- agentdeck/composition.py +213 -0
- agentdeck/core/__init__.py +110 -0
- agentdeck/core/base.py +47 -0
- agentdeck/core/content.py +166 -0
- agentdeck/core/context.py +136 -0
- agentdeck/core/control.py +150 -0
- agentdeck/core/events.py +468 -0
- agentdeck/core/invocable.py +38 -0
- agentdeck/core/ports/__init__.py +26 -0
- agentdeck/core/ports/control.py +35 -0
- agentdeck/core/ports/engine.py +66 -0
- agentdeck/core/ports/sink.py +53 -0
- agentdeck/core/ports/store.py +170 -0
- agentdeck/core/ports/tools.py +57 -0
- agentdeck/core/reporting.py +75 -0
- agentdeck/core/status.py +75 -0
- agentdeck/deck.py +894 -0
- agentdeck/errors.py +67 -0
- agentdeck/mcp.py +82 -0
- agentdeck/observers.py +111 -0
- agentdeck/py.typed +0 -0
- agentdeck/runtime/__init__.py +1 -0
- agentdeck/runtime/capture.py +32 -0
- agentdeck/runtime/config.default.yaml +38 -0
- agentdeck/runtime/discovery.py +175 -0
- agentdeck/runtime/dispatch.py +440 -0
- agentdeck/runtime/registry.py +173 -0
- agentdeck/runtime/service.py +671 -0
- agentdeck/runtime/settings.py +568 -0
- agentdeck/serve.py +330 -0
- agentdeck/skills/__init__.py +114 -0
- agentdeck/skills/bundle.py +65 -0
- agentdeck/surfaces/__init__.py +4 -0
- agentdeck/surfaces/cli/__init__.py +7 -0
- agentdeck/surfaces/cli/chat.py +88 -0
- agentdeck/surfaces/serve/__init__.py +7 -0
- agentdeck/surfaces/serve/app.py +71 -0
- agentdeck/surfaces/serve/compat.py +212 -0
- agentdeck/surfaces/serve/workflows.py +69 -0
- agentdeck/testing.py +364 -0
- agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
- agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
- agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
- agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
- agentdeck_sdk-3.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""The event log in Redis: the same contract as ``adapters.stores.sqlite``, shared by every
|
|
2
|
+
worker that can reach the instance.
|
|
3
|
+
|
|
4
|
+
Redis has no query planner to lean on, so the log carries its own indexes — a list per log
|
|
5
|
+
for append order, a list per run for a run's own slice, a sorted set of that run's ``seq``
|
|
6
|
+
numbers, the run's latest lifecycle event, and a set of the runs each log and each namespace
|
|
7
|
+
owns. Every write updates all of them inside one ``MULTI``/``EXEC``, so a reader never sees
|
|
8
|
+
an event in the log that is missing from its run's index. Status is still *derived* by
|
|
9
|
+
folding through ``core.status`` (ADR-D5: the log is the sole source of truth) — what the
|
|
10
|
+
indexes store is the last lifecycle **event**, never a status of their own.
|
|
11
|
+
|
|
12
|
+
Every key sits under one prefix (``agentdeck:events`` by default), which is how the
|
|
13
|
+
operational separation ADR-D5 asks for is expressed here: an instance that also holds the
|
|
14
|
+
openai-agents adapter's ``RedisSession`` conversations (``agents:session``) keeps the
|
|
15
|
+
platform record and the engine's private execution state in disjoint keyspaces, and either
|
|
16
|
+
can be dropped without touching the other.
|
|
17
|
+
|
|
18
|
+
**Operating notes.** ``append`` returning means Redis acknowledged the write, which is only
|
|
19
|
+
as durable as the instance is configured to be. The port promises an event a consumer has
|
|
20
|
+
seen is already in the store, so a deployment that uses this store as its record wants
|
|
21
|
+
``appendonly yes``; with the default snapshot-only persistence a crash can lose the last
|
|
22
|
+
seconds of a log. It also wants ``maxmemory-policy noeviction``: this is a record, not a
|
|
23
|
+
cache, and an evicted key does not merely disappear — a run whose latest lifecycle event was
|
|
24
|
+
evicted stops being seen as holding its session, so a live turn can have its session taken
|
|
25
|
+
from under it. Both settings belong to the whole instance, which is another reason to give
|
|
26
|
+
the log its own rather than share a cache's.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
from urllib.parse import quote, unquote
|
|
35
|
+
|
|
36
|
+
from redis.asyncio import Redis
|
|
37
|
+
from redis.exceptions import RedisError, WatchError
|
|
38
|
+
|
|
39
|
+
from agentdeck.core.events import Event
|
|
40
|
+
from agentdeck.core.ports import EventStorePort, RunSummary, SessionClaim
|
|
41
|
+
from agentdeck.core.status import LIFECYCLE_KINDS, TERMINAL_STATUSES, can_resume, status_of
|
|
42
|
+
from agentdeck.errors import StoreError
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
46
|
+
from datetime import timedelta
|
|
47
|
+
|
|
48
|
+
from redis.asyncio.client import Pipeline
|
|
49
|
+
|
|
50
|
+
from agentdeck.core.context import RunContext
|
|
51
|
+
from agentdeck.core.events import KnownPayload, RunResumed, RunStarted
|
|
52
|
+
from agentdeck.core.status import RunStatus
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _now() -> datetime:
|
|
56
|
+
return datetime.now(UTC)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_DEFAULT_PREFIX = "agentdeck:events"
|
|
60
|
+
|
|
61
|
+
# A claim re-reads and re-decides when a peer wrote to the same log under it. Bounded
|
|
62
|
+
# because an unbounded optimistic retry is a hang: past this many rounds the honest answer
|
|
63
|
+
# is that the store is too contended to settle, which is a store error and not a refusal.
|
|
64
|
+
#
|
|
65
|
+
# The loop is lock-step: one winner per round dirties every other watcher on the key, so N
|
|
66
|
+
# simultaneous contenders need exactly N rounds to drain. This must therefore stay well clear
|
|
67
|
+
# of any test's contender count — at 20 it sat exactly on `_CONCURRENT_APPENDS`, where one more
|
|
68
|
+
# contender turns a passing case into a permanently red one rather than a flaky one.
|
|
69
|
+
_CLAIM_ATTEMPTS = 64
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _segment(value: str) -> str:
|
|
73
|
+
"""One key segment, escaped so ``:`` inside a namespace or session id cannot forge another.
|
|
74
|
+
|
|
75
|
+
Without this, namespace ``"a:b"`` + log ``"c"`` and namespace ``"a"`` + log ``"b:c"`` are the
|
|
76
|
+
same key, and two namespaces read each other's runs — the isolation every store owes.
|
|
77
|
+
"""
|
|
78
|
+
return quote(value, safe="")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class RedisEventStore(EventStorePort):
|
|
82
|
+
"""Append-only lists and their indexes under one Redis key prefix.
|
|
83
|
+
|
|
84
|
+
Every write goes through ``WATCH``/``MULTI``/``EXEC`` — the two claims and the plain
|
|
85
|
+
append, which is conditional on one ``seq`` per run: the keys the decision reads are
|
|
86
|
+
watched, the decision itself runs here in Python — so ``core.status`` stays the one place
|
|
87
|
+
a status is derived — and ``EXEC`` refuses to apply the write if any of those keys moved
|
|
88
|
+
in between. A peer that got there first therefore never loses to a stale read; the write
|
|
89
|
+
re-reads and answers from what the peer actually wrote. That is what makes this hold
|
|
90
|
+
between two servers and not merely between two tasks.
|
|
91
|
+
|
|
92
|
+
A ``redis`` exception never crosses the port: everything reaches the caller as
|
|
93
|
+
``StoreError``.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, url: str, *, prefix: str = _DEFAULT_PREFIX, clock: Callable[[], datetime] = _now) -> None:
|
|
97
|
+
# decode_responses because every value here is UTF-8 JSON this store wrote itself.
|
|
98
|
+
self._client: Redis = Redis.from_url(url, decode_responses=True)
|
|
99
|
+
self._prefix = prefix
|
|
100
|
+
# An injected callable rather than Redis's own TIME, per ADR-D11 §4: reading the server's
|
|
101
|
+
# clock costs a round trip on the hot path, and unlike the SQL stores this one already
|
|
102
|
+
# holds a WATCH across the read — the clock is not what its atomicity rests on.
|
|
103
|
+
self._clock = clock
|
|
104
|
+
|
|
105
|
+
def _log_key(self, namespace: str, log_key: str) -> str:
|
|
106
|
+
return f"{self._prefix}:log:{_segment(namespace)}:{_segment(log_key)}"
|
|
107
|
+
|
|
108
|
+
def _run_key(self, namespace: str, log_key: str, run_id: str) -> str:
|
|
109
|
+
return f"{self._prefix}:run:{_segment(namespace)}:{_segment(log_key)}:{_segment(run_id)}"
|
|
110
|
+
|
|
111
|
+
def _seq_key(self, namespace: str, log_key: str, run_id: str) -> str:
|
|
112
|
+
return f"{self._prefix}:seq:{_segment(namespace)}:{_segment(log_key)}:{_segment(run_id)}"
|
|
113
|
+
|
|
114
|
+
def _life_key(self, namespace: str, log_key: str, run_id: str) -> str:
|
|
115
|
+
return f"{self._prefix}:life:{_segment(namespace)}:{_segment(log_key)}:{_segment(run_id)}"
|
|
116
|
+
|
|
117
|
+
def _log_runs_key(self, namespace: str, log_key: str) -> str:
|
|
118
|
+
return f"{self._prefix}:logruns:{_segment(namespace)}:{_segment(log_key)}"
|
|
119
|
+
|
|
120
|
+
def _namespace_runs_key(self, namespace: str) -> str:
|
|
121
|
+
return f"{self._prefix}:runs:{_segment(namespace)}"
|
|
122
|
+
|
|
123
|
+
def _queue_writes(self, pipe: Pipeline, namespace: str, log_key: str, events: Iterable[Event]) -> None:
|
|
124
|
+
"""Buffer one batch's writes — the log, the run's slice and every index — onto a
|
|
125
|
+
pipeline already in ``MULTI``, so no concurrent reader sees part of them.
|
|
126
|
+
|
|
127
|
+
``MULTI`` is atomic against *other clients*, not against a command of its own
|
|
128
|
+
failing: Redis runs the queued commands back to back and reports per-command errors
|
|
129
|
+
without rolling the rest back. Nothing here can fail on well-formed input — the keys
|
|
130
|
+
are this store's own and each command matches the type it created — so the case that
|
|
131
|
+
remains is a keyspace somebody else has written incompatible types into, which is
|
|
132
|
+
unrecoverable by any means this store has.
|
|
133
|
+
"""
|
|
134
|
+
for event in events:
|
|
135
|
+
data = event.model_dump_json()
|
|
136
|
+
pipe.rpush(self._log_key(namespace, log_key), data)
|
|
137
|
+
pipe.rpush(self._run_key(namespace, log_key, event.run_id), data)
|
|
138
|
+
pipe.zadd(self._seq_key(namespace, log_key, event.run_id), {str(event.seq): event.seq})
|
|
139
|
+
if event.kind in LIFECYCLE_KINDS:
|
|
140
|
+
pipe.set(self._life_key(namespace, log_key, event.run_id), data)
|
|
141
|
+
pipe.sadd(self._log_runs_key(namespace, log_key), event.run_id)
|
|
142
|
+
pipe.sadd(self._namespace_runs_key(namespace), _member(log_key, event.run_id))
|
|
143
|
+
|
|
144
|
+
async def _stamp(
|
|
145
|
+
self, pipe: Pipeline, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str
|
|
146
|
+
) -> list[Event]:
|
|
147
|
+
"""Read this run's next ``seq`` off its index and build the events, **watching** that
|
|
148
|
+
index so a peer spending the same number between here and ``EXEC`` aborts this write
|
|
149
|
+
instead of doubling it.
|
|
150
|
+
|
|
151
|
+
That watch is the whole atomicity mechanism, and it replaces the explicit spent-``seq``
|
|
152
|
+
check this store used to run: the number is no longer supplied by a caller who might
|
|
153
|
+
reuse one, and a peer racing for it loses the ``EXEC`` rather than being refused. The
|
|
154
|
+
index is a **ZSET** — ADR-D11 §6 originally prescribed ``INCR`` here, which would have
|
|
155
|
+
returned ``WRONGTYPE`` on every append; #153 corrected it.
|
|
156
|
+
|
|
157
|
+
Every payload in one call shares one ``ts``: a batch is one ``MULTI``, so it happened at
|
|
158
|
+
one instant.
|
|
159
|
+
"""
|
|
160
|
+
seq_key = self._seq_key(ctx.namespace_key, log_key, ctx.run_id)
|
|
161
|
+
await pipe.watch(seq_key)
|
|
162
|
+
scored = await pipe.zrange(seq_key, 0, 0, desc=True, withscores=True)
|
|
163
|
+
seq = (int(scored[0][1]) if scored else -1) + 1
|
|
164
|
+
now = self._clock()
|
|
165
|
+
events = []
|
|
166
|
+
for offset, payload in enumerate(payloads):
|
|
167
|
+
events.append(
|
|
168
|
+
Event(
|
|
169
|
+
kind=payload.kind,
|
|
170
|
+
seq=seq + offset,
|
|
171
|
+
run_id=ctx.run_id,
|
|
172
|
+
session_id=ctx.session_id,
|
|
173
|
+
namespace=ctx.namespace,
|
|
174
|
+
origin=origin,
|
|
175
|
+
ts=now,
|
|
176
|
+
payload=payload,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
return events
|
|
180
|
+
|
|
181
|
+
async def append(self, log_key: str, payloads: Sequence[KnownPayload], ctx: RunContext, origin: str) -> list[Event]:
|
|
182
|
+
"""A plain append is a conditional one too: the run's ``seq`` index is watched, read and
|
|
183
|
+
extended over one ``WATCH``/``MULTI``/``EXEC``, exactly as the claims do."""
|
|
184
|
+
if not payloads:
|
|
185
|
+
return []
|
|
186
|
+
|
|
187
|
+
async def _attempt(pipe: Pipeline) -> list[Event]:
|
|
188
|
+
events = await self._stamp(pipe, log_key, payloads, ctx, origin)
|
|
189
|
+
pipe.multi()
|
|
190
|
+
self._queue_writes(pipe, ctx.namespace_key, log_key, events)
|
|
191
|
+
await pipe.execute()
|
|
192
|
+
return events
|
|
193
|
+
|
|
194
|
+
return await self._watched(_attempt, "append")
|
|
195
|
+
|
|
196
|
+
async def read(self, log_key: str, ctx: RunContext, offset: int = 0, limit: int | None = None) -> list[Event]:
|
|
197
|
+
if limit is not None and limit < 0:
|
|
198
|
+
raise ValueError(f"limit must be None or >= 0, got {limit}")
|
|
199
|
+
start = max(offset, 0)
|
|
200
|
+
end = -1 if limit is None else start + limit - 1
|
|
201
|
+
# A zero limit computes an end before the start, and LRANGE reads a negative index
|
|
202
|
+
# from the tail — which for offset 0 would be the whole log rather than no page.
|
|
203
|
+
if limit is not None and end < start:
|
|
204
|
+
return []
|
|
205
|
+
try:
|
|
206
|
+
rows = await self._client.lrange(self._log_key(ctx.namespace_key, log_key), start, end)
|
|
207
|
+
except RedisError as exc:
|
|
208
|
+
raise StoreError(f"event log read failed: {exc}") from exc
|
|
209
|
+
return [Event.model_validate(json.loads(row)) for row in rows]
|
|
210
|
+
|
|
211
|
+
async def read_run(self, log_key: str, run_id: str, ctx: RunContext, from_seq: int = 0) -> list[Event]:
|
|
212
|
+
try:
|
|
213
|
+
rows = await self._client.lrange(self._run_key(ctx.namespace_key, log_key, run_id), 0, -1)
|
|
214
|
+
except RedisError as exc:
|
|
215
|
+
raise StoreError(f"event log read_run failed: {exc}") from exc
|
|
216
|
+
events = [Event.model_validate(json.loads(row)) for row in rows]
|
|
217
|
+
return [event for event in events if event.seq >= from_seq]
|
|
218
|
+
|
|
219
|
+
async def claim_start(
|
|
220
|
+
self, log_key: str, opening: RunStarted, ctx: RunContext, origin: str, stale_after: timedelta
|
|
221
|
+
) -> tuple[SessionClaim, Event | None]:
|
|
222
|
+
"""The port's session claim over ``WATCH``/``MULTI``/``EXEC``: the log's set of runs
|
|
223
|
+
and every open run's own keys are watched, so a peer opening a run under this
|
|
224
|
+
decision aborts the write rather than doubling it.
|
|
225
|
+
|
|
226
|
+
A refusal is data, as the port requires — the losing caller re-reads and names the
|
|
227
|
+
run that actually holds the session. Only an unreachable store or a hopelessly
|
|
228
|
+
contended one raises.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
async def _attempt(pipe: Pipeline) -> tuple[SessionClaim, Event | None]:
|
|
232
|
+
await pipe.watch(self._log_runs_key(ctx.namespace_key, log_key))
|
|
233
|
+
run_ids = _sorted_text(await pipe.smembers(self._log_runs_key(ctx.namespace_key, log_key)))
|
|
234
|
+
if run_ids:
|
|
235
|
+
await pipe.watch(
|
|
236
|
+
*(self._life_key(ctx.namespace_key, log_key, run_id) for run_id in run_ids),
|
|
237
|
+
*(self._run_key(ctx.namespace_key, log_key, run_id) for run_id in run_ids),
|
|
238
|
+
)
|
|
239
|
+
stale_before = self._clock() - stale_after
|
|
240
|
+
overridden: list[Event] = []
|
|
241
|
+
for run_id in run_ids:
|
|
242
|
+
life = await pipe.get(self._life_key(ctx.namespace_key, log_key, run_id))
|
|
243
|
+
# A key the same MULTI filled in coming back empty means the keyspace lost it
|
|
244
|
+
# — eviction, or an operator's DEL. Read as a run holding nothing rather than
|
|
245
|
+
# crashing the claim, and note what that costs: a *live* run whose lifecycle
|
|
246
|
+
# key went missing silently loses its session hold, and is not even reported
|
|
247
|
+
# in `overridden` for the winner to close. Hence `noeviction` up top; there is
|
|
248
|
+
# no answer a store can give here that is better than not evicting the record.
|
|
249
|
+
if life is None or status_of([Event.model_validate(json.loads(life))]) in TERMINAL_STATUSES:
|
|
250
|
+
continue
|
|
251
|
+
last = await pipe.lindex(self._run_key(ctx.namespace_key, log_key, run_id), -1)
|
|
252
|
+
if last is None:
|
|
253
|
+
continue
|
|
254
|
+
tail = Event.model_validate(json.loads(last))
|
|
255
|
+
if tail.ts > stale_before:
|
|
256
|
+
return SessionClaim(held_by=run_id), None
|
|
257
|
+
overridden.append(tail)
|
|
258
|
+
events = await self._stamp(pipe, log_key, [opening], ctx, origin)
|
|
259
|
+
pipe.multi()
|
|
260
|
+
self._queue_writes(pipe, ctx.namespace_key, log_key, events)
|
|
261
|
+
await pipe.execute()
|
|
262
|
+
return SessionClaim(overridden=tuple(overridden)), events[0]
|
|
263
|
+
|
|
264
|
+
return await self._watched(_attempt, "claim_start")
|
|
265
|
+
|
|
266
|
+
async def claim_resume(
|
|
267
|
+
self, log_key: str, run_id: str, resumed: RunResumed, ctx: RunContext, origin: str
|
|
268
|
+
) -> Event | None:
|
|
269
|
+
"""The port's conditional append over ``WATCH``/``MULTI``/``EXEC``: the run's status
|
|
270
|
+
and its ``seq`` index are watched, so the write that publishes the
|
|
271
|
+
``WAITING_HUMAN`` -> ``RUNNING`` transition is the write that tested for it.
|
|
272
|
+
|
|
273
|
+
A loser gets its clean ``None`` from what the winner wrote, never from a guess. An
|
|
274
|
+
unreachable store raises instead, because it cannot know whether anybody resumed.
|
|
275
|
+
"""
|
|
276
|
+
if ctx.run_id != run_id:
|
|
277
|
+
raise ValueError(f"a claim on run {run_id!r} cannot be made in the context of {ctx.run_id!r}")
|
|
278
|
+
|
|
279
|
+
async def _attempt(pipe: Pipeline) -> Event | None:
|
|
280
|
+
life_key = self._life_key(ctx.namespace_key, log_key, run_id)
|
|
281
|
+
await pipe.watch(life_key)
|
|
282
|
+
life = await pipe.get(life_key)
|
|
283
|
+
if not can_resume(status_of([Event.model_validate(json.loads(life))] if life is not None else [])):
|
|
284
|
+
return None
|
|
285
|
+
events = await self._stamp(pipe, log_key, [resumed], ctx, origin)
|
|
286
|
+
pipe.multi()
|
|
287
|
+
self._queue_writes(pipe, ctx.namespace_key, log_key, events)
|
|
288
|
+
await pipe.execute()
|
|
289
|
+
return events[0]
|
|
290
|
+
|
|
291
|
+
return await self._watched(_attempt, "claim_resume")
|
|
292
|
+
|
|
293
|
+
async def list_runs(self, ctx: RunContext, status: RunStatus | None = None) -> list[RunSummary]:
|
|
294
|
+
"""Overrides the port's per-run fold: the namespace's runs come off one set and each
|
|
295
|
+
one's status off its stored last lifecycle event, so a listing deserializes one
|
|
296
|
+
event per run instead of every event of every log."""
|
|
297
|
+
try:
|
|
298
|
+
members = _sorted_text(await self._client.smembers(self._namespace_runs_key(ctx.namespace_key)))
|
|
299
|
+
runs = [_split(member) for member in members]
|
|
300
|
+
async with self._client.pipeline(transaction=False) as pipe:
|
|
301
|
+
for log_key, run_id in runs:
|
|
302
|
+
pipe.get(self._life_key(ctx.namespace_key, log_key, run_id))
|
|
303
|
+
lifecycles = await pipe.execute()
|
|
304
|
+
except RedisError as exc:
|
|
305
|
+
raise StoreError(f"event log list_runs failed: {exc}") from exc
|
|
306
|
+
summaries = [
|
|
307
|
+
RunSummary(log_key=log_key, run_id=run_id, status=status_of([Event.model_validate(json.loads(life))]))
|
|
308
|
+
for (log_key, run_id), life in zip(runs, lifecycles, strict=True)
|
|
309
|
+
if life is not None
|
|
310
|
+
]
|
|
311
|
+
return [summary for summary in summaries if status is None or summary.status is status]
|
|
312
|
+
|
|
313
|
+
async def _watched[T](self, attempt: Callable[[Pipeline], Awaitable[T]], op: str) -> T:
|
|
314
|
+
"""Run one optimistic write until ``EXEC`` is not aborted by a concurrent one.
|
|
315
|
+
|
|
316
|
+
Every write this store does goes through here — the two claims and the plain append,
|
|
317
|
+
which is conditional on one ``seq`` per run. A fresh pipeline per round, so a round
|
|
318
|
+
that lost its watch leaves nothing behind.
|
|
319
|
+
"""
|
|
320
|
+
try:
|
|
321
|
+
for _ in range(_CLAIM_ATTEMPTS):
|
|
322
|
+
async with self._client.pipeline(transaction=True) as pipe:
|
|
323
|
+
try:
|
|
324
|
+
return await attempt(pipe)
|
|
325
|
+
except WatchError:
|
|
326
|
+
continue
|
|
327
|
+
except RedisError as exc:
|
|
328
|
+
raise StoreError(f"event log {op} failed: {exc}") from exc
|
|
329
|
+
raise StoreError(f"event log {op} gave up after {_CLAIM_ATTEMPTS} contended attempts")
|
|
330
|
+
|
|
331
|
+
async def aclose(self) -> None:
|
|
332
|
+
try:
|
|
333
|
+
await self._client.aclose()
|
|
334
|
+
except RedisError as exc:
|
|
335
|
+
raise StoreError(f"closing the event log failed: {exc}") from exc
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _sorted_text(members: Iterable[bytes | str]) -> list[str]:
|
|
339
|
+
"""One set's members as sorted text.
|
|
340
|
+
|
|
341
|
+
``redis`` types every reply as ``bytes | str`` because decoding is a client option
|
|
342
|
+
rather than a protocol fact, and this client asks for text. Decoding the other branch
|
|
343
|
+
anyway costs nothing and keeps the annotation honest. Sorted so that a log with two
|
|
344
|
+
live runs names the same holder to every caller instead of whichever one the set
|
|
345
|
+
happened to yield first.
|
|
346
|
+
"""
|
|
347
|
+
return sorted(member.decode() if isinstance(member, bytes) else member for member in members)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _member(log_key: str, run_id: str) -> str:
|
|
351
|
+
return f"{_segment(log_key)}:{_segment(run_id)}"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _split(member: str) -> tuple[str, str]:
|
|
355
|
+
log_key, _, run_id = member.partition(":")
|
|
356
|
+
return unquote(log_key), unquote(run_id)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
__all__ = ["RedisEventStore"]
|