openscript-sdk 0.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.
- openscript_sdk/__init__.py +217 -0
- openscript_sdk/contracts/__init__.py +82 -0
- openscript_sdk/contracts/interceptor.py +98 -0
- openscript_sdk/contracts/server_types.py +87 -0
- openscript_sdk/contracts/session_graph.py +123 -0
- openscript_sdk/contracts/types.py +76 -0
- openscript_sdk/events/__init__.py +0 -0
- openscript_sdk/events/approvals.py +309 -0
- openscript_sdk/events/models.py +26 -0
- openscript_sdk/events/store.py +79 -0
- openscript_sdk/events/writer.py +100 -0
- openscript_sdk/hooks/claude_code.py +479 -0
- openscript_sdk/hooks/claude_code_example.yaml +62 -0
- openscript_sdk/hooks/claude_code_strict_example.yaml +73 -0
- openscript_sdk/hooks/config.py +144 -0
- openscript_sdk/hooks/settings_example.json +28 -0
- openscript_sdk/hooks/settings_strict_example.json +28 -0
- openscript_sdk/integrations/langchain.py +32 -0
- openscript_sdk/integrations/langgraph.py +32 -0
- openscript_sdk/interceptors/base.py +15 -0
- openscript_sdk/interceptors/event_writer.py +131 -0
- openscript_sdk/interceptors/pii.py +168 -0
- openscript_sdk/interceptors/threat.py +674 -0
- openscript_sdk/logging.py +94 -0
- openscript_sdk/middleware/middleware.py +543 -0
- openscript_sdk/middleware/tool.py +247 -0
- openscript_sdk/observability/__init__.py +11 -0
- openscript_sdk/observability/metrics.py +105 -0
- openscript_sdk/observability/risk.py +54 -0
- openscript_sdk/observability/tracing.py +67 -0
- openscript_sdk/policies/compliance.py +261 -0
- openscript_sdk/policies/config.py +244 -0
- openscript_sdk/policies/harmful_request.py +617 -0
- openscript_sdk/policies/normalize.py +489 -0
- openscript_sdk/policies/output_schema.py +353 -0
- openscript_sdk/policies/policies_example.yaml +36 -0
- openscript_sdk/policies/redaction.py +359 -0
- openscript_sdk/policies/secrets.py +239 -0
- openscript_sdk/policies/semantic.py +348 -0
- openscript_sdk/policies/semantic_head.py +9 -0
- openscript_sdk/policies/session_taint.py +399 -0
- openscript_sdk/policies/taint_store.py +111 -0
- openscript_sdk/policies/tool_firewall.py +434 -0
- openscript_sdk/policies/tools_example.yaml +81 -0
- openscript_sdk/policies/toxicity.py +247 -0
- openscript_sdk/py.typed +0 -0
- openscript_sdk/server/__init__.py +1 -0
- openscript_sdk/server/app.py +153 -0
- openscript_sdk/server/auth.py +19 -0
- openscript_sdk/server/guard.py +126 -0
- openscript_sdk/server/guard_example.yaml +55 -0
- openscript_sdk/server/routers/__init__.py +0 -0
- openscript_sdk/server/routers/approvals.py +69 -0
- openscript_sdk/server/routers/guard.py +231 -0
- openscript_sdk/server/routers/sessions.py +80 -0
- openscript_sdk/server/routers/sse.py +69 -0
- openscript_sdk/server/routers/threat.py +119 -0
- openscript_sdk/server/static/dashboard/index.html +209 -0
- openscript_sdk/server/static/dashboard/session.html +339 -0
- openscript_sdk-0.1.0.dist-info/METADATA +225 -0
- openscript_sdk-0.1.0.dist-info/RECORD +65 -0
- openscript_sdk-0.1.0.dist-info/WHEEL +5 -0
- openscript_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- openscript_sdk-0.1.0.dist-info/licenses/LICENSE +190 -0
- openscript_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
7
|
+
from datetime import UTC, datetime, timedelta
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
DEFAULT_TTL_SECONDS = 3600 # approvals expire back to blocked after 1h
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ApprovalStatus(StrEnum):
|
|
19
|
+
PENDING = "pending"
|
|
20
|
+
APPROVED = "approved"
|
|
21
|
+
DENIED = "denied"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _typed(value: Any) -> Any:
|
|
25
|
+
"""*value* as plain JSON in which every piece says what type it was.
|
|
26
|
+
|
|
27
|
+
Exact types, not isinstance: an IntEnum member is not the int it compares
|
|
28
|
+
equal to, and should not be approved as if it were. A list and a tuple are
|
|
29
|
+
deliberately the same thing here; nothing can be done with one that cannot
|
|
30
|
+
be done with the other.
|
|
31
|
+
"""
|
|
32
|
+
kind = type(value)
|
|
33
|
+
if value is None:
|
|
34
|
+
return ["none"]
|
|
35
|
+
if kind is bool:
|
|
36
|
+
return ["bool", value]
|
|
37
|
+
if kind is int:
|
|
38
|
+
return ["int", str(value)]
|
|
39
|
+
if kind is float:
|
|
40
|
+
return ["float", repr(value)]
|
|
41
|
+
if kind is str:
|
|
42
|
+
return ["str", value]
|
|
43
|
+
if kind in (bytes, bytearray):
|
|
44
|
+
return ["bytes", bytes(value).hex()]
|
|
45
|
+
if kind in (list, tuple):
|
|
46
|
+
return ["list", [_typed(item) for item in value]]
|
|
47
|
+
if kind in (set, frozenset):
|
|
48
|
+
return ["set", sorted((_typed(item) for item in value), key=_order)]
|
|
49
|
+
if kind is dict:
|
|
50
|
+
pairs = ([_typed(key), _typed(item)] for key, item in value.items())
|
|
51
|
+
return ["dict", sorted(pairs, key=_order)]
|
|
52
|
+
# Anything else is named by its type as well as its text, so a Decimal, a
|
|
53
|
+
# UUID or a date cannot stand in for the string that spells it.
|
|
54
|
+
return ["object", f"{kind.__module__}.{kind.__qualname__}", str(value)]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _order(encoded: Any) -> str:
|
|
58
|
+
return json.dumps(encoded)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def action_hash(action: str, input_data: dict[str, Any]) -> str:
|
|
62
|
+
"""Canonical fingerprint of (action, input) for approval binding.
|
|
63
|
+
|
|
64
|
+
An approval is redeemable only against the exact action it was requested
|
|
65
|
+
for — a matching hash proves the retried call carries the same action
|
|
66
|
+
name and input/args, so a granted approval can't be replayed against a
|
|
67
|
+
different tool call.
|
|
68
|
+
|
|
69
|
+
"The same" means the same values of the same types. This used to be
|
|
70
|
+
json.dumps(default=str), and str() forgets what it was given: Decimal("10")
|
|
71
|
+
and the text "10" fingerprinted alike, as did {1: x} and {"1": x}. An
|
|
72
|
+
approval for one was an approval for the other.
|
|
73
|
+
"""
|
|
74
|
+
canonical = json.dumps(["action", action, _typed(input_data)], separators=(",", ":"))
|
|
75
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _new_expiry(ttl_seconds: int = DEFAULT_TTL_SECONDS) -> datetime:
|
|
79
|
+
return datetime.now(UTC) + timedelta(seconds=ttl_seconds)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ApprovalRecord:
|
|
84
|
+
session_id: str
|
|
85
|
+
agent_id: str
|
|
86
|
+
action: str
|
|
87
|
+
reason: str
|
|
88
|
+
action_hash: str
|
|
89
|
+
approval_id: str = field(default_factory=lambda: uuid.uuid4().hex)
|
|
90
|
+
status: ApprovalStatus = ApprovalStatus.PENDING
|
|
91
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
92
|
+
expires_at: datetime = field(default_factory=_new_expiry)
|
|
93
|
+
decided_by: str = ""
|
|
94
|
+
#: What the person deciding said about it. A denial with no reason sends
|
|
95
|
+
#: the requester back to guess; this is where the reason travels.
|
|
96
|
+
decision_note: str = ""
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def expired(self) -> bool:
|
|
100
|
+
return datetime.now(UTC) >= self.expires_at
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict[str, Any]:
|
|
103
|
+
data = asdict(self)
|
|
104
|
+
data["status"] = self.status.value
|
|
105
|
+
data["created_at"] = self.created_at.isoformat()
|
|
106
|
+
data["expires_at"] = self.expires_at.isoformat()
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, data: dict[str, Any]) -> ApprovalRecord:
|
|
111
|
+
return cls(
|
|
112
|
+
session_id=data["session_id"],
|
|
113
|
+
agent_id=data["agent_id"],
|
|
114
|
+
action=data["action"],
|
|
115
|
+
reason=data["reason"],
|
|
116
|
+
action_hash=data["action_hash"],
|
|
117
|
+
approval_id=data["approval_id"],
|
|
118
|
+
status=ApprovalStatus(data["status"]),
|
|
119
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
120
|
+
expires_at=datetime.fromisoformat(data["expires_at"]),
|
|
121
|
+
decided_by=data.get("decided_by", ""),
|
|
122
|
+
# Absent from records written before the field existed.
|
|
123
|
+
decision_note=data.get("decision_note", ""),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class ApprovalStore(Protocol):
|
|
128
|
+
"""Storage for pending/decided approval records.
|
|
129
|
+
|
|
130
|
+
Expired records behave as denied everywhere: they drop out of
|
|
131
|
+
list_pending, can no longer be decided, and never redeem.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
async def create(self, record: ApprovalRecord) -> None: ...
|
|
135
|
+
|
|
136
|
+
async def get(self, approval_id: str) -> ApprovalRecord | None: ...
|
|
137
|
+
|
|
138
|
+
async def list_pending(self) -> list[ApprovalRecord]: ...
|
|
139
|
+
|
|
140
|
+
async def decide(
|
|
141
|
+
self, approval_id: str, approved: bool, decided_by: str = "", note: str = ""
|
|
142
|
+
) -> ApprovalRecord | None:
|
|
143
|
+
"""Resolve a pending record. Returns the updated record, or None when
|
|
144
|
+
the id is unknown. Raises ValueError when the record is no longer
|
|
145
|
+
pending (already decided or expired). *note* is the decider's reason,
|
|
146
|
+
kept on the record as ``decision_note``."""
|
|
147
|
+
...
|
|
148
|
+
|
|
149
|
+
async def consume(self, approval_id: str, expected_hash: str) -> bool:
|
|
150
|
+
"""Single-use redemption: True iff the record exists, is approved,
|
|
151
|
+
is not expired, and its action_hash matches — the record is deleted
|
|
152
|
+
so it can never redeem twice."""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class InMemoryApprovalStore:
|
|
157
|
+
"""Dict-backed store for SINGLE-PROCESS use and tests only.
|
|
158
|
+
|
|
159
|
+
SecureAgent runs in the caller's process while /v1/approvals runs in the
|
|
160
|
+
server — an in-memory store is never visible to both. Use
|
|
161
|
+
RedisApprovalStore whenever approvals are decided over the HTTP API.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(self) -> None:
|
|
165
|
+
self._records: dict[str, ApprovalRecord] = {}
|
|
166
|
+
|
|
167
|
+
async def create(self, record: ApprovalRecord) -> None:
|
|
168
|
+
self._records[record.approval_id] = replace(record)
|
|
169
|
+
|
|
170
|
+
async def get(self, approval_id: str) -> ApprovalRecord | None:
|
|
171
|
+
record = self._records.get(approval_id)
|
|
172
|
+
return replace(record) if record is not None else None
|
|
173
|
+
|
|
174
|
+
async def list_pending(self) -> list[ApprovalRecord]:
|
|
175
|
+
self._purge_expired()
|
|
176
|
+
return [replace(r) for r in self._records.values() if r.status == ApprovalStatus.PENDING]
|
|
177
|
+
|
|
178
|
+
async def decide(
|
|
179
|
+
self, approval_id: str, approved: bool, decided_by: str = "", note: str = ""
|
|
180
|
+
) -> ApprovalRecord | None:
|
|
181
|
+
record = self._records.get(approval_id)
|
|
182
|
+
if record is None:
|
|
183
|
+
return None
|
|
184
|
+
if record.status != ApprovalStatus.PENDING or record.expired:
|
|
185
|
+
raise ValueError(f"approval '{approval_id}' is not pending")
|
|
186
|
+
record.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.DENIED
|
|
187
|
+
record.decided_by = decided_by
|
|
188
|
+
record.decision_note = note
|
|
189
|
+
return replace(record)
|
|
190
|
+
|
|
191
|
+
async def consume(self, approval_id: str, expected_hash: str) -> bool:
|
|
192
|
+
record = self._records.get(approval_id)
|
|
193
|
+
if (
|
|
194
|
+
record is None
|
|
195
|
+
or record.status != ApprovalStatus.APPROVED
|
|
196
|
+
or record.expired
|
|
197
|
+
or record.action_hash != expected_hash
|
|
198
|
+
):
|
|
199
|
+
return False
|
|
200
|
+
del self._records[approval_id]
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
def _purge_expired(self) -> None:
|
|
204
|
+
expired = [aid for aid, r in self._records.items() if r.expired]
|
|
205
|
+
for aid in expired:
|
|
206
|
+
del self._records[aid]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class RedisApprovalStore:
|
|
210
|
+
"""Redis-backed store — required whenever SecureAgent and the server run
|
|
211
|
+
in separate processes (i.e. any real deployment of the approvals API).
|
|
212
|
+
|
|
213
|
+
Records live at {prefix}:{approval_id} as JSON with a Redis TTL matching
|
|
214
|
+
expires_at; pending ids are tracked in the {prefix}:pending set. Single-use
|
|
215
|
+
redemption relies on DELETE's atomicity: only the caller whose DELETE
|
|
216
|
+
returns 1 wins a concurrent redeem race. A decision is made under WATCH,
|
|
217
|
+
so of two people deciding the same request at once, one is told it was
|
|
218
|
+
already decided.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(
|
|
222
|
+
self,
|
|
223
|
+
url: str = "redis://localhost:6379/0",
|
|
224
|
+
*,
|
|
225
|
+
key_prefix: str = "openscript:approval",
|
|
226
|
+
) -> None:
|
|
227
|
+
import redis.asyncio as aioredis
|
|
228
|
+
|
|
229
|
+
self._redis = aioredis.from_url(url, decode_responses=True)
|
|
230
|
+
self._prefix = key_prefix
|
|
231
|
+
self._pending_key = f"{key_prefix}:pending"
|
|
232
|
+
|
|
233
|
+
def _key(self, approval_id: str) -> str:
|
|
234
|
+
return f"{self._prefix}:{approval_id}"
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _ttl_seconds(record: ApprovalRecord) -> int:
|
|
238
|
+
return max(1, int((record.expires_at - datetime.now(UTC)).total_seconds()))
|
|
239
|
+
|
|
240
|
+
async def create(self, record: ApprovalRecord) -> None:
|
|
241
|
+
await self._redis.set(
|
|
242
|
+
self._key(record.approval_id),
|
|
243
|
+
json.dumps(record.to_dict()),
|
|
244
|
+
ex=self._ttl_seconds(record),
|
|
245
|
+
)
|
|
246
|
+
await self._redis.sadd(self._pending_key, record.approval_id)
|
|
247
|
+
|
|
248
|
+
async def get(self, approval_id: str) -> ApprovalRecord | None:
|
|
249
|
+
raw = await self._redis.get(self._key(approval_id))
|
|
250
|
+
return ApprovalRecord.from_dict(json.loads(raw)) if raw else None
|
|
251
|
+
|
|
252
|
+
async def list_pending(self) -> list[ApprovalRecord]:
|
|
253
|
+
pending: list[ApprovalRecord] = []
|
|
254
|
+
for approval_id in await self._redis.smembers(self._pending_key):
|
|
255
|
+
record = await self.get(str(approval_id))
|
|
256
|
+
if record is None or record.status != ApprovalStatus.PENDING:
|
|
257
|
+
# expired (key TTL fired) or already decided — clean the set
|
|
258
|
+
await self._redis.srem(self._pending_key, approval_id)
|
|
259
|
+
continue
|
|
260
|
+
pending.append(record)
|
|
261
|
+
return pending
|
|
262
|
+
|
|
263
|
+
async def decide(
|
|
264
|
+
self, approval_id: str, approved: bool, decided_by: str = "", note: str = ""
|
|
265
|
+
) -> ApprovalRecord | None:
|
|
266
|
+
from redis.exceptions import WatchError
|
|
267
|
+
|
|
268
|
+
key = self._key(approval_id)
|
|
269
|
+
# Read, check and write as one step. Without the WATCH, two people
|
|
270
|
+
# deciding at the same moment both read "pending", both write, and both
|
|
271
|
+
# are told it worked — an approval and a denial of the same request,
|
|
272
|
+
# with whichever landed second quietly winning.
|
|
273
|
+
# Typed Any: redis-py leaves multi() unannotated, and whether that is
|
|
274
|
+
# an error depends on whether its types are installed where mypy runs.
|
|
275
|
+
transaction: Any = self._redis.pipeline(transaction=True)
|
|
276
|
+
async with transaction as pipe:
|
|
277
|
+
while True:
|
|
278
|
+
try:
|
|
279
|
+
await pipe.watch(key)
|
|
280
|
+
raw = await pipe.get(key)
|
|
281
|
+
record = ApprovalRecord.from_dict(json.loads(raw)) if raw else None
|
|
282
|
+
if record is None:
|
|
283
|
+
return None
|
|
284
|
+
if record.status != ApprovalStatus.PENDING or record.expired:
|
|
285
|
+
raise ValueError(f"approval '{approval_id}' is not pending")
|
|
286
|
+
record.status = ApprovalStatus.APPROVED if approved else ApprovalStatus.DENIED
|
|
287
|
+
record.decided_by = decided_by
|
|
288
|
+
record.decision_note = note
|
|
289
|
+
pipe.multi()
|
|
290
|
+
pipe.set(key, json.dumps(record.to_dict()), ex=self._ttl_seconds(record))
|
|
291
|
+
pipe.srem(self._pending_key, approval_id)
|
|
292
|
+
await pipe.execute()
|
|
293
|
+
return record
|
|
294
|
+
except WatchError:
|
|
295
|
+
# Someone else wrote the record first. Read it again: it is
|
|
296
|
+
# no longer pending, and the check above says so.
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
async def consume(self, approval_id: str, expected_hash: str) -> bool:
|
|
300
|
+
record = await self.get(approval_id)
|
|
301
|
+
if (
|
|
302
|
+
record is None
|
|
303
|
+
or record.status != ApprovalStatus.APPROVED
|
|
304
|
+
or record.expired
|
|
305
|
+
or record.action_hash != expected_hash
|
|
306
|
+
):
|
|
307
|
+
return False
|
|
308
|
+
# DELETE returning 1 is the atomic single-use gate under concurrency
|
|
309
|
+
return await self._redis.delete(self._key(approval_id)) == 1
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import DateTime, Index, Integer, String
|
|
7
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Base(DeclarativeBase):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventRow(Base):
|
|
16
|
+
__tablename__ = "events"
|
|
17
|
+
|
|
18
|
+
id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
19
|
+
session_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
|
20
|
+
agent_id: Mapped[str] = mapped_column(String, nullable=False)
|
|
21
|
+
event_type: Mapped[str] = mapped_column(String, nullable=False)
|
|
22
|
+
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, server_default="{}")
|
|
23
|
+
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
24
|
+
sequence_num: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
25
|
+
|
|
26
|
+
__table_args__ = (Index("ix_events_session_sequence", "session_id", "sequence_num"),)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
from sqlalchemy import and_, select
|
|
7
|
+
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
|
8
|
+
|
|
9
|
+
from openscript_sdk.contracts.server_types import Event, EventType
|
|
10
|
+
from openscript_sdk.events.models import EventRow
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EventStore:
|
|
16
|
+
"""Read/write interface for the append-only events table."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, engine: AsyncEngine) -> None:
|
|
19
|
+
self._engine = engine
|
|
20
|
+
self._session_factory = async_sessionmaker(
|
|
21
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
async def insert_events(self, events: list[Event]) -> None:
|
|
25
|
+
async with self._session_factory() as session:
|
|
26
|
+
for event in events:
|
|
27
|
+
row = EventRow(
|
|
28
|
+
id=event.id,
|
|
29
|
+
session_id=event.session_id,
|
|
30
|
+
agent_id=event.agent_id,
|
|
31
|
+
event_type=event.event_type.value,
|
|
32
|
+
payload=event.payload,
|
|
33
|
+
timestamp=event.timestamp,
|
|
34
|
+
sequence_num=event.sequence_num,
|
|
35
|
+
)
|
|
36
|
+
session.add(row)
|
|
37
|
+
await session.commit()
|
|
38
|
+
logger.debug("events_inserted", count=len(events))
|
|
39
|
+
|
|
40
|
+
async def get_events(
|
|
41
|
+
self,
|
|
42
|
+
session_id: str,
|
|
43
|
+
event_types: list[EventType] | None = None,
|
|
44
|
+
after_sequence: int | None = None,
|
|
45
|
+
limit: int = 1000,
|
|
46
|
+
) -> list[Event]:
|
|
47
|
+
async with self._session_factory() as session:
|
|
48
|
+
conditions = [EventRow.session_id == session_id]
|
|
49
|
+
if event_types:
|
|
50
|
+
conditions.append(EventRow.event_type.in_([et.value for et in event_types]))
|
|
51
|
+
if after_sequence is not None:
|
|
52
|
+
conditions.append(EventRow.sequence_num > after_sequence)
|
|
53
|
+
|
|
54
|
+
stmt = (
|
|
55
|
+
select(EventRow)
|
|
56
|
+
.where(and_(*conditions))
|
|
57
|
+
.order_by(EventRow.sequence_num)
|
|
58
|
+
.limit(limit)
|
|
59
|
+
)
|
|
60
|
+
result = await session.execute(stmt)
|
|
61
|
+
rows = result.scalars().all()
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
Event(
|
|
65
|
+
id=row.id,
|
|
66
|
+
session_id=row.session_id,
|
|
67
|
+
agent_id=row.agent_id,
|
|
68
|
+
event_type=EventType(row.event_type),
|
|
69
|
+
payload=row.payload,
|
|
70
|
+
timestamp=row.timestamp,
|
|
71
|
+
sequence_num=row.sequence_num,
|
|
72
|
+
)
|
|
73
|
+
for row in rows
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
async def stream_events(self, session_id: str) -> AsyncIterator[Event]:
|
|
77
|
+
events = await self.get_events(session_id=session_id)
|
|
78
|
+
for event in events:
|
|
79
|
+
yield event
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
import structlog
|
|
8
|
+
|
|
9
|
+
from openscript_sdk.contracts.server_types import Event
|
|
10
|
+
|
|
11
|
+
logger = structlog.get_logger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventSink(Protocol):
|
|
15
|
+
"""Anything that can persist a batch of events."""
|
|
16
|
+
|
|
17
|
+
async def insert_events(self, events: list[Event]) -> None: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EventWriter:
|
|
21
|
+
"""Async bounded queue that drains events to a sink in batches.
|
|
22
|
+
|
|
23
|
+
Uses DROP_OLDEST policy when the queue is full — the newest event
|
|
24
|
+
always wins. Every drop is logged with a running counter so ops
|
|
25
|
+
can alert on sustained back-pressure.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
store: EventSink,
|
|
31
|
+
max_queue_size: int = 10_000,
|
|
32
|
+
batch_size: int = 100,
|
|
33
|
+
flush_interval: float = 0.1,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._store = store
|
|
36
|
+
self._queue: asyncio.Queue[Event] = asyncio.Queue(maxsize=max_queue_size)
|
|
37
|
+
self._batch_size = batch_size
|
|
38
|
+
self._flush_interval = flush_interval
|
|
39
|
+
self._events_dropped_count: int = 0
|
|
40
|
+
self._task: asyncio.Task[None] | None = None
|
|
41
|
+
self._running = False
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def events_dropped_count(self) -> int:
|
|
45
|
+
return self._events_dropped_count
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def queue_size(self) -> int:
|
|
49
|
+
return self._queue.qsize()
|
|
50
|
+
|
|
51
|
+
async def start(self) -> None:
|
|
52
|
+
self._running = True
|
|
53
|
+
self._task = asyncio.create_task(self._drain_loop())
|
|
54
|
+
|
|
55
|
+
async def stop(self) -> None:
|
|
56
|
+
self._running = False
|
|
57
|
+
if self._task is not None:
|
|
58
|
+
await self._task
|
|
59
|
+
self._task = None
|
|
60
|
+
|
|
61
|
+
async def write(self, event: Event) -> None:
|
|
62
|
+
try:
|
|
63
|
+
self._queue.put_nowait(event)
|
|
64
|
+
except asyncio.QueueFull:
|
|
65
|
+
with contextlib.suppress(asyncio.QueueEmpty):
|
|
66
|
+
self._queue.get_nowait()
|
|
67
|
+
self._events_dropped_count += 1
|
|
68
|
+
logger.warning(
|
|
69
|
+
"event_queue_full_drop_oldest",
|
|
70
|
+
events_dropped_count=self._events_dropped_count,
|
|
71
|
+
session_id=event.session_id,
|
|
72
|
+
)
|
|
73
|
+
self._queue.put_nowait(event)
|
|
74
|
+
|
|
75
|
+
async def _drain_loop(self) -> None:
|
|
76
|
+
while self._running or not self._queue.empty():
|
|
77
|
+
batch: list[Event] = []
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
event = await asyncio.wait_for(self._queue.get(), timeout=self._flush_interval)
|
|
81
|
+
batch.append(event)
|
|
82
|
+
except TimeoutError:
|
|
83
|
+
if not self._running:
|
|
84
|
+
break
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
while len(batch) < self._batch_size:
|
|
88
|
+
try:
|
|
89
|
+
batch.append(self._queue.get_nowait())
|
|
90
|
+
except asyncio.QueueEmpty:
|
|
91
|
+
break
|
|
92
|
+
|
|
93
|
+
if batch:
|
|
94
|
+
try:
|
|
95
|
+
await self._store.insert_events(batch)
|
|
96
|
+
except Exception:
|
|
97
|
+
logger.exception(
|
|
98
|
+
"event_writer_flush_failed",
|
|
99
|
+
batch_size=len(batch),
|
|
100
|
+
)
|